feat(tunnel): implement the fake tunnel (#298)

This functionality should be helpful to test that the general
interface of the tunnel package is okay from the engine package.

Part of https://github.com/ooni/probe/issues/985
This commit is contained in:
Simone Basso 2021-04-05 17:41:15 +02:00 committed by GitHub
parent 76a50facc3
commit 973501dd11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 234 additions and 4 deletions

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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{},

View File

@ -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) {

View File

@ -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
}

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/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()
}

View File

@ -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")
}
}

View File

@ -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":