fix(tuic): bound one client's connections and UDP relays

The UDP association cap applied per QUIC connection, and nothing limited
how many connections one credential could authenticate, so a single
client could hold enough UDP relays (each a host port) to exhaust the
host's ephemeral range. A client now gets at most 32 connections and 512
open UDP relays across all of them; past that a connection is refused at
authentication and a relay is not opened.
This commit is contained in:
MHSanaei
2026-10-31 14:59:20 +01:00
parent 838923058b
commit e51a32b045
5 changed files with 66 additions and 2 deletions
+9
View File
@@ -17,6 +17,13 @@ var (
ErrInvalidTLSState = errors.New("tuic: TLS connection state not available") ErrInvalidTLSState = errors.New("tuic: TLS connection state not available")
) )
// One credential's limits across all its connections: each UDP relay holds a host port, so
// a per-connection cap alone lets one client take the host's whole ephemeral range.
const (
maxUserSessions = 32
maxUserUdpRelays = 512
)
// User represents a configured TUIC client for authentication and billing. // User represents a configured TUIC client for authentication and billing.
type User struct { type User struct {
TrafficID int TrafficID int
@@ -26,6 +33,8 @@ type User struct {
Email string Email string
Traffic *UserTraffic Traffic *UserTraffic
sessions atomic.Int64 sessions atomic.Int64
// udpRelays counts the open UDP relays over all the client's connections.
udpRelays atomic.Int64
} }
type UserTraffic struct { type UserTraffic struct {
+10 -2
View File
@@ -196,14 +196,18 @@ func (s *Server) applyCongestionControl(conn *quic.Conn) {
} }
} }
func (s *Server) registerConn(user *User, conn *quic.Conn) { func (s *Server) registerConn(user *User, conn *quic.Conn) bool {
s.activeConnsMu.Lock() s.activeConnsMu.Lock()
defer s.activeConnsMu.Unlock() defer s.activeConnsMu.Unlock()
if user.sessions.Load() >= maxUserSessions {
return false
}
if s.activeConns[user.UUID] == nil { if s.activeConns[user.UUID] == nil {
s.activeConns[user.UUID] = make(map[*quic.Conn]*User) s.activeConns[user.UUID] = make(map[*quic.Conn]*User)
} }
s.activeConns[user.UUID][conn] = user s.activeConns[user.UUID][conn] = user
user.sessions.Add(1) user.sessions.Add(1)
return true
} }
func (s *Server) unregisterConn(user *User, conn *quic.Conn) { func (s *Server) unregisterConn(user *User, conn *quic.Conn) {
@@ -379,8 +383,12 @@ func (s *Server) handleConn(conn *quic.Conn) {
if !authState.CompareAndSwap(0, 1) { if !authState.CompareAndSwap(0, 1) {
return authState.Load() == 1 && authUser.Load() == user return authState.Load() == 1 && authUser.Load() == user
} }
if !s.registerConn(user, conn) {
authState.Store(2)
s.logLimited(tuicLogWarn, "tuic-sessions", 30*time.Second, "client refused: more than %d connections", maxUserSessions)
return false
}
authUser.Store(user) authUser.Store(user)
s.registerConn(user, conn)
s.markActive(user.Email) s.markActive(user.Email)
authTimer.Stop() authTimer.Stop()
s.logf(tuicLogInfo, "client authenticated") s.logf(tuicLogInfo, "client authenticated")
+5
View File
@@ -180,6 +180,8 @@ type SocksUDPSession struct {
targetEP net.Addr targetEP net.Addr
user string user string
closed atomic.Bool closed atomic.Bool
// onClose runs once when the session closes, releasing its place in a budget.
onClose func()
} }
// DialUDP establishes a SOCKS5 UDP ASSOCIATE tunnel to Xray. // DialUDP establishes a SOCKS5 UDP ASSOCIATE tunnel to Xray.
@@ -312,6 +314,9 @@ func (s *SocksUDPSession) Close() error {
if s.closed.Swap(true) { if s.closed.Swap(true) {
return nil return nil
} }
if s.onClose != nil {
s.onClose()
}
_ = s.udpConn.Close() _ = s.udpConn.Close()
return s.ctrl.Close() return s.ctrl.Close()
} }
@@ -252,3 +252,37 @@ func TestDissociateStopsAResponseRelayBlockedOnTheClient(t *testing.T) {
t.Fatal("the response relay is still running after its association was dissociated") t.Fatal("the response relay is still running after its association was dissociated")
} }
} }
// The per-connection association cap means nothing if one credential may open any number
// of connections: each UDP relay holds a host port, so the host's whole range ran out.
func TestOneClientsSessionsAndUDPRelaysAreBounded(t *testing.T) {
socksAddr, cleanup := startMockSocks5Server(t, "udp-bound@x", "lifecycle-socks-password")
defer cleanup()
server, _, clientID, _ := startLifecycleTestServer(t, socksAddr, "udp-bound@x")
_, user := authenticatedServerConnection(t, server, clientID)
for range maxUserSessions + 5 {
server.registerConn(user, nil)
}
if got := user.sessions.Load(); got > maxUserSessions {
t.Fatalf("one client holds %d sessions, want at most %d", got, maxUserSessions)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
registry := newUdpAssociationRegistry(maxUdpRelayPacketSize)
user.udpRelays.Store(maxUserUdpRelays)
association := &udpAssociation{responseTransport: packetTransportDatagram, lastActive: time.Now()}
registry.associations[1] = association
if _, _, err := registry.ensureRelay(ctx, 1, association, user, server.relay); err == nil {
t.Fatal("a client past its UDP relay budget opened another relay")
}
user.udpRelays.Store(0)
if _, created, err := registry.ensureRelay(ctx, 1, association, user, server.relay); err != nil || !created {
t.Fatalf("ensureRelay under budget = %v, %v", created, err)
}
registry.dissociate(1)
if got := user.udpRelays.Load(); got != 0 {
t.Fatalf("closed relay still counted: %d", got)
}
}
+8
View File
@@ -14,6 +14,8 @@ const (
var errUdpAssociationClosed = errors.New("tuic: UDP association is closed") var errUdpAssociationClosed = errors.New("tuic: UDP association is closed")
var errUserUdpRelayBudget = errors.New("tuic: the client has too many open UDP relays")
type udpAssociation struct { type udpAssociation struct {
responseTransport uint8 responseTransport uint8
relay *udpRelaySession relay *udpRelaySession
@@ -98,10 +100,16 @@ func (r *udpAssociationRegistry) ensureRelay(ctx context.Context, assocID uint16
} }
r.mu.Unlock() r.mu.Unlock()
if user.udpRelays.Add(1) > maxUserUdpRelays {
user.udpRelays.Add(-1)
return nil, false, errUserUdpRelayBudget
}
socksSession, err := relay.DialUDP(ctx, user.Email) socksSession, err := relay.DialUDP(ctx, user.Email)
if err != nil { if err != nil {
user.udpRelays.Add(-1)
return nil, false, err return nil, false, err
} }
socksSession.onClose = func() { user.udpRelays.Add(-1) }
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()