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.