SOCKS5 UDP associations have no idle timeout — ephemeral port exhaustion
Describe the bug
Every SOCKS5 UDP association binds an ephemeral port for its relay socket and holds it until the control TCP connection closes. There is no idle timeout, so a client that opens many associations and keeps the control connections open — the ordinary shape of a tun-to-SOCKS bridge forwarding DNS — pins one port per association indefinitely.
Once the ephemeral range is exhausted the damage is host-wide, not local to the proxy: any process that needs a new socket fails, and the proxy itself stops accepting connections while its listen backlog grows.
app/internal/socks5/server.go, handleUDP() creates a fresh net.ListenUDP
per association plus a QUIC session and two goroutines, then waits on
closeErr = <-errChan — that is, until the control connection ends. No deadline
is set anywhere in that file:
grep -nE "SetDeadline|SetReadDeadline|Timeout|idle|Idle" app/internal/socks5/server.go
→ no matches
udpServer() blocks on udpConn.ReadFromUDP(buf) with no deadline, and the
control connection is drained with io.Copy(io.Discard, conn), also with none.
UDP has no FIN or RST, so an association can only be known to be finished by the
control connection closing or by an idle timeout — and the second does not
exist. A client is under no obligation to close anything.
To Reproduce
Open several SOCKS5 UDP associations, leave every control connection open, send no datagrams, and count how many relay ports stay bound. A test doing exactly this is included in the linked PR:
go test ./app/internal/socks5/ -run TestUDPAssociationsDoNotAccumulate -v
Expected behavior
An association that stops carrying traffic releases its ephemeral port on its
own, as tproxy, tun and forwarding already do, rather than waiting for a
close that may never come.
Logs
From the test above rather than from a deployment, so the numbers are reproducible. 50 associations, all control connections open, no datagrams sent:
before the fix
held while live: 50 of 50
held after idle: 50 of 50 (control connections still open)
FAIL: all idle associations must release their ports
after the fix
held while live: 50 of 50
held after idle: 0 of 50 (control connections still open)
PASS
The first line of each run is a control: without it the test would pass even if
UDP ASSOCIATE had never bound anything at all. The only difference between the
two runs is whether the idle deadline is refreshed.
Two companion tests cover the boundaries:
TestUDPAssociationIdleTimeout— one idle association releases its port; on unpatched code it fails withidle association must release its ephemeral port.TestUDPAssociationSurvivesTraffic— an association carrying traffic is not torn down, so the fix cannot be mistaken for a hard lifetime cap.
Device and Operating System
Linux x86_64, app/v2.12.2. master is two commits ahead of that tag; one of
them, #1676, touches app/internal/http/server.go. app/internal/socks5/server.go
is unchanged since the tag. The linked PR is based on master, not on the tag.
Additional context
Three of the four UDP entry points already solve this, which is why the fix is a port of existing code rather than a new mechanism:
| entry point | idle timeout |
|---|---|
tproxy |
yes — defaultTimeout = 60 * time.Second, configurable, refreshed on activity |
tun |
yes — UDPTimeout |
forwarding |
yes — defaultTimeout = 60 * time.Second |
socks5 |
no |
UDPTProxy.updateConnDeadline in app/internal/tproxy/udp_linux.go is already
the right shape.
Observed in production before the fix as ephemeral port exhaustion on a client carrying DNS over SOCKS5 UDP. After deploying the patch the same counter peaks and falls back instead of climbing, and it does so while offered traffic is rising — so the fall is not simply load going away. That still only shows the absence of accumulation; the causal evidence is the test, where the deadline refresh is the only variable.
A closed report, #923, describes this exhaustion class reaching the system
file-descriptor limit instead of the ephemeral port range. It is a different code
path — the server's socks5 outbound, not the client's inbound listener — and
it was closed without a root cause after the reporter was asked to check lsof,
so it is offered as context rather than as a duplicate.
The same class is documented publicly by proxy operators and tracked in Envoy as issue #21712; the failure mode there is identical, because the exhausted resource is host-wide.
Source: apernet/hysteria