diff --git a/go.mod b/go.mod index 5716fb9..380af63 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/alecthomas/kingpin v2.2.6+incompatible github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15 // indirect github.com/apex/log v1.9.0 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/creack/goselect v0.1.2 // indirect github.com/cretz/bine v0.1.0 github.com/dchest/siphash v1.2.2 // indirect diff --git a/go.sum b/go.sum index 187b8a6..040a2b2 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= diff --git a/internal/engine/session.go b/internal/engine/session.go index 4c1d31c..e78c999 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -167,7 +167,7 @@ func NewSession(ctx context.Context, config SessionConfig) (*Session, error) { proxyURL := config.ProxyURL if proxyURL != nil { switch proxyURL.Scheme { - case "psiphon", "tor": + case "psiphon", "tor", "fake": tunnel, err := tunnel.Start(ctx, &tunnel.Config{ Name: proxyURL.Scheme, Session: &sessionTunnelEarlySession{}, diff --git a/internal/engine/tunnel/config.go b/internal/engine/tunnel/config.go index 2e3003a..9ccd1aa 100644 --- a/internal/engine/tunnel/config.go +++ b/internal/engine/tunnel/config.go @@ -2,8 +2,10 @@ package tunnel import ( "context" + "net" "os" + "github.com/armon/go-socks5" "github.com/cretz/bine/control" "github.com/cretz/bine/tor" "github.com/ooni/psiphon/oopsi/github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib" @@ -14,7 +16,9 @@ import ( // structure while in use, because that may lead to data races. type Config struct { // Name is the mandatory name of the tunnel. We support - // "tor" and "psiphon" tunnels. + // "tor", "psiphon", and "fake" tunnels. You SHOULD + // use "fake" tunnels only for testing: they don't provide + // any real tunneling, just a socks5 proxy. Name string // Session is the mandatory measurement session, or a suitable @@ -40,6 +44,12 @@ type Config struct { // testMkdirAll allows us to mock os.MkdirAll in testing code. testMkdirAll func(path string, perm os.FileMode) error + // testNetListen allows us to mock net.Listen in testing code. + testNetListen func(network string, address string) (net.Listener, error) + + // testSocks5New allows us to mock socks5.New in testing code. + testSocks5New func(conf *socks5.Config) (*socks5.Server, error) + // testStartPsiphon allows us to mock psiphon's clientlib.StartTunnel. testStartPsiphon func(ctx context.Context, config []byte, workdir string) (*clientlib.PsiphonTunnel, error) @@ -64,6 +74,22 @@ func (c *Config) mkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) } +// netListen calls either testNetListen or net.Listen. +func (c *Config) netListen(network string, address string) (net.Listener, error) { + if c.testNetListen != nil { + return c.testNetListen(network, address) + } + return net.Listen(network, address) +} + +// socks5New calls either testSocks5New or socks5.New +func (c *Config) socks5New(conf *socks5.Config) (*socks5.Server, error) { + if c.testSocks5New != nil { + return c.testSocks5New(conf) + } + return socks5.New(conf) +} + // startPsiphon calls either testStartPsiphon or psiphon's clientlib.StartTunnel. func (c *Config) startPsiphon(ctx context.Context, config []byte, workdir string) (*clientlib.PsiphonTunnel, error) { diff --git a/internal/engine/tunnel/fake.go b/internal/engine/tunnel/fake.go new file mode 100644 index 0000000..b35924e --- /dev/null +++ b/internal/engine/tunnel/fake.go @@ -0,0 +1,67 @@ +package tunnel + +import ( + "context" + "net" + "net/url" + "sync" + "time" + + "github.com/armon/go-socks5" +) + +// fakeTunnel is a fake tunnel. +type fakeTunnel struct { + addr net.Addr + bootstrapTime time.Duration + listener net.Listener + once sync.Once +} + +// BootstrapTime implements Tunnel.BootstrapTime. +func (t *fakeTunnel) BootstrapTime() time.Duration { + return t.bootstrapTime +} + +// Stop implements Tunnel.Stop. +func (t *fakeTunnel) Stop() { + // Implementation note: closing the listener causes + // the socks5 server.Serve to return an error + t.once.Do(func() { t.listener.Close() }) +} + +// SOCKS5ProxyURL returns the SOCKS5 proxy URL. +func (t *fakeTunnel) SOCKS5ProxyURL() *url.URL { + return &url.URL{ + Scheme: "socks5", + Host: t.addr.String(), + } +} + +// fakeStart starts the fake tunnel. +func fakeStart(ctx context.Context, config *Config) (Tunnel, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() // simplifies unit testing this code + default: + } + if config.TunnelDir == "" { + return nil, ErrEmptyTunnelDir + } + server, err := config.socks5New(&socks5.Config{}) + if err != nil { + return nil, err + } + start := time.Now() + listener, err := config.netListen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + bootstrapTime := time.Since(start) + go server.Serve(listener) + return &fakeTunnel{ + addr: listener.Addr(), + bootstrapTime: bootstrapTime, + listener: listener, + }, nil +} diff --git a/internal/engine/tunnel/fake_integration_test.go b/internal/engine/tunnel/fake_integration_test.go new file mode 100644 index 0000000..921c8d6 --- /dev/null +++ b/internal/engine/tunnel/fake_integration_test.go @@ -0,0 +1,44 @@ +package tunnel_test + +import ( + "context" + "io/ioutil" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/tunnel" +) + +func TestFakeStartStop(t *testing.T) { + // no need to skip because the bootstrap is obviously fast + tunnelDir, err := ioutil.TempDir("testdata", "fake") + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + sess, err := engine.NewSession(ctx, engine.SessionConfig{ + Logger: log.Log, + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + TunnelDir: tunnelDir, + }) + if err != nil { + t.Fatal(err) + } + tunnel, err := tunnel.Start(context.Background(), &tunnel.Config{ + Name: "fake", + Session: sess, + TunnelDir: tunnelDir, + }) + if err != nil { + t.Fatal(err) + } + if tunnel.SOCKS5ProxyURL() == nil { + t.Fatal("expected non nil URL here") + } + if tunnel.BootstrapTime() <= 0 { + t.Fatal("expected positive bootstrap time here") + } + tunnel.Stop() +} diff --git a/internal/engine/tunnel/fake_test.go b/internal/engine/tunnel/fake_test.go new file mode 100644 index 0000000..d5c7107 --- /dev/null +++ b/internal/engine/tunnel/fake_test.go @@ -0,0 +1,80 @@ +package tunnel + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/armon/go-socks5" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" +) + +func TestFakeWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately fail + sess := &mockable.Session{} + tunnel, err := fakeStart(ctx, &Config{ + Session: sess, + TunnelDir: "testdata", + }) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestFakeWithEmptyTunnelDir(t *testing.T) { + ctx := context.Background() + sess := &mockable.Session{} + tunnel, err := fakeStart(ctx, &Config{ + Session: sess, + TunnelDir: "", + }) + if !errors.Is(err, ErrEmptyTunnelDir) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestFakeSocks5NewFails(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + sess := &mockable.Session{} + tunnel, err := fakeStart(ctx, &Config{ + Session: sess, + TunnelDir: "testdata", + testSocks5New: func(conf *socks5.Config) (*socks5.Server, error) { + return nil, expected + }, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestFakeNetListenFails(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + sess := &mockable.Session{} + tunnel, err := fakeStart(ctx, &Config{ + Session: sess, + TunnelDir: "testdata", + testNetListen: func(network, address string) (net.Listener, error) { + return nil, expected + }, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} diff --git a/internal/engine/tunnel/tunnel.go b/internal/engine/tunnel/tunnel.go index 0238334..1261b4d 100644 --- a/internal/engine/tunnel/tunnel.go +++ b/internal/engine/tunnel/tunnel.go @@ -34,6 +34,10 @@ import ( // Session is a measurement session. We filter for the only // functionality we're interested to use. That is, fetching the // psiphon configuration from the OONI backend (if possible). +// +// Depending on how OONI is compiled, the psiphon configuration +// may be embedded into the binary. In such a case, we won't +// need to download the configuration from the backend. type Session interface { // FetchPsiphonConfig should fetch and return the psiphon config // as a serialized JSON, or fail with an error. @@ -72,14 +76,20 @@ var ErrUnsupportedTunnelName = errors.New("unsupported tunnel name") // The "psiphon" tunnel requires a configuration. Some builds of // ooniprobe embed a configuration into the binary. When this // is the case, the config.Session is a mocked object that just -// retuns such configuration. +// returns such a configuration. // // Otherwise, If there is no embedded psiphon configuration, the -// config.Session will must be an ordinary session. In such a +// config.Session must be an ordinary engine.Session. In such a // case, fetching the Psiphon configuration from the backend may // fail when the backend is not reachable. +// +// The "fake" tunnel is a fake tunnel that just exposes a +// SOCKS5 proxy and then connects directly to server. We use +// this special kind of tunnel to implement tests. func Start(ctx context.Context, config *Config) (Tunnel, error) { switch config.Name { + case "fake": + return fakeStart(ctx, config) case "psiphon": return psiphonStart(ctx, config) case "tor":