From e51a32b04560593dc55c28e7bf937ea16d8a4e5c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:59:20 +0100 Subject: [PATCH] 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. --- internal/tuic/auth.go | 9 ++++++ internal/tuic/server.go | 12 ++++++-- internal/tuic/socks_bridge.go | 5 ++++ internal/tuic/stream_udp_lifecycle_test.go | 34 ++++++++++++++++++++++ internal/tuic/udp_associations.go | 8 +++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/internal/tuic/auth.go b/internal/tuic/auth.go index f33ed4afc..a0aca19b8 100644 --- a/internal/tuic/auth.go +++ b/internal/tuic/auth.go @@ -17,6 +17,13 @@ var ( 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. type User struct { TrafficID int @@ -26,6 +33,8 @@ type User struct { Email string Traffic *UserTraffic sessions atomic.Int64 + // udpRelays counts the open UDP relays over all the client's connections. + udpRelays atomic.Int64 } type UserTraffic struct { diff --git a/internal/tuic/server.go b/internal/tuic/server.go index f1622edf1..c73b1f677 100644 --- a/internal/tuic/server.go +++ b/internal/tuic/server.go @@ -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() defer s.activeConnsMu.Unlock() + if user.sessions.Load() >= maxUserSessions { + return false + } if s.activeConns[user.UUID] == nil { s.activeConns[user.UUID] = make(map[*quic.Conn]*User) } s.activeConns[user.UUID][conn] = user user.sessions.Add(1) + return true } 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) { 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) - s.registerConn(user, conn) s.markActive(user.Email) authTimer.Stop() s.logf(tuicLogInfo, "client authenticated") diff --git a/internal/tuic/socks_bridge.go b/internal/tuic/socks_bridge.go index f64ad86f7..cf2d14321 100644 --- a/internal/tuic/socks_bridge.go +++ b/internal/tuic/socks_bridge.go @@ -180,6 +180,8 @@ type SocksUDPSession struct { targetEP net.Addr user string 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. @@ -312,6 +314,9 @@ func (s *SocksUDPSession) Close() error { if s.closed.Swap(true) { return nil } + if s.onClose != nil { + s.onClose() + } _ = s.udpConn.Close() return s.ctrl.Close() } diff --git a/internal/tuic/stream_udp_lifecycle_test.go b/internal/tuic/stream_udp_lifecycle_test.go index 87b9d3496..aa2d2dd6c 100644 --- a/internal/tuic/stream_udp_lifecycle_test.go +++ b/internal/tuic/stream_udp_lifecycle_test.go @@ -252,3 +252,37 @@ func TestDissociateStopsAResponseRelayBlockedOnTheClient(t *testing.T) { 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) + } +} diff --git a/internal/tuic/udp_associations.go b/internal/tuic/udp_associations.go index 98d62e36d..85ba2089f 100644 --- a/internal/tuic/udp_associations.go +++ b/internal/tuic/udp_associations.go @@ -14,6 +14,8 @@ const ( 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 { responseTransport uint8 relay *udpRelaySession @@ -98,10 +100,16 @@ func (r *udpAssociationRegistry) ensureRelay(ctx context.Context, assocID uint16 } r.mu.Unlock() + if user.udpRelays.Add(1) > maxUserUdpRelays { + user.udpRelays.Add(-1) + return nil, false, errUserUdpRelayBudget + } socksSession, err := relay.DialUDP(ctx, user.Email) if err != nil { + user.udpRelays.Add(-1) return nil, false, err } + socksSession.onClose = func() { user.udpRelays.Add(-1) } r.mu.Lock() defer r.mu.Unlock()