From 5f81ae37431fac54010b83f7a567520aac73e0ff Mon Sep 17 00:00:00 2001 From: appolimp Date: Sat, 4 Apr 2026 12:01:33 +0300 Subject: [PATCH] Improve TCP keepalive and idle timeout for mobile clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TCP keepalive was configured (SetKeepAlivePeriod) but never actually enabled (SO_KEEPALIVE) on accepted client connections. Go 1.26's SetKeepAlivePeriod only sets TCP_KEEPIDLE — it does not call setsockopt(SO_KEEPALIVE, 1). Without SO_KEEPALIVE the kernel never sends probe packets, so dead connections from sleeping mobile clients linger until the idle timeout fires. Replace SetKeepAlive + SetKeepAlivePeriod with net.KeepAliveConfig (available since Go 1.24) for explicit per-socket control: Idle: 30s (time before first probe) Interval: 10s (between probes) Count: 3 (failed probes to declare dead) This detects dead connections in ~60s instead of relying on system defaults (tcp_keepalive_intvl=75s, probes=9 → up to 11 minutes). Increase the default idle timeout from 1 minute to 5 minutes. MTProto clients send ping_delay_disconnect every ~60s, which resets the idle timer. The previous 1-minute default created a race: if a ping arrived even 1–2 seconds late the relay was killed. A 5-minute window also survives typical mobile sleep periods (phone idle 2–5 min) where the NAT mapping is still alive and the connection can resume without reconnection. Ref: #132 --- example.config.toml | 2 +- internal/cli/run_proxy.go | 3 +- mtglib/init.go | 5 +- mtglib/proxy_opts.go | 2 +- network/init.go | 14 ++++++ network/sockopts.go | 9 +++- network/sockopts_test.go | 93 +++++++++++++++++++++++++++++++++++++ network/v2/init.go | 14 ++++++ network/v2/sockopts.go | 9 +++- network/v2/sockopts_test.go | 92 ++++++++++++++++++++++++++++++++++++ 10 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 network/sockopts_test.go create mode 100644 network/v2/sockopts_test.go diff --git a/example.config.toml b/example.config.toml index abc0bff..38cd940 100644 --- a/example.config.toml +++ b/example.config.toml @@ -211,7 +211,7 @@ proxies = [ [network.timeout] tcp = "5s" http = "10s" -idle = "1m" +idle = "5m" # mtg has to mimic real websites. It does not mean domain fronting, it also # means that traffic characteristics should be similar to real world traffic. diff --git a/internal/cli/run_proxy.go b/internal/cli/run_proxy.go index c57374b..8eabc3d 100644 --- a/internal/cli/run_proxy.go +++ b/internal/cli/run_proxy.go @@ -5,7 +5,6 @@ import ( "fmt" "net" "os" - "time" "github.com/9seconds/mtg/v2/antireplay" "github.com/9seconds/mtg/v2/events" @@ -263,7 +262,7 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen AllowFallbackOnUnknownDC: conf.AllowFallbackOnUnknownDC.Get(false), TolerateTimeSkewness: conf.TolerateTimeSkewness.Value, - IdleTimeout: conf.Network.Timeout.Idle.Get(time.Minute), + IdleTimeout: conf.Network.Timeout.Idle.Get(mtglib.DefaultIdleTimeout), DoppelGangerURLs: doppelGangerURLs, DoppelGangerPerRaid: conf.Defense.Doppelganger.Repeats.Get(mtglib.DoppelGangerPerRaid), diff --git a/mtglib/init.go b/mtglib/init.go index 3d7f630..327539a 100644 --- a/mtglib/init.go +++ b/mtglib/init.go @@ -77,8 +77,9 @@ const ( // DefaultIdleTimeout is a default timeout for closing a connection in case of // idling. // - // Deprecated: no longer in use because of changed TCP relay algorithm. - DefaultIdleTimeout = time.Minute + // Set to 5 minutes to survive typical mobile sleep periods (2-5 min) and + // avoid racing with MTProto ping_delay_disconnect (~60s interval). + DefaultIdleTimeout = 5 * time.Minute // DefaultTolerateTimeSkewness is a default timeout for time skewness on a // faketls timeout verification. diff --git a/mtglib/proxy_opts.go b/mtglib/proxy_opts.go index fd44783..c4516c8 100644 --- a/mtglib/proxy_opts.go +++ b/mtglib/proxy_opts.go @@ -217,7 +217,7 @@ func (p ProxyOpts) getPreferIP() string { func (p ProxyOpts) getIdleTimeout() time.Duration { if p.IdleTimeout == 0 { - return time.Minute + return DefaultIdleTimeout } return p.IdleTimeout diff --git a/network/init.go b/network/init.go index 3baa8b0..ffebc3d 100644 --- a/network/init.go +++ b/network/init.go @@ -36,8 +36,22 @@ const ( // DefaultTCPKeepAlivePeriod defines a time period between 2 consequitive // probes. + // + // Deprecated: use DefaultKeepAliveIdle and DefaultKeepAliveInterval instead. DefaultTCPKeepAlivePeriod = 10 * time.Second + // DefaultKeepAliveIdle is the time a connection must be idle before + // the first keepalive probe is sent. + DefaultKeepAliveIdle = 30 * time.Second + + // DefaultKeepAliveInterval is the time between consecutive keepalive + // probes. + DefaultKeepAliveInterval = 10 * time.Second + + // DefaultKeepAliveCount is the number of unacknowledged probes before + // the connection is considered dead. + DefaultKeepAliveCount = 3 + // ProxyDialerOpenThreshold is used for load balancing SOCKS5 dialer only. // // This dialer uses circuit breaker with of 3 stages: OPEN, HALF_OPEN and diff --git a/network/sockopts.go b/network/sockopts.go index 22ec16b..7d537e4 100644 --- a/network/sockopts.go +++ b/network/sockopts.go @@ -20,8 +20,13 @@ func SetServerSocketOptions(conn net.Conn, bufferSize int) error { } func setCommonSocketOptions(conn *net.TCPConn) error { - if err := conn.SetKeepAlivePeriod(DefaultTCPKeepAlivePeriod); err != nil { - return fmt.Errorf("cannot set time period of TCP keepalive probes: %w", err) + if err := conn.SetKeepAliveConfig(net.KeepAliveConfig{ + Enable: true, + Idle: DefaultKeepAliveIdle, + Interval: DefaultKeepAliveInterval, + Count: DefaultKeepAliveCount, + }); err != nil { + return fmt.Errorf("cannot configure TCP keepalive: %w", err) } if err := conn.SetLinger(tcpLingerTimeout); err != nil { diff --git a/network/sockopts_test.go b/network/sockopts_test.go new file mode 100644 index 0000000..25ddbdb --- /dev/null +++ b/network/sockopts_test.go @@ -0,0 +1,93 @@ +//go:build linux || darwin +// +build linux darwin + +package network_test + +import ( + "net" + "runtime" + "syscall" + "testing" + "time" + + "github.com/9seconds/mtg/v2/network" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func tcpKeepIdleOption() int { + if runtime.GOOS == "darwin" { + return 0x10 // TCP_KEEPALIVE on macOS + } + + return 0x4 // TCP_KEEPIDLE on Linux +} + +func TestSetClientSocketOptionsKeepAlive(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { + err := listener.Close() + require.NoError(t, err) + }() + + type dialResult struct { + conn net.Conn + err error + } + + dialDone := make(chan dialResult, 1) + + go func() { + c, err := net.Dial("tcp", listener.Addr().String()) + dialDone <- dialResult{conn: c, err: err} + }() + + tcpListener, ok := listener.(*net.TCPListener) + require.True(t, ok, "listener must be a *net.TCPListener") + + require.NoError(t, tcpListener.SetDeadline(time.Now().Add(5*time.Second))) + + accepted, err := listener.Accept() + require.NoError(t, err) + defer func() { + err := accepted.Close() + require.NoError(t, err) + }() + + dr := <-dialDone + require.NoError(t, dr.err) + defer func() { + err := dr.conn.Close() + require.NoError(t, err) + }() + + err = network.SetClientSocketOptions(accepted, 0) + require.NoError(t, err) + + tcpConn := accepted.(*net.TCPConn) + + rawConn, err := tcpConn.SyscallConn() + require.NoError(t, err) + + err = rawConn.Control(func(fd uintptr) { + val, err := unix.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_KEEPALIVE) + require.NoError(t, err) + require.NotEqual(t, 0, val, "SO_KEEPALIVE should be enabled") + + idle, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, tcpKeepIdleOption()) + require.NoError(t, err) + require.Equal(t, int(network.DefaultKeepAliveIdle.Seconds()), idle) + + interval, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPINTVL) + require.NoError(t, err) + require.Equal(t, int(network.DefaultKeepAliveInterval.Seconds()), interval) + + count, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPCNT) + require.NoError(t, err) + require.Equal(t, network.DefaultKeepAliveCount, count) + }) + require.NoError(t, err) +} diff --git a/network/v2/init.go b/network/v2/init.go index 14c6144..abcc064 100644 --- a/network/v2/init.go +++ b/network/v2/init.go @@ -26,8 +26,22 @@ const ( // DefaultTCPKeepAlivePeriod defines a time period between 2 consecuitive // probes. + // + // Deprecated: use DefaultKeepAliveIdle and DefaultKeepAliveInterval instead. DefaultTCPKeepAlivePeriod = 10 * time.Second + // DefaultKeepAliveIdle is the time a connection must be idle before + // the first keepalive probe is sent. + DefaultKeepAliveIdle = 30 * time.Second + + // DefaultKeepAliveInterval is the time between consecutive keepalive + // probes. + DefaultKeepAliveInterval = 10 * time.Second + + // DefaultKeepAliveCount is the number of unacknowledged probes before + // the connection is considered dead. + DefaultKeepAliveCount = 3 + // User Agent to use in HTTP client. UserAgent = "curl/8.5.0" diff --git a/network/v2/sockopts.go b/network/v2/sockopts.go index 378db00..e89b1ea 100644 --- a/network/v2/sockopts.go +++ b/network/v2/sockopts.go @@ -6,8 +6,13 @@ import ( ) func setCommonSocketOptions(conn *net.TCPConn) error { - if err := conn.SetKeepAlivePeriod(DefaultTCPKeepAlivePeriod); err != nil { - return fmt.Errorf("cannot set time period of TCP keepalive probes: %w", err) + if err := conn.SetKeepAliveConfig(net.KeepAliveConfig{ + Enable: true, + Idle: DefaultKeepAliveIdle, + Interval: DefaultKeepAliveInterval, + Count: DefaultKeepAliveCount, + }); err != nil { + return fmt.Errorf("cannot configure TCP keepalive: %w", err) } if err := conn.SetLinger(tcpLingerTimeout); err != nil { diff --git a/network/v2/sockopts_test.go b/network/v2/sockopts_test.go new file mode 100644 index 0000000..94226c1 --- /dev/null +++ b/network/v2/sockopts_test.go @@ -0,0 +1,92 @@ +//go:build linux || darwin +// +build linux darwin + +package network + +import ( + "net" + "runtime" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func tcpKeepIdleOption() int { + if runtime.GOOS == "darwin" { + return 0x10 // TCP_KEEPALIVE on macOS + } + + return 0x4 // TCP_KEEPIDLE on Linux +} + +func TestSetCommonSocketOptionsKeepAlive(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { + err := listener.Close() + require.NoError(t, err) + }() + + type dialResult struct { + conn net.Conn + err error + } + + dialDone := make(chan dialResult, 1) + + go func() { + c, err := net.Dial("tcp", listener.Addr().String()) + dialDone <- dialResult{conn: c, err: err} + }() + + tcpListener, ok := listener.(*net.TCPListener) + require.True(t, ok, "listener must be a *net.TCPListener") + + require.NoError(t, tcpListener.SetDeadline(time.Now().Add(5*time.Second))) + + accepted, err := listener.Accept() + require.NoError(t, err) + defer func() { + err := accepted.Close() + require.NoError(t, err) + }() + + dr := <-dialDone + require.NoError(t, dr.err) + defer func() { + err := dr.conn.Close() + require.NoError(t, err) + }() + + tcpConn := accepted.(*net.TCPConn) + + err = setCommonSocketOptions(tcpConn) + require.NoError(t, err) + + rawConn, err := tcpConn.SyscallConn() + require.NoError(t, err) + + err = rawConn.Control(func(fd uintptr) { + val, err := unix.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_KEEPALIVE) + require.NoError(t, err) + require.NotEqual(t, 0, val, "SO_KEEPALIVE should be enabled") + + idle, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, tcpKeepIdleOption()) + require.NoError(t, err) + require.Equal(t, int(DefaultKeepAliveIdle.Seconds()), idle, "keepalive idle should match DefaultKeepAliveIdle") + + interval, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPINTVL) + require.NoError(t, err) + require.Equal(t, int(DefaultKeepAliveInterval.Seconds()), interval, "keepalive interval should match DefaultKeepAliveInterval") + + count, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPCNT) + require.NoError(t, err) + require.Equal(t, DefaultKeepAliveCount, count, "keepalive count should match DefaultKeepAliveCount") + }) + require.NoError(t, err) +}