From b8e2fc076593443245b74078f1a0f95acc31977d Mon Sep 17 00:00:00 2001 From: Maksim Merzhanov Date: Sat, 4 Apr 2026 18:19:18 +0300 Subject: [PATCH] socks5: add optional username + password auth --- application_test.go | 60 +++++++++++++++++++- config/config.go | 3 + service/socks5.go | 2 +- socks5/client.go | 46 +++++++++------ socks5/server.go | 1 - socks5/server_test.go | 128 ++++++++++++++++++++++++++++++++++-------- 6 files changed, 194 insertions(+), 46 deletions(-) diff --git a/application_test.go b/application_test.go index b4882a96..fb2d5ce5 100644 --- a/application_test.go +++ b/application_test.go @@ -425,6 +425,56 @@ func TestSOCKS5ProxyFallbackToOldProtocol(t *testing.T) { testSOCKS5Proxy(ts, peer1.app.Conf.SOCKS5.ListenAddress, "") } +func TestSOCKS5ProxyWithLocalAuth(t *testing.T) { + ts := NewTestSuite(t) + + peer1 := ts.NewTestPeerWithConfig(func(c *config.Config) { + c.SOCKS5 = config.SOCKS5Config{ + ListenerEnabled: true, + ProxyingEnabled: true, + ListenAddress: pickFreeAddr(ts.t), + Username: "testuser", + Password: "testpass", + } + }) + peer2 := ts.NewTestPeer(false) + + ts.makeFriends(peer2, peer1) + + // 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) + + proxyAddr := peer1.app.Conf.SOCKS5.ListenAddress + + // Correct credentials — should succeed + testSOCKS5ProxyWithAuth(ts, proxyAddr, &proxy.Auth{User: "testuser", Password: "testpass"}, 1, "") + + // Wrong password — should fail with auth error + testSOCKS5ProxyWithAuth(ts, proxyAddr, &proxy.Auth{User: "testuser", Password: "wrong"}, 1, "username/password authentication failed") + + // No credentials — should fail (server requires user/pass, client offers no auth only) + testSOCKS5ProxyWithAuth(ts, proxyAddr, nil, 1, "no acceptable authentication methods") +} + func TestUpdatePeerSettingsIPAddr(t *testing.T) { ts := NewTestSuite(t) @@ -782,6 +832,10 @@ func TestDisableVPNInterface(t *testing.T) { } func testSOCKS5Proxy(ts *TestSuite, proxyAddr string, expectSocksErr string) { + testSOCKS5ProxyWithAuth(ts, proxyAddr, nil, 20, expectSocksErr) +} + +func testSOCKS5ProxyWithAuth(ts *TestSuite, proxyAddr string, auth *proxy.Auth, iterations int, expectSocksErr string) { // setup mock server expectedBody := strings.Repeat("test text", 10_000) addr := pickFreeAddr(ts.t) @@ -799,13 +853,13 @@ func testSOCKS5Proxy(ts *TestSuite, proxyAddr string, expectSocksErr string) { }() // client - dialer, err := proxy.SOCKS5("tcp", proxyAddr, nil, nil) + dialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, nil) ts.NoError(err) httpTransport := &http.Transport{DialContext: dialer.(proxy.ContextDialer).DialContext} httpClient := http.Client{Transport: httpTransport} // test - for range 20 { + for range iterations { response, err := httpClient.Get(fmt.Sprintf("http://%s/test", addr)) if expectSocksErr != "" { ts.Error(err) @@ -816,7 +870,7 @@ func testSOCKS5Proxy(ts *TestSuite, proxyAddr string, expectSocksErr string) { ts.ErrorAs(urlErr.Err, &netErr) ts.Equal("socks connect", netErr.Op) - ts.EqualError(netErr.Err, expectSocksErr) + ts.Contains(netErr.Err.Error(), expectSocksErr) continue } diff --git a/config/config.go b/config/config.go index 64aab7e0..31ee0e78 100644 --- a/config/config.go +++ b/config/config.go @@ -84,6 +84,9 @@ type ( ListenAddress string `json:"listenAddress"` // peer that is set as proxy UsingPeerID string `json:"usingPeerID"` + // Optional local auth credentials. If both are set, SOCKS5 clients must authenticate. + Username string `json:"username"` + Password string `json:"password"` } DNSConfig struct { DisableDNS bool `json:"disableDNS"` diff --git a/service/socks5.go b/service/socks5.go index 73733cc3..6aab938d 100644 --- a/service/socks5.go +++ b/service/socks5.go @@ -35,7 +35,7 @@ func NewSOCKS5(p2pService P2p, conf *config.Config) (*SOCKS5, error) { var client *socks5.Client if conf.SOCKS5.ListenerEnabled { var err error - client, err = socks5.NewClient(conf.SOCKS5.ListenAddress) + client, err = socks5.NewClient(conf.SOCKS5.ListenAddress, conf.SOCKS5.Username, conf.SOCKS5.Password) if err != nil { return nil, fmt.Errorf("failed to start socks5 listener: %v", err) } diff --git a/socks5/client.go b/socks5/client.go index 7cc4680d..532c6927 100644 --- a/socks5/client.go +++ b/socks5/client.go @@ -11,12 +11,13 @@ import ( ) type Client struct { - listener net.Listener - connsCh chan net.Conn - logger *log.ZapEventLogger + listener net.Listener + connsCh chan net.Conn + logger *log.ZapEventLogger + authenticator socks5Lib.Authenticator } -func NewClient(listenAddr string) (*Client, error) { +func NewClient(listenAddr string, username, password string) (*Client, error) { // TODO: add support for udp? listener, err := net.Listen("tcp", listenAddr) if err != nil { @@ -25,10 +26,20 @@ func NewClient(listenAddr string) (*Client, error) { logger := log.Logger("socks5/client") + var authenticator socks5Lib.Authenticator + if username != "" && password != "" { + authenticator = socks5Lib.UserPassAuthenticator{ + Credentials: socks5Lib.StaticCredentials{username: password}, + } + } else { + authenticator = socks5Lib.NoAuthAuthenticator{} + } + cli := Client{ - listener: listener, - connsCh: make(chan net.Conn, 1), - logger: logger, + listener: listener, + connsCh: make(chan net.Conn, 1), + logger: logger, + authenticator: authenticator, } go func() { serveErr := cli.serve() @@ -48,8 +59,8 @@ 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. +// HandleLocalAuth performs the SOCKS5 auth negotiation locally. +// It reads the version byte and offered methods, then delegates to the configured authenticator. func (c *Client) HandleLocalAuth(conn net.Conn) error { // Read version byte version := []byte{0} @@ -66,21 +77,22 @@ func (c *Client) HandleLocalAuth(conn net.Conn) error { return fmt.Errorf("failed to read auth methods: %w", err) } - // Check NoAuth is offered - hasNoAuth := false + // Check if the client offers our required method + requiredMethod := c.authenticator.GetCode() + hasMethod := false for _, m := range methods { - if m == socks5Lib.AuthMethodNoAuth { - hasNoAuth = true + if m == requiredMethod { + hasMethod = true break } } - if !hasNoAuth { + if !hasMethod { _, _ = conn.Write([]byte{0x05, socks5Lib.AuthMethodNoAcceptable}) - return fmt.Errorf("client does not support NoAuth method") + return fmt.Errorf("client does not support required auth method %d", requiredMethod) } - // Respond: NoAuth selected - _, err = conn.Write([]byte{0x05, socks5Lib.AuthMethodNoAuth}) + // Delegate to the authenticator (writes method selection + handles subnegotiation) + _, err = c.authenticator.Authenticate(conn, conn) return err } diff --git a/socks5/server.go b/socks5/server.go index 912af7e3..1415f4d7 100644 --- a/socks5/server.go +++ b/socks5/server.go @@ -31,7 +31,6 @@ func NewServer() *Server { Rules: rule, Logger: NewLogger(), Resolver: nil, - // TODO: add optional password authentication method support } server, err := socks5.New(conf) if err != nil { diff --git a/socks5/server_test.go b/socks5/server_test.go index 37d08829..3ff0e6c2 100644 --- a/socks5/server_test.go +++ b/socks5/server_test.go @@ -20,41 +20,50 @@ func TestProxy(t *testing.T) { listenAddr := pickFreeAddr(t) socksServer := NewServer() socksServer.SetRules(NewRulePermitAll()) - socksClient, err := NewClient(listenAddr) + socksClient, err := NewClient(listenAddr, "", "") require.NoError(t, err) wg := &sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() - conn := <-socksClient.ConnsChan() socksServer.ServeConn(conn) }() - upstreamAddr := pickFreeAddr(t) - mux := http.NewServeMux() - mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { - _, _ = fmt.Fprintf(w, "test text") - }) - //nolint - httpServer := &http.Server{Addr: upstreamAddr, Handler: mux} + upstreamAddr := startUpstreamServer(t) + httpClient, transport := newSOCKS5HttpClient(listenAddr, nil) + + response, err := httpClient.Get(fmt.Sprintf("http://%s/test", upstreamAddr)) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + err = response.Body.Close() + require.NoError(t, err) + + require.Equal(t, "test text", string(body)) + + transport.CloseIdleConnections() + wg.Wait() +} + +func TestProxyWithAuth(t *testing.T) { + listenAddr := pickFreeAddr(t) + socksServer := NewServer() + socksServer.SetRules(NewRulePermitAll()) + socksClient, err := NewClient(listenAddr, "testuser", "testpass") + require.NoError(t, err) + + wg := &sync.WaitGroup{} + wg.Add(1) go func() { - _ = httpServer.ListenAndServe() - }() - defer func() { - httpServer.Shutdown(context.Background()) + defer wg.Done() + conn := <-socksClient.ConnsChan() + socksServer.ServeConn(conn) }() - httpTransport := &http.Transport{ - Proxy: func(*http.Request) (*url.URL, error) { - return &url.URL{ - Scheme: "socks5", - Host: listenAddr, - }, nil - }, - } - httpClient := http.Client{Transport: httpTransport} + upstreamAddr := startUpstreamServer(t) + httpClient, transport := newSOCKS5HttpClient(listenAddr, url.UserPassword("testuser", "testpass")) response, err := httpClient.Get(fmt.Sprintf("http://%s/test", upstreamAddr)) require.NoError(t, err) @@ -62,13 +71,51 @@ func TestProxy(t *testing.T) { require.NoError(t, err) err = response.Body.Close() require.NoError(t, err) - require.Equal(t, "test text", string(body)) - httpTransport.CloseIdleConnections() + transport.CloseIdleConnections() wg.Wait() } +func TestProxyWithAuthRejection(t *testing.T) { + tests := []struct { + name string + userinfo *url.Userinfo + }{ + {"WrongPassword", url.UserPassword("testuser", "wrongpass")}, + {"NoCredentials", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + listenAddr := pickFreeAddr(t) + socksClient, err := NewClient(listenAddr, "testuser", "testpass") + require.NoError(t, err) + + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + conn := <-socksClient.ConnsChan() + _ = socksClient.HandleLocalAuth(conn) + conn.Close() + }() + + upstreamAddr := startUpstreamServer(t) + httpClient, transport := newSOCKS5HttpClient(listenAddr, tt.userinfo) + + resp, err := httpClient.Get(fmt.Sprintf("http://%s/test", upstreamAddr)) + if resp != nil { + resp.Body.Close() + } + require.Error(t, err) + + transport.CloseIdleConnections() + wg.Wait() + }) + } +} + func pickFreeAddr(t testing.TB) string { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -78,3 +125,36 @@ func pickFreeAddr(t testing.TB) string { return l.Addr().String() } + +// startUpstreamServer starts an HTTP server that responds with "test text" on /test. +func startUpstreamServer(t testing.TB) string { + addr := pickFreeAddr(t) + mux := http.NewServeMux() + mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "test text") + }) + //nolint + httpServer := &http.Server{Addr: addr, Handler: mux} + go func() { + _ = httpServer.ListenAndServe() + }() + t.Cleanup(func() { + httpServer.Shutdown(context.Background()) + }) + return addr +} + +// newSOCKS5HttpClient creates an HTTP client that routes through a SOCKS5 proxy. +// Pass nil userinfo for no auth credentials. +func newSOCKS5HttpClient(proxyAddr string, userinfo *url.Userinfo) (http.Client, *http.Transport) { + transport := &http.Transport{ + Proxy: func(*http.Request) (*url.URL, error) { + return &url.URL{ + Scheme: "socks5", + User: userinfo, + Host: proxyAddr, + }, nil + }, + } + return http.Client{Transport: transport}, transport +}