From e146d99360e2ebbabcb66f410859f88ff86765a2 Mon Sep 17 00:00:00 2001 From: Simone Basso Date: Sat, 16 Sep 2023 18:03:52 +0200 Subject: [PATCH] feat: support for testing socks5 clients (#1280) This diff imports a fork of github.com/armon/go-socks5 that has been adapted to use netem and simplified to suit our testing needs. With this functionality in tree, we can start thinking about writing better netem based tests for the ooniprobe bootstrap. The overall idea is to be well positioned to improve the bootstrap and introduce dynamic support for beacons. While there, discover that we were using `log.SetLevel(log.DebugLevel)` in a racy way in tests, so remove all the instances of this call from tests given that we can always add it when needed and we don't want to keep commented out code as a general policy anyway. Reference issue: https://github.com/ooni/probe/issues/2531 --- internal/legacy/netx/integration_test.go | 1 - internal/netemx/oohelperd_test.go | 2 - internal/testingproxy/hosthttp.go | 2 - internal/testingproxy/hosthttps.go | 2 - internal/testingproxy/netemhttp.go | 2 - internal/testingproxy/netemhttps.go | 2 - internal/testingproxy/qa_test.go | 16 +- internal/testingproxy/sockshost.go | 72 +++ internal/testingproxy/socksnetem.go | 134 ++++++ internal/testingproxy/testcase.go | 23 +- internal/testingsocks5/LICENSE | 20 + internal/testingsocks5/auth.go | 83 ++++ internal/testingsocks5/client.go | 45 ++ internal/testingsocks5/client_test.go | 76 +++ internal/testingsocks5/doc.go | 2 + internal/testingsocks5/internal_test.go | 560 +++++++++++++++++++++++ internal/testingsocks5/qa_test.go | 94 ++++ internal/testingsocks5/request.go | 224 +++++++++ internal/testingsocks5/request_test.go | 134 ++++++ internal/testingsocks5/server.go | 122 +++++ internal/testingx/httpproxy_test.go | 2 +- internal/testingx/tlssniproxy_test.go | 2 - internal/testingx/tlsx_test.go | 2 - script/nocopyreadall.bash | 6 + 24 files changed, 1606 insertions(+), 22 deletions(-) create mode 100644 internal/testingproxy/sockshost.go create mode 100644 internal/testingproxy/socksnetem.go create mode 100644 internal/testingsocks5/LICENSE create mode 100644 internal/testingsocks5/auth.go create mode 100644 internal/testingsocks5/client.go create mode 100644 internal/testingsocks5/client_test.go create mode 100644 internal/testingsocks5/doc.go create mode 100644 internal/testingsocks5/internal_test.go create mode 100644 internal/testingsocks5/qa_test.go create mode 100644 internal/testingsocks5/request.go create mode 100644 internal/testingsocks5/request_test.go create mode 100644 internal/testingsocks5/server.go diff --git a/internal/legacy/netx/integration_test.go b/internal/legacy/netx/integration_test.go index 2310312ccf..8f4757a8fb 100644 --- a/internal/legacy/netx/integration_test.go +++ b/internal/legacy/netx/integration_test.go @@ -15,7 +15,6 @@ func TestHTTPTransportWorkingAsIntended(t *testing.T) { if testing.Short() { t.Skip("skip test in short mode") } - log.SetLevel(log.DebugLevel) counter := bytecounter.New() config := Config{ BogonIsError: true, diff --git a/internal/netemx/oohelperd_test.go b/internal/netemx/oohelperd_test.go index eff7916ec8..6455d69166 100644 --- a/internal/netemx/oohelperd_test.go +++ b/internal/netemx/oohelperd_test.go @@ -31,8 +31,6 @@ func TestOOHelperDHandler(t *testing.T) { } thReqRaw := runtimex.Try1(json.Marshal(thReq)) - //log.SetLevel(log.DebugLevel) - // TODO(https://github.com/ooni/probe/issues/2534): NewHTTPClientStdlib has QUIRKS but they're not needed here httpClient := netxlite.NewHTTPClientStdlib(log.Log) diff --git a/internal/testingproxy/hosthttp.go b/internal/testingproxy/hosthttp.go index cb3b1773a3..bc4de6ec7f 100644 --- a/internal/testingproxy/hosthttp.go +++ b/internal/testingproxy/hosthttp.go @@ -48,8 +48,6 @@ func (tc *hostNetworkTestCaseWithHTTP) Run(t *testing.T) { proxyServer := testingx.MustNewHTTPServer(testingx.NewHTTPProxyHandler(log.Log, netx)) defer proxyServer.Close() - //log.SetLevel(log.DebugLevel) - // create an HTTP client configured to use the given proxy // // note how we use a dialer that asserts that we're using the proxy IP address diff --git a/internal/testingproxy/hosthttps.go b/internal/testingproxy/hosthttps.go index 67be7cb417..751f346acf 100644 --- a/internal/testingproxy/hosthttps.go +++ b/internal/testingproxy/hosthttps.go @@ -49,8 +49,6 @@ func (tc *hostNetworkTestCaseWithHTTPWithTLS) Run(t *testing.T) { proxyServer := testingx.MustNewHTTPServerTLS(testingx.NewHTTPProxyHandler(log.Log, netx)) defer proxyServer.Close() - //log.SetLevel(log.DebugLevel) - // extend the default cert pool with the proxy's own CA pool := netxlite.NewMozillaCertPool() pool.AddCert(proxyServer.CACert) diff --git a/internal/testingproxy/netemhttp.go b/internal/testingproxy/netemhttp.go index c478ad4178..60f43e5530 100644 --- a/internal/testingproxy/netemhttp.go +++ b/internal/testingproxy/netemhttp.go @@ -104,8 +104,6 @@ func (tc *netemTestCaseWithHTTP) Run(t *testing.T) { // create the netx instance for the client netx := &netxlite.Netx{Underlying: &netxlite.NetemUnderlyingNetworkAdapter{UNet: clientStack}} - //log.SetLevel(log.DebugLevel) - // create an HTTP client configured to use the given proxy // // note how we use a dialer that asserts that we're using the proxy IP address diff --git a/internal/testingproxy/netemhttps.go b/internal/testingproxy/netemhttps.go index c44e836c00..e3e0541dcc 100644 --- a/internal/testingproxy/netemhttps.go +++ b/internal/testingproxy/netemhttps.go @@ -105,8 +105,6 @@ func (tc *netemTestCaseWithHTTPWithTLS) Run(t *testing.T) { // create the netx instance for the client netx := &netxlite.Netx{Underlying: &netxlite.NetemUnderlyingNetworkAdapter{UNet: clientStack}} - //log.SetLevel(log.DebugLevel) - // create an HTTP client configured to use the given proxy // // note how we use a dialer that asserts that we're using the proxy IP address diff --git a/internal/testingproxy/qa_test.go b/internal/testingproxy/qa_test.go index 93095ccd61..00a76a3d25 100644 --- a/internal/testingproxy/qa_test.go +++ b/internal/testingproxy/qa_test.go @@ -6,8 +6,20 @@ import ( "github.com/ooni/probe-cli/v3/internal/testingproxy" ) -func TestWorkingAsIntended(t *testing.T) { - for _, testCase := range testingproxy.AllTestCases { +func TestHTTPProxy(t *testing.T) { + for _, testCase := range testingproxy.HTTPTestCases { + t.Run(testCase.Name(), func(t *testing.T) { + short := testCase.Short() + if !short && testing.Short() { + t.Skip("skip test in short mode") + } + testCase.Run(t) + }) + } +} + +func TestSOCKSProxy(t *testing.T) { + for _, testCase := range testingproxy.SOCKSTestCases { t.Run(testCase.Name(), func(t *testing.T) { short := testCase.Short() if !short && testing.Short() { diff --git a/internal/testingproxy/sockshost.go b/internal/testingproxy/sockshost.go new file mode 100644 index 0000000000..6249b0397c --- /dev/null +++ b/internal/testingproxy/sockshost.go @@ -0,0 +1,72 @@ +package testingproxy + +import ( + "fmt" + "net" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/testingsocks5" +) + +// WithHostNetworkSOCKSProxyAndURL returns a [TestCase] where: +// +// - we fetch a URL; +// +// - using the host network; +// +// - and a SOCKS5 proxy. +// +// Because this [TestCase] uses the host network, it does not run in -short mode. +func WithHostNetworkSOCKSProxyAndURL(URL string) TestCase { + return &hostNetworkTestCaseWithSOCKS{ + TargetURL: URL, + } +} + +type hostNetworkTestCaseWithSOCKS struct { + TargetURL string +} + +var _ TestCase = &hostNetworkTestCaseWithSOCKS{} + +// Name implements TestCase. +func (tc *hostNetworkTestCaseWithSOCKS) Name() string { + return fmt.Sprintf("fetching %s using the host network and a SOCKS5 proxy", tc.TargetURL) +} + +// Run implements TestCase. +func (tc *hostNetworkTestCaseWithSOCKS) Run(t *testing.T) { + // create an instance of Netx where the underlying network is nil, + // which means we're using the host's network + netx := &netxlite.Netx{Underlying: nil} + + // create the proxy server using the host network + endpoint := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0} + proxyServer := testingsocks5.MustNewServer(log.Log, netx, endpoint) + defer proxyServer.Close() + + // create an HTTP client configured to use the given proxy + // + // note how we use a dialer that asserts that we're using the proxy IP address + // rather than the host address, so we're sure that we're using the proxy + dialer := &dialerWithAssertions{ + ExpectAddress: "127.0.0.1", + Dialer: netx.NewDialerWithResolver(log.Log, netx.NewStdlibResolver(log.Log)), + } + tlsDialer := netxlite.NewTLSDialer(dialer, netxlite.NewTLSHandshakerStdlib(log.Log)) + txp := netxlite.NewHTTPTransportWithOptions(log.Log, dialer, tlsDialer, + netxlite.HTTPTransportOptionProxyURL(proxyServer.URL())) + client := &http.Client{Transport: txp} + defer client.CloseIdleConnections() + + // get the homepage and assert we're getting a succesful response + httpCheckResponse(t, client, tc.TargetURL) +} + +// Short implements TestCase. +func (tc *hostNetworkTestCaseWithSOCKS) Short() bool { + return false +} diff --git a/internal/testingproxy/socksnetem.go b/internal/testingproxy/socksnetem.go new file mode 100644 index 0000000000..e942b9dd77 --- /dev/null +++ b/internal/testingproxy/socksnetem.go @@ -0,0 +1,134 @@ +package testingproxy + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/netem" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/testingsocks5" + "github.com/ooni/probe-cli/v3/internal/testingx" +) + +// WithNetemSOCKSProxyAndURL returns a [TestCase] where: +// +// - we fetch a URL; +// +// - using the github.com/ooni.netem; +// +// - and a SOCKS5 proxy. +// +// Because this [TestCase] uses netem, it also runs in -short mode. +func WithNetemSOCKSProxyAndURL(URL string) TestCase { + return &netemTestCaseWithSOCKS{ + TargetURL: URL, + } +} + +type netemTestCaseWithSOCKS struct { + TargetURL string +} + +var _ TestCase = &netemTestCaseWithSOCKS{} + +// Name implements TestCase. +func (tc *netemTestCaseWithSOCKS) Name() string { + return fmt.Sprintf("fetching %s using netem and a SOCKS5 proxy", tc.TargetURL) +} + +// Run implements TestCase. +func (tc *netemTestCaseWithSOCKS) Run(t *testing.T) { + topology := runtimex.Try1(netem.NewStarTopology(log.Log)) + defer topology.Close() + + const ( + wwwIPAddr = "93.184.216.34" + proxyIPAddr = "10.0.0.1" + clientIPAddr = "10.0.0.2" + ) + + // create: + // + // - a www stack modeling www.example.com + // + // - a proxy stack + // + // - a client stack + // + // Note that www.example.com's IP address is also the resolver used by everyone + wwwStack := runtimex.Try1(topology.AddHost(wwwIPAddr, wwwIPAddr, &netem.LinkConfig{})) + proxyStack := runtimex.Try1(topology.AddHost(proxyIPAddr, wwwIPAddr, &netem.LinkConfig{})) + clientStack := runtimex.Try1(topology.AddHost(clientIPAddr, wwwIPAddr, &netem.LinkConfig{})) + + // configure the wwwStack as the DNS resolver with proper configuration + dnsConfig := netem.NewDNSConfig() + dnsConfig.AddRecord("www.example.com.", "", wwwIPAddr) + dnsServer := runtimex.Try1(netem.NewDNSServer(log.Log, wwwStack, wwwIPAddr, dnsConfig)) + defer dnsServer.Close() + + // configure the wwwStack to respond to HTTP requests on port 80 + wwwServer80 := testingx.MustNewHTTPServerEx( + &net.TCPAddr{IP: net.ParseIP(wwwIPAddr), Port: 80}, + wwwStack, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Bonsoir, Elliot!\r\n")) + }), + ) + defer wwwServer80.Close() + + // configure the wwwStack to respond to HTTPS requests on port 443 + wwwServer443 := testingx.MustNewHTTPServerTLSEx( + &net.TCPAddr{IP: net.ParseIP(wwwIPAddr), Port: 443}, + wwwStack, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Bonsoir, Elliot!\r\n")) + }), + wwwStack, + ) + defer wwwServer443.Close() + + // configure the proxyStack to implement the SOCKS proxy on port 9050 + proxyServer := testingsocks5.MustNewServer( + log.Log, + &netxlite.Netx{ + Underlying: &netxlite.NetemUnderlyingNetworkAdapter{UNet: proxyStack}}, + &net.TCPAddr{IP: net.ParseIP(proxyIPAddr), Port: 9050}, + ) + defer proxyServer.Close() + + // create the netx instance for the client + netx := &netxlite.Netx{Underlying: &netxlite.NetemUnderlyingNetworkAdapter{UNet: clientStack}} + + // create an HTTP client configured to use the given proxy + // + // note how we use a dialer that asserts that we're using the proxy IP address + // rather than the host address, so we're sure that we're using the proxy + dialer := &dialerWithAssertions{ + ExpectAddress: proxyIPAddr, + Dialer: netx.NewDialerWithResolver(log.Log, netx.NewStdlibResolver(log.Log)), + } + tlsDialer := netxlite.NewTLSDialer(dialer, netx.NewTLSHandshakerStdlib(log.Log)) + txp := netxlite.NewHTTPTransportWithOptions(log.Log, dialer, tlsDialer, + netxlite.HTTPTransportOptionProxyURL(proxyServer.URL()), + + // TODO(https://github.com/ooni/probe/issues/2536) + netxlite.HTTPTransportOptionTLSClientConfig(&tls.Config{ + RootCAs: runtimex.Try1(clientStack.DefaultCertPool()), + }), + ) + client := &http.Client{Transport: txp} + defer client.CloseIdleConnections() + + // get the homepage and assert we're getting a succesful response + httpCheckResponse(t, client, tc.TargetURL) +} + +// Short implements TestCase. +func (tc *netemTestCaseWithSOCKS) Short() bool { + return true +} diff --git a/internal/testingproxy/testcase.go b/internal/testingproxy/testcase.go index f2fab7e6b9..44f5f61304 100644 --- a/internal/testingproxy/testcase.go +++ b/internal/testingproxy/testcase.go @@ -14,13 +14,28 @@ type TestCase interface { Short() bool } -// AllTestCases contains all the test cases. -var AllTestCases = []TestCase{ - // host network and HTTP proxy +// SOCKSTestCases contains the SOCKS test cases. +var SOCKSTestCases = []TestCase{ + // with host network + WithHostNetworkSOCKSProxyAndURL("http://www.example.com/"), + WithHostNetworkSOCKSProxyAndURL("https://www.example.com/"), + + // with netem + WithNetemSOCKSProxyAndURL("http://www.example.com/"), + WithNetemSOCKSProxyAndURL("https://www.example.com/"), + + // with netem and IPv4 addresses so we test another SOCKS5 dialing mode + WithNetemSOCKSProxyAndURL("http://93.184.216.34/"), + WithNetemSOCKSProxyAndURL("https://93.184.216.34/"), +} + +// HTTPTestCases contains the HTTP test cases. +var HTTPTestCases = []TestCase{ + // with host network and HTTP proxy WithHostNetworkHTTPProxyAndURL("http://www.example.com/"), WithHostNetworkHTTPProxyAndURL("https://www.example.com/"), - // host network and HTTPS proxy + // with host network and HTTPS proxy WithHostNetworkHTTPWithTLSProxyAndURL("http://www.example.com/"), WithHostNetworkHTTPWithTLSProxyAndURL("https://www.example.com/"), diff --git a/internal/testingsocks5/LICENSE b/internal/testingsocks5/LICENSE new file mode 100644 index 0000000000..a5df10e675 --- /dev/null +++ b/internal/testingsocks5/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Armon Dadgar + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/internal/testingsocks5/auth.go b/internal/testingsocks5/auth.go new file mode 100644 index 0000000000..63f8200bbf --- /dev/null +++ b/internal/testingsocks5/auth.go @@ -0,0 +1,83 @@ +package testingsocks5 + +import ( + "fmt" + "io" + "net" +) + +// Codes representing authentication mechanisms +const ( + noAuth = uint8(0) + noAcceptable = uint8(255) + userPassAuth = uint8(2) + userAuthVersion = uint8(1) + authSuccess = uint8(0) + authFailure = uint8(1) +) + +var ( + errNoSupportedAuth = fmt.Errorf("no supported authentication mechanism") +) + +// A Request encapsulates authentication state provided +// during negotiation +type authContext struct { + // Provided auth method + Method uint8 + + // Payload provided during negotiation. + // Keys depend on the used auth method. + // For UserPassauth contains Username + Payload map[string]string +} + +// noAuthAuthenticator is used to handle the "No Authentication" mode +type noAuthAuthenticator struct{} + +func (a noAuthAuthenticator) Authenticate(cconn net.Conn) (*authContext, error) { + _, err := cconn.Write([]byte{socks5Version, noAuth}) + return &authContext{noAuth, nil}, err +} + +// authenticate is used to handle connection authentication +func (s *Server) authenticate(cconn net.Conn) (*authContext, error) { + // Get the methods + methods, err := readAuthMethods(cconn) + if err != nil { + return nil, fmt.Errorf("failed to get auth methods: %w", err) + } + + // Select a usable method + for _, method := range methods { + switch method { + case noAuth: + return (noAuthAuthenticator{}).Authenticate(cconn) + + default: + // nothing + } + } + + // No usable method found + return nil, noAcceptableAuth(cconn) +} + +// noAcceptableAuth is used to handle when we have no eligible authentication mechanism +func noAcceptableAuth(conn net.Conn) error { + conn.Write([]byte{socks5Version, noAcceptable}) + return errNoSupportedAuth +} + +// readAuthMethods is used to read the number of methods and proceeding auth methods +func readAuthMethods(cconn net.Conn) ([]byte, error) { + header := []byte{0} + if _, err := io.ReadFull(cconn, header); err != nil { + return nil, err + } + + numMethods := uint8(header[0]) + methods := make([]byte, numMethods) + _, err := io.ReadFull(cconn, methods) + return methods, err +} diff --git a/internal/testingsocks5/client.go b/internal/testingsocks5/client.go new file mode 100644 index 0000000000..46b211d88d --- /dev/null +++ b/internal/testingsocks5/client.go @@ -0,0 +1,45 @@ +package testingsocks5 + +import ( + "errors" + "fmt" + "io" + "net" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/model" +) + +// client is a minimal client used for testing the server +type client struct { + exchanges []exchange +} + +// exchange is a byte exchange between the client and the server: the client +// sends the bytes to send and then reads and checks whether it has received +// the expected response from the server. +type exchange struct { + send []byte + expect []byte +} + +var errUnexpectedResponse = errors.New("unexpected response") + +func (ic *client) run(logger model.Logger, conn net.Conn) error { + for _, exchange := range ic.exchanges { + logger.Infof("SOCKS5_CLIENT: sending: %v", exchange.send) + if _, err := conn.Write(exchange.send); err != nil { + return err + } + logger.Infof("SOCKS5_CLIENT: expecting: %v", exchange.expect) + buffer := make([]byte, len(exchange.expect)) + if _, err := io.ReadFull(conn, buffer); err != nil { + return err + } + logger.Infof("SOCKS5_CLIENT: got: %v", buffer) + if diff := cmp.Diff(exchange.expect, buffer); diff != "" { + return fmt.Errorf("%w: %s", errUnexpectedResponse, diff) + } + } + return nil +} diff --git a/internal/testingsocks5/client_test.go b/internal/testingsocks5/client_test.go new file mode 100644 index 0000000000..198d2aaf15 --- /dev/null +++ b/internal/testingsocks5/client_test.go @@ -0,0 +1,76 @@ +package testingsocks5 + +import ( + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/mocks" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/runtimex" +) + +func TestClientErrorPaths(t *testing.T) { + t.Run("conn.Write fails", func(t *testing.T) { + expected := errors.New("mocked error") + conn := &mocks.Conn{ + MockWrite: func(b []byte) (int, error) { + return 0, expected + }, + } + c := &client{ + exchanges: []exchange{{ + send: []byte{1, 2, 3, 4}, + expect: []byte{}, + }}, + } + err := c.run(model.DiscardLogger, conn) + if !errors.Is(err, expected) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("conn.Read fails", func(t *testing.T) { + expected := errors.New("mocked error") + conn := &mocks.Conn{ + MockWrite: func(b []byte) (int, error) { + return len(b), nil + }, + MockRead: func(b []byte) (int, error) { + return 0, expected + }, + } + c := &client{ + exchanges: []exchange{{ + send: []byte{1, 2, 3, 4}, + expect: []byte{4, 3, 2, 1}, + }}, + } + err := c.run(model.DiscardLogger, conn) + if !errors.Is(err, expected) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("when we get an unexpected response", func(t *testing.T) { + conn := &mocks.Conn{ + MockWrite: func(b []byte) (int, error) { + return len(b), nil + }, + MockRead: func(b []byte) (int, error) { + runtimex.Assert(len(b) == 4, "unexpected buffer length") + copy(b, []byte{1, 2, 3, 4}) + return len(b), nil + }, + } + c := &client{ + exchanges: []exchange{{ + send: []byte{1, 2, 3, 4}, + expect: []byte{4, 3, 2, 1}, + }}, + } + err := c.run(model.DiscardLogger, conn) + if !errors.Is(err, errUnexpectedResponse) { + t.Fatal("unexpected error", err) + } + }) +} diff --git a/internal/testingsocks5/doc.go b/internal/testingsocks5/doc.go new file mode 100644 index 0000000000..eaec83198a --- /dev/null +++ b/internal/testingsocks5/doc.go @@ -0,0 +1,2 @@ +// Package testingsock5 is a netem-aware fork of https://github.com/armon/go-socks5. +package testingsocks5 diff --git a/internal/testingsocks5/internal_test.go b/internal/testingsocks5/internal_test.go new file mode 100644 index 0000000000..d9d24cc987 --- /dev/null +++ b/internal/testingsocks5/internal_test.go @@ -0,0 +1,560 @@ +package testingsocks5 + +import ( + "errors" + "io" + "net" + "sync" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/mocks" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/runtimex" +) + +func TestReadVersionError(t *testing.T) { + server := &Server{ + closeOnce: sync.Once{}, + listener: &mocks.Listener{ + MockClose: func() error { + return nil + }, + }, + logger: log.Log, + netx: &netxlite.Netx{Underlying: nil}, + } + defer server.Close() + + conn := &mocks.Conn{ + MockClose: func() error { + return nil + }, + MockRead: func(b []byte) (int, error) { + return 0, io.EOF + }, + } + + err := server.serveConn(conn) + if !errors.Is(err, io.EOF) { + t.Fatal("unexpected error", err) + } +} + +func TestServerClosesConn(t *testing.T) { + server := &Server{ + closeOnce: sync.Once{}, + listener: &mocks.Listener{ + MockClose: func() error { + return nil + }, + }, + logger: log.Log, + netx: &netxlite.Netx{Underlying: nil}, + } + defer server.Close() + + called := false + conn := &mocks.Conn{ + MockClose: func() error { + called = true + return nil + }, + MockRead: func(b []byte) (int, error) { + return 0, io.EOF + }, + } + + err := server.serveConn(conn) + if !errors.Is(err, io.EOF) { + t.Fatal("unexpected error", err) + } + if !called { + t.Fatal("did not call close") + } +} + +func TestInvalidVersion(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: the protocol version must be 5 + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{17, 0, 0, 1})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 17, // version + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestReadAuthMethodsFailure(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: the protocol expects something after we have sent the version + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestNoAcceptableAuth(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: we don't support username and password authentication + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 2, // username and password + 1, // version of the username and password authentication + 3, // username length + 'f', 'o', 'o', // username + '3', // password length + 'b', 'a', 'r', // password + }, + expect: []byte{5, 255}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestNewRequestReadError(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: the second message should contain something after the version + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestNewRequestWithIncompatibleVersion(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: the second message should contain again version equal to 5 + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 17, // version + 2, // bind command + 0, // reserved + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestUnsupportedCommand(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: we only support the connect command + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + 1, // IPv4 + 127, 0, 0, 1, // address + 0, 80, // port + }, + expect: []byte{5, 7, 0, 1, 0, 0, 0, 0, 0, 0}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestUnrecognizedAddrType(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: 55 is an invalid address type + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + 55, // ??? + 127, 0, 0, 1, // address + 0, 80, // port + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestReadAddrSpecFailureReadingAddrType(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: here there is nothing after the reserved byte + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestReadAddrSpecFailureReadingIPv4Address(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: here the IPv4 address bytes are missing + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + 1, // IPv4 + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestReadAddrSpecFailureReadingIPv6Address(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: here the IPv6 address bytes are missing + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + 4, // IPv6 + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestReadAddrSpecFailureReadingFQDNLength(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: here the length of the FQDN is missing + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + 3, // FQDN + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestReadAddrSpecFailureReadingFQDNString(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: here the bytes of the FQDN are missing + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + 3, // FQDN + 10, // length of FQDN + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} + +func TestReadAddrSpecFailureReadingPortWithIPv6(t *testing.T) { + server := MustNewServer( + log.Log, + &netxlite.Netx{Underlying: nil}, + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }, + ) + defer server.Close() + + // Note: here the ports bytes are missing + conn := runtimex.Try1(net.Dial("tcp", server.Endpoint())) + _ = runtimex.Try1(conn.Write([]byte{})) + defer conn.Close() + + client := &client{ + exchanges: []exchange{{ + send: []byte{ + 5, // version + 1, // number of authentication methods supported + 0, // no authentication + }, + expect: []byte{5, 0}, + }, { + send: []byte{ + 5, // version + 2, // bind command + 0, // reserved + 4, // IPv6, + 0, 0, 0, 0, // IPv6 addr + 0, 0, 0, 0, // IPv6 addr + 0, 0, 0, 0, // IPv6 addr + 0, 0, 0, 0, // IPv6 addr + }, + expect: []byte{}, + }}, + } + if err := client.run(log.Log, conn); err != nil { + t.Fatal(err) + } +} diff --git a/internal/testingsocks5/qa_test.go b/internal/testingsocks5/qa_test.go new file mode 100644 index 0000000000..c79b9080bf --- /dev/null +++ b/internal/testingsocks5/qa_test.go @@ -0,0 +1,94 @@ +package testingsocks5_test + +import ( + "crypto/tls" + "net" + "net/http" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/netem" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/testingproxy" + "github.com/ooni/probe-cli/v3/internal/testingsocks5" +) + +func TestNetem(t *testing.T) { + for _, testCase := range testingproxy.SOCKSTestCases { + t.Run(testCase.Name(), func(t *testing.T) { + short := testCase.Short() + if !short && testing.Short() { + t.Skip("skip test in short mode") + } + testCase.Run(t) + }) + } +} + +func TestNetemDialFailure(t *testing.T) { + topology := runtimex.Try1(netem.NewStarTopology(log.Log)) + defer topology.Close() + + const ( + wwwIPAddr = "93.184.216.34" + proxyIPAddr = "10.0.0.1" + clientIPAddr = "10.0.0.2" + ) + + // create: + // + // - a www stack modeling www.example.com (but w/o any listener, so connect will fail) + // + // - a proxy stack + // + // - a client stack + // + // Note that www.example.com's IP address is also the resolver used by everyone + wwwStack := runtimex.Try1(topology.AddHost(wwwIPAddr, wwwIPAddr, &netem.LinkConfig{})) + proxyStack := runtimex.Try1(topology.AddHost(proxyIPAddr, wwwIPAddr, &netem.LinkConfig{})) + clientStack := runtimex.Try1(topology.AddHost(clientIPAddr, wwwIPAddr, &netem.LinkConfig{})) + + // configure the wwwStack as the DNS resolver with proper configuration + dnsConfig := netem.NewDNSConfig() + dnsConfig.AddRecord("www.example.com.", "", wwwIPAddr) + dnsServer := runtimex.Try1(netem.NewDNSServer(log.Log, wwwStack, wwwIPAddr, dnsConfig)) + defer dnsServer.Close() + + // configure the proxyStack to implement the SOCKS proxy on port 9050 + proxyServer := testingsocks5.MustNewServer( + log.Log, + &netxlite.Netx{ + Underlying: &netxlite.NetemUnderlyingNetworkAdapter{UNet: proxyStack}}, + &net.TCPAddr{IP: net.ParseIP(proxyIPAddr), Port: 9050}, + ) + defer proxyServer.Close() + + // create the netx instance for the client + netx := &netxlite.Netx{Underlying: &netxlite.NetemUnderlyingNetworkAdapter{UNet: clientStack}} + + // create an HTTP client configured to use the given proxy + dialer := netx.NewDialerWithResolver(log.Log, netx.NewStdlibResolver(log.Log)) + tlsDialer := netxlite.NewTLSDialer(dialer, netx.NewTLSHandshakerStdlib(log.Log)) + txp := netxlite.NewHTTPTransportWithOptions(log.Log, dialer, tlsDialer, + netxlite.HTTPTransportOptionProxyURL(proxyServer.URL()), + + // TODO(https://github.com/ooni/probe/issues/2536) + netxlite.HTTPTransportOptionTLSClientConfig(&tls.Config{ + RootCAs: runtimex.Try1(clientStack.DefaultCertPool()), + }), + ) + client := &http.Client{Transport: txp} + defer client.CloseIdleConnections() + + // because the TCP/IP stack exists but we're not listening, we should get an error (the + // SOCKS5 library has been simplified to always return "host unreachabile") + resp, err := client.Get("https://www.example.com/") + if err == nil || !strings.HasSuffix(err.Error(), "host unreachable") { + t.Fatal("unexpected error", err) + } + if resp != nil { + t.Fatal("expected nil resp") + } +} diff --git a/internal/testingsocks5/request.go b/internal/testingsocks5/request.go new file mode 100644 index 0000000000..4d9d472109 --- /dev/null +++ b/internal/testingsocks5/request.go @@ -0,0 +1,224 @@ +package testingsocks5 + +import ( + "fmt" + "io" + "net" + "strconv" + "sync" + + "context" +) + +const ( + connectCommand = uint8(1) + bindCommand = uint8(2) + associateCommand = uint8(3) + ipv4Address = uint8(1) + fqdnAddress = uint8(3) + ipv6Address = uint8(4) +) + +const ( + successReply uint8 = iota + serverFailure + ruleFailure + networkUnreachable + hostUnreachable + connectionRefused + ttlExpired + commandNotSupported + addrTypeNotSupported +) + +var ( + errUnrecognizedAddrType = fmt.Errorf("unrecognized address type") +) + +// addrSpec is used to return the target addrSpec +// which may be specified as IPv4, IPv6, or a FQDN +type addrSpec struct { + Address string + Port int +} + +// A request represents request received by a server +type request struct { + // Protocol version + Version uint8 + + // Requested command + Command uint8 + + // AddrSpec of the desired destination + DestAddr *addrSpec +} + +// newRequest creates a new request from the tcp connection +func newRequest(cconn net.Conn) (*request, error) { + // Read the version byte + header := []byte{0, 0, 0} + if _, err := io.ReadFull(cconn, header); err != nil { + return nil, fmt.Errorf("failed to get command version: %w", err) + } + + // Ensure we are compatible + if header[0] != socks5Version { + return nil, fmt.Errorf("unsupported command version: %v", header[0]) + } + + // Read in the destination address + dest, err := readAddrSpec(cconn) + if err != nil { + return nil, err + } + + request := &request{ + Version: socks5Version, + Command: header[1], + DestAddr: dest, + } + + return request, nil +} + +// handleRequest is used for request processing after authentication +func (s *Server) handleRequest(req *request, cconn net.Conn) error { + ctx := context.Background() + if req.Command != connectCommand { + return sendReply(cconn, commandNotSupported, &net.TCPAddr{}) + } + return s.handleConnect(ctx, cconn, req) +} + +// handleConnect is used to handle a connect command +func (s *Server) handleConnect(ctx context.Context, cconn net.Conn, req *request) error { + s.logger.Info("handling CONNECT command") + + // Attempt to connect + endpoint := net.JoinHostPort(req.DestAddr.Address, strconv.Itoa(req.DestAddr.Port)) + s.logger.Infof("endpoint: %s", endpoint) + dialer := s.netx.NewDialerWithResolver(s.logger, s.netx.NewStdlibResolver(s.logger)) + sconn, err := dialer.DialContext(ctx, "tcp", endpoint) + if err != nil { + // Note: the original go-socks5 selects the proper error but it does not + // matter for our purposes, so we always return hostUnreachable. + return sendReply(cconn, hostUnreachable, &net.TCPAddr{}) + } + defer sconn.Close() + + // Send success + local := sconn.LocalAddr().(*net.TCPAddr) + if err := sendReply(cconn, successReply, local); err != nil { + return fmt.Errorf("failed to send reply: %w", err) + } + + // Start proxying + wg := &sync.WaitGroup{} + wg.Add(2) + go func() { + _, _ = io.Copy(cconn, sconn) + wg.Done() + }() + go func() { + _, _ = io.Copy(sconn, cconn) + wg.Done() + }() + wg.Wait() + return nil +} + +// readAddrSpec is used to read AddrSpec. +// Expects an address type byte, follwed by the address and port. +func readAddrSpec(cconn net.Conn) (*addrSpec, error) { + d := &addrSpec{} + + // Get the address type + addrType := []byte{0} + if _, err := io.ReadFull(cconn, addrType); err != nil { + return nil, err + } + + // Handle on a per type basis + switch addrType[0] { + case ipv4Address: + addr := make([]byte, 4) + if _, err := io.ReadFull(cconn, addr); err != nil { + return nil, err + } + d.Address = net.IP(addr).String() + + case ipv6Address: + addr := make([]byte, 16) + if _, err := io.ReadFull(cconn, addr); err != nil { + return nil, err + } + d.Address = net.IP(addr).String() + + case fqdnAddress: + lengthBuffer := []byte{0} + if _, err := io.ReadFull(cconn, lengthBuffer); err != nil { + return nil, err + } + addrLen := int(lengthBuffer[0]) + fqdn := make([]byte, addrLen) + if _, err := io.ReadFull(cconn, fqdn); err != nil { + return nil, err + } + d.Address = string(fqdn) + + default: + return nil, errUnrecognizedAddrType + } + + // Read the port + port := []byte{0, 0} + if _, err := io.ReadFull(cconn, port); err != nil { + return nil, err + } + d.Port = (int(port[0]) << 8) | int(port[1]) + + return d, nil +} + +// sendReply is used to send a reply message +func sendReply(w io.Writer, resp uint8, addr *net.TCPAddr) error { + // Format the address + var ( + addrType uint8 + addrBody []byte + addrPort uint16 + ) + + // Note: the order of these cases matters! + switch { + case addr.IP.To4() != nil: + addrType = ipv4Address + addrBody = []byte(addr.IP.To4()) + addrPort = uint16(addr.Port) + + case addr.IP.To16() != nil: + addrType = ipv6Address + addrBody = []byte(addr.IP.To16()) + addrPort = uint16(addr.Port) + + default: + addrType = ipv4Address + addrBody = []byte{0, 0, 0, 0} + addrPort = 0 + } + + // Format the message + msg := make([]byte, 6+len(addrBody)) + msg[0] = socks5Version + msg[1] = resp + msg[2] = 0 // Reserved + msg[3] = addrType + copy(msg[4:], addrBody) + msg[4+len(addrBody)] = byte(addrPort >> 8) + msg[4+len(addrBody)+1] = byte(addrPort & 0xff) + + // Send the message + _, err := w.Write(msg) + return err +} diff --git a/internal/testingsocks5/request_test.go b/internal/testingsocks5/request_test.go new file mode 100644 index 0000000000..02a1327d53 --- /dev/null +++ b/internal/testingsocks5/request_test.go @@ -0,0 +1,134 @@ +package testingsocks5 + +import ( + "bytes" + "context" + "errors" + "net" + "sync" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/mocks" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +func TestServerHandleConnect(t *testing.T) { + t.Run("sendReply failure", func(t *testing.T) { + // create a connection that fails as soon as we try to send + expectedErr := errors.New("mocked error") + cconn := &mocks.Conn{ + MockWrite: func(b []byte) (int, error) { + return 0, expectedErr + }, + } + + // create a netx where we fake dialing + netx := &netxlite.Netx{ + Underlying: &mocks.UnderlyingNetwork{ + MockDialTimeout: func() time.Duration { + return 15 * time.Second + }, + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + sconn := &mocks.Conn{ + MockClose: func() error { + return nil + }, + MockLocalAddr: func() net.Addr { + return &net.TCPAddr{ + IP: net.ParseIP("::17"), + Port: 54321, + } + }, + } + return sconn, nil + }, + }, + } + + // create fake server and request + server := &Server{ + closeOnce: sync.Once{}, + listener: &mocks.Listener{}, // not used + logger: model.DiscardLogger, + netx: netx, + } + req := &request{ + Version: socks5Version, + Command: connectCommand, + DestAddr: &addrSpec{ + Address: "::55", + Port: 80, + }, + } + + err := server.handleConnect(context.Background(), cconn, req) + if !errors.Is(err, expectedErr) { + t.Fatal("unexpected error", err) + } + }) +} + +func TestSendReply(t *testing.T) { + t.Run("we can serialize an IPv4 address", func(t *testing.T) { + buffer := &bytes.Buffer{} + err := sendReply(buffer, successReply, &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 80}) + if err != nil { + t.Fatal(err) + } + expected := []byte{ + 0x05, // version + 0x00, // successful response + 0x00, // reserved + 0x01, // IPv4 + 0x7f, 0x00, 0x00, 0x01, // 127.0.0.1 + 0x00, 0x50, // port 80 + } + if diff := cmp.Diff(expected, buffer.Bytes()); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("we can serialize an IPv6 address", func(t *testing.T) { + buffer := &bytes.Buffer{} + err := sendReply(buffer, successReply, &net.TCPAddr{IP: net.ParseIP("::1"), Port: 80}) + if err != nil { + t.Fatal(err) + } + expected := []byte{ + 0x05, // version + 0x00, // successful response + 0x00, // reserved + 0x04, // IPv6 + 0x00, 0x00, 0x00, 0x00, // ::1 (1/4) + 0x00, 0x00, 0x00, 0x00, // ::1 (2/4) + 0x00, 0x00, 0x00, 0x00, // ::1 (3/4) + 0x00, 0x00, 0x00, 0x01, // ::1 (4/4) + 0x00, 0x50, // port 80 + } + if diff := cmp.Diff(expected, buffer.Bytes()); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("we correctly handle the neither-IPv4-nor-IPv6 case", func(t *testing.T) { + buffer := &bytes.Buffer{} + err := sendReply(buffer, successReply, &net.TCPAddr{IP: nil, Port: 80}) + if err != nil { + t.Fatal(err) + } + expected := []byte{ + 0x05, // version + 0x00, // successful response + 0x00, // reserved + 0x01, // IPv4 + 0x00, 0x00, 0x00, 0x00, // 0.0.0.0 + 0x00, 0x00, // port 0 + } + if diff := cmp.Diff(expected, buffer.Bytes()); diff != "" { + t.Fatal(diff) + } + }) +} diff --git a/internal/testingsocks5/server.go b/internal/testingsocks5/server.go new file mode 100644 index 0000000000..a17ef098cf --- /dev/null +++ b/internal/testingsocks5/server.go @@ -0,0 +1,122 @@ +package testingsocks5 + +import ( + "fmt" + "io" + "net" + "net/url" + "sync" + + "github.com/ooni/probe-cli/v3/internal/logx" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/runtimex" +) + +const ( + socks5Version = uint8(5) +) + +// Server accepts connections and implements the SOCSK5 protocol. +// +// The zero value is invalid; please, use [NewServer]. +type Server struct { + // closeOnce ensures close has "once" semantics. + closeOnce sync.Once + + // listener is the underlying listener. + listener net.Listener + + // logger is the logger to use. + logger model.Logger + + // netx is the network abstraction to use. + netx *netxlite.Netx +} + +// MustNewServer creates a new Server instance. +func MustNewServer(logger model.Logger, netx *netxlite.Netx, addr *net.TCPAddr) *Server { + listener := runtimex.Try1(netx.ListenTCP("tcp", addr)) + server := &Server{ + closeOnce: sync.Once{}, + listener: listener, + logger: &logx.PrefixLogger{ + Prefix: "SOCKS5: ", + Logger: logger, + }, + netx: netx, + } + go server.Serve() + return server +} + +// Serve is used to Serve connections from a given listener. +func (s *Server) Serve() error { + for { + cconn, err := s.listener.Accept() + if err != nil { + return err + } + go func() { + if err := s.serveConn(cconn); err != nil { + s.logger.Warnf("s.serveConn: %s", err.Error()) + } + }() + } +} + +// serveConn is used to serve SOCKS5 over a single connection. +func (s *Server) serveConn(cconn net.Conn) error { + defer cconn.Close() + + // Read the version byte + version := []byte{0} + if _, err := io.ReadFull(cconn, version); err != nil { + return fmt.Errorf("failed to get version byte: %w", err) + } + + s.logger.Infof("got version: %v", version) + + // Ensure we are compatible + if version[0] != socks5Version { + return fmt.Errorf("unsupported SOCKS version: %v", version) + } + + // Authenticate the connection + auth, err := s.authenticate(cconn) + if err != nil { + return fmt.Errorf("failed to authenticate: %w", err) + } + + s.logger.Infof("authenticated: %+v", auth) + + request, err := newRequest(cconn) + if err != nil { + return fmt.Errorf("failed to read destination address: %w", err) + } + + // Process the client request + return s.handleRequest(request, cconn) +} + +// Close closes the listener and waits for all goroutines to join +func (s *Server) Close() (err error) { + s.closeOnce.Do(func() { + err = s.listener.Close() + }) + return +} + +// Endpoint returns the server endpoint. +func (s *Server) Endpoint() string { + return s.listener.Addr().String() +} + +// URL returns a socks5 URL for the local listening address +func (s *Server) URL() *url.URL { + return &url.URL{ + Scheme: "socks5", + Host: s.Endpoint(), + Path: "/", + } +} diff --git a/internal/testingx/httpproxy_test.go b/internal/testingx/httpproxy_test.go index 40cbaeb719..8286541188 100644 --- a/internal/testingx/httpproxy_test.go +++ b/internal/testingx/httpproxy_test.go @@ -16,7 +16,7 @@ import ( ) func TestHTTPProxyHandler(t *testing.T) { - for _, testCase := range testingproxy.AllTestCases { + for _, testCase := range testingproxy.HTTPTestCases { t.Run(testCase.Name(), func(t *testing.T) { short := testCase.Short() if !short && testing.Short() { diff --git a/internal/testingx/tlssniproxy_test.go b/internal/testingx/tlssniproxy_test.go index 4ad9000c8c..b5e80d74e3 100644 --- a/internal/testingx/tlssniproxy_test.go +++ b/internal/testingx/tlssniproxy_test.go @@ -96,8 +96,6 @@ func TestTLSSNIProxy(t *testing.T) { } }() - //log.SetLevel(log.DebugLevel) - tlsConfig := &tls.Config{ ServerName: "www.google.com", } diff --git a/internal/testingx/tlsx_test.go b/internal/testingx/tlsx_test.go index 5784e892b0..4d758dae2d 100644 --- a/internal/testingx/tlsx_test.go +++ b/internal/testingx/tlsx_test.go @@ -215,8 +215,6 @@ func TestTLSHandlerWithNetem(t *testing.T) { t.Skip(tc.reasonToSkip) } - //log.SetLevel(log.DebugLevel) - // create a star topology for this test case topology := runtimex.Try1(netem.NewStarTopology(log.Log)) defer topology.Close() diff --git a/script/nocopyreadall.bash b/script/nocopyreadall.bash index 29bf5de6e4..78a36fe549 100755 --- a/script/nocopyreadall.bash +++ b/script/nocopyreadall.bash @@ -21,6 +21,12 @@ for file in $(find . -type f -name \*.go); do continue fi + if [ "$file" = "./internal/testingsocks5/request.go" ]; then + # We're allowed to use ReadAll and Copy in this file because + # it's code that we only use for testing purposes. + continue + fi + if [ "$file" = "./internal/testingx/dnsoverhttps.go" ]; then # We're allowed to use ReadAll and Copy in this file because # it's code that we only use for testing purposes.