Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 1 addition & 1 deletion service/socks5.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
46 changes: 29 additions & 17 deletions socks5/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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}
Expand All @@ -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
}

Expand Down
1 change: 0 additions & 1 deletion socks5/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
128 changes: 104 additions & 24 deletions socks5/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,55 +20,102 @@ 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)
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))

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 {
Expand All @@ -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
}