feat: introduce ptx package for pluggable transports dialers (#373)
* feat: introduce ptx package for pluggable transports dialers Version 2 of the pluggable transports specification defines a function that's like `Dial() (net.Conn, error`). Because we use contexts as much as possible in `probe-cli`, we are wrapping such an interface into a `DialContext` func. The code for obfs4 is adapted from https://github.com/ooni/probe-cli/pull/341. The code for snowflake is significantly easier than it is in https://github.com/ooni/probe-cli/pull/341, because now Snowflake supports the PTv2 spec (thanks @cohosh!). The code for setting up a pluggable transport listener has also been adapted from https://github.com/ooni/probe-cli/pull/341. We cannot merge this code yet, because we need unit testing, yet the newly added code already seems suitable for these use cases: 1. testing by dialing and seeing whether we can dial (which is not very useful but still better than not doing it); 2. spawning tor+pluggable transports for circumvention (we need a little more hammering like we did in https://github.com/ooni/probe-cli/pull/341, which is basically https://github.com/ooni/probe/issues/1565, and then we will be able to do that, as demonstrated by the new, simple client which already allows us to use pluggable transports with tor); 3. testing by launching tor (when available) with a set of pluggable transports (which depends on https://github.com/ooni/probe-engine/issues/897 and has not been assigned an issue yet). * fix: tweaks after self code-review * feat: write quick tests for ptx/obfs4 (They run in 0.4s, so I think it's fine for them to always run.) * feat(ptx/snowflake): write unit and integration tests * feat: create a fake PTDialer The idea is that we'll use this simpler PTDialer for testing. * feat: finish writing tests for new package * Apply suggestions from code review * Update internal/ptx/dependencies_test.go Co-authored-by: Arturo Filastò <arturo@openobservatory.org> * Update internal/ptx/dependencies_test.go Co-authored-by: Arturo Filastò <arturo@openobservatory.org> * chore: use as testing bridge one that's used by tor browser The previous testing bridge used to be used by tor browser but it was subsequently removed here: https://gitlab.torproject.org/tpo/applications/tor-browser-build/-/commit/e26e91bef8bd8d04d79bdd69f087efd808bc925d See https://github.com/ooni/probe-cli/pull/373#discussion_r649820724 Co-authored-by: Arturo Filastò <arturo@openobservatory.org>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// UnderlyingDialer is the underlying dialer used for dialing.
|
||||
type UnderlyingDialer interface {
|
||||
// DialContext behaves like net.Dialer.DialContext.
|
||||
DialContext(ctx context.Context, network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Logger allows us to log messages.
|
||||
type Logger interface {
|
||||
// Debugf formats and emits a debug message.
|
||||
Debugf(format string, v ...interface{})
|
||||
|
||||
// Infof formats and emits an informational message.
|
||||
Infof(format string, v ...interface{})
|
||||
|
||||
// Warnf formats and emits a warning message.
|
||||
Warnf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// silentLogger implements Logger.
|
||||
type silentLogger struct{}
|
||||
|
||||
// Debugf implements Logger.Debugf.
|
||||
func (*silentLogger) Debugf(format string, v ...interface{}) {}
|
||||
|
||||
// Infof implements Logger.Infof.
|
||||
func (*silentLogger) Infof(format string, v ...interface{}) {}
|
||||
|
||||
// Warnf implements Logger.Warnf.
|
||||
func (*silentLogger) Warnf(format string, v ...interface{}) {}
|
||||
|
||||
// defaultLogger is the default silentLogger instance.
|
||||
var defaultLogger Logger = &silentLogger{}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ptx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCoverSilentLogger(t *testing.T) {
|
||||
// let us not be distracted by uncovered lines that can
|
||||
// easily be covered, we can easily cover defaultLogger
|
||||
defaultLogger.Debugf("foo")
|
||||
defaultLogger.Infof("bar")
|
||||
defaultLogger.Warnf("baz")
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package ptx contains code to use pluggable transports.
|
||||
package ptx
|
||||
@@ -0,0 +1,32 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// FakeDialer is a fake pluggable transport dialer. It actually
|
||||
// just creates a TCP connection with the given address.
|
||||
type FakeDialer struct {
|
||||
// Address is the real destination address.
|
||||
Address string
|
||||
}
|
||||
|
||||
var _ PTDialer = &FakeDialer{}
|
||||
|
||||
// DialContext establishes a TCP connection with d.Address.
|
||||
func (d *FakeDialer) DialContext(ctx context.Context) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "tcp", d.Address)
|
||||
}
|
||||
|
||||
// AsBridgeArgument returns the argument to be passed to
|
||||
// the tor command line to declare this bridge.
|
||||
func (d *FakeDialer) AsBridgeArgument() string {
|
||||
return fmt.Sprintf("fake %s", d.Address)
|
||||
}
|
||||
|
||||
// Name returns the pluggable transport name.
|
||||
func (d *FakeDialer) Name() string {
|
||||
return "fake"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFakeDialerWorks(t *testing.T) {
|
||||
fd := &FakeDialer{Address: "8.8.8.8:53"}
|
||||
conn, err := fd.DialContext(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fd.Name() != "fake" {
|
||||
t.Fatal("invalid value returned by fd.Name")
|
||||
}
|
||||
if fd.AsBridgeArgument() != "fake 8.8.8.8:53" {
|
||||
t.Fatal("invalid value returned by fd.AsBridgeArgument")
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
pt "git.torproject.org/pluggable-transports/goptlib.git"
|
||||
"github.com/ooni/probe-cli/v3/internal/runtimex"
|
||||
"gitlab.com/yawning/obfs4.git/transports/base"
|
||||
"gitlab.com/yawning/obfs4.git/transports/obfs4"
|
||||
)
|
||||
|
||||
// DefaultTestingOBFS4Bridge is a factory that returns you
|
||||
// an OBFS4Dialer configured for the bridge we use by default
|
||||
// when testing. Of course, given the nature of obfs4, it's
|
||||
// not wise to use this bridge in general. But, feel free to
|
||||
// use this bridge for integration testing of this code.
|
||||
func DefaultTestingOBFS4Bridge() *OBFS4Dialer {
|
||||
// TODO(bassosimone): this is a public working bridge we have found
|
||||
// with @hellais. We should ask @cohosh whether there's some obfs4 bridge
|
||||
// dedicated to integration testing that we should use instead.
|
||||
return &OBFS4Dialer{
|
||||
Address: "192.95.36.142:443",
|
||||
Cert: "qUVQ0srL1JI/vO6V6m/24anYXiJD3QP2HgzUKQtQ7GRqqUvs7P+tG43RtAqdhLOALP7DJQ",
|
||||
DataDir: "testdata",
|
||||
Fingerprint: "CDF2E852BF539B82BD10E27E9115A31734E378C2",
|
||||
IATMode: "1",
|
||||
}
|
||||
}
|
||||
|
||||
// OBFS4Dialer is a dialer for obfs4. Make sure you fill all
|
||||
// the fields marked as mandatory before using.
|
||||
type OBFS4Dialer struct {
|
||||
// Address contains the MANDATORY proxy address.
|
||||
Address string
|
||||
|
||||
// Cert contains the MANDATORY certificate parameter.
|
||||
Cert string
|
||||
|
||||
// DataDir is the MANDATORY directory where to store obfs4 data.
|
||||
DataDir string
|
||||
|
||||
// Fingerprint is the MANDATORY bridge fingerprint.
|
||||
Fingerprint string
|
||||
|
||||
// IATMode contains the MANDATORY iat-mode parameter.
|
||||
IATMode string
|
||||
|
||||
// UnderlyingDialer is the optional underlying dialer to
|
||||
// use. If not set, we will use &net.Dialer{}.
|
||||
UnderlyingDialer UnderlyingDialer
|
||||
}
|
||||
|
||||
// DialContext establishes a connection with the given obfs4 proxy. The context
|
||||
// argument allows to interrupt this operation midway.
|
||||
func (d *OBFS4Dialer) DialContext(ctx context.Context) (net.Conn, error) {
|
||||
cd, err := d.newCancellableDialer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cd.dial(ctx, "tcp", d.Address)
|
||||
}
|
||||
|
||||
// newCancellableDialer constructs a new cancellable dialer. This function
|
||||
// is separate from DialContext for testing purposes.
|
||||
func (d *OBFS4Dialer) newCancellableDialer() (*obfs4CancellableDialer, error) {
|
||||
factory := d.newFactory()
|
||||
parsedargs, err := d.parseargs(factory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &obfs4CancellableDialer{
|
||||
done: make(chan interface{}),
|
||||
ud: d.underlyingDialer(), // choose proper dialer
|
||||
factory: factory,
|
||||
parsedargs: parsedargs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newFactory creates an obfs4 factory instance.
|
||||
func (d *OBFS4Dialer) newFactory() base.ClientFactory {
|
||||
o4f := &obfs4.Transport{}
|
||||
cf, err := o4f.ClientFactory(filepath.Join(d.DataDir, "obfs4"))
|
||||
// the source code for this transport always returns a nil error
|
||||
runtimex.PanicOnError(err, "unexpected o4f.ClientFactory failure")
|
||||
return cf
|
||||
}
|
||||
|
||||
// parseargs parses the obfs4 arguments.
|
||||
func (d *OBFS4Dialer) parseargs(factory base.ClientFactory) (interface{}, error) {
|
||||
args := &pt.Args{"cert": []string{d.Cert}, "iat-mode": []string{d.IATMode}}
|
||||
return factory.ParseArgs(args)
|
||||
}
|
||||
|
||||
// underlyingDialer returns a suitable UnderlyingDialer.
|
||||
func (d *OBFS4Dialer) underlyingDialer() UnderlyingDialer {
|
||||
if d.UnderlyingDialer != nil {
|
||||
return d.UnderlyingDialer
|
||||
}
|
||||
return &net.Dialer{
|
||||
Timeout: 15 * time.Second, // eventually interrupt connect
|
||||
}
|
||||
}
|
||||
|
||||
// obfs4CancellableDialer is a cancellable dialer for obfs4. It will run
|
||||
// the dial proper in a background goroutine, thus allowing for its early
|
||||
// cancellation.
|
||||
type obfs4CancellableDialer struct {
|
||||
// done is a channel that will be closed when done. In normal
|
||||
// usage you don't want to await for this signal. But it's useful
|
||||
// for testing to know that the background goroutine joined.
|
||||
done chan interface{}
|
||||
|
||||
// factory is the factory for obfs4.
|
||||
factory base.ClientFactory
|
||||
|
||||
// parsedargs contains the parsed args for obfs4.
|
||||
parsedargs interface{}
|
||||
|
||||
// ud is the underlying Dialer to use.
|
||||
ud UnderlyingDialer
|
||||
}
|
||||
|
||||
// dial performs the dial.
|
||||
func (d *obfs4CancellableDialer) dial(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
connch, errch := make(chan net.Conn), make(chan error, 1)
|
||||
go func() {
|
||||
defer close(d.done) // signal we're joining
|
||||
conn, err := d.factory.Dial(network, address, d.innerDial, d.parsedargs)
|
||||
if err != nil {
|
||||
errch <- err // buffered channel
|
||||
return
|
||||
}
|
||||
select {
|
||||
case connch <- conn:
|
||||
default:
|
||||
conn.Close() // context won the race
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case err := <-errch:
|
||||
return nil, err
|
||||
case conn := <-connch:
|
||||
return conn, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// innerDial performs the inner dial using the underlying dialer.
|
||||
func (d *obfs4CancellableDialer) innerDial(network, address string) (net.Conn, error) {
|
||||
return d.ud.DialContext(context.Background(), network, address)
|
||||
}
|
||||
|
||||
// AsBridgeArgument returns the argument to be passed to
|
||||
// the tor command line to declare this bridge.
|
||||
func (d *OBFS4Dialer) AsBridgeArgument() string {
|
||||
return fmt.Sprintf("obfs4 %s %s cert=%s iat-mode=%s",
|
||||
d.Address, d.Fingerprint, d.Cert, d.IATMode)
|
||||
}
|
||||
|
||||
// Name returns the pluggable transport name.
|
||||
func (d *OBFS4Dialer) Name() string {
|
||||
return "obfs4"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ooni/probe-cli/v3/internal/atomicx"
|
||||
"github.com/ooni/probe-cli/v3/internal/engine/netx/mockablex"
|
||||
)
|
||||
|
||||
func TestOBFS4DialerWorks(t *testing.T) {
|
||||
// This test is 0.3 seconds in my machine, so it's ~fine
|
||||
// to run it even when we're in short mode
|
||||
o4d := DefaultTestingOBFS4Bridge()
|
||||
conn, err := o4d.DialContext(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("expected non-nil conn here")
|
||||
}
|
||||
if o4d.Name() != "obfs4" {
|
||||
t.Fatal("unexpected value returned by Name")
|
||||
}
|
||||
bridgearg := o4d.AsBridgeArgument()
|
||||
expectedbridge := "obfs4 192.95.36.142:443 CDF2E852BF539B82BD10E27E9115A31734E378C2 cert=qUVQ0srL1JI/vO6V6m/24anYXiJD3QP2HgzUKQtQ7GRqqUvs7P+tG43RtAqdhLOALP7DJQ iat-mode=1"
|
||||
if bridgearg != expectedbridge {
|
||||
t.Fatal("unexpected AsBridgeArgument value", bridgearg)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestOBFS4DialerFailsWithInvalidCert(t *testing.T) {
|
||||
o4d := DefaultTestingOBFS4Bridge()
|
||||
o4d.Cert = "antani!!!"
|
||||
conn, err := o4d.DialContext(context.Background())
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "failed to decode cert:") {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOBFS4DialerFailsWithConnectionErrorAndNoContextExpiration(t *testing.T) {
|
||||
expected := errors.New("mocked error")
|
||||
o4d := DefaultTestingOBFS4Bridge()
|
||||
o4d.UnderlyingDialer = &mockablex.Dialer{
|
||||
MockDialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
return nil, expected
|
||||
},
|
||||
}
|
||||
conn, err := o4d.DialContext(context.Background())
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOBFS4DialerFailsWithConnectionErrorAndContextExpiration(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
expected := errors.New("mocked error")
|
||||
o4d := DefaultTestingOBFS4Bridge()
|
||||
o4d.UnderlyingDialer = &mockablex.Dialer{
|
||||
MockDialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
// We cancel the context before returning the error, which makes
|
||||
// the context cancellation happen before us returning.
|
||||
cancel()
|
||||
return nil, expected
|
||||
},
|
||||
}
|
||||
conn, err := o4d.DialContext(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
}
|
||||
|
||||
// obfs4connwrapper allows us to observe that Close has been called
|
||||
type obfs4connwrapper struct {
|
||||
net.Conn
|
||||
called *atomicx.Int64
|
||||
}
|
||||
|
||||
// Close implements net.Conn.Close
|
||||
func (c *obfs4connwrapper) Close() error {
|
||||
c.called.Add(1)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
func TestOBFS4DialerWorksWithContextExpiration(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
called := &atomicx.Int64{}
|
||||
o4d := DefaultTestingOBFS4Bridge()
|
||||
o4d.UnderlyingDialer = &mockablex.Dialer{
|
||||
MockDialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
// We cancel the context before returning the error, which makes
|
||||
// the context cancellation happen before us returning.
|
||||
cancel()
|
||||
conn, err := net.Dial(network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &obfs4connwrapper{
|
||||
Conn: conn,
|
||||
called: called,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
cd, err := o4d.newCancellableDialer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := cd.dial(ctx, "tcp", o4d.Address)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
// The point of returning early when the context expires is
|
||||
// to NOT wait for the background goroutine to terminate, but
|
||||
// here we wanna observe whether it terminates and whether
|
||||
// it calls close. Hence, well, we need to wait :^).
|
||||
<-cd.done
|
||||
if called.Load() != 1 {
|
||||
t.Fatal("the goroutine did not call close")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package ptx
|
||||
|
||||
/*-
|
||||
This file is derived from client/snowflake.go
|
||||
in git.torproject.org/pluggable-transports/snowflake.git
|
||||
whose license is the following:
|
||||
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2016, Serene Han, Arlo Breault
|
||||
Copyright (c) 2019-2020, The Tor Project, Inc
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation and/or
|
||||
other materials provided with the distribution.
|
||||
|
||||
* Neither the names of the copyright owners nor the names of its
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
================================================================================
|
||||
*/
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
pt "git.torproject.org/pluggable-transports/goptlib.git"
|
||||
)
|
||||
|
||||
// PTDialer is a generic pluggable transports dialer.
|
||||
type PTDialer interface {
|
||||
// DialContext establishes a connection to the pluggable
|
||||
// transport backend according to PT-specific configuration
|
||||
// and returns you such a connection.
|
||||
DialContext(ctx context.Context) (net.Conn, error)
|
||||
|
||||
// AsBridgeArgument returns the argument to be passed to
|
||||
// the tor command line to declare this bridge.
|
||||
AsBridgeArgument() string
|
||||
|
||||
// Name returns the pluggable transport name.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Listener is a generic pluggable transports listener. Make sure
|
||||
// you fill the mandatory fields before using it. Do not modify public
|
||||
// fields after you called Start, since this causes data races.
|
||||
type Listener struct {
|
||||
// PTDialer is the MANDATORY pluggable transports dialer
|
||||
// to use. Both SnowflakeDialer and OBFS4Dialer implement this
|
||||
// interface and can be thus safely used here.
|
||||
PTDialer PTDialer
|
||||
|
||||
// Logger is the optional logger. When not set, this library
|
||||
// will not emit logs. (But the underlying pluggable transport
|
||||
// may still emit its own log messages.)
|
||||
Logger Logger
|
||||
|
||||
// mu provides mutual exclusion for accessing internals.
|
||||
mu sync.Mutex
|
||||
|
||||
// cancel allows stopping the forwarders.
|
||||
cancel context.CancelFunc
|
||||
|
||||
// laddr is the listen address.
|
||||
laddr net.Addr
|
||||
|
||||
// listener allows us to stop the listener.
|
||||
listener ptxSocksListener
|
||||
|
||||
// overrideListenSocks allows us to override pt.ListenSocks.
|
||||
overrideListenSocks func(network string, laddr string) (ptxSocksListener, error)
|
||||
}
|
||||
|
||||
// logger returns the Logger, if set, or the defaultLogger.
|
||||
func (lst *Listener) logger() Logger {
|
||||
if lst.Logger != nil {
|
||||
return lst.Logger
|
||||
}
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
// forward forwards the traffic from left to right and from right to left
|
||||
// and closes the done channel when it is done. This function DOES NOT
|
||||
// take ownership of the left, right net.Conn arguments.
|
||||
func (lst *Listener) forward(left, right net.Conn, done chan struct{}) {
|
||||
defer close(done) // signal termination
|
||||
wg := new(sync.WaitGroup)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(left, right)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(right, left)
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// forwardWithContext forwards the traffic from left to right and
|
||||
// form right to left, interrupting when the context is done. This
|
||||
// function TAKES OWNERSHIP of the two connections and ensures
|
||||
// that they are closed when we are done.
|
||||
func (lst *Listener) forwardWithContext(ctx context.Context, left, right net.Conn) {
|
||||
defer left.Close()
|
||||
defer right.Close()
|
||||
done := make(chan struct{})
|
||||
go lst.forward(left, right, done)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
// handleSocksConn handles a new SocksConn connection by establishing
|
||||
// the corresponding PT connection and forwarding traffic. This
|
||||
// function TAKES OWNERSHIP of the socksConn argument.
|
||||
func (lst *Listener) handleSocksConn(ctx context.Context, socksConn ptxSocksConn) error {
|
||||
err := socksConn.Grant(&net.TCPAddr{IP: net.IPv4zero, Port: 0})
|
||||
if err != nil {
|
||||
lst.logger().Warnf("ptx: socksConn.Grant error: %s", err)
|
||||
return err // used for testing
|
||||
}
|
||||
ptConn, err := lst.PTDialer.DialContext(ctx)
|
||||
if err != nil {
|
||||
socksConn.Close() // we own it
|
||||
lst.logger().Warnf("ptx: ContextDialer.DialContext error: %s", err)
|
||||
return err // used for testing
|
||||
}
|
||||
lst.forwardWithContext(ctx, socksConn, ptConn) // transfer ownership
|
||||
return nil // used for testing
|
||||
}
|
||||
|
||||
// ptxSocksListener is a pt.SocksListener-like structure.
|
||||
type ptxSocksListener interface {
|
||||
// AcceptSocks accepts a socks conn
|
||||
AcceptSocks() (ptxSocksConn, error)
|
||||
|
||||
// Addr returns the listening address.
|
||||
Addr() net.Addr
|
||||
|
||||
// Close closes the listener
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ptxSocksConn is a pt.SocksConn-like structure.
|
||||
type ptxSocksConn interface {
|
||||
// net.Conn is the embedded interface.
|
||||
net.Conn
|
||||
|
||||
// Grant grants access to a specific IP address.
|
||||
Grant(addr *net.TCPAddr) error
|
||||
}
|
||||
|
||||
// acceptLoop accepts and handles local socks connection. This function
|
||||
// DOES NOT take ownership of the socks listener.
|
||||
func (lst *Listener) acceptLoop(ctx context.Context, ln ptxSocksListener) {
|
||||
for {
|
||||
conn, err := ln.AcceptSocks()
|
||||
if err != nil {
|
||||
if err, ok := err.(net.Error); ok && err.Temporary() {
|
||||
continue
|
||||
}
|
||||
lst.logger().Warnf("ptx: socks accept error: %s", err)
|
||||
return
|
||||
}
|
||||
go lst.handleSocksConn(ctx, conn)
|
||||
}
|
||||
}
|
||||
|
||||
// Addr returns the listening address. This function should not
|
||||
// be called after you have called the Stop method or before the
|
||||
// Start method has successfully returned. When invoked in such
|
||||
// conditions, this function may return nil. Otherwise, it will
|
||||
// return the valid net.Addr where we are listening.
|
||||
func (lst *Listener) Addr() net.Addr {
|
||||
return lst.laddr
|
||||
}
|
||||
|
||||
// Start starts the pluggable transport Listener. The pluggable transport will
|
||||
// run in a background goroutine until txp.Stop is called. Attempting to
|
||||
// call Start when the pluggable transport is already running is a
|
||||
// no-op causing no error and no data races.
|
||||
func (lst *Listener) Start() error {
|
||||
lst.mu.Lock()
|
||||
defer lst.mu.Unlock()
|
||||
if lst.cancel != nil {
|
||||
return nil // already started
|
||||
}
|
||||
// TODO(bassosimone): be able to recover when SOCKS dies?
|
||||
ln, err := lst.listenSocks("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lst.laddr = ln.Addr()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
lst.cancel = cancel
|
||||
lst.listener = ln
|
||||
go lst.acceptLoop(ctx, ln)
|
||||
lst.logger().Infof("ptx: started socks listener at %v", ln.Addr())
|
||||
lst.logger().Debugf("ptx: test with `%s`", lst.torCmdLine())
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenSocks calles either pt.ListenSocks or lst.overrideListenSocks.
|
||||
func (lst *Listener) listenSocks(network string, laddr string) (ptxSocksListener, error) {
|
||||
if lst.overrideListenSocks != nil {
|
||||
return lst.overrideListenSocks(network, laddr)
|
||||
}
|
||||
return lst.castListener(pt.ListenSocks(network, laddr))
|
||||
}
|
||||
|
||||
// castListener casts a pt.SocksListener to ptxSocksListener.
|
||||
func (lst *Listener) castListener(in *pt.SocksListener, err error) (ptxSocksListener, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ptxSocksListenerAdapter{in}, nil
|
||||
}
|
||||
|
||||
// ptxSocksListenerAdapter adapts pt.SocksListener to ptxSocksListener.
|
||||
type ptxSocksListenerAdapter struct {
|
||||
*pt.SocksListener
|
||||
}
|
||||
|
||||
// AcceptSocks adapts pt.SocksListener.AcceptSocks to ptxSockListener.AcceptSocks.
|
||||
func (la *ptxSocksListenerAdapter) AcceptSocks() (ptxSocksConn, error) {
|
||||
return la.SocksListener.AcceptSocks()
|
||||
}
|
||||
|
||||
// torCmdLine prints the command line for testing this listener. This method is here to
|
||||
// facilitate debugging with `ptxclient`, so there is no need to be too precise with arguments
|
||||
// quoting. Remember to improve upon this aspect if you plan on using it beyond testing.
|
||||
func (lst *Listener) torCmdLine() string {
|
||||
return strings.Join([]string{
|
||||
"tor",
|
||||
"DataDirectory",
|
||||
"testdata",
|
||||
"UseBridges",
|
||||
"1",
|
||||
"ClientTransportPlugin",
|
||||
"'" + lst.AsClientTransportPluginArgument() + "'",
|
||||
"Bridge",
|
||||
"'" + lst.PTDialer.AsBridgeArgument() + "'",
|
||||
}, " ")
|
||||
}
|
||||
|
||||
// Stop stops the pluggable transport. This method is idempotent
|
||||
// and asks the background goroutine(s) to stop just once. Also, this
|
||||
// method is safe to call from any goroutine.
|
||||
func (lst *Listener) Stop() {
|
||||
defer lst.mu.Unlock()
|
||||
lst.mu.Lock()
|
||||
if lst.cancel != nil {
|
||||
lst.cancel() // cancel is idempotent
|
||||
}
|
||||
if lst.listener != nil {
|
||||
lst.listener.Close() // should be idempotent
|
||||
}
|
||||
}
|
||||
|
||||
// AsClientTransportPluginArgument converts the current configuration
|
||||
// of the pluggable transport to a ClientTransportPlugin argument to be
|
||||
// passed to the tor daemon command line. This function must be
|
||||
// called after Start and before Stop so that we have a valid Addr.
|
||||
//
|
||||
// Assuming that we are listening at 127.0.0.1:12345, then this
|
||||
// function will return the following string:
|
||||
//
|
||||
// obfs4 socks5 127.0.0.1:12345
|
||||
//
|
||||
// The correct configuration line for the `torrc` would be:
|
||||
//
|
||||
// ClientTransportPlugin obfs4 socks5 127.0.0.1:12345
|
||||
//
|
||||
// Since we pass configuration to tor using the command line, it
|
||||
// is more convenient to us to avoid including ClientTransportPlugin
|
||||
// in the returned string. In fact, ClientTransportPlugin and its
|
||||
// arguments need to be two consecutive argv strings.
|
||||
func (lst *Listener) AsClientTransportPluginArgument() string {
|
||||
return fmt.Sprintf("%s socks5 %s", lst.PTDialer.Name(), lst.laddr.String())
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apex/log"
|
||||
"github.com/ooni/probe-cli/v3/internal/atomicx"
|
||||
"github.com/ooni/probe-cli/v3/internal/engine/netx/mockablex"
|
||||
)
|
||||
|
||||
func TestListenerLoggerWorks(t *testing.T) {
|
||||
lst := &Listener{Logger: log.Log}
|
||||
if lst.logger() != log.Log {
|
||||
t.Fatal("logger() returned an unexpected value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerWorksWithFakeDialer(t *testing.T) {
|
||||
// start the fake PT
|
||||
fd := &FakeDialer{Address: "google.com:80"}
|
||||
lst := &Listener{PTDialer: fd}
|
||||
if err := lst.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// calling lst.Start again should be idempotent and race-free
|
||||
if err := lst.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// let us now _use_ the PT with a custom HTTP client.
|
||||
addr := lst.Addr()
|
||||
if addr == nil {
|
||||
t.Fatal("expected non-nil addr here")
|
||||
}
|
||||
URL := &url.URL{Scheme: "socks5", Host: addr.String()}
|
||||
clnt := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// no redirection because we force connecting to google.com:80
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{Proxy: func(r *http.Request) (*url.URL, error) {
|
||||
// force always using this proxy
|
||||
return URL, nil
|
||||
}},
|
||||
}
|
||||
resp, err := clnt.Get("http://google.com/humans.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log(len(data))
|
||||
resp.Body.Close()
|
||||
clnt.CloseIdleConnections()
|
||||
|
||||
// Stop the listener
|
||||
lst.Stop()
|
||||
lst.Stop() // should be idempotent and race free
|
||||
}
|
||||
|
||||
func TestListenerCannotListen(t *testing.T) {
|
||||
expected := errors.New("mocked error")
|
||||
lst := &Listener{
|
||||
overrideListenSocks: func(network, laddr string) (ptxSocksListener, error) {
|
||||
return nil, expected
|
||||
},
|
||||
}
|
||||
if err := lst.Start(); !errors.Is(err, expected) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerCastListenerWorksFineOnError(t *testing.T) {
|
||||
expected := errors.New("mocked error")
|
||||
lst := &Listener{}
|
||||
out, err := lst.castListener(nil, expected)
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatal("expected to see nil here")
|
||||
}
|
||||
}
|
||||
|
||||
// mockableSocksConn is a mockable ptxSocksConn.
|
||||
type mockableSocksConn struct {
|
||||
// mockablex.Conn allows to mock all net.Conn functionality.
|
||||
*mockablex.Conn
|
||||
|
||||
// MockGrant allows to mock the Grant function.
|
||||
MockGrant func(addr *net.TCPAddr) error
|
||||
}
|
||||
|
||||
// Grant grants access to a specific IP address.
|
||||
func (c *mockableSocksConn) Grant(addr *net.TCPAddr) error {
|
||||
return c.MockGrant(addr)
|
||||
}
|
||||
|
||||
func TestListenerHandleSocksConnWithGrantFailure(t *testing.T) {
|
||||
expected := errors.New("mocked error")
|
||||
lst := &Listener{}
|
||||
c := &mockableSocksConn{
|
||||
MockGrant: func(addr *net.TCPAddr) error {
|
||||
return expected
|
||||
},
|
||||
}
|
||||
err := lst.handleSocksConn(context.Background(), c)
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mockableDialer is a mockable PTDialer
|
||||
type mockableDialer struct {
|
||||
// MockDialContext allows to mock DialContext.
|
||||
MockDialContext func(ctx context.Context) (net.Conn, error)
|
||||
|
||||
// MockAsBridgeArgument allows to mock AsBridgeArgument.
|
||||
MockAsBridgeArgument func() string
|
||||
|
||||
// MockName allows to mock Name.
|
||||
MockName func() string
|
||||
}
|
||||
|
||||
// DialContext implements PTDialer.DialContext.
|
||||
func (d *mockableDialer) DialContext(ctx context.Context) (net.Conn, error) {
|
||||
return d.MockDialContext(ctx)
|
||||
}
|
||||
|
||||
// AsBridgeArgument implements PTDialer.AsBridgeArgument.
|
||||
func (d *mockableDialer) AsBridgeArgument() string {
|
||||
return d.MockAsBridgeArgument()
|
||||
}
|
||||
|
||||
// Name implements PTDialer.Name.
|
||||
func (d *mockableDialer) Name() string {
|
||||
return d.MockName()
|
||||
}
|
||||
|
||||
var _ PTDialer = &mockableDialer{}
|
||||
|
||||
func TestListenerHandleSocksConnWithDialContextFailure(t *testing.T) {
|
||||
expected := errors.New("mocked error")
|
||||
d := &mockableDialer{
|
||||
MockDialContext: func(ctx context.Context) (net.Conn, error) {
|
||||
return nil, expected
|
||||
},
|
||||
}
|
||||
lst := &Listener{PTDialer: d}
|
||||
c := &mockableSocksConn{
|
||||
Conn: &mockablex.Conn{
|
||||
MockClose: func() error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
MockGrant: func(addr *net.TCPAddr) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
err := lst.handleSocksConn(context.Background(), c)
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerForwardWithContextWithContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
lst := &Listener{}
|
||||
left, right := net.Pipe()
|
||||
go lst.forwardWithContext(ctx, left, right)
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestListenerForwardWithNaturalTermination(t *testing.T) {
|
||||
lst := &Listener{}
|
||||
left, right := net.Pipe()
|
||||
go lst.forwardWithContext(context.Background(), left, right)
|
||||
right.Close()
|
||||
}
|
||||
|
||||
// mockableSocksListener is a mockable ptxSocksListener.
|
||||
type mockableSocksListener struct {
|
||||
// MockAcceptSocks allows to mock AcceptSocks.
|
||||
MockAcceptSocks func() (ptxSocksConn, error)
|
||||
|
||||
// MockAddr allows to mock Addr.
|
||||
MockAddr func() net.Addr
|
||||
|
||||
// MockClose allows to mock Close.
|
||||
MockClose func() error
|
||||
}
|
||||
|
||||
// AcceptSocks implemements ptxSocksListener.AcceptSocks.
|
||||
func (m *mockableSocksListener) AcceptSocks() (ptxSocksConn, error) {
|
||||
return m.MockAcceptSocks()
|
||||
}
|
||||
|
||||
// Addr implemements ptxSocksListener.Addr.
|
||||
func (m *mockableSocksListener) Addr() net.Addr {
|
||||
return m.MockAddr()
|
||||
}
|
||||
|
||||
// Close implemements ptxSocksListener.Close.
|
||||
func (m *mockableSocksListener) Close() error {
|
||||
return m.MockClose()
|
||||
}
|
||||
|
||||
func TestListenerLoopWithTemporaryError(t *testing.T) {
|
||||
isclosed := &atomicx.Int64{}
|
||||
sl := &mockableSocksListener{
|
||||
MockAcceptSocks: func() (ptxSocksConn, error) {
|
||||
if isclosed.Load() > 0 {
|
||||
return nil, io.EOF
|
||||
}
|
||||
// this error should be temporary
|
||||
return nil, &net.OpError{
|
||||
Op: "accept",
|
||||
Err: syscall.ECONNABORTED,
|
||||
}
|
||||
},
|
||||
MockClose: func() error {
|
||||
isclosed.Add(1)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
lst := &Listener{
|
||||
cancel: func() {},
|
||||
listener: sl,
|
||||
}
|
||||
go lst.acceptLoop(context.Background(), sl)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
lst.Stop()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
sflib "git.torproject.org/pluggable-transports/snowflake.git/client/lib"
|
||||
)
|
||||
|
||||
// SnowflakeDialer is a dialer for snowflake. When optional fields are
|
||||
// not specified, we use defaults from the snowflake repository.
|
||||
type SnowflakeDialer struct {
|
||||
// BrokerURL is the optional broker URL. If not specified,
|
||||
// we will be using a sensible default value.
|
||||
BrokerURL string
|
||||
|
||||
// FrontDomain is the domain to use for fronting. If not
|
||||
// specified, we will be using a sensible default.
|
||||
FrontDomain string
|
||||
|
||||
// ICEAddresses contains the addresses to use for ICE. If not
|
||||
// specified, we will be using a sensible default.
|
||||
ICEAddresses []string
|
||||
|
||||
// MaxSnowflakes is the maximum number of snowflakes we
|
||||
// should create per dialer. If negative or zero, we will
|
||||
// be using a sensible default.
|
||||
MaxSnowflakes int
|
||||
|
||||
// newClientTransport is an optional hook for creating
|
||||
// an alternative snowflakeTransport in testing.
|
||||
newClientTransport func(brokerURL string, frontDomain string,
|
||||
iceAddresses []string, keepLocalAddresses bool,
|
||||
maxSnowflakes int) (snowflakeTransport, error)
|
||||
}
|
||||
|
||||
// snowflakeTransport is anything that allows us to dial a snowflake
|
||||
type snowflakeTransport interface {
|
||||
Dial() (net.Conn, error)
|
||||
}
|
||||
|
||||
// DialContext establishes a connection with the given SF proxy. The context
|
||||
// argument allows to interrupt this operation midway.
|
||||
func (d *SnowflakeDialer) DialContext(ctx context.Context) (net.Conn, error) {
|
||||
conn, _, err := d.dialContext(ctx)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (d *SnowflakeDialer) dialContext(
|
||||
ctx context.Context) (net.Conn, chan interface{}, error) {
|
||||
done := make(chan interface{})
|
||||
txp, err := d.newSnowflakeClient(
|
||||
d.brokerURL(), d.frontDomain(), d.iceAddresses(),
|
||||
false, d.maxSnowflakes(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
connch, errch := make(chan net.Conn), make(chan error, 1)
|
||||
go func() {
|
||||
defer close(done) // allow tests to synchronize with this goroutine's exit
|
||||
conn, err := txp.Dial()
|
||||
if err != nil {
|
||||
errch <- err // buffered channel
|
||||
return
|
||||
}
|
||||
select {
|
||||
case connch <- conn:
|
||||
default:
|
||||
conn.Close() // context won the race
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case conn := <-connch:
|
||||
return conn, done, nil
|
||||
case err := <-errch:
|
||||
return nil, done, err
|
||||
case <-ctx.Done():
|
||||
return nil, done, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// newSnowflakeClient allows us to call a mock rather than
|
||||
// the real sflib.NewSnowflakeClient.
|
||||
func (d *SnowflakeDialer) newSnowflakeClient(brokerURL string, frontDomain string,
|
||||
iceAddresses []string, keepLocalAddresses bool,
|
||||
maxSnowflakes int) (snowflakeTransport, error) {
|
||||
if d.newClientTransport != nil {
|
||||
return d.newClientTransport(brokerURL, frontDomain, iceAddresses,
|
||||
keepLocalAddresses, maxSnowflakes)
|
||||
}
|
||||
return sflib.NewSnowflakeClient(
|
||||
brokerURL, frontDomain, iceAddresses,
|
||||
keepLocalAddresses, maxSnowflakes)
|
||||
}
|
||||
|
||||
// brokerURL returns a suitable broker URL.
|
||||
func (d *SnowflakeDialer) brokerURL() string {
|
||||
if d.BrokerURL != "" {
|
||||
return d.BrokerURL
|
||||
}
|
||||
return "https://snowflake-broker.torproject.net.global.prod.fastly.net/"
|
||||
}
|
||||
|
||||
// frontDomain returns a suitable front domain.
|
||||
func (d *SnowflakeDialer) frontDomain() string {
|
||||
if d.FrontDomain != "" {
|
||||
return d.FrontDomain
|
||||
}
|
||||
return "cdn.sstatic.net"
|
||||
}
|
||||
|
||||
// iceAddresses returns suitable ICE addresses.
|
||||
func (d *SnowflakeDialer) iceAddresses() []string {
|
||||
if len(d.ICEAddresses) > 0 {
|
||||
return d.ICEAddresses
|
||||
}
|
||||
// TODO(bassosimone): add them to the stunreachability
|
||||
return []string{
|
||||
"stun:stun.voip.blackberry.com:3478",
|
||||
"stun:stun.altar.com.pl:3478",
|
||||
"stun:stun.antisip.com:3478",
|
||||
"stun:stun.bluesip.net:3478",
|
||||
"stun:stun.dus.net:3478",
|
||||
"stun:stun.epygi.com:3478",
|
||||
"stun:stun.sonetel.com:3478",
|
||||
"stun:stun.sonetel.net:3478",
|
||||
"stun:stun.stunprotocol.org:3478",
|
||||
"stun:stun.uls.co.za:3478",
|
||||
"stun:stun.voipgate.com:3478",
|
||||
"stun:stun.voys.nl:3478",
|
||||
}
|
||||
}
|
||||
|
||||
// maxSnowflakes returns the number of snowflakes to collect.
|
||||
func (d *SnowflakeDialer) maxSnowflakes() int {
|
||||
if d.MaxSnowflakes > 0 {
|
||||
return d.MaxSnowflakes
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// AsBridgeArgument returns the argument to be passed to
|
||||
// the tor command line to declare this bridge.
|
||||
func (d *SnowflakeDialer) AsBridgeArgument() string {
|
||||
return "snowflake 192.0.2.3:1 2B280B23E1107BB62ABFC40DDCC8824814F80A72"
|
||||
}
|
||||
|
||||
// Name returns the pluggable transport name.
|
||||
func (d *SnowflakeDialer) Name() string {
|
||||
return "snowflake"
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package ptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/ooni/probe-cli/v3/internal/atomicx"
|
||||
"github.com/ooni/probe-cli/v3/internal/engine/netx/mockablex"
|
||||
)
|
||||
|
||||
func TestSnowflakeDialerWorks(t *testing.T) {
|
||||
// This test may sadly run for a very long time (~10s)
|
||||
if testing.Short() {
|
||||
t.Skip("skip test in short mode")
|
||||
}
|
||||
sfd := &SnowflakeDialer{}
|
||||
conn, err := sfd.DialContext(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("expected non-nil conn here")
|
||||
}
|
||||
if sfd.Name() != "snowflake" {
|
||||
t.Fatal("the Name function returned an unexpected value")
|
||||
}
|
||||
expect := "snowflake 192.0.2.3:1 2B280B23E1107BB62ABFC40DDCC8824814F80A72"
|
||||
if v := sfd.AsBridgeArgument(); v != expect {
|
||||
t.Fatal("AsBridgeArgument returned an unexpected value", v)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
// mockableSnowflakeTransport is a mock for snowflakeTransport
|
||||
type mockableSnowflakeTransport struct {
|
||||
MockDial func() (net.Conn, error)
|
||||
}
|
||||
|
||||
// Dial implements snowflakeTransport.Dial.
|
||||
func (txp *mockableSnowflakeTransport) Dial() (net.Conn, error) {
|
||||
return txp.MockDial()
|
||||
}
|
||||
|
||||
var _ snowflakeTransport = &mockableSnowflakeTransport{}
|
||||
|
||||
func TestSnowflakeDialerWorksWithMocks(t *testing.T) {
|
||||
sfd := &SnowflakeDialer{
|
||||
newClientTransport: func(brokerURL, frontDomain string, iceAddresses []string, keepLocalAddresses bool, maxSnowflakes int) (snowflakeTransport, error) {
|
||||
return &mockableSnowflakeTransport{
|
||||
MockDial: func() (net.Conn, error) {
|
||||
return &mockablex.Conn{
|
||||
MockClose: func() error {
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
conn, err := sfd.DialContext(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("expected non-nil conn here")
|
||||
}
|
||||
if sfd.Name() != "snowflake" {
|
||||
t.Fatal("the Name function returned an unexpected value")
|
||||
}
|
||||
expect := "snowflake 192.0.2.3:1 2B280B23E1107BB62ABFC40DDCC8824814F80A72"
|
||||
if v := sfd.AsBridgeArgument(); v != expect {
|
||||
t.Fatal("AsBridgeArgument returned an unexpected value", v)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestSnowflakeDialerCannotCreateTransport(t *testing.T) {
|
||||
expected := errors.New("mocked error")
|
||||
sfd := &SnowflakeDialer{
|
||||
newClientTransport: func(brokerURL, frontDomain string, iceAddresses []string, keepLocalAddresses bool, maxSnowflakes int) (snowflakeTransport, error) {
|
||||
return nil, expected
|
||||
},
|
||||
}
|
||||
conn, err := sfd.DialContext(context.Background())
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnowflakeDialerCannotCreateConnWithNoContextExpiration(t *testing.T) {
|
||||
expected := errors.New("mocked error")
|
||||
sfd := &SnowflakeDialer{
|
||||
newClientTransport: func(brokerURL, frontDomain string, iceAddresses []string, keepLocalAddresses bool, maxSnowflakes int) (snowflakeTransport, error) {
|
||||
return &mockableSnowflakeTransport{
|
||||
MockDial: func() (net.Conn, error) {
|
||||
return nil, expected
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
conn, err := sfd.DialContext(context.Background())
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnowflakeDialerCannotCreateConnWithContextExpiration(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
expected := errors.New("mocked error")
|
||||
sfd := &SnowflakeDialer{
|
||||
newClientTransport: func(brokerURL, frontDomain string, iceAddresses []string, keepLocalAddresses bool, maxSnowflakes int) (snowflakeTransport, error) {
|
||||
return &mockableSnowflakeTransport{
|
||||
MockDial: func() (net.Conn, error) {
|
||||
cancel() // before returning to the caller
|
||||
return nil, expected
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
conn, err := sfd.DialContext(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnowflakeDialerWorksWithWithCancelledContext(t *testing.T) {
|
||||
called := &atomicx.Int64{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sfd := &SnowflakeDialer{
|
||||
newClientTransport: func(brokerURL, frontDomain string, iceAddresses []string, keepLocalAddresses bool, maxSnowflakes int) (snowflakeTransport, error) {
|
||||
return &mockableSnowflakeTransport{
|
||||
MockDial: func() (net.Conn, error) {
|
||||
cancel() // cause a cancel before we can really have a conn
|
||||
return &mockablex.Conn{
|
||||
MockClose: func() error {
|
||||
called.Add(1)
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
conn, done, err := sfd.dialContext(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("not the error we expected", err)
|
||||
}
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn here")
|
||||
}
|
||||
// synchronize with the end of the inner goroutine
|
||||
<-done
|
||||
if called.Load() != 1 {
|
||||
t.Fatal("the goroutine did not call close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnowflakeWeCanSetCustomValues(t *testing.T) {
|
||||
sfd := &SnowflakeDialer{
|
||||
BrokerURL: "antani",
|
||||
FrontDomain: "mascetti",
|
||||
ICEAddresses: []string{"melandri"},
|
||||
MaxSnowflakes: 11,
|
||||
}
|
||||
if sfd.brokerURL() != "antani" {
|
||||
t.Fatal("invalid broker URL")
|
||||
}
|
||||
if sfd.frontDomain() != "mascetti" {
|
||||
t.Fatal("invalid front domain")
|
||||
}
|
||||
if v := sfd.iceAddresses(); len(v) != 1 || v[0] != "melandri" {
|
||||
t.Fatal("invalid ICE addresses")
|
||||
}
|
||||
if sfd.maxSnowflakes() != 11 {
|
||||
t.Fatal("invalid max number of snowflakes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
*
|
||||
Reference in New Issue
Block a user