fix(handler/forward): split readTimeout from pipe idleTimeout to prevent 15s download abort

The readTimeout field (default 15s) was being applied via xnet.Pipe to both
directions of a bidirectional proxy connection. During asymmetric transfers
(e.g. HTTP file download), the direction reading from the tunnel sees no
data after the initial request is forwarded, causing SetReadDeadline to fire
after 15s and abort the entire transfer.

Fix: add a separate idleTimeout field (default 0=disabled) to the metadata
structs in both forward/local and forward/remote handlers, and switch
xnet.Pipe to use idleTimeout instead of readTimeout. The readTimeout field
now only applies to the initial protocol sniffing/handshake phase.

Also document readTimeout vs idleTimeout semantics across all 24 locations
in the x/ module where these timeouts appear:
- readTimeout: handshake sniffing deadline (handlers), upstream response
  header timeout (http.Transport), or transport-level read deadline
- idleTimeout: idle read deadline per Pipe direction (0=disabled)
- ReadTimeout on Sniffer/SnifferBuilder: upstream response header/TLS
  handshake read timeout during sniffing
This commit is contained in:
ginuerzh
2026-05-31 17:05:21 +08:00
parent b8d9c79f72
commit 722dde5cfc
24 changed files with 128 additions and 6 deletions
+5
View File
@@ -18,6 +18,11 @@ const (
// metadata holds parsed DNS handler configuration. // metadata holds parsed DNS handler configuration.
type metadata struct { type metadata struct {
// readTimeout is the deadline for reading DNS query and writing DNS
// response on the client connection. Each DNS exchange (one query +
// one response) must complete within this window.
// Default: 0 (no timeout set by handler — DNS upstream timeout is
// controlled separately by the "timeout" field).
readTimeout time.Duration readTimeout time.Duration
ttl time.Duration ttl time.Duration
timeout time.Duration timeout time.Duration
+1 -1
View File
@@ -82,7 +82,7 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
t := time.Now() t := time.Now()
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr) log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.readTimeout)); err != nil { if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout)); err != nil {
log.Debugf("pipe: %v", err) log.Debugf("pipe: %v", err)
} }
log.WithFields(map[string]any{ log.WithFields(map[string]any{
+15
View File
@@ -13,7 +13,17 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for the initial protocol sniffing phase
// (used by SnifferBuilder.ReadTimeout). After sniffing completes and
// raw forwarding begins, this timeout no longer applies.
// Default: 15s.
readTimeout time.Duration readTimeout time.Duration
// idleTimeout is the idle read timeout applied to each direction of
// the bidirectional pipe (xnet.Pipe). A value of 0 or negative disables
// the timeout entirely, relying on TCP keepalives and context
// cancellation to detect dead connections.
// Default: 0 (disabled).
idleTimeout time.Duration
httpKeepalive bool httpKeepalive bool
proxyProtocol int proxyProtocol int
@@ -34,6 +44,11 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.readTimeout = 15 * time.Second h.md.readTimeout = 15 * time.Second
} }
h.md.idleTimeout = mdutil.GetDuration(md, "idleTimeout")
if h.md.idleTimeout < 0 {
h.md.idleTimeout = 0
}
h.md.httpKeepalive = mdutil.GetBool(md, "http.keepalive") h.md.httpKeepalive = mdutil.GetBool(md, "http.keepalive")
h.md.proxyProtocol = mdutil.GetInt(md, "proxyProtocol") h.md.proxyProtocol = mdutil.GetInt(md, "proxyProtocol")
+3
View File
@@ -32,6 +32,9 @@ type SnifferBuilder struct {
ALPN string ALPN string
CertPool tls_util.CertPool CertPool tls_util.CertPool
MitmBypass bypass.Bypass MitmBypass bypass.Bypass
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
// See forwarder.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration ReadTimeout time.Duration
} }
+1 -1
View File
@@ -78,7 +78,7 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
t := time.Now() t := time.Now()
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr) log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.readTimeout)); err != nil { if err := xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout)); err != nil {
log.Debugf("pipe: %v", err) log.Debugf("pipe: %v", err)
} }
log.WithFields(map[string]any{ log.WithFields(map[string]any{
+15
View File
@@ -13,7 +13,17 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for the initial protocol sniffing phase
// (used by SnifferBuilder.ReadTimeout). After sniffing completes and
// raw forwarding begins, this timeout no longer applies.
// Default: 15s.
readTimeout time.Duration readTimeout time.Duration
// idleTimeout is the idle read timeout applied to each direction of
// the bidirectional pipe (xnet.Pipe). A value of 0 or negative disables
// the timeout entirely, relying on TCP keepalives and context
// cancellation to detect dead connections.
// Default: 0 (disabled).
idleTimeout time.Duration
httpKeepalive bool httpKeepalive bool
proxyProtocol int proxyProtocol int
@@ -34,6 +44,11 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.readTimeout = 15 * time.Second h.md.readTimeout = 15 * time.Second
} }
h.md.idleTimeout = mdutil.GetDuration(md, "idleTimeout")
if h.md.idleTimeout < 0 {
h.md.idleTimeout = 0
}
h.md.httpKeepalive = mdutil.GetBool(md, "http.keepalive") h.md.httpKeepalive = mdutil.GetBool(md, "http.keepalive")
h.md.proxyProtocol = mdutil.GetInt(md, "proxyProtocol") h.md.proxyProtocol = mdutil.GetInt(md, "proxyProtocol")
+3
View File
@@ -32,6 +32,9 @@ type SnifferBuilder struct {
ALPN string ALPN string
CertPool tls_util.CertPool CertPool tls_util.CertPool
MitmBypass bypass.Bypass MitmBypass bypass.Bypass
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
// See forwarder.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration ReadTimeout time.Duration
} }
+3
View File
@@ -230,6 +230,9 @@ type SnifferBuilder struct {
ALPN string ALPN string
CertPool tls_util.CertPool CertPool tls_util.CertPool
MitmBypass bypass.Bypass MitmBypass bypass.Bypass
// ReadTimeout is the timeout for reading upstream HTTP response headers
// and TLS ServerHello during sniffing. Passed through to sniffing.Sniffer.
// See sniffing.Sniffer.ReadTimeout for details.
ReadTimeout time.Duration ReadTimeout time.Duration
} }
+2 -2
View File
@@ -23,8 +23,8 @@ const (
// Fields are populated by parseMetadata and read throughout the handler's // Fields are populated by parseMetadata and read throughout the handler's
// request-processing methods. // request-processing methods.
type metadata struct { type metadata struct {
readTimeout time.Duration // read timeout for upstream responses; default 15s, negative disables readTimeout time.Duration // deadline for upstream response headers (http.Transport.ResponseHeaderTimeout); 0=15s default, negative=disabled
idleTimeout time.Duration // idle timeout for pipe forwarding; 0 means disabled idleTimeout time.Duration // idle read deadline per Pipe direction during CONNECT/forwarding; 0 or negative = disabled
keepalive bool // enable HTTP keep-alive on the upstream transport keepalive bool // enable HTTP keep-alive on the upstream transport
compression bool // enable HTTP compression on the upstream transport compression bool // enable HTTP compression on the upstream transport
+1 -1
View File
@@ -20,7 +20,7 @@ type metadata struct {
authBasicRealm string authBasicRealm string
observerPeriod time.Duration observerPeriod time.Duration
observerResetTraffic bool observerResetTraffic bool
idleTimeout time.Duration idleTimeout time.Duration // idle read deadline per Pipe direction during forwarding; read from "readTimeout" or "read.timeout" metadata key. 0 = disabled.
limiterRefreshInterval time.Duration limiterRefreshInterval time.Duration
limiterCleanupInterval time.Duration limiterCleanupInterval time.Duration
+5
View File
@@ -13,6 +13,11 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is passed to SnifferBuilder as the timeout for reading
// upstream response headers during HTTP/TLS sniffing. It is NOT used
// as a deadline on the initial client connection (unlike socks/ss
// handlers), because redirect/tcp has no protocol handshake.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
tproxy bool tproxy bool
+6
View File
@@ -14,6 +14,12 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for reading the initial relay protocol
// handshake (relay.Request) from the client connection. The deadline
// is cleared immediately after the handshake completes, so it does
// not affect subsequent data transfer. Also passed to SnifferBuilder
// for the upstream response header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
udpBufferSize int udpBufferSize int
enableBind bool enableBind bool
+5
View File
@@ -18,6 +18,11 @@ const (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for reading the initial relay protocol
// handshake from the client connection. The deadline is cleared
// after the handshake, so it does not affect subsequent data
// transfer.
// 0 or negative means no timeout is applied.
readTimeout time.Duration readTimeout time.Duration
bufferSize int bufferSize int
+6
View File
@@ -13,6 +13,12 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is passed to SnifferBuilder as the timeout for reading
// upstream response headers during HTTP/TLS sniffing. It is NOT used
// as a deadline on the initial client connection (unlike socks/ss
// handlers), because SNI routing has no protocol handshake beyond
// the TLS ClientHello which is parsed during sniffing.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
hash string hash string
+6
View File
@@ -13,6 +13,12 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for reading the initial SOCKS4/4A
// handshake (connect/bind request) from the client connection. The
// deadline is cleared after the handshake, so it does not affect
// subsequent data transfer. Also passed to SnifferBuilder for the
// upstream response header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
hash string hash string
+6
View File
@@ -15,6 +15,12 @@ import (
type metadata struct { type metadata struct {
publicAddr string publicAddr string
// readTimeout is the deadline for reading the initial SOCKS5
// handshake (auth + connect/associate/udp request) from the client
// connection. The deadline is cleared after the handshake, so it
// does not affect subsequent data transfer. Also passed to
// SnifferBuilder for the upstream response header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
noTLS bool noTLS bool
enableBind bool enableBind bool
+6
View File
@@ -16,6 +16,12 @@ import (
type metadata struct { type metadata struct {
key string key string
hash string hash string
// readTimeout is the deadline for reading the initial Shadowsocks
// handshake from the client connection. The deadline is cleared
// after the handshake, so it does not affect subsequent data
// transfer. Also passed to SnifferBuilder for the upstream response
// header read timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
sniffing bool sniffing bool
+5
View File
@@ -14,6 +14,11 @@ const (
type metadata struct { type metadata struct {
key string key string
// readTimeout is the deadline for reading a single UDP datagram from
// the client. Since UDP is connectionless, this timeout applies to
// each individual datagram read. Default: 1 minute (longer than the
// typical 15s used by TCP handlers to accommodate the stateless
// nature of UDP).
readTimeout time.Duration readTimeout time.Duration
udpBufferSize int udpBufferSize int
+6
View File
@@ -13,6 +13,12 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for reading the initial SSH handshake
// from the client connection. The deadline is cleared after the
// handshake, so it does not affect subsequent data transfer. Also
// passed to SnifferBuilder for the upstream response header read
// timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
sniffing bool sniffing bool
+3
View File
@@ -63,6 +63,9 @@ type entrypoint struct {
sniffingWebsocket bool sniffingWebsocket bool
websocketSampleRate float64 websocketSampleRate float64
// readTimeout is applied as SetReadDeadline on the upstream connection
// before sniffing HTTP/TLS reads. It mirrors entryPointReadTimeout
// from the handler metadata.
readTimeout time.Duration readTimeout time.Duration
} }
+5 -1
View File
@@ -21,6 +21,10 @@ const (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for reading the initial relay protocol
// handshake from the client connection. The deadline is cleared
// after the handshake, so it does not affect subsequent data
// transfer. 0 or negative means no timeout is applied.
readTimeout time.Duration readTimeout time.Duration
entrypoints []entrypointConfig entrypoints []entrypointConfig
@@ -30,7 +34,7 @@ type metadata struct {
entryPointProxyProtocol int entryPointProxyProtocol int
entryPointKeepalive bool entryPointKeepalive bool
entryPointCompression bool entryPointCompression bool
entryPointReadTimeout time.Duration entryPointReadTimeout time.Duration // deadline for reading upstream HTTP response headers in the entrypoint's http.Transport.ResponseHeaderTimeout. Also passed as readTimeout to entrypoint dialer for SetReadDeadline on upstream conn. 0 or negative defaults to 15s.
sniffingWebsocket bool sniffingWebsocket bool
sniffingWebsocketSampleRate float64 sniffingWebsocketSampleRate float64
+6
View File
@@ -13,6 +13,12 @@ import (
) )
type metadata struct { type metadata struct {
// readTimeout is the deadline for reading data from the upstream
// (the target Unix socket or pipe). It is set on the upstream conn
// via SetReadDeadline before each sniffing HTTP/TLS read. Also
// passed to SnifferBuilder for the upstream response header read
// timeout.
// 0 or negative defaults to 15s.
readTimeout time.Duration readTimeout time.Duration
sniffing bool sniffing bool
+7
View File
@@ -122,6 +122,13 @@ type Sniffer struct {
CertPool tls_util.CertPool CertPool tls_util.CertPool
MitmBypass bypass.Bypass MitmBypass bypass.Bypass
// ReadTimeout is the deadline for reading the upstream response
// headers during HTTP sniffing (http.ReadResponse) and the TLS
// ServerHello during TLS sniffing. This timeout is applied once
// per request/response pair in the HTTP keep-alive loop and cleared
// after each response is received. It does NOT affect the client
// connection or the response body transfer.
// Default: DefaultReadTimeout (30s) if not set.
ReadTimeout time.Duration ReadTimeout time.Duration
} }
+7
View File
@@ -103,6 +103,13 @@ type Sniffer struct {
CertPool tls_util.CertPool CertPool tls_util.CertPool
MitmBypass bypass.Bypass MitmBypass bypass.Bypass
// ReadTimeout is the deadline for reading the upstream response
// headers during HTTP sniffing (http.ReadResponse) and the TLS
// ServerHello during TLS sniffing. This timeout is applied once
// per request/response pair in the HTTP keep-alive loop and cleared
// after each response is received. It does NOT affect the client
// connection or the response body transfer.
// Default: DefaultReadTimeout (30s) if not set.
ReadTimeout time.Duration ReadTimeout time.Duration
} }