From f2b88ddb4ad5ab18bce148e1a27df8eeef289a39 Mon Sep 17 00:00:00 2001 From: DecFox <33030671+DecFox@users.noreply.github.com> Date: Thu, 8 Sep 2022 21:01:03 +0530 Subject: [PATCH] feat: tlsmiddlebox experiment (#817) See https://github.com/ooni/probe/issues/2124 --- .../engine/experiment/tlsmiddlebox/config.go | 79 ++++++ .../experiment/tlsmiddlebox/config_test.go | 83 ++++++ .../engine/experiment/tlsmiddlebox/conn.go | 62 +++++ .../experiment/tlsmiddlebox/conn_test.go | 163 +++++++++++ .../engine/experiment/tlsmiddlebox/connect.go | 27 ++ .../engine/experiment/tlsmiddlebox/dialer.go | 45 ++++ .../experiment/tlsmiddlebox/dialer_test.go | 54 ++++ .../engine/experiment/tlsmiddlebox/dns.go | 29 ++ .../engine/experiment/tlsmiddlebox/doc.go | 4 + .../experiment/tlsmiddlebox/measurer.go | 115 ++++++++ .../experiment/tlsmiddlebox/measurer_test.go | 246 +++++++++++++++++ .../engine/experiment/tlsmiddlebox/summary.go | 18 ++ .../experiment/tlsmiddlebox/syscall_cgo.go | 29 ++ .../tlsmiddlebox/syscall_otherwise.go | 15 ++ .../experiment/tlsmiddlebox/syscall_unix.go | 53 ++++ .../tlsmiddlebox/syscall_windows.go | 54 ++++ .../experiment/tlsmiddlebox/testkeys.go | 46 ++++ .../engine/experiment/tlsmiddlebox/trace.go | 54 ++++ .../engine/experiment/tlsmiddlebox/tracing.go | 145 ++++++++++ .../experiment/tlsmiddlebox/tracing_test.go | 255 ++++++++++++++++++ .../engine/experiment/tlsmiddlebox/utils.go | 25 ++ .../experiment/tlsmiddlebox/utils_test.go | 46 ++++ internal/model/archival.go | 1 + internal/registry/tlsmiddlebox.go | 22 ++ 24 files changed, 1670 insertions(+) create mode 100644 internal/engine/experiment/tlsmiddlebox/config.go create mode 100644 internal/engine/experiment/tlsmiddlebox/config_test.go create mode 100644 internal/engine/experiment/tlsmiddlebox/conn.go create mode 100644 internal/engine/experiment/tlsmiddlebox/conn_test.go create mode 100644 internal/engine/experiment/tlsmiddlebox/connect.go create mode 100644 internal/engine/experiment/tlsmiddlebox/dialer.go create mode 100644 internal/engine/experiment/tlsmiddlebox/dialer_test.go create mode 100644 internal/engine/experiment/tlsmiddlebox/dns.go create mode 100644 internal/engine/experiment/tlsmiddlebox/doc.go create mode 100644 internal/engine/experiment/tlsmiddlebox/measurer.go create mode 100644 internal/engine/experiment/tlsmiddlebox/measurer_test.go create mode 100644 internal/engine/experiment/tlsmiddlebox/summary.go create mode 100644 internal/engine/experiment/tlsmiddlebox/syscall_cgo.go create mode 100644 internal/engine/experiment/tlsmiddlebox/syscall_otherwise.go create mode 100644 internal/engine/experiment/tlsmiddlebox/syscall_unix.go create mode 100644 internal/engine/experiment/tlsmiddlebox/syscall_windows.go create mode 100644 internal/engine/experiment/tlsmiddlebox/testkeys.go create mode 100644 internal/engine/experiment/tlsmiddlebox/trace.go create mode 100644 internal/engine/experiment/tlsmiddlebox/tracing.go create mode 100644 internal/engine/experiment/tlsmiddlebox/tracing_test.go create mode 100644 internal/engine/experiment/tlsmiddlebox/utils.go create mode 100644 internal/engine/experiment/tlsmiddlebox/utils_test.go create mode 100644 internal/registry/tlsmiddlebox.go diff --git a/internal/engine/experiment/tlsmiddlebox/config.go b/internal/engine/experiment/tlsmiddlebox/config.go new file mode 100644 index 0000000..7649dfc --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/config.go @@ -0,0 +1,79 @@ +package tlsmiddlebox + +// +// Config for the tlsmiddlebox experiment +// + +import ( + "net/url" + "time" +) + +// Config contains the experiment configuration. +type Config struct { + // ResolverURL is the default DoH resolver + ResolverURL string `ooni:"URL for DoH resolver"` + + // SNIPass is the SNI value we don't expect to be blocked + SNIControl string `ooni:"control SNI value for testhelper"` + + // Delay is the delay between each iteration (in milliseconds). + Delay int64 `ooni:"delay between consecutive iterations"` + + // MaxTTL is the default number of interations we trace + MaxTTL int64 `ooni:"maximum TTL value to iterate upto"` + + // TestHelper is the testhelper host for iterative tracing + TestHelper string `ooni:"testhelper URL to use for tracing"` + + // ClientId is the client fingerprint to use + ClientId int `ooni:"ClientHello fingerprint to use"` +} + +func (c Config) resolverURL() string { + if c.ResolverURL != "" { + return c.ResolverURL + } + return "https://mozilla.cloudflare-dns.com/dns-query" +} + +func (c Config) snicontrol() string { + if c.SNIControl != "" { + return c.SNIControl + } + return "example.com" +} + +func (c Config) delay() time.Duration { + if c.Delay > 0 { + return time.Duration(c.Delay) * time.Millisecond + } + return 100 * time.Millisecond +} + +func (c Config) maxttl() int64 { + if c.MaxTTL > 0 { + return c.MaxTTL + } + return 20 +} + +func (c Config) testhelper(address string) (URL *url.URL, err error) { + // TODO(DecFox, bassosimone): We want to replace this with a generic input parser + // Issue: https://github.com/ooni/probe/issues/2239 + if c.TestHelper != "" { + return url.Parse(c.TestHelper) + } + URL = &url.URL{ + Host: address, + Scheme: "tlshandshake", + } + return +} + +func (c Config) clientid() int { + if c.ClientId > 0 { + return c.ClientId + } + return 0 +} diff --git a/internal/engine/experiment/tlsmiddlebox/config_test.go b/internal/engine/experiment/tlsmiddlebox/config_test.go new file mode 100644 index 0000000..f743d34 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/config_test.go @@ -0,0 +1,83 @@ +package tlsmiddlebox + +import ( + "testing" + "time" +) + +func TestConfig_maxttl(t *testing.T) { + c := Config{} + if c.maxttl() != 20 { + t.Fatal("invalid default number of repetitions") + } +} + +func TestConfig_delay(t *testing.T) { + c := Config{} + if c.delay() != 100*time.Millisecond { + t.Fatal("invalid default delay") + } +} + +func TestConfig_resolver(t *testing.T) { + c := Config{} + if c.resolverURL() != "https://mozilla.cloudflare-dns.com/dns-query" { + t.Fatal("invalid resolver URL") + } +} + +func TestConfig_snipass(t *testing.T) { + c := Config{} + if c.snicontrol() != "example.com" { + t.Fatal("invalid pass SNI") + } +} + +func TestConfig_testhelper(t *testing.T) { + t.Run("without config", func(t *testing.T) { + c := Config{} + th, err := c.testhelper("example.com") + if err != nil { + t.Fatal("unexpected error") + } + if th.Scheme != "tlshandshake" { + t.Fatal("unexpected scheme") + } + if th.Host != "example.com" { + t.Fatal("unexpected host") + } + }) + + t.Run("with config", func(t *testing.T) { + c := Config{ + TestHelper: "tlshandshake://example.com:80", + } + th, err := c.testhelper("google.com") + if err != nil { + t.Fatal("unexpected error") + } + if th.Scheme != "tlshandshake" { + t.Fatal("unexpected scheme") + } + if th.Host != "example.com:80" { + t.Fatal("unexpected host") + } + }) + + t.Run("failure case", func(t *testing.T) { + c := Config{ + TestHelper: "\t", + } + th, _ := c.testhelper("google.com") + if th != nil { + t.Fatal("expected nil url") + } + }) +} + +func TestConfig_clientid(t *testing.T) { + c := Config{} + if c.clientid() != 0 { + t.Fatal("invalid default ClientHello ID") + } +} diff --git a/internal/engine/experiment/tlsmiddlebox/conn.go b/internal/engine/experiment/tlsmiddlebox/conn.go new file mode 100644 index 0000000..a1ff73f --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/conn.go @@ -0,0 +1,62 @@ +package tlsmiddlebox + +// +// Wrapped TTL conn +// + +import ( + "errors" + "net" + "syscall" + + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +var errInvalidConnWrapper = errors.New("invalid conn wrapper") + +// setConnTTL calls SetTTL to set the TTL for a dialerTTLWrapperConn +func setConnTTL(conn net.Conn, ttl int) error { + ttlWrapper, ok := conn.(*dialerTTLWrapperConn) + if !ok { + return errInvalidConnWrapper + } + return ttlWrapper.SetTTL(ttl) +} + +// getSoErr calls GetSoErr to fetch the SO_ERROR value +func getSoErr(conn net.Conn) (soErr error, err error) { + ttlWrapper, ok := conn.(*dialerTTLWrapperConn) + if !ok { + return nil, errInvalidConnWrapper + } + errno, err := ttlWrapper.GetSoErr() + if err != nil { + return nil, err + } + return syscall.Errno(errno), nil +} + +// dialerTTLWrapperConn wraps errors as well as allows us to set the TTL +type dialerTTLWrapperConn struct { + net.Conn +} + +var _ net.Conn = &dialerTTLWrapperConn{} + +// Read implements net.Conn.Read +func (c *dialerTTLWrapperConn) Read(b []byte) (int, error) { + count, err := c.Conn.Read(b) + if err != nil { + return 0, netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.ReadOperation, err) + } + return count, nil +} + +// Write implements net.Conn.Write +func (c *dialerTTLWrapperConn) Write(b []byte) (int, error) { + count, err := c.Conn.Write(b) + if err != nil { + return 0, netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.WriteOperation, err) + } + return count, nil +} diff --git a/internal/engine/experiment/tlsmiddlebox/conn_test.go b/internal/engine/experiment/tlsmiddlebox/conn_test.go new file mode 100644 index 0000000..a34f722 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/conn_test.go @@ -0,0 +1,163 @@ +package tlsmiddlebox + +import ( + "context" + "errors" + "io" + "syscall" + "testing" + + "github.com/ooni/probe-cli/v3/internal/model/mocks" + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +func TestDialerTTLWrapperConn(t *testing.T) { + t.Run("Read", func(t *testing.T) { + t.Run("on success", func(t *testing.T) { + b := make([]byte, 128) + conn := &dialerTTLWrapperConn{ + Conn: &mocks.Conn{ + MockRead: func(b []byte) (int, error) { + return len(b), nil + }, + }, + } + count, err := conn.Read(b) + if err != nil { + t.Fatal(err) + } + if count != len(b) { + t.Fatal("unexpected count") + } + }) + + t.Run("on failure", func(t *testing.T) { + b := make([]byte, 128) + expectedErr := io.EOF + conn := &dialerTTLWrapperConn{ + Conn: &mocks.Conn{ + MockRead: func(b []byte) (int, error) { + return 0, expectedErr + }, + }, + } + count, err := conn.Read(b) + if err == nil || err.Error() != netxlite.FailureEOFError { + t.Fatal("unexpected err", err) + } + if count != 0 { + t.Fatal("unexpected count") + } + }) + }) + + t.Run("Write", func(t *testing.T) { + t.Run("on success", func(t *testing.T) { + b := make([]byte, 128) + conn := &dialerTTLWrapperConn{ + Conn: &mocks.Conn{ + MockWrite: func(b []byte) (int, error) { + return len(b), nil + }, + }, + } + count, err := conn.Write(b) + if err != nil { + t.Fatal(err) + } + if count != len(b) { + t.Fatal("unexpected count") + } + }) + + t.Run("on failure", func(t *testing.T) { + b := make([]byte, 128) + expectedErr := io.EOF + conn := &dialerTTLWrapperConn{ + Conn: &mocks.Conn{ + MockWrite: func(b []byte) (int, error) { + return 0, expectedErr + }, + }, + } + count, err := conn.Write(b) + if err == nil || err.Error() != netxlite.FailureEOFError { + t.Fatal("unexpected err", err) + } + if count != 0 { + t.Fatal("unexpected count") + } + }) + }) +} + +func TestSetTTL(t *testing.T) { + t.Run("success case", func(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + d := NewDialerTTLWrapper() + ctx := context.Background() + conn, err := d.DialContext(ctx, "tcp", "1.1.1.1:80") + if err != nil { + t.Fatal("expected non-nil conn") + } + // test TTL set + err = setConnTTL(conn, 1) + if err != nil { + t.Fatal("unexpected error in setting TTL", err) + } + var buf [512]byte + _, err = conn.Write([]byte("1111")) + if err != nil { + t.Fatal("error writing", err) + } + r, _ := conn.Read(buf[:]) + if r != 0 { + t.Fatal("unexpected output size", r) + } + setConnTTL(conn, 64) // reset TTL to ensure conn closes successfully + conn.Close() + _, err = conn.Read(buf[:]) + if err == nil || err.Error() != netxlite.FailureConnectionAlreadyClosed { + t.Fatal("failed to reset TTL") + } + }) + + t.Run("failure case", func(t *testing.T) { + conn := &mocks.Conn{} + err := setConnTTL(conn, 1) + if !errors.Is(err, errInvalidConnWrapper) { + t.Fatal("unexpected error") + } + }) +} + +func TestGetSoErr(t *testing.T) { + t.Run("success case", func(t *testing.T) { + d := NewDialerTTLWrapper() + ctx := context.Background() + conn, err := d.DialContext(ctx, "tcp", "1.1.1.1:80") + if err != nil { + t.Fatal(err) + } + errno, err := getSoErr(conn) + if err != nil { + t.Fatal("unexpected error", err) + } + if !errors.Is(errno, syscall.Errno(0)) { + t.Fatal("unexpected errno") + } + }) + + t.Run("failure case", func(t *testing.T) { + conn := &mocks.Conn{} + errno, err := getSoErr(conn) + if !errors.Is(err, errInvalidConnWrapper) { + t.Fatal("unexpected error") + } + if errno != nil { + t.Fatal("expected nil errorno") + } + }) +} diff --git a/internal/engine/experiment/tlsmiddlebox/connect.go b/internal/engine/experiment/tlsmiddlebox/connect.go new file mode 100644 index 0000000..7f91a2e --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/connect.go @@ -0,0 +1,27 @@ +package tlsmiddlebox + +// +// TCP Connect for tlsmiddlebox +// + +import ( + "context" + "time" + + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" +) + +// TCPConnect performs a TCP connect to filter working addresses +func (m *Measurer) TCPConnect(ctx context.Context, index int64, zeroTime time.Time, + logger model.Logger, address string, tk *TestKeys) error { + trace := measurexlite.NewTrace(index, zeroTime) + dialer := trace.NewDialerWithoutResolver(logger) + ol := measurexlite.NewOperationLogger(logger, "TCPConnect #%d %s", index, address) + conn, err := dialer.DialContext(ctx, "tcp", address) + ol.Stop(err) + measurexlite.MaybeClose(conn) + tcpEvents := trace.TCPConnects() + tk.addTCPConnect(tcpEvents) + return err +} diff --git a/internal/engine/experiment/tlsmiddlebox/dialer.go b/internal/engine/experiment/tlsmiddlebox/dialer.go new file mode 100644 index 0000000..5db437b --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/dialer.go @@ -0,0 +1,45 @@ +package tlsmiddlebox + +// +// Custom TTL dialer +// + +import ( + "context" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +const timeout time.Duration = 15 * time.Second + +func NewDialerTTLWrapper() model.Dialer { + return &dialerTTLWrapper{ + Dialer: &net.Dialer{Timeout: timeout}, + } +} + +// dialerTTLWrapper wraps errors and also returns a TTL wrapped conn +type dialerTTLWrapper struct { + Dialer model.SimpleDialer +} + +var _ model.Dialer = &dialerTTLWrapper{} + +// DialContext implements model.Dialer.DialContext +func (d *dialerTTLWrapper) DialContext(ctx context.Context, network string, address string) (net.Conn, error) { + conn, err := d.Dialer.DialContext(ctx, network, address) + if err != nil { + return nil, netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.ConnectOperation, err) + } + return &dialerTTLWrapperConn{ + Conn: conn, + }, nil +} + +// CloseIdleConnections implements model.Dialer.CloseIdleConnections +func (d *dialerTTLWrapper) CloseIdleConnections() { + // nothing to do here +} diff --git a/internal/engine/experiment/tlsmiddlebox/dialer_test.go b/internal/engine/experiment/tlsmiddlebox/dialer_test.go new file mode 100644 index 0000000..be2bab9 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/dialer_test.go @@ -0,0 +1,54 @@ +package tlsmiddlebox + +import ( + "context" + "io" + "net" + "testing" + + "github.com/ooni/probe-cli/v3/internal/model/mocks" + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +func TestDialerWithTTL(t *testing.T) { + t.Run("DialContext on success", func(t *testing.T) { + t.Run("on success", func(t *testing.T) { + expectedConn := &mocks.Conn{} + d := &dialerTTLWrapper{ + Dialer: &mocks.Dialer{ + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return expectedConn, nil + }, + }, + } + ctx := context.Background() + conn, err := d.DialContext(ctx, "", "") + if err != nil { + t.Fatal(err) + } + errWrapperConn := conn.(*dialerTTLWrapperConn) + if errWrapperConn.Conn != expectedConn { + t.Fatal("unexpected conn") + } + }) + + t.Run("on failure", func(t *testing.T) { + expectedErr := io.EOF + d := &dialerTTLWrapper{ + Dialer: &mocks.Dialer{ + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return nil, expectedErr + }, + }, + } + ctx := context.Background() + conn, err := d.DialContext(ctx, "", "") + if err == nil || err.Error() != netxlite.FailureEOFError { + t.Fatal("unexpected err", err) + } + if conn != nil { + t.Fatal("expected nil conn") + } + }) + }) +} diff --git a/internal/engine/experiment/tlsmiddlebox/dns.go b/internal/engine/experiment/tlsmiddlebox/dns.go new file mode 100644 index 0000000..2aea09a --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/dns.go @@ -0,0 +1,29 @@ +package tlsmiddlebox + +// +// DNS Lookup for tlsmiddlebox +// + +import ( + "context" + "time" + + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" +) + +// DNSLookup performs a DNS Lookup for the passed domain +func (m *Measurer) DNSLookup(ctx context.Context, index int64, zeroTime time.Time, + logger model.Logger, domain string, tk *TestKeys) ([]string, error) { + url := m.config.resolverURL() + trace := measurexlite.NewTrace(index, zeroTime) + ol := measurexlite.NewOperationLogger(logger, "DNSLookup #%d, %s, %s", index, url, domain) + // TODO(DecFox, bassosimone): We are currently using the DoH resolver, we will + // switch to the TRR2 resolver once we have it in measurexlite + // Issue: https://github.com/ooni/probe/issues/2185 + resolver := trace.NewParallelDNSOverHTTPSResolver(logger, url) + addrs, err := resolver.LookupHost(ctx, domain) + ol.Stop(err) + tk.addQueries(trace.DNSLookupsFromRoundTrip()) + return addrs, err +} diff --git a/internal/engine/experiment/tlsmiddlebox/doc.go b/internal/engine/experiment/tlsmiddlebox/doc.go new file mode 100644 index 0000000..9029856 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/doc.go @@ -0,0 +1,4 @@ +// Package tlsmiddlebox implements the tlsmiddlebox experiment +// +// Spec: https://github.com/ooni/spec/blob/master/nettests/ts-037-tlsmiddlebox.md. +package tlsmiddlebox diff --git a/internal/engine/experiment/tlsmiddlebox/measurer.go b/internal/engine/experiment/tlsmiddlebox/measurer.go new file mode 100644 index 0000000..d157ab0 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/measurer.go @@ -0,0 +1,115 @@ +package tlsmiddlebox + +// +// Measurer +// + +import ( + "context" + "errors" + "net/url" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +const ( + testName = "tlsmiddlebox" + testVersion = "0.1.0" +) + +// Measurer performs the measurement. +type Measurer struct { + config Config +} + +// ExperimentName implements ExperimentMeasurer.ExperimentName. +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +var ( + // errNoInputProvided indicates you didn't provide any input + errNoInputProvided = errors.New("no input provided") + + // errInputIsNotAnURL indicates that input is not an URL + errInputIsNotAnURL = errors.New("input is not an URL") + + // errInvalidInputScheme indicates that the input scheme is invalid + errInvalidInputScheme = errors.New("input scheme must be tlstrace") + + // errInvalidTestHelper indicates that the testhelper is invalid + errInvalidTestHelper = errors.New("invalid testhelper") + + // errInvalidTHScheme indicates that the TH scheme is invalid + errInvalidTHScheme = errors.New("th scheme must be tlshandshake") +) + +// // Run implements ExperimentMeasurer.Run. +func (m *Measurer) Run( + ctx context.Context, + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, +) error { + if measurement.Input == "" { + return errNoInputProvided + } + parsed, err := url.Parse(string(measurement.Input)) + if err != nil { + return errInputIsNotAnURL + } + if parsed.Scheme != "tlstrace" { + return errInvalidInputScheme + } + th, err := m.config.testhelper(parsed.Host) + if err != nil { + return errInvalidTestHelper + } + if th.Scheme != "tlshandshake" { + return errInvalidTHScheme + } + tk := NewTestKeys() + measurement.TestKeys = tk + wg := new(sync.WaitGroup) + // 1. perform a DNSLookup + addrs, err := m.DNSLookup(ctx, 0, measurement.MeasurementStartTimeSaved, sess.Logger(), th.Hostname(), tk) + if err != nil { + return err + } + // 2. measure addresses + addrs = prepareAddrs(addrs, th.Port()) + for i, addr := range addrs { + wg.Add(1) + go m.TraceAddress(ctx, int64(i), measurement.MeasurementStartTimeSaved, sess.Logger(), addr, parsed.Hostname(), tk, wg) + } + wg.Wait() + return nil +} + +// TraceAddress measures a single address after the DNSLookup +func (m *Measurer) TraceAddress(ctx context.Context, index int64, zeroTime time.Time, logger model.Logger, + address string, sni string, tk *TestKeys, wg *sync.WaitGroup) error { + defer wg.Done() + trace := &CompleteTrace{ + Address: address, + } + tk.addTrace(trace) + err := m.TCPConnect(ctx, index, zeroTime, logger, address, tk) + if err != nil { + return err // skip tracing if we cannot connect with default TTL + } + m.TLSTrace(ctx, index, zeroTime, logger, address, sni, trace) + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) *Measurer { + return &Measurer{config: config} +} diff --git a/internal/engine/experiment/tlsmiddlebox/measurer_test.go b/internal/engine/experiment/tlsmiddlebox/measurer_test.go new file mode 100644 index 0000000..f5bf815 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/measurer_test.go @@ -0,0 +1,246 @@ +package tlsmiddlebox + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/model/mocks" + "github.com/ooni/probe-cli/v3/internal/netxlite/filtering" +) + +func TestMeasurerExperimentNameVersion(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "tlsmiddlebox" { + t.Fatal("unexpected ExperimentName") + } + if measurer.ExperimentVersion() != "0.1.0" { + t.Fatal("unexpected ExperimentVersion") + } +} + +func TestMeasurer_input_failure(t *testing.T) { + runHelper := func(ctx context.Context, input string, th string, sniControl string) (*model.Measurement, model.ExperimentMeasurer, error) { + m := NewExperimentMeasurer(Config{ + TestHelper: th, + SNIControl: sniControl, + }) + meas := &model.Measurement{ + Input: model.MeasurementTarget(input), + } + sess := &mocks.Session{ + MockLogger: func() model.Logger { + return model.DiscardLogger + }, + } + callbacks := model.NewPrinterCallbacks(model.DiscardLogger) + err := m.Run(ctx, sess, meas, callbacks) + return meas, m, err + } + + t.Run("with empty input", func(t *testing.T) { + _, _, err := runHelper(context.Background(), "", "", "") + if !errors.Is(err, errNoInputProvided) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid URL", func(t *testing.T) { + _, _, err := runHelper(context.Background(), "\t", "", "") + if !errors.Is(err, errInputIsNotAnURL) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid scheme", func(t *testing.T) { + _, _, err := runHelper(context.Background(), "http://8.8.8.8:443/", "", "") + if !errors.Is(err, errInvalidInputScheme) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid testhelper", func(t *testing.T) { + _, _, err := runHelper(context.Background(), "tlstrace://example.com", "\t", "") + if !errors.Is(err, errInvalidTestHelper) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid TH scheme", func(t *testing.T) { + _, _, err := runHelper(context.Background(), "tlstrace://example.com", "http://google.com", "") + if !errors.Is(err, errInvalidTHScheme) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with local listener and successful outcome", func(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + URL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + URL.Scheme = "tlshandshake" + meas, m, err := runHelper(context.Background(), "tlstrace://google.com", URL.String(), "") + if err != nil { + t.Fatal(err) + } + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + + t.Run("testkeys", func(t *testing.T) { + tk := meas.TestKeys.(*TestKeys) + tr := tk.IterativeTrace + if len(tr) != 1 { + t.Fatal("unexpected number of trace") + } + trace := tr[0] + if trace.Address != URL.Host { + t.Fatal("unexpected trace address") + } + + t.Run("control trace", func(t *testing.T) { + if trace.ControlTrace == nil || trace.ControlTrace.SNI != "example.com" { + t.Fatal("unexpected control trace for url") + } + if len(trace.ControlTrace.Iterations) != 1 { + t.Fatal("unexpected number of iterations") + } + }) + + t.Run("target trace", func(t *testing.T) { + if trace.TargetTrace == nil || trace.TargetTrace.SNI != "google.com" { + t.Fatal("unexpected target trace for url") + } + if len(trace.TargetTrace.Iterations) != 1 { + t.Fatal("unexpected number of iterations") + } + }) + }) + }) + + t.Run("with local listener and timeout", func(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + server := filtering.NewTLSServer(filtering.TLSActionTimeout) + defer server.Close() + th := "tlshandshake://" + server.Endpoint() + URL, err := url.Parse(th) + if err != nil { + t.Fatal(err) + } + meas, m, err := runHelper(context.Background(), "tlstrace://google.com", URL.String(), "") + if err != nil { + t.Fatal(err) + } + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + + t.Run("testkeys", func(t *testing.T) { + tk := meas.TestKeys.(*TestKeys) + tr := tk.IterativeTrace + if len(tr) != 1 { + t.Fatal("unexpected number of trace") + } + trace := tr[0] + if trace.Address != URL.Host { + t.Fatal("unexpected trace address") + } + + t.Run("control trace", func(t *testing.T) { + if trace.ControlTrace == nil || trace.ControlTrace.SNI != "example.com" { + t.Fatal("unexpected control trace for url") + } + if len(trace.ControlTrace.Iterations) != 20 { + t.Fatal("unexpected number of iterations") + } + }) + + t.Run("target trace", func(t *testing.T) { + if trace.TargetTrace == nil || trace.TargetTrace.SNI != "google.com" { + t.Fatal("unexpected target trace for url") + } + if len(trace.TargetTrace.Iterations) != 20 { + t.Fatal("unexpected number of iterations") + } + }) + }) + }) + + t.Run("with local listener and connect issues", func(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + server := filtering.NewTLSServer(filtering.TLSActionReset) + defer server.Close() + th := "tlshandshake://" + server.Endpoint() + URL, err := url.Parse(th) + if err != nil { + t.Fatal(err) + } + meas, m, err := runHelper(context.Background(), "tlstrace://google.com", URL.String(), "") + if err != nil { + t.Fatal(err) + } + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + + t.Run("testkeys", func(t *testing.T) { + tk := meas.TestKeys.(*TestKeys) + tr := tk.IterativeTrace + if len(tr) != 1 { + t.Fatal("unexpected number of trace") + } + trace := tr[0] + if trace.Address != URL.Host { + t.Fatal("unexpected trace address") + } + + t.Run("control trace", func(t *testing.T) { + if trace.ControlTrace == nil || trace.ControlTrace.SNI != "example.com" { + t.Fatal("unexpected control trace for url") + } + if len(trace.ControlTrace.Iterations) != 1 { + t.Fatal("unexpected number of iterations") + } + }) + + t.Run("target trace", func(t *testing.T) { + if trace.TargetTrace == nil || trace.TargetTrace.SNI != "google.com" { + t.Fatal("unexpected target trace for url") + } + if len(trace.TargetTrace.Iterations) != 1 { + t.Fatal("unexpected number of iterations") + } + }) + }) + }) +} diff --git a/internal/engine/experiment/tlsmiddlebox/summary.go b/internal/engine/experiment/tlsmiddlebox/summary.go new file mode 100644 index 0000000..e42728f --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/summary.go @@ -0,0 +1,18 @@ +package tlsmiddlebox + +// +// Summary +// + +import "github.com/ooni/probe-cli/v3/internal/model" + +// Summary contains the summary results +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + // TODO(DecFox, bassosimone): Add anomaly logic to generate summary keys for the experiment + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/tlsmiddlebox/syscall_cgo.go b/internal/engine/experiment/tlsmiddlebox/syscall_cgo.go new file mode 100644 index 0000000..67bbd3c --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/syscall_cgo.go @@ -0,0 +1,29 @@ +//go:build cgo && windows + +package tlsmiddlebox + +// +// CGO support for SO_ERROR +// + +/* +#cgo windows LDFLAGS: -lws2_32 + +#ifdef _WIN32 +#include +#endif +*/ +import "C" + +import "unsafe" + +// getErrFromSockOpt returns the errno of the SO_ERROR +// +// This is the CGO_ENABLED=1 implementation of this function, which +// returns the errno obtained from the getsockopt call +func getErrFromSockOpt(fd uintptr) int { + var cErrno C.int + szInt := C.sizeof_int + C.getsockopt((C.SOCKET)(fd), (C.int)(C.SOL_SOCKET), (C.int)(C.SO_ERROR), (*C.char)(unsafe.Pointer(&cErrno)), (*C.int)(unsafe.Pointer(&szInt))) + return int(cErrno) +} diff --git a/internal/engine/experiment/tlsmiddlebox/syscall_otherwise.go b/internal/engine/experiment/tlsmiddlebox/syscall_otherwise.go new file mode 100644 index 0000000..acc367b --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/syscall_otherwise.go @@ -0,0 +1,15 @@ +//go:build !cgo + +package tlsmiddlebox + +// +// Disabled CGO for SO_ERROR +// + +// getErrFromSockOpt returns the errno of the SO_ERROR +// +// This is the CGO_ENABLED=0 implementation of this function, which +// always returns errno=0 for SO_ERROR +func getErrFromSockOpt(fd uintptr) int { + return 0 +} diff --git a/internal/engine/experiment/tlsmiddlebox/syscall_unix.go b/internal/engine/experiment/tlsmiddlebox/syscall_unix.go new file mode 100644 index 0000000..9d3533f --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/syscall_unix.go @@ -0,0 +1,53 @@ +//go:build aix || darwin || dragonfly || freebsd || (js && wasm) || linux || nacl || netbsd || openbsd || solaris + +package tlsmiddlebox + +// +// syscall utilities for dialerTTLWrapperConn +// + +import ( + "net" + "syscall" +) + +// SetTTL sets the IP TTL field for the underlying net.TCPConn +func (c *dialerTTLWrapperConn) SetTTL(ttl int) error { + conn := c.Conn + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return errInvalidConnWrapper + } + rawConn, err := tcpConn.SyscallConn() + if err != nil { + return err + } + rawErr := rawConn.Control(func(fd uintptr) { + err = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_TTL, ttl) + }) + // The syscall err is given a higher priority and returned early if non-nil + if err != nil { + return err + } + return rawErr +} + +// GetSoErr fetches the SO_ERROR value to look for soft ICMP errors in TCP +func (c *dialerTTLWrapperConn) GetSoErr() (errno int, err error) { + conn := c.Conn + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return 0, errInvalidConnWrapper + } + rawConn, err := tcpConn.SyscallConn() + if err != nil { + return 0, errInvalidConnWrapper + } + rawErr := rawConn.Control(func(fd uintptr) { + errno, err = syscall.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_ERROR) + }) + if rawErr != nil { + return 0, rawErr + } + return +} diff --git a/internal/engine/experiment/tlsmiddlebox/syscall_windows.go b/internal/engine/experiment/tlsmiddlebox/syscall_windows.go new file mode 100644 index 0000000..51ef782 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/syscall_windows.go @@ -0,0 +1,54 @@ +// go:build windows + +package tlsmiddlebox + +// +// syscall utilities for dialerTTLWrapperConn +// + +import ( + "net" + "syscall" +) + +// SetTTL sets the IP TTL field for the underlying net.TCPConn +func (c *dialerTTLWrapperConn) SetTTL(ttl int) error { + conn := c.Conn + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return errInvalidConnWrapper + } + rawConn, err := tcpConn.SyscallConn() + if err != nil { + return err + } + rawErr := rawConn.Control(func(fd uintptr) { + err = syscall.SetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IP, syscall.IP_TTL, ttl) + }) + // The syscall err is given a higher priority and returned early if non-nil + if err != nil { + return err + } + return rawErr +} + +// GetSoErr fetches the SO_ERROR value at look for soft ICMP errors in TCP +func (c *dialerTTLWrapperConn) GetSoErr() (int, error) { + var cErrno int + conn := c.Conn + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return 0, errInvalidConnWrapper + } + rawConn, err := tcpConn.SyscallConn() + if err != nil { + return 0, errInvalidConnWrapper + } + rawErr := rawConn.Control(func(fd uintptr) { + cErrno = getErrFromSockOpt(fd) + }) + if rawErr != nil { + return 0, rawErr + } + return cErrno, nil +} diff --git a/internal/engine/experiment/tlsmiddlebox/testkeys.go b/internal/engine/experiment/tlsmiddlebox/testkeys.go new file mode 100644 index 0000000..25ad04d --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/testkeys.go @@ -0,0 +1,46 @@ +package tlsmiddlebox + +import ( + "sync" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +// TestKeys contains the experiment results +type TestKeys struct { + Queries []*model.ArchivalDNSLookupResult `json:"queries"` + TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"` + IterativeTrace []*CompleteTrace `json:"iterative_trace"` + + mu sync.Mutex +} + +// NewTestKeys creates new tlsmiddlebox TestKeys +func NewTestKeys() *TestKeys { + return &TestKeys{ + Queries: []*model.ArchivalDNSLookupResult{}, + TCPConnect: []*model.ArchivalTCPConnectResult{}, + IterativeTrace: []*CompleteTrace{}, + } +} + +// addqueries adds []*model.ArchivalDNSLookupResut to the test keys queries +func (tk *TestKeys) addQueries(ev []*model.ArchivalDNSLookupResult) { + tk.mu.Lock() + tk.Queries = append(tk.Queries, ev...) + tk.mu.Unlock() +} + +// addTCPConnect adds []*model.ArchivalTCPConnectResult to the test keys TCPConnect +func (tk *TestKeys) addTCPConnect(ev []*model.ArchivalTCPConnectResult) { + tk.mu.Lock() + tk.TCPConnect = append(tk.TCPConnect, ev...) + tk.mu.Unlock() +} + +// addTrace adds []*CompleteTrace to the test keys Trace +func (tk *TestKeys) addTrace(ev ...*CompleteTrace) { + tk.mu.Lock() + tk.IterativeTrace = append(tk.IterativeTrace, ev...) + tk.mu.Unlock() +} diff --git a/internal/engine/experiment/tlsmiddlebox/trace.go b/internal/engine/experiment/tlsmiddlebox/trace.go new file mode 100644 index 0000000..54a7742 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/trace.go @@ -0,0 +1,54 @@ +package tlsmiddlebox + +import ( + "sync" + + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/tracex" +) + +// CompleteTrace records the result of the network trace +// using a control SNI and a target SNI +type CompleteTrace struct { + Address string `json:"address"` + ControlTrace *IterativeTrace `json:"control_trace"` + TargetTrace *IterativeTrace `json:"target_trace"` +} + +// Trace is an iterative trace for the corresponding servername and address +type IterativeTrace struct { + SNI string `json:"server_name"` + Iterations []*Iteration `json:"iterations"` + + mu sync.Mutex +} + +// Iteration is a single network iteration with variable TTL +type Iteration struct { + TTL int `json:"ttl"` + Handshake *model.ArchivalTLSOrQUICHandshakeResult `json:"handshake"` +} + +// NewIterationFromHandshake returns a new iteration from a model.ArchivalTLSOrQUICHandshakeResult +func newIterationFromHandshake(ttl int, err error, soErr error, handshake *model.ArchivalTLSOrQUICHandshakeResult) *Iteration { + if err != nil { + return &Iteration{ + TTL: ttl, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: tracex.NewFailure(err), + }, + } + } + handshake.SoError = tracex.NewFailure(soErr) + return &Iteration{ + TTL: ttl, + Handshake: handshake, + } +} + +// addIterations adds iterations to the trace +func (t *IterativeTrace) addIterations(ev ...*Iteration) { + t.mu.Lock() + t.Iterations = append(t.Iterations, ev...) + t.mu.Unlock() +} diff --git a/internal/engine/experiment/tlsmiddlebox/tracing.go b/internal/engine/experiment/tlsmiddlebox/tracing.go new file mode 100644 index 0000000..1def097 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/tracing.go @@ -0,0 +1,145 @@ +package tlsmiddlebox + +// +// Iterative network tracing +// + +import ( + "context" + "crypto/tls" + "errors" + "net" + "sort" + "sync" + "syscall" + "time" + + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" + utls "gitlab.com/yawning/utls.git" +) + +// ClientIDs to map configurable inputs to uTLS fingerprints +// We use a non-zero index to map to each ClientID +var ClientIDs = map[int]*utls.ClientHelloID{ + 1: &utls.HelloGolang, + 2: &utls.HelloChrome_Auto, + 3: &utls.HelloFirefox_Auto, + 4: &utls.HelloIOS_Auto, +} + +// TLSTrace performs tracing using control and target SNI +func (m *Measurer) TLSTrace(ctx context.Context, index int64, zeroTime time.Time, logger model.Logger, + address string, targetSNI string, trace *CompleteTrace) { + // perform an iterative trace with the control SNI + trace.ControlTrace = m.startIterativeTrace(ctx, index, zeroTime, logger, address, m.config.snicontrol()) + // perform an iterative trace with the target SNI + trace.TargetTrace = m.startIterativeTrace(ctx, index, zeroTime, logger, address, targetSNI) +} + +// startIterativeTrace creates a Trace and calls iterativeTrace +func (m *Measurer) startIterativeTrace(ctx context.Context, index int64, zeroTime time.Time, logger model.Logger, + address string, sni string) (tr *IterativeTrace) { + tr = &IterativeTrace{ + SNI: sni, + Iterations: []*Iteration{}, + } + maxTTL := m.config.maxttl() + m.traceWithIncreasingTTLs(ctx, index, zeroTime, logger, address, sni, maxTTL, tr) + tr.Iterations = alignIterations(tr.Iterations) + return +} + +// traceWithIncreasingTTLs performs iterative tracing with increasing TTL values +func (m *Measurer) traceWithIncreasingTTLs(ctx context.Context, index int64, zeroTime time.Time, logger model.Logger, + address string, sni string, maxTTL int64, trace *IterativeTrace) { + ticker := time.NewTicker(m.config.delay()) + wg := new(sync.WaitGroup) + for i := int64(1); i <= maxTTL; i++ { + wg.Add(1) + go m.handshakeWithTTL(ctx, index, zeroTime, logger, address, sni, int(i), trace, wg) + <-ticker.C + } + wg.Wait() +} + +// handshakeWithTTL performs the TLS Handshake using the passed ttl value +func (m *Measurer) handshakeWithTTL(ctx context.Context, index int64, zeroTime time.Time, logger model.Logger, + address string, sni string, ttl int, tr *IterativeTrace, wg *sync.WaitGroup) { + defer wg.Done() + trace := measurexlite.NewTrace(index, zeroTime) + // 1. Connect to the target IP + // TODO(DecFox, bassosimone): Do we need a trace for this TCP connect? + d := NewDialerTTLWrapper() + ol := measurexlite.NewOperationLogger(logger, "Handshake Trace #%d TTL %d %s %s", index, ttl, address, sni) + conn, err := d.DialContext(ctx, "tcp", address) + if err != nil { + iteration := newIterationFromHandshake(ttl, err, nil, nil) + tr.addIterations(iteration) + ol.Stop(err) + return + } + defer conn.Close() + // 2. Set the TTL to the passed value + err = setConnTTL(conn, ttl) + if err != nil { + iteration := newIterationFromHandshake(ttl, err, nil, nil) + tr.addIterations(iteration) + ol.Stop(err) + return + } + // 3. Perform the handshake and extract the SO_ERROR value (if any) + // Note: we switch to a uTLS Handshaker if the configured ClientID is non-zero + thx := trace.NewTLSHandshakerStdlib(logger) + clientId := m.config.clientid() + if clientId > 0 { + thx = trace.NewTLSHandshakerUTLS(logger, ClientIDs[clientId]) + } + _, _, err = thx.Handshake(ctx, conn, genTLSConfig(sni)) + ol.Stop(err) + soErr := extractSoError(conn) + // 4. reset the TTL value to ensure that conn closes successfully + // Note: Do not check for errors here + _ = setConnTTL(conn, 64) + iteration := newIterationFromHandshake(ttl, nil, soErr, trace.FirstTLSHandshakeOrNil()) + tr.addIterations(iteration) +} + +// extractSoError fetches the SO_ERROR value and returns a non-nil error if +// it qualifies as a valid ICMP soft error +// Note: The passed conn must be of type dialerTTLWrapperConn +func extractSoError(conn net.Conn) error { + soErrno, err := getSoErr(conn) + if err != nil || errors.Is(soErrno, syscall.Errno(0)) { + return nil + } + soErr := netxlite.MaybeNewErrWrapper(netxlite.ClassifyGenericError, netxlite.TLSHandshakeOperation, soErrno) + return soErr +} + +// genTLSConfig generates tls.Config from a given SNI +func genTLSConfig(sni string) *tls.Config { + return &tls.Config{ + RootCAs: netxlite.NewDefaultCertPool(), + ServerName: sni, + NextProtos: []string{"h2", "http/1.1"}, + InsecureSkipVerify: true, + } +} + +// alignIterEvents sorts the iterEvents according to increasing TTL +// and stops when we receive a nil or connection_reset +func alignIterations(in []*Iteration) (out []*Iteration) { + out = []*Iteration{} + sort.Slice(in, func(i int, j int) bool { + return in[i].TTL < in[j].TTL + }) + for _, iter := range in { + out = append(out, iter) + if iter.Handshake.Failure == nil || *iter.Handshake.Failure == netxlite.FailureConnectionReset { + break + } + } + return out +} diff --git a/internal/engine/experiment/tlsmiddlebox/tracing_test.go b/internal/engine/experiment/tlsmiddlebox/tracing_test.go new file mode 100644 index 0000000..7d8e50c --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/tracing_test.go @@ -0,0 +1,255 @@ +package tlsmiddlebox + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/netxlite/filtering" +) + +func TestStartIterativeTrace(t *testing.T) { + t.Run("on success", func(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + URL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + m := NewExperimentMeasurer(Config{}) + zeroTime := time.Now() + ctx := context.Background() + trace := m.startIterativeTrace(ctx, 0, zeroTime, model.DiscardLogger, URL.Host, "example.com") + if trace.SNI != "example.com" { + t.Fatal("unexpected servername") + } + if len(trace.Iterations) != 1 { + t.Fatal("unexpected number of iterations") + } + for i, ev := range trace.Iterations { + if ev.TTL != i+1 { + t.Fatal("unexpected TTL value") + } + } + }) + + t.Run("failure case", func(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + server := filtering.NewTLSServer(filtering.TLSActionTimeout) + defer server.Close() + th := "tlshandshake://" + server.Endpoint() + URL, err := url.Parse(th) + if err != nil { + t.Fatal(err) + } + URL.Scheme = "tlshandshake" + m := NewExperimentMeasurer(Config{}) + zeroTime := time.Now() + ctx := context.Background() + trace := m.startIterativeTrace(ctx, 0, zeroTime, model.DiscardLogger, URL.Host, "example.com") + if trace.SNI != "example.com" { + t.Fatal("unexpected servername") + } + if len(trace.Iterations) != 20 { + t.Fatal("unexpected number of iterations") + } + for i, ev := range trace.Iterations { + if ev.TTL != i+1 { + t.Fatal("unexpected TTL value") + } + } + }) +} + +func TestHandshakeWithTTL(t *testing.T) { + t.Run("on success", func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + URL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + m := NewExperimentMeasurer(Config{}) + tr := &IterativeTrace{} + zeroTime := time.Now() + ctx := context.Background() + wg := new(sync.WaitGroup) + wg.Add(1) + m.handshakeWithTTL(ctx, 0, zeroTime, model.DiscardLogger, URL.Host, "example.com", 3, tr, wg) + if len(tr.Iterations) != 1 { + t.Fatal("unexpected number of iterations") + } + iter := tr.Iterations[0] + if iter.TTL != 3 { + t.Fatal("unexpected TTL value") + } + if iter.Handshake == nil || iter.Handshake.ServerName != "example.com" { + t.Fatal("unexpected servername") + } + if iter.Handshake.Failure != nil { + t.Fatal("unexpected error", *iter.Handshake.Failure) + } + }) + + t.Run("on failure", func(t *testing.T) { + server := filtering.NewTLSServer(filtering.TLSActionReset) + defer server.Close() + th := "tlshandshake://" + server.Endpoint() + URL, err := url.Parse(th) + if err != nil { + t.Fatal(err) + } + URL.Scheme = "tlshandshake" + m := NewExperimentMeasurer(Config{}) + tr := &IterativeTrace{} + zeroTime := time.Now() + ctx := context.Background() + wg := new(sync.WaitGroup) + wg.Add(1) + m.handshakeWithTTL(ctx, 0, zeroTime, model.DiscardLogger, URL.Host, "example.com", 3, tr, wg) + if len(tr.Iterations) != 1 { + t.Fatal("unexpected number of iterations") + } + iter := tr.Iterations[0] + if iter.TTL != 3 { + t.Fatal("unexpected TTL value") + } + if iter.Handshake == nil || iter.Handshake.ServerName != "example.com" { + t.Fatal("unexpected servername") + } + if *iter.Handshake.Failure != netxlite.FailureConnectionReset { + t.Fatal("unexpected error", *iter.Handshake.Failure) + } + }) +} + +func TestAlignIterations(t *testing.T) { + var ( + failureTimeout = "generic_timeout_err" + failureConnectionReset = "connection_reset" + ) + tests := []struct { + name string + input []*Iteration + want []*Iteration + }{{ + name: "with failure", + input: []*Iteration{{ + TTL: 2, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }, { + TTL: 3, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }, { + TTL: 1, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }}, + want: []*Iteration{{ + TTL: 1, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }, { + TTL: 2, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }, { + TTL: 3, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }}, + }, { + name: "without failure", + input: []*Iteration{{ + TTL: 2, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: nil, + }, + }, { + TTL: 3, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }, { + TTL: 1, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }}, + want: []*Iteration{{ + TTL: 1, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }, { + TTL: 2, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: nil, + }, + }}, + }, { + name: "with connection reset", + input: []*Iteration{{ + TTL: 2, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureConnectionReset, + }, + }, { + TTL: 3, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureConnectionReset, + }, + }, { + TTL: 1, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }}, + want: []*Iteration{{ + TTL: 1, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureTimeout, + }, + }, { + TTL: 2, + Handshake: &model.ArchivalTLSOrQUICHandshakeResult{ + Failure: &failureConnectionReset, + }, + }}, + }, { + name: "empty input", + input: []*Iteration{}, + want: []*Iteration{}, + }} + + for _, tt := range tests { + out := alignIterations(tt.input) + if diff := cmp.Diff(out, tt.want); diff != "" { + t.Fatal(diff) + } + } +} diff --git a/internal/engine/experiment/tlsmiddlebox/utils.go b/internal/engine/experiment/tlsmiddlebox/utils.go new file mode 100644 index 0000000..61923ef --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/utils.go @@ -0,0 +1,25 @@ +package tlsmiddlebox + +// +// Utility functions for tlsmiddlebox +// + +import ( + "net" +) + +// prepareAddrs prepares the resolved IP addresses by +// adding the configured port as a prefix +func prepareAddrs(addrs []string, port string) (out []string) { + if port == "" { + port = "443" + } + for _, addr := range addrs { + if net.ParseIP(addr) == nil { + continue + } + addr = net.JoinHostPort(addr, port) + out = append(out, addr) + } + return +} diff --git a/internal/engine/experiment/tlsmiddlebox/utils_test.go b/internal/engine/experiment/tlsmiddlebox/utils_test.go new file mode 100644 index 0000000..ebb9727 --- /dev/null +++ b/internal/engine/experiment/tlsmiddlebox/utils_test.go @@ -0,0 +1,46 @@ +package tlsmiddlebox + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestPrepareAddrs(t *testing.T) { + type arg struct { + addrs []string + port string + } + tests := []struct { + name string + args arg + want []string + }{{ + name: "with valid input", + args: arg{ + addrs: []string{"1.1.1.1", "2001:4860:4860::8844"}, + port: "", + }, + want: []string{"1.1.1.1:443", "[2001:4860:4860::8844]:443"}, + }, { + name: "with invalid input", + args: arg{ + addrs: []string{"1.1.1.1.1", "2001:4860:4860::8844"}, + port: "", + }, + want: []string{"[2001:4860:4860::8844]:443"}, + }, { + name: "with custom port", + args: arg{ + addrs: []string{"1.1.1.1", "2001:4860:4860::8844"}, + port: "80", + }, + want: []string{"1.1.1.1:80", "[2001:4860:4860::8844]:80"}, + }} + for _, tt := range tests { + out := prepareAddrs(tt.args.addrs, tt.args.port) + if diff := cmp.Diff(out, tt.want); diff != "" { + t.Fatal(diff) + } + } +} diff --git a/internal/model/archival.go b/internal/model/archival.go index 887dfa3..640e9b9 100644 --- a/internal/model/archival.go +++ b/internal/model/archival.go @@ -174,6 +174,7 @@ type ArchivalTLSOrQUICHandshakeResult struct { Address string `json:"address"` CipherSuite string `json:"cipher_suite"` Failure *string `json:"failure"` + SoError *string `json:"so_error,omitempty"` NegotiatedProtocol string `json:"negotiated_protocol"` NoTLSVerify bool `json:"no_tls_verify"` PeerCertificates []ArchivalMaybeBinaryData `json:"peer_certificates"` diff --git a/internal/registry/tlsmiddlebox.go b/internal/registry/tlsmiddlebox.go new file mode 100644 index 0000000..1a675ac --- /dev/null +++ b/internal/registry/tlsmiddlebox.go @@ -0,0 +1,22 @@ +package registry + +// +// Registers the `tlsmiddlebox' experiment. +// + +import ( + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlsmiddlebox" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func init() { + AllExperiments["tlsmiddlebox"] = &Factory{ + build: func(config interface{}) model.ExperimentMeasurer { + return tlsmiddlebox.NewExperimentMeasurer( + *config.(*tlsmiddlebox.Config), + ) + }, + config: &tlsmiddlebox.Config{}, + inputPolicy: model.InputStrictlyRequired, + } +}