From ea84c587c693731d3199ddc242e71c64d44c1dc8 Mon Sep 17 00:00:00 2001 From: Maksim Merzhanov Date: Sat, 4 Apr 2026 16:26:11 +0300 Subject: [PATCH] socks5: halve connection TTFB latency by removing unnecessary auth round-trip Move SOCKS5 auth negotiation to localhost, eliminating one RTT. Auth was always NoAuth, so the remote exchange only added latency. --- application.go | 1 + application_simnet_test.go | 10 ++++++---- application_test.go | 38 ++++++++++++++++++++++++++++++++++++++ cmd/awl-tray/go.mod | 2 +- cmd/awl-tray/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- p2p/p2p.go | 5 +++++ protocol/protocol.go | 1 + service/auth_status.go | 1 + service/socks5.go | 18 +++++++++++++++--- socks5/client.go | 38 ++++++++++++++++++++++++++++++++++++++ socks5/server.go | 5 +++++ 13 files changed, 116 insertions(+), 13 deletions(-) diff --git a/application.go b/application.go index d875ec69..3cf1dc7b 100644 --- a/application.go +++ b/application.go @@ -135,6 +135,7 @@ func (a *Application) Init(ctx context.Context, tunDevice tun.Device) error { p2pHost.SetStreamHandler(protocol.TunnelPacketMethod, a.Tunnel.StreamHandler) } p2pHost.SetStreamHandler(protocol.Socks5PacketMethod, a.SOCKS5.ProxyStreamHandler) + p2pHost.SetStreamHandler(protocol.Socks5NoAuthMethod, a.SOCKS5.ProxyStreamHandler) if a.Tunnel != nil { awlevent.WrapSubscriptionToCallback(a.ctx, func(_ interface{}) { diff --git a/application_simnet_test.go b/application_simnet_test.go index b8f4173a..a1200215 100644 --- a/application_simnet_test.go +++ b/application_simnet_test.go @@ -16,6 +16,8 @@ import ( "github.com/anywherelan/awl/vpn" ) +const EnvRunPerfTests = "AWL_PERF_TESTS" + /* TestSimulatedTunnelPerformance performs a benchmark of the AWL VPN tunnel under simulated network conditions. @@ -32,8 +34,8 @@ It uses: func TestSimulatedTunnelPerformance(t *testing.T) { const Mbps = 1_000_000 - if os.Getenv("CI") != "" { - t.Skip("skip in CI because it's a benchmark") + if os.Getenv(EnvRunPerfTests) == "" { + t.Skipf("skip perf test because %s env is empty", EnvRunPerfTests) } scenarios := []struct { @@ -230,8 +232,8 @@ func TestSimulatedSOCKS5ProxyPerformance(t *testing.T) { const Mbps = 1_000_000 - if os.Getenv("CI") != "" { - t.Skip("skip in CI because it's a benchmark") + if os.Getenv(EnvRunPerfTests) == "" { + t.Skipf("skip perf test because %s env is empty", EnvRunPerfTests) } scenarios := []struct { diff --git a/application_test.go b/application_test.go index 30c39195..31c8fba3 100644 --- a/application_test.go +++ b/application_test.go @@ -21,6 +21,7 @@ import ( "github.com/anywherelan/awl/api" "github.com/anywherelan/awl/config" "github.com/anywherelan/awl/entity" + "github.com/anywherelan/awl/protocol" ) func TestMakeFriends(t *testing.T) { @@ -391,6 +392,43 @@ func TestUpdateUseAsExitNodeConfig(t *testing.T) { ts.Equal("", info.SOCKS5.UsingPeerID) } +func TestSOCKS5ProxyFallbackToOldProtocol(t *testing.T) { + ts := NewTestSuite(t) + + peer1 := ts.NewTestPeer(false) // client + peer2 := ts.NewTestPeer(false) // server (simulates old version) + + ts.makeFriends(peer2, peer1) + + // Remove new protocol handler from peer2 to simulate old peer + peer2.app.P2p.Host().RemoveStreamHandler(protocol.Socks5NoAuthMethod) + + // Allow peer1 to use peer2 as exit node + peer1Config, err := peer2.api.KnownPeerConfig(peer1.PeerID()) + ts.NoError(err) + + err = peer2.api.UpdatePeerSettings(entity.UpdatePeerSettingsRequest{ + PeerID: peer1.PeerID(), + Alias: peer1Config.Alias, + DomainName: peer1Config.DomainName, + IPAddr: peer1Config.IPAddr, + AllowUsingAsExitNode: true, + }) + ts.NoError(err) + + ts.Eventually(func() bool { + peer2Config, err := peer1.api.KnownPeerConfig(peer2.PeerID()) + ts.NoError(err) + return peer2Config.AllowedUsingAsExitNode + }, 15*time.Second, 100*time.Millisecond) + + peer1.app.SOCKS5.SetProxyPeerID(peer2.PeerID()) + peer2.app.SOCKS5.SetProxyingLocalhostEnabled(true) + + // Verify SOCKS5 still works via fallback to old protocol + testSOCKS5Proxy(ts, peer1.app.Conf.SOCKS5.ListenAddress, "") +} + func TestUpdatePeerSettingsIPAddr(t *testing.T) { ts := NewTestSuite(t) diff --git a/cmd/awl-tray/go.mod b/cmd/awl-tray/go.mod index b66b04b7..b7a21fb5 100644 --- a/cmd/awl-tray/go.mod +++ b/cmd/awl-tray/go.mod @@ -4,7 +4,7 @@ go 1.25.0 replace ( github.com/anywherelan/awl => ../../ - github.com/haxii/socks5 => github.com/anywherelan/socks5 v0.0.0-20260112065346-bea9abfe9bdc + github.com/haxii/socks5 => github.com/anywherelan/socks5 v0.0.0-20260404130029-d414a53712fc github.com/ipfs/go-log/v2 => github.com/anywherelan/go-log/v2 v2.0.3-0.20221101180049-46e3967f6fe5 github.com/ncruces/zenity => github.com/pymq/zenity v0.0.0-20230509161854-c117c448544d ) diff --git a/cmd/awl-tray/go.sum b/cmd/awl-tray/go.sum index 6738cff9..0341a29e 100644 --- a/cmd/awl-tray/go.sum +++ b/cmd/awl-tray/go.sum @@ -11,8 +11,8 @@ github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/anywherelan/go-log/v2 v2.0.3-0.20221101180049-46e3967f6fe5 h1:uQsw+HnQo6Ru5eFgUdEQYKMwkiYkoNZDQmY2ob1E58Y= github.com/anywherelan/go-log/v2 v2.0.3-0.20221101180049-46e3967f6fe5/go.mod h1:r8UEDyeHO6bYVcP9R2/HnK2ZSZ5CJp89gubcHLKfRv0= -github.com/anywherelan/socks5 v0.0.0-20260112065346-bea9abfe9bdc h1:BxUih+wVbxEfAAC4+vzXSoDXHNFLgnLR9AmeXRtJbuk= -github.com/anywherelan/socks5 v0.0.0-20260112065346-bea9abfe9bdc/go.mod h1:KpbKhNH2RqojJGPVT9faDXnYWkqzfgpKeehC8xTKaeY= +github.com/anywherelan/socks5 v0.0.0-20260404130029-d414a53712fc h1:icO+nO4NZtJLZS8JnrCpGk8GGaaKNeleuY7WH7qzVik= +github.com/anywherelan/socks5 v0.0.0-20260404130029-d414a53712fc/go.mod h1:KpbKhNH2RqojJGPVT9faDXnYWkqzfgpKeehC8xTKaeY= github.com/anywherelan/ts-dns v0.0.0-20240721135326-6d6b7b811853 h1:RVKWGnppAfxgD2wphkq+OYDOqqI8zgbymBLl2pxYKzY= github.com/anywherelan/ts-dns v0.0.0-20240721135326-6d6b7b811853/go.mod h1:ly7HpPle1G3D0jwrr12uolTGWKN3DPgxzBYNR086BLo= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= diff --git a/go.mod b/go.mod index 088a64a3..7a27f257 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/anywherelan/awl go 1.25.0 replace ( - github.com/haxii/socks5 => github.com/anywherelan/socks5 v0.0.0-20260112065346-bea9abfe9bdc + github.com/haxii/socks5 => github.com/anywherelan/socks5 v0.0.0-20260404130029-d414a53712fc github.com/ipfs/go-log/v2 => github.com/anywherelan/go-log/v2 v2.0.3-0.20221101180049-46e3967f6fe5 ) diff --git a/go.sum b/go.sum index 9bd97677..efadf7c8 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ github.com/GrigoryKrasnochub/updaterini v0.1.0 h1:csl3bqQIShuI8RnXzKprTgEhqi3I59 github.com/GrigoryKrasnochub/updaterini v0.1.0/go.mod h1:w+8blQZCZzGmJGXqmXbeSOsUOF6LqyeqckxSiZah7RI= github.com/anywherelan/go-log/v2 v2.0.3-0.20221101180049-46e3967f6fe5 h1:uQsw+HnQo6Ru5eFgUdEQYKMwkiYkoNZDQmY2ob1E58Y= github.com/anywherelan/go-log/v2 v2.0.3-0.20221101180049-46e3967f6fe5/go.mod h1:r8UEDyeHO6bYVcP9R2/HnK2ZSZ5CJp89gubcHLKfRv0= -github.com/anywherelan/socks5 v0.0.0-20260112065346-bea9abfe9bdc h1:BxUih+wVbxEfAAC4+vzXSoDXHNFLgnLR9AmeXRtJbuk= -github.com/anywherelan/socks5 v0.0.0-20260112065346-bea9abfe9bdc/go.mod h1:KpbKhNH2RqojJGPVT9faDXnYWkqzfgpKeehC8xTKaeY= +github.com/anywherelan/socks5 v0.0.0-20260404130029-d414a53712fc h1:icO+nO4NZtJLZS8JnrCpGk8GGaaKNeleuY7WH7qzVik= +github.com/anywherelan/socks5 v0.0.0-20260404130029-d414a53712fc/go.mod h1:KpbKhNH2RqojJGPVT9faDXnYWkqzfgpKeehC8xTKaeY= github.com/anywherelan/ts-dns v0.0.0-20240721135326-6d6b7b811853 h1:RVKWGnppAfxgD2wphkq+OYDOqqI8zgbymBLl2pxYKzY= github.com/anywherelan/ts-dns v0.0.0-20240721135326-6d6b7b811853/go.mod h1:ly7HpPle1G3D0jwrr12uolTGWKN3DPgxzBYNR086BLo= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= diff --git a/p2p/p2p.go b/p2p/p2p.go index 9b8eca73..e2aee4ec 100644 --- a/p2p/p2p.go +++ b/p2p/p2p.go @@ -248,6 +248,11 @@ func (p *P2p) NewStream(ctx context.Context, id peer.ID, proto protocol.ID) (net return p.host.NewStream(ctx, id, proto) } +func (p *P2p) NewStreamMulti(ctx context.Context, id peer.ID, protos ...protocol.ID) (network.Stream, error) { + ctx = network.WithAllowLimitedConn(ctx, "awl") + return p.host.NewStream(ctx, id, protos...) +} + func (p *P2p) NewStreamWithDedicatedConn(ctx context.Context, id peer.ID, proto protocol.ID) (network.Stream, error) { ctx = network.WithAllowLimitedConn(ctx, "awl") diff --git a/protocol/protocol.go b/protocol/protocol.go index 00944682..73d7da5c 100644 --- a/protocol/protocol.go +++ b/protocol/protocol.go @@ -17,6 +17,7 @@ const ( GetStatusMethod protocol.ID = basePath + "/status/" TunnelPacketMethod protocol.ID = basePath + "/tunnel/" Socks5PacketMethod protocol.ID = basePath + "/socks5/" + Socks5NoAuthMethod protocol.ID = basePath + "/socks5-noauth/" ) type ( diff --git a/service/auth_status.go b/service/auth_status.go index b69063ad..bf9ee65a 100644 --- a/service/auth_status.go +++ b/service/auth_status.go @@ -29,6 +29,7 @@ type P2p interface { ConnectPeer(ctx context.Context, peerID peer.ID) error IsConnected(peerID peer.ID) bool NewStream(ctx context.Context, id peer.ID, proto libp2pProtocol.ID) (network.Stream, error) + NewStreamMulti(ctx context.Context, id peer.ID, protos ...libp2pProtocol.ID) (network.Stream, error) NewStreamWithDedicatedConn(ctx context.Context, id peer.ID, proto libp2pProtocol.ID) (network.Stream, error) SubscribeConnectionEvents(onConnected, onDisconnected func(network.Network, network.Conn)) RecordPeerLatency(id peer.ID, rtt time.Duration) diff --git a/service/socks5.go b/service/socks5.go index 6ecc104d..2a783e2d 100644 --- a/service/socks5.go +++ b/service/socks5.go @@ -125,12 +125,18 @@ func (s *SOCKS5) ProxyStreamHandler(stream network.Stream) { if !enabled { metrics.SOCKS5ErrorsTotal.WithLabelValues("server", "proxying_disabled").Inc() - _ = s.server.SendServerFailureReply(stream) + if stream.Protocol() != protocol.Socks5NoAuthMethod { + _ = s.server.SendServerFailureReply(stream) + } return } // ignore error, we can do nothing about it - _ = s.server.ServeStreamConn(stream) + if stream.Protocol() == protocol.Socks5NoAuthMethod { + _ = s.server.ServeStreamConnNoAuth(stream) + } else { + _ = s.server.ServeStreamConn(stream) + } // stream.Write() + stream.Reset() are not guaranteed to run sequentially // e.g reader on the other side may not read everything we sent because of stream.Reset() @@ -201,7 +207,7 @@ func (s *SOCKS5) proxyConn(ctx context.Context, conn net.Conn) error { return err } - stream, err := s.p2p.NewStream(ctx, remotePeerID, protocol.Socks5PacketMethod) + stream, err := s.p2p.NewStreamMulti(ctx, remotePeerID, protocol.Socks5NoAuthMethod, protocol.Socks5PacketMethod) if err != nil { metrics.SOCKS5ErrorsTotal.WithLabelValues("client", "peer_stream_failed").Inc() return err @@ -210,6 +216,12 @@ func (s *SOCKS5) proxyConn(ctx context.Context, conn net.Conn) error { _ = stream.Reset() }() + if stream.Protocol() == protocol.Socks5NoAuthMethod { + if err := s.client.HandleLocalAuth(conn); err != nil { + return err + } + } + s.handleStream(conn, stream) // stream.Write() + stream.Reset() are not guaranteed to run sequentially diff --git a/socks5/client.go b/socks5/client.go index 47276999..7cc4680d 100644 --- a/socks5/client.go +++ b/socks5/client.go @@ -2,9 +2,11 @@ package socks5 import ( "errors" + "fmt" "net" "time" + socks5Lib "github.com/haxii/socks5" "github.com/ipfs/go-log/v2" ) @@ -46,6 +48,42 @@ func (c *Client) ConnsChan() <-chan net.Conn { return c.connsCh } +// HandleLocalAuth performs the SOCKS5 auth negotiation locally, responding with NoAuth. +// This avoids sending the auth handshake over the network to the remote peer. +func (c *Client) HandleLocalAuth(conn net.Conn) error { + // Read version byte + version := []byte{0} + if _, err := conn.Read(version); err != nil { + return fmt.Errorf("failed to read version: %w", err) + } + if version[0] != 0x05 { + return fmt.Errorf("unsupported SOCKS version: %d", version[0]) + } + + // Read offered auth methods + methods, err := socks5Lib.ReadMethods(conn) + if err != nil { + return fmt.Errorf("failed to read auth methods: %w", err) + } + + // Check NoAuth is offered + hasNoAuth := false + for _, m := range methods { + if m == socks5Lib.AuthMethodNoAuth { + hasNoAuth = true + break + } + } + if !hasNoAuth { + _, _ = conn.Write([]byte{0x05, socks5Lib.AuthMethodNoAcceptable}) + return fmt.Errorf("client does not support NoAuth method") + } + + // Respond: NoAuth selected + _, err = conn.Write([]byte{0x05, socks5Lib.AuthMethodNoAuth}) + return err +} + func (c *Client) serve() error { defer close(c.connsCh) diff --git a/socks5/server.go b/socks5/server.go index 1b4a9d41..912af7e3 100644 --- a/socks5/server.go +++ b/socks5/server.go @@ -55,6 +55,11 @@ func (s *Server) ServeStreamConn(stream network.Stream) error { return s.socks.ServeConn(conn) } +func (s *Server) ServeStreamConnNoAuth(stream network.Stream) error { + conn := StreamConnWrapper{Stream: stream} + return s.socks.ServeConnNoAuth(conn) +} + // ServeConn is only used in tests. TODO: refactor tests func (s *Server) ServeConn(ioConn io.ReadWriteCloser) error { conn := ReadWriterConnWrapper{ReadWriteCloser: ioConn}