refactor: move tunnel pkg down one level (#358)

* refactor: move tunnel pkg down one level

While there, reduce unnecessary dependency on external packages.

* file I forgot to commit
This commit is contained in:
Simone Basso
2021-06-04 15:15:41 +02:00
committed by GitHub
parent 39aec6677d
commit 3cb6c7c6fb
21 changed files with 69 additions and 65 deletions
+174
View File
@@ -0,0 +1,174 @@
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"
"golang.org/x/sys/execabs"
)
// Logger is the logger to use. Its signature is compatibile
// with the apex/log logger signature.
type Logger interface {
// Infof formats and emits an informative message
Infof(format string, v ...interface{})
}
// Config contains the configuration for creating a Tunnel instance. You need
// to fill all the mandatory fields. You SHOULD NOT modify the content of this
// 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", "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
// mock of the required functionality. That is, the possibility
// of obtaining a valid psiphon configuration.
Session Session
// TunnelDir is the MANDATORY directory in which the tunnel SHOULD
// store its state, if any. If this field is empty, the
// Start function fails with ErrEmptyTunnelDir.
TunnelDir string
// Logger is the optional logger to use. If empty we use a default
// implementation that does not emit any output.
Logger Logger
// TorArgs contains the optional arguments that you want us to pass
// to the tor binary when invoking it. By default we do not
// pass any extra argument. This flag might be useful to
// configure pluggable transports.
TorArgs []string
// TorBinary is the optional path of the TorBinary we SHOULD be
// executing. When not set, we execute `tor`.
TorBinary string
// testExecabsLookPath allows us to mock exeabs.LookPath
testExecabsLookPath func(name string) (string, error)
// 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)
// testTorStart allows us to mock tor.Start.
testTorStart func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error)
// testTorEnableNetwork allows us to fake a failure when
// telling to the tor daemon to enable the network.
testTorEnableNetwork func(ctx context.Context, tor *tor.Tor, wait bool) error
// testTorGetInfo allows us to fake a failure when
// getting info from the tor control port.
testTorGetInfo func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error)
}
// silentLogger is a logger that does not emit output.
type silentLogger struct{}
// Infof implements Logger.Infof.
func (sl *silentLogger) Infof(format string, v ...interface{}) {}
// defaultLogger is the default logger.
var defaultLogger = &silentLogger{}
// logger returns the logger to use.
func (c *Config) logger() Logger {
if c.Logger != nil {
return c.Logger
}
return defaultLogger
}
// execabsLookPath calls either testExeabsLookPath or execabs.LookPath
func (c *Config) execabsLookPath(name string) (string, error) {
if c.testExecabsLookPath != nil {
return c.testExecabsLookPath(name)
}
return execabs.LookPath(name)
}
// mkdirAll calls either testMkdirAll or os.MkdirAll.
func (c *Config) mkdirAll(path string, perm os.FileMode) error {
if c.testMkdirAll != nil {
return c.testMkdirAll(path, perm)
}
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) {
if c.testStartPsiphon != nil {
return c.testStartPsiphon(ctx, config, workdir)
}
return clientlib.StartTunnel(ctx, config, "", clientlib.Parameters{
DataRootDirectory: &workdir}, nil, nil)
}
// torBinary returns the tor binary path, if configured, or
// the default path, otherwise.
func (c *Config) torBinary() string {
if c.TorBinary != "" {
return c.TorBinary
}
return "tor"
}
// torStart calls either testTorStart or tor.Start.
func (c *Config) torStart(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
if c.testTorStart != nil {
return c.testTorStart(ctx, conf)
}
return tor.Start(ctx, conf)
}
// torEnableNetwork calls either testTorEnableNetwork or tor.EnableNetwork.
func (c *Config) torEnableNetwork(ctx context.Context, tor *tor.Tor, wait bool) error {
if c.testTorEnableNetwork != nil {
return c.testTorEnableNetwork(ctx, tor, wait)
}
return tor.EnableNetwork(ctx, wait)
}
// torGetInfo calls either testTorGetInfo or ctrl.GetInfo.
func (c *Config) torGetInfo(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
if c.testTorGetInfo != nil {
return c.testTorGetInfo(ctrl, keys...)
}
return ctrl.GetInfo(keys...)
}
+36
View File
@@ -0,0 +1,36 @@
package tunnel
import (
"testing"
"github.com/apex/log"
)
func TestConfigLoggerDefault(t *testing.T) {
config := &Config{}
if config.logger() != defaultLogger {
t.Fatal("not the logger we expected")
}
}
func TestConfigLoggerCustom(t *testing.T) {
config := &Config{Logger: log.Log}
if config.logger() != log.Log {
t.Fatal("not the logger we expected")
}
}
func TestTorBinaryNotSet(t *testing.T) {
config := &Config{}
if config.torBinary() != "tor" {
t.Fatal("not the result we expected")
}
}
func TestTorBinarySet(t *testing.T) {
path := "/usr/local/bin/tor"
config := &Config{TorBinary: path}
if config.torBinary() != path {
t.Fatal("not the result we expected")
}
}
+80
View File
@@ -0,0 +1,80 @@
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) {
// do the same things other tunnels do:
//
// 1. abort if context is cancelled
//
// 2. check for tunnelDir being not empty
//
// 3. attempt to create tunnelDir
//
// after that, it's all fake and we just create a simple
// socks5 server that we can use
select {
case <-ctx.Done():
return nil, ctx.Err() // simplifies unit testing this code
default:
}
if config.TunnelDir == "" {
return nil, ErrEmptyTunnelDir
}
if err := config.mkdirAll(config.TunnelDir, 0700); err != nil {
return nil, err
}
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
}
+44
View File
@@ -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/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()
}
+99
View File
@@ -0,0 +1,99 @@
package tunnel
import (
"context"
"errors"
"net"
"os"
"testing"
"github.com/armon/go-socks5"
)
func TestFakeWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // immediately fail
sess := &MockableSession{}
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 := &MockableSession{}
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 TestFakeWithFailingMkdirAll(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
sess := &MockableSession{}
tunnel, err := fakeStart(ctx, &Config{
Session: sess,
TunnelDir: "testdata",
testMkdirAll: func(dir string, mode os.FileMode) error {
return expected
},
})
if !errors.Is(err, expected) {
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 := &MockableSession{}
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 := &MockableSession{}
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")
}
}
+76
View File
@@ -0,0 +1,76 @@
package tunnel
import (
"context"
"fmt"
"net"
"net/url"
"path/filepath"
"time"
"github.com/ooni/psiphon/oopsi/github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib"
)
// psiphonTunnel is a psiphon tunnel
type psiphonTunnel struct {
// bootstrapTime is the bootstrapTime of the bootstrap
bootstrapTime time.Duration
// tunnel is the underlying psiphon tunnel
tunnel *clientlib.PsiphonTunnel
}
// psiphonMakeWorkingDir creates the working directory
func psiphonMakeWorkingDir(config *Config) (string, error) {
workdir := filepath.Join(config.TunnelDir, config.Name)
if err := config.mkdirAll(workdir, 0700); err != nil {
return "", err
}
return workdir, nil
}
// psiphonStart starts the psiphon tunnel.
func psiphonStart(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
}
configJSON, err := config.Session.FetchPsiphonConfig(ctx)
if err != nil {
return nil, err
}
workdir, err := psiphonMakeWorkingDir(config)
if err != nil {
return nil, err
}
start := time.Now()
tunnel, err := config.startPsiphon(ctx, configJSON, workdir)
if err != nil {
return nil, err
}
stop := time.Now()
return &psiphonTunnel{tunnel: tunnel, bootstrapTime: stop.Sub(start)}, nil
}
// Stop is an idempotent method that shuts down the tunnel
func (t *psiphonTunnel) Stop() {
t.tunnel.Stop()
}
// SOCKS5ProxyURL returns the SOCKS5 proxy URL.
func (t *psiphonTunnel) SOCKS5ProxyURL() *url.URL {
return &url.URL{
Scheme: "socks5",
Host: net.JoinHostPort(
"127.0.0.1", fmt.Sprintf("%d", t.tunnel.SOCKSProxyPort)),
}
}
// BootstrapTime returns the bootstrap time
func (t *psiphonTunnel) BootstrapTime() time.Duration {
return t.bootstrapTime
}
@@ -0,0 +1,46 @@
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/tunnel"
)
func TestPsiphonStartStop(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
tunnelDir, err := ioutil.TempDir("testdata", "psiphon")
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: "psiphon",
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()
}
+99
View File
@@ -0,0 +1,99 @@
package tunnel
import (
"context"
"errors"
"os"
"testing"
"github.com/ooni/psiphon/oopsi/github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib"
)
func TestPsiphonWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // immediately fail
sess := &MockableSession{}
tunnel, err := psiphonStart(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 TestPsiphonWithEmptyTunnelDir(t *testing.T) {
ctx := context.Background()
sess := &MockableSession{}
tunnel, err := psiphonStart(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 TestPsiphonFetchPsiphonConfigFailure(t *testing.T) {
expected := errors.New("mocked error")
sess := &MockableSession{
Err: expected,
}
tunnel, err := psiphonStart(context.Background(), &Config{
Session: sess,
TunnelDir: "testdata",
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestPsiphonMkdirAllFailure(t *testing.T) {
expected := errors.New("mocked error")
sess := &MockableSession{
Result: []byte(`{}`),
}
tunnel, err := psiphonStart(context.Background(), &Config{
Session: sess,
TunnelDir: "testdata",
testMkdirAll: func(path string, perm os.FileMode) error {
return expected
},
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestPsiphonStartFailure(t *testing.T) {
expected := errors.New("mocked error")
sess := &MockableSession{
Result: []byte(`{}`),
}
tunnel, err := psiphonStart(context.Background(), &Config{
Session: sess,
TunnelDir: "testdata",
testStartPsiphon: func(ctx context.Context, config []byte,
workdir string) (*clientlib.PsiphonTunnel, 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")
}
}
+17
View File
@@ -0,0 +1,17 @@
package tunnel
import "context"
// MockableSession is a mockable session.
type MockableSession struct {
// Result contains the bytes of the psiphon config.
Result []byte
// Err is the error, if any.
Err error
}
// FetchPsiphonConfig implements ExperimentSession.FetchPsiphonConfig
func (sess *MockableSession) FetchPsiphonConfig(ctx context.Context) ([]byte, error) {
return sess.Result, sess.Err
}
+1
View File
@@ -0,0 +1 @@
*
+137
View File
@@ -0,0 +1,137 @@
package tunnel
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/cretz/bine/tor"
)
// torProcess is a running tor process.
type torProcess interface {
io.Closer
}
// torTunnel is the Tor tunnel
type torTunnel struct {
// bootstrapTime is the duration of the bootstrap
bootstrapTime time.Duration
// instance is the running tor instance
instance torProcess
// proxy is the SOCKS5 proxy URL
proxy *url.URL
}
// BootstrapTime returns the bootstrap time
func (tt *torTunnel) BootstrapTime() time.Duration {
return tt.bootstrapTime
}
// SOCKS5ProxyURL returns the URL of the SOCKS5 proxy
func (tt *torTunnel) SOCKS5ProxyURL() *url.URL {
return tt.proxy
}
// Stop stops the Tor tunnel
func (tt *torTunnel) Stop() {
tt.instance.Close()
}
// ErrTorUnableToGetSOCKSProxyAddress indicates that we could not
// get the SOCKS proxy address via the control port.
var ErrTorUnableToGetSOCKSProxyAddress = errors.New(
"unable to get socks proxy address")
// ErrTorReturnedUnsupportedProxy indicates that tor returned to
// us the address of a proxy that we don't support.
var ErrTorReturnedUnsupportedProxy = errors.New(
"tor returned unsupported proxy")
// torStart starts the tor tunnel.
func torStart(ctx context.Context, config *Config) (Tunnel, error) {
select {
case <-ctx.Done():
return nil, ctx.Err() // allows to write unit tests using this code
default:
}
if config.TunnelDir == "" {
return nil, ErrEmptyTunnelDir
}
stateDir := filepath.Join(config.TunnelDir, "tor")
logfile := filepath.Join(stateDir, "tor.log")
maybeCleanupTunnelDir(stateDir, logfile)
extraArgs := append([]string{}, config.TorArgs...)
extraArgs = append(extraArgs, "Log")
extraArgs = append(extraArgs, "notice stderr")
extraArgs = append(extraArgs, "Log")
extraArgs = append(extraArgs, fmt.Sprintf(`notice file %s`, logfile))
// Implementation note: here we make sure that we're not going to
// execute a binary called "tor" in the current directory on Windows
// as documented in https://blog.golang.org/path-security.
exePath, err := config.execabsLookPath(config.torBinary())
if err != nil {
return nil, err
}
config.logger().Infof("tunnel: exec: %s %+v", exePath, extraArgs)
instance, err := config.torStart(ctx, &tor.StartConf{
DataDir: stateDir,
ExtraArgs: extraArgs,
ExePath: exePath,
NoHush: true,
})
if err != nil {
return nil, err
}
instance.StopProcessOnClose = true
start := time.Now()
if err := config.torEnableNetwork(ctx, instance, true); err != nil {
instance.Close()
return nil, err
}
stop := time.Now()
// Adapted from <https://git.io/Jfc7N>
info, err := config.torGetInfo(instance.Control, "net/listeners/socks")
if err != nil {
instance.Close()
return nil, err
}
if len(info) != 1 || info[0].Key != "net/listeners/socks" {
instance.Close()
return nil, ErrTorUnableToGetSOCKSProxyAddress
}
proxyAddress := info[0].Val
if strings.HasPrefix(proxyAddress, "unix:") {
instance.Close()
return nil, ErrTorReturnedUnsupportedProxy
}
return &torTunnel{
bootstrapTime: stop.Sub(start),
instance: instance,
proxy: &url.URL{Scheme: "socks5", Host: proxyAddress},
}, nil
}
// maybeCleanupTunnelDir removes stale files inside
// of the tunnel directory.
func maybeCleanupTunnelDir(dir, logfile string) {
os.Remove(logfile)
removeWithGlob(filepath.Join(dir, "torrc-*"))
removeWithGlob(filepath.Join(dir, "control-port-*"))
}
// removeWithGlob globs and removes files.
func removeWithGlob(pattern string) {
files, _ := filepath.Glob(pattern)
for _, file := range files {
os.Remove(file)
}
}
+52
View File
@@ -0,0 +1,52 @@
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/tunnel"
"golang.org/x/sys/execabs"
)
func TestTorStartStop(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
torBinaryPath, err := execabs.LookPath("tor")
if err != nil {
t.Skip("missing precondition for the test: tor not in PATH")
}
tunnelDir, err := ioutil.TempDir("testdata", "tor")
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: "tor",
Session: sess,
TorBinary: torBinaryPath,
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()
}
+319
View File
@@ -0,0 +1,319 @@
package tunnel
import (
"context"
"errors"
"fmt"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/cretz/bine/control"
"github.com/cretz/bine/tor"
)
// torCloser is used to mock a running tor process, which
// we abstract as a io.Closer in tor.go.
type torCloser struct {
counter int
}
// Close implements io.Closer.Close.
func (c *torCloser) Close() error {
c.counter++
return errors.New("mocked mocked mocked")
}
func TestTorTunnelNonNil(t *testing.T) {
closer := new(torCloser)
proxy := &url.URL{Scheme: "x", Host: "10.0.0.1:443"}
tun := &torTunnel{
bootstrapTime: 128,
instance: closer,
proxy: proxy,
}
if tun.BootstrapTime() != 128 {
t.Fatal("not the bootstrap time we expected")
}
if tun.SOCKS5ProxyURL() != proxy {
t.Fatal("not the url we expected")
}
tun.Stop()
if closer.counter != 1 {
t.Fatal("something went wrong while stopping the tunnel")
}
}
func TestTorWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // fail immediately
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
})
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorWithEmptyTunnelDir(t *testing.T) {
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "",
})
if !errors.Is(err, ErrEmptyTunnelDir) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorBinaryNotFoundFailure(t *testing.T) {
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TorBinary: "/nonexistent/directory/tor",
TunnelDir: "testdata",
})
if !errors.Is(err, syscall.ENOENT) {
t.Fatal("not the error we expected", err)
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorStartFailure(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
testExecabsLookPath: func(name string) (string, error) {
return "/usr/local/bin/tor", nil
},
testTorStart: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return nil, expected
},
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorEnableNetworkFailure(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
testExecabsLookPath: func(name string) (string, error) {
return "/usr/local/bin/tor", nil
},
testTorStart: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
testTorEnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return expected
},
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorGetInfoFailure(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
testExecabsLookPath: func(name string) (string, error) {
return "/usr/local/bin/tor", nil
},
testTorStart: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
testTorEnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
testTorGetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return nil, expected
},
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorGetInfoInvalidNumberOfKeys(t *testing.T) {
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
testExecabsLookPath: func(name string) (string, error) {
return "/usr/local/bin/tor", nil
},
testTorStart: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
testTorEnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
testTorGetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return nil, nil
},
})
if !errors.Is(err, ErrTorUnableToGetSOCKSProxyAddress) {
t.Fatal("not the error we expected", err)
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorGetInfoInvalidKey(t *testing.T) {
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
testExecabsLookPath: func(name string) (string, error) {
return "/usr/local/bin/tor", nil
},
testTorStart: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
testTorEnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
testTorGetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return []*control.KeyVal{{}}, nil
},
})
if !errors.Is(err, ErrTorUnableToGetSOCKSProxyAddress) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorGetInfoInvalidProxyType(t *testing.T) {
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
testExecabsLookPath: func(name string) (string, error) {
return "/usr/local/bin/tor", nil
},
testTorStart: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
testTorEnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
testTorGetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return []*control.KeyVal{{Key: "net/listeners/socks", Val: "127.0.0.1:9050"}}, nil
},
})
if err != nil {
t.Fatal(err)
}
if tun == nil {
t.Fatal("expected non-nil tunnel here")
}
}
func TestTorUnsupportedProxy(t *testing.T) {
ctx := context.Background()
tun, err := torStart(ctx, &Config{
Session: &MockableSession{},
TunnelDir: "testdata",
testExecabsLookPath: func(name string) (string, error) {
return "/usr/local/bin/tor", nil
},
testTorStart: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
testTorEnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
testTorGetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return []*control.KeyVal{{Key: "net/listeners/socks", Val: "unix:/foo/bar"}}, nil
},
})
if !errors.Is(err, ErrTorReturnedUnsupportedProxy) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestMaybeCleanupTunnelDir(t *testing.T) {
fakeTunDir := filepath.Join("testdata", "fake-tun-dir")
if err := os.RemoveAll(fakeTunDir); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(fakeTunDir, 0700); err != nil {
t.Fatal(err)
}
fakeData := []byte("deadbeef\n")
logfile := filepath.Join(fakeTunDir, "tor.log")
if err := ioutil.WriteFile(logfile, fakeData, 0600); err != nil {
t.Fatal(err)
}
for idx := 0; idx < 3; idx++ {
filename := filepath.Join(fakeTunDir, fmt.Sprintf("torrc-%d", idx))
if err := ioutil.WriteFile(filename, fakeData, 0600); err != nil {
t.Fatal(err)
}
filename = filepath.Join(fakeTunDir, fmt.Sprintf("control-port-%d", idx))
if err := ioutil.WriteFile(filename, fakeData, 0600); err != nil {
t.Fatal(err)
}
filename = filepath.Join(fakeTunDir, fmt.Sprintf("antani-%d", idx))
if err := ioutil.WriteFile(filename, fakeData, 0600); err != nil {
t.Fatal(err)
}
}
files, err := filepath.Glob(filepath.Join(fakeTunDir, "*"))
if err != nil {
t.Fatal(err)
}
if len(files) != 10 {
t.Fatal("unexpected number of files")
}
maybeCleanupTunnelDir(fakeTunDir, logfile)
files, err = filepath.Glob(filepath.Join(fakeTunDir, "*"))
if err != nil {
t.Fatal(err)
}
if len(files) != 3 {
t.Fatal("unexpected number of files")
}
expectPrefix := filepath.Join(fakeTunDir, "antani-")
for _, file := range files {
if !strings.HasPrefix(file, expectPrefix) {
t.Fatal("unexpected file name: ", file)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// Package tunnel allows to create tunnels to speak
// with OONI backends and other services.
//
// You need to fill a Config object and call Start to
// obtain an instance of Tunnel. The tunnel will expose
// a SOCKS5 proxy. You need to configure your HTTP
// code to use such a proxy. Remember to call the Stop
// method of a tunnel when you are done.
//
// There are two use cases for this package. The first
// use case is to enable urlgetter to perform measurements
// over tunnels (mainly psiphon).
//
// The second use case is to use tunnels to reach to the
// OONI backend when it's blocked. For the latter case
// we currently mainly use psiphon. In such a case, we'll
// use a psiphon configuration embedded into the OONI
// binary itself. When you are running a version of OONI
// that does not embed such a configuration, it won't
// be possible to address this use case.
//
// See session.go in the engine package for more details
// concerning this second use case.
package tunnel
import (
"context"
"errors"
"fmt"
"net/url"
"time"
)
// 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.
FetchPsiphonConfig(ctx context.Context) ([]byte, error)
}
// Tunnel is a tunnel for communicating with OONI backends
// (and other services) to circumvent blocking.
type Tunnel interface {
// BootstrapTime returns the time it required to
// create a new tunnel instance.
BootstrapTime() time.Duration
// SOCKS5ProxyURL returns the SOCSK5 proxy URL.
SOCKS5ProxyURL() *url.URL
// Stop stops the tunnel. You should not attempt to
// use any other tunnel method after Stop.
Stop()
}
// ErrEmptyTunnelDir indicates that config.TunnelDir is empty.
var ErrEmptyTunnelDir = errors.New("TunnelDir is empty")
// ErrUnsupportedTunnelName indicates that the given tunnel name
// is not supported by this package.
var ErrUnsupportedTunnelName = errors.New("unsupported tunnel name")
// Start starts a new tunnel by name or returns an error. We currently
// support the following tunnels:
//
// The "tor" tunnel requires the "tor" binary to be installed on
// your system. You can use config.TorArgs and config.TorBinary to
// select what binary to execute and with which arguments.
//
// 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
// returns such a configuration.
//
// Otherwise, If there is no embedded psiphon configuration, the
// 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":
return torStart(ctx, config)
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedTunnelName, config.Name)
}
}
+70
View File
@@ -0,0 +1,70 @@
package tunnel_test
import (
"context"
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/tunnel"
)
func TestStartNoTunnel(t *testing.T) {
ctx := context.Background()
tun, err := tunnel.Start(ctx, &tunnel.Config{
Name: "",
Session: &tunnel.MockableSession{},
})
if !errors.Is(err, tunnel.ErrUnsupportedTunnelName) {
t.Fatal("not the error we expected", err)
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartPsiphonWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // fail immediately
tun, err := tunnel.Start(ctx, &tunnel.Config{
Name: "psiphon",
Session: &tunnel.MockableSession{},
TunnelDir: "testdata",
})
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartTorWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // fail immediately
tun, err := tunnel.Start(ctx, &tunnel.Config{
Name: "tor",
Session: &tunnel.MockableSession{},
TunnelDir: "testdata",
})
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartInvalidTunnel(t *testing.T) {
ctx := context.Background()
tun, err := tunnel.Start(ctx, &tunnel.Config{
Name: "antani",
Session: &tunnel.MockableSession{},
TunnelDir: "testdata",
})
if !errors.Is(err, tunnel.ErrUnsupportedTunnelName) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}