mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
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:
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user