Skip to content

socks5: add an idle timeout to UDP associations - #1678

Open
eidos-case wants to merge 1 commit into
HyNetworks:masterfrom
eidos-case:socks5-udp-idle-timeout
Open

socks5: add an idle timeout to UDP associations#1678
eidos-case wants to merge 1 commit into
HyNetworks:masterfrom
eidos-case:socks5-udp-idle-timeout

Conversation

@eidos-case

Copy link
Copy Markdown

Fixes #1677.

  1. socks5: Server gains UDPTimeout time.Duration (zero → 60s, matching the
    other entry points). The read deadline slides forward on activity in both
    directions, and the resulting timeout is treated as an ordinary end of life
    rather than an error, so the event log is not filled with routine expiries.
    Exposed in the client config as socks5.timeout, spelled the way
    udpTProxyConfig, udpRedirectConfig and tunConfig already spell the same
    knob — tunConfig included, which likewise carries both TCP and UDP and still
    calls it timeout. Internally the field is UDPTimeout, matching what
    tun/server.go already passes down. This mirrors
    UDPTProxy.updateConnDeadline rather than introducing a different mechanism.

  2. Three neighbouring reads are bounded, each of which can pin a goroutine and a
    file descriptor for the lifetime of the process when a client connects and
    then stays silent:

    • app/internal/proxymux/mux.go, dispatch() — the one-byte protocol sniff;
    • app/internal/socks5/server.go, dispatch() — the SOCKS5 handshake;
    • app/internal/http/server.go, dispatch() — the request header read.

    Each deadline is cleared once the header is in, so none of them ever applies
    to a request body or to proxied traffic, which is legitimately long-lived and
    idle. That distinction is the same one fix: plain HTTP requests through the HTTP proxy cut off after 10 seconds #1676 has just drawn in this very file,
    replacing a whole-request Timeout with ResponseHeaderTimeout; this change
    is deliberately on that side of it.

    In http the first header read and the wait between keep-alive requests are
    given different bounds — 10s and 60s. A connection that opens and never speaks
    is not a client waiting to be served; a keep-alive connection that goes quiet
    is ordinary, and browsers hold those for tens of seconds, so a single 10s
    bound would trade one leak for a lot of needless reconnects.

Tests: TestUDPAssociationsDoNotAccumulate, TestUDPAssociationIdleTimeout,
TestUDPAssociationSurvivesTraffic.

The first two were verified to fail on unpatched code — with the deadline refresh
removed as the only variable, they report all idle associations must release their ports and idle association must release its ephemeral port. So they
measure the change rather than merely accompanying it.

The third passes without the fix, and must: it asserts that an association
carrying traffic is not torn down. It is a control against the fix being a hard
lifetime cap, so a version of it that failed on unpatched code would be testing
the wrong thing.

app/internal/proxymux/mux_test.go gains a SetReadDeadline expectation on its
mock net.Conn; without it the strict mock rejects the new call. On this machine
the full app/... suite has the same set of failures before and after the change
(http.TestServer, proxymux × 3, socks5.TestServer, utils. TestCertificateLoaderFullChain), all of which are the macOS inability to bind
addresses other than 127.0.0.1.

Every SOCKS5 UDP association binds an ephemeral port for its relay socket and
holds it until the control TCP connection closes. UDP has no FIN or RST, so a
client that opens many associations and never closes their control connections
pins one port per association indefinitely, until the whole ephemeral range is
gone and every process on the host fails to open a socket.

Server gains UDPTimeout, zero meaning 60s. The read deadline slides forward on
activity in both directions, and the resulting timeout is treated as an ordinary
end of life rather than an error, so the event log is not filled with routine
expiries. This mirrors UDPTProxy.updateConnDeadline rather than introducing a
different mechanism: tproxy, tun and forwarding all already have this timeout,
and socks5 was the only entry point without one.

Exposed as socks5.timeout, spelled the way udpTProxyConfig, udpRedirectConfig
and tunConfig already spell the same knob.

Three neighbouring reads are bounded too, each able to pin a goroutine and a
descriptor for the lifetime of the process when a client connects and then stays
silent: the proxymux protocol sniff, the SOCKS5 handshake, and the HTTP request
header read. Each deadline is cleared the moment the header is in, so none of
them ever reaches a request body or proxied traffic.

In http the first header read and the wait between keep-alive requests get
different bounds, 10s and 60s. A connection that opens and never speaks is not a
client waiting to be served; a keep-alive connection that goes quiet is
ordinary, and browsers hold those for tens of seconds.

Tests: TestUDPAssociationsDoNotAccumulate and TestUDPAssociationIdleTimeout both
fail on unpatched code with the deadline refresh removed as the only variable.
TestUDPAssociationSurvivesTraffic passes without the fix, and must: it is the
control against this being a hard lifetime cap.

client_test.yaml and client_test.go gain the new config field, without which the
existing config-coverage check fails; mux_test.go gains a SetReadDeadline
expectation, without which its strict mock rejects the new call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SOCKS5 UDP associations have no idle timeout — ephemeral port exhaustion

1 participant