From f35dd19ee72e166e1e6f9bdefad11eb63474b180 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Tue, 19 May 2026 12:47:24 +0000 Subject: [PATCH] socks5 implementation --- internal/socks5/constants.go | 86 ++++ internal/socks5/interfaces.go | 6 + internal/socks5/mocks_generate_test.go | 3 + internal/socks5/mocks_test.go | 68 +++ internal/socks5/response.go | 109 ++++ internal/socks5/server.go | 112 ++++ internal/socks5/settings.go | 8 + internal/socks5/socks5.go | 268 ++++++++++ internal/socks5/socks5_test.go | 682 +++++++++++++++++++++++++ internal/socks5/usernamepassword.go | 69 +++ 10 files changed, 1411 insertions(+) create mode 100644 internal/socks5/constants.go create mode 100644 internal/socks5/interfaces.go create mode 100644 internal/socks5/mocks_generate_test.go create mode 100644 internal/socks5/mocks_test.go create mode 100644 internal/socks5/response.go create mode 100644 internal/socks5/server.go create mode 100644 internal/socks5/settings.go create mode 100644 internal/socks5/socks5.go create mode 100644 internal/socks5/socks5_test.go create mode 100644 internal/socks5/usernamepassword.go diff --git a/internal/socks5/constants.go b/internal/socks5/constants.go new file mode 100644 index 00000000..bb185ad6 --- /dev/null +++ b/internal/socks5/constants.go @@ -0,0 +1,86 @@ +package socks5 + +import "fmt" + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-3 +type authMethod byte + +const ( + authNotRequired authMethod = 0 + authGssapi authMethod = 1 + authUsernamePassword authMethod = 2 + authNotAcceptable authMethod = 255 +) + +func (a authMethod) String() string { + switch a { + case authNotRequired: + return "no authentication required" + case authGssapi: + return "GSSAPI" + case authUsernamePassword: + return "username/password" + case authNotAcceptable: + return "no acceptable methods" + default: + return fmt.Sprintf("unknown method (%d)", a) + } +} + +// Subnegotiation version +// See https://datatracker.ietf.org/doc/html/rfc1929#section-2 +const ( + authUsernamePasswordSubNegotiation1 byte = 1 +) + +// SOCKS versions. +const ( + socks5Version byte = 5 +) + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-4 +type cmdType byte + +const ( + connect cmdType = 1 + bind cmdType = 2 + udpAssociate cmdType = 3 +) + +func (c cmdType) String() string { + switch c { + case connect: + return "connect" + case bind: + return "bind" + case udpAssociate: + return "UDP associate" + default: + return fmt.Sprintf("unknown command (%d)", c) + } +} + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-4 and +// https://datatracker.ietf.org/doc/html/rfc1928#section-5 +type addrType byte + +const ( + ipv4 addrType = 1 + domainName addrType = 3 + ipv6 addrType = 4 +) + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-6 +type replyCode byte + +const ( + succeeded replyCode = iota + generalServerFailure + connectionNotAllowedByRuleset + networkUnreachable + hostUnreachable + connectionRefused + ttlExpired + commandNotSupported + addressTypeNotSupported +) diff --git a/internal/socks5/interfaces.go b/internal/socks5/interfaces.go new file mode 100644 index 00000000..a9951848 --- /dev/null +++ b/internal/socks5/interfaces.go @@ -0,0 +1,6 @@ +package socks5 + +type Logger interface { + Infof(format string, a ...interface{}) + Warnf(format string, a ...interface{}) +} diff --git a/internal/socks5/mocks_generate_test.go b/internal/socks5/mocks_generate_test.go new file mode 100644 index 00000000..5797ec72 --- /dev/null +++ b/internal/socks5/mocks_generate_test.go @@ -0,0 +1,3 @@ +package socks5 + +//go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . Logger diff --git a/internal/socks5/mocks_test.go b/internal/socks5/mocks_test.go new file mode 100644 index 00000000..79aaa8e2 --- /dev/null +++ b/internal/socks5/mocks_test.go @@ -0,0 +1,68 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/qdm12/gluetun/internal/socks5 (interfaces: Logger) + +// Package socks5 is a generated GoMock package. +package socks5 + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockLogger is a mock of Logger interface. +type MockLogger struct { + ctrl *gomock.Controller + recorder *MockLoggerMockRecorder +} + +// MockLoggerMockRecorder is the mock recorder for MockLogger. +type MockLoggerMockRecorder struct { + mock *MockLogger +} + +// NewMockLogger creates a new mock instance. +func NewMockLogger(ctrl *gomock.Controller) *MockLogger { + mock := &MockLogger{ctrl: ctrl} + mock.recorder = &MockLoggerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { + return m.recorder +} + +// Infof mocks base method. +func (m *MockLogger) Infof(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Infof", varargs...) +} + +// Infof indicates an expected call of Infof. +func (mr *MockLoggerMockRecorder) Infof(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...) +} + +// Warnf mocks base method. +func (m *MockLogger) Warnf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnf", varargs...) +} + +// Warnf indicates an expected call of Warnf. +func (mr *MockLoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockLogger)(nil).Warnf), varargs...) +} diff --git a/internal/socks5/response.go b/internal/socks5/response.go new file mode 100644 index 00000000..decd0bae --- /dev/null +++ b/internal/socks5/response.go @@ -0,0 +1,109 @@ +package socks5 + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/netip" +) + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-6 +func (c *socksConn) encodeFailedResponse(writer io.Writer, socksVersion byte, reply replyCode) { //nolint:unparam + _, err := writer.Write([]byte{ + socksVersion, + byte(reply), + 0, // RSV byte + // The RFC requires a full response frame even for failures. + // Use IPv4 address type with zeroed address and port. + byte(ipv4), // ATYP + 0, 0, 0, 0, // BND.ADDR (zeroed) + 0, 0, // BND.PORT (zeroed) + }) + if err != nil { + c.logger.Warnf("failed writing failed response: %s", err) + } +} + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-6 +func (c *socksConn) encodeSuccessResponse(writer io.Writer, socksVersion byte, + reply replyCode, bindAddrType addrType, bindAddress string, + bindPort uint16, +) (err error) { + bindData, err := encodeBindData(bindAddrType, bindAddress, bindPort) + if err != nil { + return err + } + + const initialPacketLength = 3 + capacity := initialPacketLength + len(bindData) + packet := make([]byte, initialPacketLength, capacity) + packet[0] = socksVersion + packet[1] = byte(reply) + packet[2] = 0 // RSV byte + packet = append(packet, bindData...) + + _, err = writer.Write(packet) + if err != nil { + c.logger.Warnf("failed writing success response: %s", err) + } + return nil +} + +var ( + ErrIPVersionUnexpected = errors.New("ip version is unexpected") + ErrDomainNameTooLong = errors.New("domain name is too long") +) + +func encodeBindData(addrType addrType, address string, port uint16) ( + data []byte, err error, +) { + capacity := bindDataLength(addrType, address) + data = make([]byte, 0, capacity) + + data = append(data, byte(addrType)) + switch addrType { + case ipv4, ipv6: + ip, err := netip.ParseAddr(address) + if err != nil { + return nil, fmt.Errorf("parsing IP address: %w", err) + } + + switch { + case addrType == ipv4 && !ip.Is4(): + return nil, fmt.Errorf("%w: expected IPv4 for %s", ErrIPVersionUnexpected, ip) + case addrType == ipv6 && !ip.Is6(): + return nil, fmt.Errorf("%w: expected IPv6 for %s", ErrIPVersionUnexpected, ip) + } + data = append(data, ip.AsSlice()...) + case domainName: + const maxDomainNameLength = 255 + if len(address) > maxDomainNameLength { + return nil, fmt.Errorf("%w: %s", ErrDomainNameTooLong, address) + } + data = append(data, byte(len(address))) + data = append(data, []byte(address)...) + default: + panic(fmt.Sprintf("unsupported address type %d", addrType)) + } + data = binary.BigEndian.AppendUint16(data, port) + return data, nil +} + +func bindDataLength(addrType addrType, address string) (maxLength int) { + maxLength++ // address type + switch addrType { + case ipv4: + maxLength += net.IPv4len + case domainName: + maxLength++ // domain name length + maxLength += len([]byte(address)) + case ipv6: + maxLength += net.IPv6len + default: + panic("unsupported address type: " + fmt.Sprint(addrType)) + } + maxLength += 2 // port + return maxLength +} diff --git a/internal/socks5/server.go b/internal/socks5/server.go new file mode 100644 index 00000000..3ea983d0 --- /dev/null +++ b/internal/socks5/server.go @@ -0,0 +1,112 @@ +package socks5 + +import ( + "context" + "fmt" + "net" + "sync" + "sync/atomic" +) + +type Server struct { + username string + password string + address string + logger Logger + + // internal fields + listener net.Listener + listening atomic.Bool + socksConnCtx context.Context //nolint:containedctx + socksConnCancel context.CancelFunc + done <-chan struct{} + stopping atomic.Bool +} + +func New(settings Settings) *Server { + return &Server{ + username: settings.Username, + password: settings.Password, + address: settings.Address, + logger: settings.Logger, + } +} + +func (s *Server) Start(ctx context.Context) (runErr <-chan error, err error) { + s.socksConnCtx, s.socksConnCancel = context.WithCancel(context.Background()) + config := &net.ListenConfig{} + s.listener, err = config.Listen(s.socksConnCtx, "tcp", s.address) + if err != nil { + return nil, fmt.Errorf("listening on %s: %w", s.address, err) + } + s.listening.Store(true) + + ready := make(chan struct{}) + runErrCh := make(chan error) + runErr = runErrCh + done := make(chan struct{}) + s.done = done + go s.runServer(ready, runErrCh, done) + select { + case <-ready: + case <-ctx.Done(): + _ = s.Stop() + return nil, fmt.Errorf("starting server: %w", ctx.Err()) + } + return runErr, nil +} + +func (s *Server) runServer(ready chan<- struct{}, + runErrCh chan<- error, done chan<- struct{}, +) { + close(ready) + defer close(done) + wg := new(sync.WaitGroup) + defer wg.Wait() + + dialer := &net.Dialer{} + for { + connection, err := s.listener.Accept() + if err != nil { + if !s.stopping.Load() { + _ = s.Stop() + runErrCh <- fmt.Errorf("accepting connection: %w", err) + } + return + } + wg.Add(1) + go func(ctx context.Context, connection net.Conn, + dialer *net.Dialer, wg *sync.WaitGroup, + ) { + defer wg.Done() + socksConn := &socksConn{ + dialer: dialer, + username: s.username, + password: s.password, + clientConn: connection, + logger: s.logger, + } + err := socksConn.run(ctx) + if err != nil { + s.logger.Infof("running socks connection: %s", err) + } + }(s.socksConnCtx, connection, dialer, wg) + } +} + +func (s *Server) Stop() (err error) { + s.stopping.Store(true) + s.listening.Store(false) + err = s.listener.Close() + s.socksConnCancel() // stop ongoing socks connections + <-s.done // wait for run goroutine to finish + s.stopping.Store(false) + return err +} + +func (s *Server) listeningAddress() net.Addr { + if s.listening.Load() { + return s.listener.Addr() + } + return nil +} diff --git a/internal/socks5/settings.go b/internal/socks5/settings.go new file mode 100644 index 00000000..77b7c9ed --- /dev/null +++ b/internal/socks5/settings.go @@ -0,0 +1,8 @@ +package socks5 + +type Settings struct { + Username string + Password string + Address string + Logger Logger +} diff --git a/internal/socks5/socks5.go b/internal/socks5/socks5.go new file mode 100644 index 00000000..0db95cfb --- /dev/null +++ b/internal/socks5/socks5.go @@ -0,0 +1,268 @@ +package socks5 + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/netip" + "strconv" + "strings" +) + +var ( + errNoMethodIdentifiers = errors.New("no method identifiers") + errNoValidMethodIdentifier = errors.New("no valid method identifier") +) + +type socksConn struct { + // Injected fields + dialer *net.Dialer + username string + password string + clientConn net.Conn + logger Logger +} + +func (c *socksConn) closeClientConn(ctxErr error) { + err := c.clientConn.Close() + if err != nil && ctxErr == nil { + c.logger.Warnf("closing client connection: %s", err) + } +} + +func (c *socksConn) run(ctx context.Context) error { + authMethod := authNotRequired + if c.username != "" || c.password != "" { + authMethod = authUsernamePassword + } + + err := verifyFirstNegotiation(c.clientConn, authMethod) + if err != nil { + replyMethod := authMethod + if errors.Is(err, errNoMethodIdentifiers) || errors.Is(err, errNoValidMethodIdentifier) { + replyMethod = authNotAcceptable + } + _, writeErr := c.clientConn.Write([]byte{socks5Version, byte(replyMethod)}) + if writeErr != nil { + c.logger.Warnf("failed writing first negotiation reply: %s", writeErr) + } + c.closeClientConn(ctx.Err()) + return fmt.Errorf("verifying first negotiation: %w", err) + } + + _, err = c.clientConn.Write([]byte{socks5Version, byte(authMethod)}) + if err != nil { + c.closeClientConn(ctx.Err()) + return fmt.Errorf("writing first negotiation reply: %w", err) + } + + switch authMethod { + case authNotRequired, authNotAcceptable: + case authGssapi: + panic("not implemented") + case authUsernamePassword: + // See https://datatracker.ietf.org/doc/html/rfc1929#section-2 + err = usernamePasswordSubnegotiate(c.clientConn, c.username, c.password) + if err != nil { + // If the server returns a `failure' (STATUS value other than X'00') status, + // it MUST close the connection. + c.closeClientConn(ctx.Err()) + return fmt.Errorf("subnegotiating username and password: %w", err) + } + default: + panic(fmt.Sprintf("unimplemented auth method %d", authMethod)) + } + + err = c.handleRequest(ctx) + c.closeClientConn(ctx.Err()) + if err != nil { + return fmt.Errorf("handling request: %w", err) + } + return nil +} + +func (c *socksConn) handleRequest(ctx context.Context) error { + const socksVersion = socks5Version + request, err := decodeRequest(c.clientConn, socksVersion) + if err != nil { + c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure) + return err + } + if request.command != connect { + c.encodeFailedResponse(c.clientConn, socksVersion, commandNotSupported) + return fmt.Errorf("command %s is not supported", request.command) + } + + destinationAddress := net.JoinHostPort(request.destination, fmt.Sprint(request.port)) + destinationConn, err := c.dialer.DialContext(ctx, "tcp", destinationAddress) + if err != nil { + c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure) + return err + } + defer destinationConn.Close() + + destinationServerAddress := destinationConn.LocalAddr().String() + destinationAddr, destinationPortStr, err := net.SplitHostPort(destinationServerAddress) + if err != nil { + return fmt.Errorf("splitting destination address: %w", err) + } + destinationPort, err := strconv.ParseUint(destinationPortStr, 10, 16) + if err != nil { + return fmt.Errorf("port is malformed: %q", destinationPortStr) + } + + var bindAddrType addrType + if ip := net.ParseIP(destinationAddr); ip != nil { + if ip.To4() != nil { + bindAddrType = ipv4 + } else { + bindAddrType = ipv6 + } + } else { + bindAddrType = domainName + } + + err = c.encodeSuccessResponse(c.clientConn, socksVersion, succeeded, bindAddrType, + destinationAddr, uint16(destinationPort)) + if err != nil { + c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure) + return fmt.Errorf("writing successful %s response: %w", request.command, err) + } + + errc := make(chan error) + go func() { + _, err := io.Copy(c.clientConn, destinationConn) + if err != nil { + err = fmt.Errorf("from backend to client: %w", err) + } + errc <- err + }() + go func() { + _, err := io.Copy(destinationConn, c.clientConn) + if err != nil { + err = fmt.Errorf("from client to backend: %w", err) + } + errc <- err + }() + select { + case err := <-errc: + return err + case <-ctx.Done(): + _ = destinationConn.Close() + _ = c.clientConn.Close() + return nil + } +} + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-3 +func verifyFirstNegotiation(reader io.Reader, requiredMethod authMethod) error { + const headerLength = 2 // version + nMethods bytes + header := make([]byte, headerLength) + _, err := io.ReadFull(reader, header) + if err != nil { + return fmt.Errorf("reading header: %w", err) + } + + if header[0] != socks5Version { + return fmt.Errorf("version is not supported: %d", header[0]) + } + + nMethods := header[1] + if nMethods == 0 { + return fmt.Errorf("%w", errNoMethodIdentifiers) + } + + methodIdentifiers := make([]byte, nMethods) + _, err = io.ReadFull(reader, methodIdentifiers) + if err != nil { + return fmt.Errorf("reading method identifiers: %w", err) + } + for _, methodIdentifier := range methodIdentifiers { + if methodIdentifier == byte(requiredMethod) { + return nil + } + } + + return makeNoAcceptableMethodError(requiredMethod, methodIdentifiers) +} + +func makeNoAcceptableMethodError(requiredAuthMethod authMethod, methodIdentifiers []byte) error { + methodNames := make([]string, len(methodIdentifiers)) + for i, methodIdentifier := range methodIdentifiers { + methodNames[i] = fmt.Sprintf("%q", authMethod(methodIdentifier)) + } + + return fmt.Errorf("%w: none of %s matches %s", + errNoValidMethodIdentifier, strings.Join(methodNames, ", "), + requiredAuthMethod) +} + +// See https://datatracker.ietf.org/doc/html/rfc1928#section-4 +type request struct { + command cmdType + destination string + port uint16 + addressType addrType +} + +func decodeRequest(reader io.Reader, expectedVersion byte) (req request, err error) { + const headerLength = 4 + header := [headerLength]byte{} + _, err = io.ReadFull(reader, header[:]) + if err != nil { + return request{}, fmt.Errorf("reading header: %w", err) + } + + version := header[0] + if header[0] != expectedVersion { + return request{}, fmt.Errorf("version is not supported: expected %d and got %d", + expectedVersion, version) + } + + req.command = cmdType(header[1]) + // header[2] is RSV byte + req.addressType = addrType(header[3]) + + switch req.addressType { + case ipv4: + var ip [4]byte + _, err = io.ReadFull(reader, ip[:]) + if err != nil { + return request{}, fmt.Errorf("reading IPv4 address: %w", err) + } + req.destination = netip.AddrFrom4(ip).String() + case ipv6: + var ip [16]byte + _, err = io.ReadFull(reader, ip[:]) + if err != nil { + return request{}, fmt.Errorf("reading IPv6 address: %w", err) + } + req.destination = netip.AddrFrom16(ip).String() + case domainName: + var header [1]byte + _, err = io.ReadFull(reader, header[:]) + if err != nil { + return request{}, fmt.Errorf("reading domain name header: %w", err) + } + domainName := make([]byte, header[0]) + _, err = io.ReadFull(reader, domainName) + if err != nil { + return request{}, fmt.Errorf("reading domain name bytes: %w", err) + } + req.destination = string(domainName) + default: + return request{}, fmt.Errorf("address type is not supported: %d", req.addressType) + } + + var portBytes [2]byte + _, err = io.ReadFull(reader, portBytes[:]) + if err != nil { + return request{}, fmt.Errorf("reading port: %w", err) + } + req.port = binary.BigEndian.Uint16(portBytes[:]) + + return req, nil +} diff --git a/internal/socks5/socks5_test.go b/internal/socks5/socks5_test.go new file mode 100644 index 00000000..87f6c2b9 --- /dev/null +++ b/internal/socks5/socks5_test.go @@ -0,0 +1,682 @@ +package socks5 + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net" + "strconv" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type noopLogger struct{} + +func (noopLogger) Infof(string, ...interface{}) {} +func (noopLogger) Warnf(string, ...interface{}) {} + +func TestServerProxy(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + username string + password string + }{ + "no_auth": {}, + "with_auth": { + username: "user", + password: "pass", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Backend TCP server: accepts one connection for the proxy to forward to. + backendListener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + backendConnCh := make(chan net.Conn, 1) + go func() { + conn, err := backendListener.Accept() + if err != nil { + return + } + backendConnCh <- conn + }() + + server := New(Settings{ + Username: testCase.username, + Password: testCase.password, + Address: "127.0.0.1:0", + Logger: noopLogger{}, + }) + _, err = server.Start(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { + _ = server.Stop() + _ = backendListener.Close() + }) + + // Dial through the SOCKS5 proxy to the backend. + // By the time dialSOCKS5 returns, the SOCKS5 server has already + // established the TCP connection to the backend, so backendConnCh + // is guaranteed to be populated. + clientConn := dialSOCKS5(t, server.listeningAddress().String(), + backendListener.Addr().String(), testCase.username, testCase.password) + defer clientConn.Close() + + backendConn := <-backendConnCh + defer backendConn.Close() + + // Verify client → backend direction. + clientMessage := []byte("hello from client") + _, err = clientConn.Write(clientMessage) + require.NoError(t, err) + + received := make([]byte, len(clientMessage)) + _, err = io.ReadFull(backendConn, received) + require.NoError(t, err) + assert.Equal(t, clientMessage, received) + + // Verify backend → client direction. + backendMessage := []byte("hello from backend") + _, err = backendConn.Write(backendMessage) + require.NoError(t, err) + + receivedByClient := make([]byte, len(backendMessage)) + _, err = io.ReadFull(clientConn, receivedByClient) + require.NoError(t, err) + assert.Equal(t, backendMessage, receivedByClient) + }) + } +} + +// dialSOCKS5 performs the full SOCKS5 handshake (with optional username/password +// subnegotiation) and returns a connected net.Conn ready for data exchange. +func dialSOCKS5(t *testing.T, proxyAddr, targetAddr, username, password string) net.Conn { + t.Helper() + + host, portStr, err := net.SplitHostPort(targetAddr) + require.NoError(t, err) + targetPort, err := strconv.Atoi(portStr) + require.NoError(t, err) + + conn, err := (&net.Dialer{}).DialContext(context.Background(), "tcp", proxyAddr) + require.NoError(t, err) + + var method authMethod + if username != "" || password != "" { + method = authUsernamePassword + } else { + method = authNotRequired + } + _, err = conn.Write([]byte{socks5Version, 1, byte(method)}) + require.NoError(t, err) + + var methodResp [2]byte + _, err = io.ReadFull(conn, methodResp[:]) + require.NoError(t, err) + require.Equal(t, socks5Version, methodResp[0]) + require.Equal(t, byte(method), methodResp[1]) + + if method == authUsernamePassword { + packet := []byte{authUsernamePasswordSubNegotiation1, byte(len(username))} + packet = append(packet, []byte(username)...) + packet = append(packet, byte(len(password))) + packet = append(packet, []byte(password)...) + _, err = conn.Write(packet) + require.NoError(t, err) + + var subnegResp [2]byte + _, err = io.ReadFull(conn, subnegResp[:]) + require.NoError(t, err) + require.Equal(t, authUsernamePasswordSubNegotiation1, subnegResp[0]) + require.Equal(t, byte(0), subnegResp[1]) + } + + var connectRequest []byte + if ip := net.ParseIP(host).To4(); ip != nil { + connectRequest = []byte{socks5Version, byte(connect), 0, byte(ipv4)} + connectRequest = append(connectRequest, ip...) + } else { + connectRequest = []byte{socks5Version, byte(connect), 0, byte(domainName), byte(len(host))} + connectRequest = append(connectRequest, []byte(host)...) + } + connectRequest = binary.BigEndian.AppendUint16(connectRequest, uint16(targetPort)) //nolint:gosec + _, err = conn.Write(connectRequest) + require.NoError(t, err) + + var responseHeader [4]byte + _, err = io.ReadFull(conn, responseHeader[:]) + require.NoError(t, err) + require.Equal(t, socks5Version, responseHeader[0]) + require.Equal(t, byte(succeeded), responseHeader[1]) + + // Consume BND.ADDR and BND.PORT (their values are irrelevant to the caller). + switch addrType(responseHeader[3]) { + case ipv4: + var addrPort [net.IPv4len + 2]byte + _, err = io.ReadFull(conn, addrPort[:]) + require.NoError(t, err) + case ipv6: + var addrPort [net.IPv6len + 2]byte + _, err = io.ReadFull(conn, addrPort[:]) + require.NoError(t, err) + case domainName: + var lenBuf [1]byte + _, err = io.ReadFull(conn, lenBuf[:]) + require.NoError(t, err) + addrPort := make([]byte, int(lenBuf[0])+2) + _, err = io.ReadFull(conn, addrPort) + require.NoError(t, err) + } + + return conn +} + +func TestNew(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + settings Settings + expected *Server + }{ + "with_auth": { + settings: Settings{ + Username: "user", + Password: "pass", + Address: "127.0.0.1:1080", + Logger: nil, + }, + expected: &Server{ + username: "user", + password: "pass", + address: "127.0.0.1:1080", + logger: nil, + }, + }, + "without_auth": { + settings: Settings{ + Address: "127.0.0.1:1080", + Logger: nil, + }, + expected: &Server{ + address: "127.0.0.1:1080", + logger: nil, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + result := New(testCase.settings) + assert.Equal(t, testCase.expected.username, result.username) + assert.Equal(t, testCase.expected.password, result.password) + assert.Equal(t, testCase.expected.address, result.address) + assert.Equal(t, testCase.expected.logger, result.logger) + }) + } +} + +func TestStartStop(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + logger := NewMockLogger(ctrl) + logger.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(0) + logger.EXPECT().Warnf(gomock.Any(), gomock.Any()).Times(0) + + server := New(Settings{ + Address: "127.0.0.1:0", + Logger: logger, + }) + + runErr, startErr := server.Start(context.Background()) + require.NoError(t, startErr) + + select { + case err := <-runErr: + t.Fatalf("unexpected error on start: %v", err) + default: + } + + address := server.listeningAddress() + assert.NotNil(t, address) + + err := server.Stop() + require.NoError(t, err) +} + +func TestEncodeBindData(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + addrType addrType + address string + port uint16 + expectedErr string + }{ + "ipv4_valid": { + addrType: ipv4, + address: "127.0.0.1", + port: 8080, + }, + "ipv6_valid": { + addrType: ipv6, + address: "::1", + port: 8080, + }, + "domain_name_valid": { + addrType: domainName, + address: "example.com", + port: 8080, + }, + "ipv4_invalid": { + addrType: ipv4, + address: "invalid", + expectedErr: "parsing IP address", + }, + "ipv4_actual_ipv6": { + addrType: ipv4, + address: "::1", + expectedErr: "ip version is unexpected", + }, + "ipv6_actual_ipv4": { + addrType: ipv6, + address: "127.0.0.1", + expectedErr: "ip version is unexpected", + }, + "domain_too_long": { + addrType: domainName, + address: strings.Repeat("a", 256), + expectedErr: "domain name is too long", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + data, err := encodeBindData(testCase.addrType, testCase.address, testCase.port) + + if testCase.expectedErr != "" { + assert.ErrorContains(t, err, testCase.expectedErr) + assert.Nil(t, data) + } else { + assert.NoError(t, err) + assert.NotNil(t, data) + + assert.Equal(t, byte(testCase.addrType), data[0]) + + portOffset := len(data) - 2 + decodedPort := binary.BigEndian.Uint16(data[portOffset:]) + assert.Equal(t, testCase.port, decodedPort) + } + }) + } +} + +func TestDecodeRequest(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + buildPacket func() []byte + expectedErr string + validate func(*testing.T, request) + }{ + "ipv4_valid": { + buildPacket: func() []byte { + packet := []byte{socks5Version, byte(connect), 0, byte(ipv4)} + packet = append(packet, 127, 0, 0, 1) + packet = append(packet, byte(0x1f), byte(0x90)) + return packet + }, + validate: func(t *testing.T, req request) { + t.Helper() + assert.Equal(t, connect, req.command) + assert.Equal(t, "127.0.0.1", req.destination) + assert.Equal(t, uint16(8080), req.port) + assert.Equal(t, ipv4, req.addressType) + }, + }, + "domain_name_valid": { + buildPacket: func() []byte { + packet := []byte{socks5Version, byte(connect), 0, byte(domainName)} + domain := "example.com" + packet = append(packet, byte(len(domain))) + packet = append(packet, []byte(domain)...) + packet = append(packet, byte(0x00), byte(0x50)) + return packet + }, + validate: func(t *testing.T, req request) { + t.Helper() + assert.Equal(t, "example.com", req.destination) + assert.Equal(t, uint16(80), req.port) + assert.Equal(t, domainName, req.addressType) + }, + }, + "version_mismatch": { + buildPacket: func() []byte { + return []byte{4, byte(connect), 0, byte(ipv4), 127, 0, 0, 1, 0, 0} + }, + expectedErr: "version is not supported", + }, + "truncated_header": { + buildPacket: func() []byte { + return []byte{socks5Version, byte(connect)} + }, + expectedErr: "reading header", + }, + "unsupported_address_type": { + buildPacket: func() []byte { + packet := []byte{socks5Version, byte(connect), 0, byte(255)} + return packet + }, + expectedErr: "address type is not supported", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + packet := testCase.buildPacket() + reader := bytes.NewReader(packet) + + req, err := decodeRequest(reader, socks5Version) + + if testCase.expectedErr != "" { + assert.ErrorContains(t, err, testCase.expectedErr) + } else { + assert.NoError(t, err) + testCase.validate(t, req) + } + }) + } +} + +func TestVerifyFirstNegotiation(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + buildPacket func() []byte + requiredAuth authMethod + expectedErr string + }{ + "version_mismatch": { + buildPacket: func() []byte { + return []byte{4, 2, byte(authNotRequired), byte(authUsernamePassword)} + }, + requiredAuth: authNotRequired, + expectedErr: "version is not supported", + }, + "no_methods": { + buildPacket: func() []byte { + return []byte{socks5Version, 0} + }, + requiredAuth: authNotRequired, + expectedErr: "no method identifiers", + }, + "required_method_not_present": { + buildPacket: func() []byte { + return []byte{socks5Version, 2, byte(authNotRequired), byte(authGssapi)} + }, + requiredAuth: authUsernamePassword, + expectedErr: "no valid method identifier", + }, + "required_method_present": { + buildPacket: func() []byte { + return []byte{socks5Version, 3, byte(authNotRequired), byte(authUsernamePassword), byte(authGssapi)} + }, + requiredAuth: authUsernamePassword, + }, + "no_auth_required": { + buildPacket: func() []byte { + return []byte{socks5Version, 1, byte(authNotRequired)} + }, + requiredAuth: authNotRequired, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + packet := testCase.buildPacket() + reader := bytes.NewReader(packet) + + err := verifyFirstNegotiation(reader, testCase.requiredAuth) + + if testCase.expectedErr != "" { + assert.ErrorContains(t, err, testCase.expectedErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUsernamePasswordSubnegotiate(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + buildPacket func() []byte + username string + password string + expectedErr string + }{ + "valid_credentials": { + buildPacket: func() []byte { + packet := []byte{authUsernamePasswordSubNegotiation1, 4} + packet = append(packet, []byte("user")...) + packet = append(packet, 4) + packet = append(packet, []byte("pass")...) + return packet + }, + username: "user", + password: "pass", + }, + "version_mismatch": { + buildPacket: func() []byte { + return []byte{2, 4, 'u', 's', 'e', 'r'} + }, + username: "user", + password: "pass", + expectedErr: "subnegotiation version not supported", + }, + "wrong_username": { + buildPacket: func() []byte { + packet := []byte{authUsernamePasswordSubNegotiation1, 4} + packet = append(packet, []byte("fake")...) + packet = append(packet, 4) + packet = append(packet, []byte("pass")...) + return packet + }, + username: "user", + password: "pass", + expectedErr: "username not valid", + }, + "wrong_password": { + buildPacket: func() []byte { + packet := []byte{authUsernamePasswordSubNegotiation1, 4} + packet = append(packet, []byte("user")...) + packet = append(packet, 4) + packet = append(packet, []byte("fake")...) + return packet + }, + username: "user", + password: "pass", + expectedErr: "password not valid", + }, + "truncated_header": { + buildPacket: func() []byte { + return []byte{authUsernamePasswordSubNegotiation1} + }, + username: "user", + password: "pass", + expectedErr: "reading header", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + packet := testCase.buildPacket() + buffer := &bytes.Buffer{} + buffer.Write(packet) + + readWriter := struct { + io.Reader + io.Writer + }{ + Reader: buffer, + Writer: io.Discard, + } + + err := usernamePasswordSubnegotiate(readWriter, testCase.username, testCase.password) + + if testCase.expectedErr != "" { + assert.ErrorContains(t, err, testCase.expectedErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBindDataLength(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + addrType addrType + address string + expectedBytes int + }{ + "ipv4": { + addrType: ipv4, + address: "127.0.0.1", + expectedBytes: 1 + 4 + 2, + }, + "ipv6": { + addrType: ipv6, + address: "::1", + expectedBytes: 1 + 16 + 2, + }, + "domain_short": { + addrType: domainName, + address: "example.com", + expectedBytes: 1 + 1 + len("example.com") + 2, + }, + "domain_long": { + addrType: domainName, + address: strings.Repeat("a", 100), + expectedBytes: 1 + 1 + 100 + 2, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + length := bindDataLength(testCase.addrType, testCase.address) + assert.Equal(t, testCase.expectedBytes, length) + }) + } +} + +func TestAuthMethodString(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + method authMethod + expectedName string + }{ + "no_auth": { + method: authNotRequired, + expectedName: "no authentication required", + }, + "gssapi": { + method: authGssapi, + expectedName: "GSSAPI", + }, + "username_password": { + method: authUsernamePassword, + expectedName: "username/password", + }, + "not_acceptable": { + method: authNotAcceptable, + expectedName: "no acceptable methods", + }, + "unknown": { + method: authMethod(99), + expectedName: "unknown method (99)", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + result := testCase.method.String() + assert.Equal(t, testCase.expectedName, result) + }) + } +} + +func TestCmdTypeString(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + cmd cmdType + expectedName string + }{ + "connect": { + cmd: connect, + expectedName: "connect", + }, + "bind": { + cmd: bind, + expectedName: "bind", + }, + "udp_associate": { + cmd: udpAssociate, + expectedName: "UDP associate", + }, + "unknown": { + cmd: cmdType(99), + expectedName: "unknown command (99)", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + result := testCase.cmd.String() + assert.Equal(t, testCase.expectedName, result) + }) + } +} + +func TestParseAddress(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + address string + expectedIP string + expectedErr string + }{ + "ipv4": { + address: "127.0.0.1", + expectedIP: "127.0.0.1", + }, + "ipv6": { + address: "::1", + expectedIP: "::1", + }, + "domain": { + address: "example.com", + expectedErr: "parsing IP address", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + if testCase.expectedErr == "" { + assert.True(t, strings.Contains(testCase.address, testCase.expectedIP) || testCase.address == testCase.expectedIP) + } + }) + } +} diff --git a/internal/socks5/usernamepassword.go b/internal/socks5/usernamepassword.go new file mode 100644 index 00000000..3cc6fcc5 --- /dev/null +++ b/internal/socks5/usernamepassword.go @@ -0,0 +1,69 @@ +package socks5 + +import ( + "errors" + "fmt" + "io" +) + +var ( + ErrSubnegotiationVersionNotSupported = errors.New("subnegotiation version not supported") + ErrUsernameNotValid = errors.New("username not valid") + ErrPasswordNotValid = errors.New("password not valid") +) + +// See https://datatracker.ietf.org/doc/html/rfc1929#section-2 +func usernamePasswordSubnegotiate(conn io.ReadWriter, username, password string) (err error) { + status := byte(1) + const defaultVersion = byte(1) + + const headerLength = 2 + var header [headerLength]byte + _, err = io.ReadFull(conn, header[:]) + if err != nil { + _, _ = conn.Write([]byte{defaultVersion, status}) + return fmt.Errorf("reading header: %w", err) + } + + if header[0] != authUsernamePasswordSubNegotiation1 { + _, _ = conn.Write([]byte{defaultVersion, status}) + return fmt.Errorf("%w: %d", ErrSubnegotiationVersionNotSupported, header[0]) + } + version := header[0] + + usernameBytes := make([]byte, header[1]) + _, err = io.ReadFull(conn, usernameBytes) + if err != nil { + _, _ = conn.Write([]byte{version, status}) + return fmt.Errorf("reading username bytes: %w", err) + } else if username != string(usernameBytes) { + _, _ = conn.Write([]byte{version, status}) + return fmt.Errorf("%w: %s", ErrUsernameNotValid, string(usernameBytes)) + } + + const passwordHeaderLength = 1 + passwordHeader := make([]byte, passwordHeaderLength) + _, err = io.ReadFull(conn, passwordHeader) + if err != nil { + _, _ = conn.Write([]byte{version, status}) + return fmt.Errorf("reading password length: %w", err) + } + + passwordBytes := make([]byte, passwordHeader[0]) + _, err = io.ReadFull(conn, passwordBytes) + if err != nil { + _, _ = conn.Write([]byte{version, status}) + return fmt.Errorf("reading password bytes: %w", err) + } else if password != string(passwordBytes) { + _, _ = conn.Write([]byte{version, status}) + return fmt.Errorf("%w: %s", ErrPasswordNotValid, string(passwordBytes)) + } + + status = 0 + _, err = conn.Write([]byte{version, status}) + if err != nil { + return fmt.Errorf("writing success status: %w", err) + } + + return nil +}