From 5815254fc3d93338b4042eab9f58d6c3462e2201 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Fri, 11 Sep 2026 23:51:55 +0200 Subject: [PATCH] fix(tuic): evict the oldest relay flow instead of refusing new clients udpRelay.flowFor returned "max relay flows reached" once the table held maxRelayFlows entries, and only the idle sweep (every minute, two-minute cutoff) freed slots. One host sending a single datagram from each of 4096 source ports therefore locked every new TUIC client out of the inbound for up to two minutes, repeatably. A full table now evicts the flow last seen longest ago, which under such a flood is one of the junk flows, and the newcomer is admitted. TestUDPRelayFullTableAdmitsNewClient fails on the refusing code with a read timeout for the third client. --- internal/tuic/relay.go | 29 ++++++++++++++++++++++------- internal/tuic/relay_test.go | 23 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/tuic/relay.go b/internal/tuic/relay.go index 505ad70cd..75d5b6207 100644 --- a/internal/tuic/relay.go +++ b/internal/tuic/relay.go @@ -17,15 +17,13 @@ const ( maxRelayFlows = 4096 ) -// udpRelay owns an inbound's public UDP port and forwards each client's -// datagrams to the sidecar on loopback, which is the only place the panel can -// count the inbound's bytes: upstream tuic-server exposes no stats API and -// its socket syscalls never reach /proc//io. Per-client attribution stays -// impossible because QUIC payloads are opaque. +// udpRelay owns an inbound's public UDP port and counts the bytes it forwards to +// the sidecar on loopback: tuic-server has no stats API and /proc/io stays at 0. type udpRelay struct { public *net.UDPConn upstream *net.UDPAddr idle time.Duration + maxFlows int up atomic.Int64 down atomic.Int64 mu sync.Mutex @@ -56,6 +54,7 @@ func startUDPRelay(bind string, upstream *net.UDPAddr, idle time.Duration) (*udp public: public, upstream: upstream, idle: idle, + maxFlows: maxRelayFlows, flows: make(map[string]*relayFlow), done: make(chan struct{}), } @@ -136,8 +135,8 @@ func (r *udpRelay) flowFor(client *net.UDPAddr) (*relayFlow, error) { f.lastSeen.Store(now) return f, nil } - if len(r.flows) >= maxRelayFlows { - return nil, errors.New("tuic: max relay flows reached") + if len(r.flows) >= r.maxFlows { + r.evictLeastRecentLocked() } conn, err := net.DialUDP("udp", nil, r.upstream) if err != nil { @@ -153,6 +152,22 @@ func (r *udpRelay) flowFor(client *net.UDPAddr) (*relayFlow, error) { return f, nil } +// Refusing a newcomer at the cap let 4096 junk datagrams lock every new client +// out until the sweep; the flow last seen longest ago is the junk one. +func (r *udpRelay) evictLeastRecentLocked() { + var oldestKey string + oldest := int64(-1) + for key, f := range r.flows { + if seen := f.lastSeen.Load(); oldest < 0 || seen < oldest { + oldest, oldestKey = seen, key + } + } + if f, ok := r.flows[oldestKey]; ok { + _ = f.conn.Close() + delete(r.flows, oldestKey) + } +} + func (r *udpRelay) pump(f *relayFlow) { defer r.wg.Done() buf := make([]byte, 65535) diff --git a/internal/tuic/relay_test.go b/internal/tuic/relay_test.go index 561340d6d..eb329ab8f 100644 --- a/internal/tuic/relay_test.go +++ b/internal/tuic/relay_test.go @@ -125,3 +125,26 @@ func TestUDPRelayRefusesFlowsAfterClose(t *testing.T) { t.Fatalf("%d flow(s) registered after Close", n) } } + +func TestUDPRelayFullTableAdmitsNewClient(t *testing.T) { + relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle) + if err != nil { + t.Fatal(err) + } + t.Cleanup(relay.Close) + relay.mu.Lock() + relay.maxFlows = 2 + relay.mu.Unlock() + + roundTrip(t, relay, []byte("a")) + roundTrip(t, relay, []byte("b")) + if got := roundTrip(t, relay, []byte("c")); got != 2 { + t.Fatalf("third client reply = %d bytes, want 2: a full table must evict, not refuse", got) + } + relay.mu.Lock() + n := len(relay.flows) + relay.mu.Unlock() + if n != 2 { + t.Fatalf("flow table holds %d flows after admitting a third client, want the cap of 2", n) + } +}