From 2917dd6c76e1bdc482d3179ce3b1caae86d72b7d Mon Sep 17 00:00:00 2001 From: Simone Basso Date: Mon, 9 May 2022 10:25:50 +0200 Subject: [PATCH] feat: introduce the tlsping experiment (#716) See https://github.com/ooni/probe/issues/2088 (issue) and https://github.com/ooni/spec/pull/236 (spec). --- internal/engine/allexperiments.go | 13 ++ internal/engine/experiment/tcpping/tcpping.go | 1 + internal/engine/experiment/tlsping/tlsping.go | 188 ++++++++++++++++++ .../engine/experiment/tlsping/tlsping_test.go | 121 +++++++++++ 4 files changed, 323 insertions(+) create mode 100644 internal/engine/experiment/tlsping/tlsping.go create mode 100644 internal/engine/experiment/tlsping/tlsping_test.go diff --git a/internal/engine/allexperiments.go b/internal/engine/allexperiments.go index 23286cd..e27d81d 100644 --- a/internal/engine/allexperiments.go +++ b/internal/engine/allexperiments.go @@ -20,6 +20,7 @@ import ( "github.com/ooni/probe-cli/v3/internal/engine/experiment/stunreachability" "github.com/ooni/probe-cli/v3/internal/engine/experiment/tcpping" "github.com/ooni/probe-cli/v3/internal/engine/experiment/telegram" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlsping" "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool" "github.com/ooni/probe-cli/v3/internal/engine/experiment/tor" "github.com/ooni/probe-cli/v3/internal/engine/experiment/torsf" @@ -228,6 +229,18 @@ var experimentsByName = map[string]func(*Session) *ExperimentBuilder{ } }, + "tlsping": func(session *Session) *ExperimentBuilder { + return &ExperimentBuilder{ + build: func(config interface{}) *Experiment { + return NewExperiment(session, tlsping.NewExperimentMeasurer( + *config.(*tlsping.Config), + )) + }, + config: &tlsping.Config{}, + inputPolicy: InputStrictlyRequired, + } + }, + "telegram": func(session *Session) *ExperimentBuilder { return &ExperimentBuilder{ build: func(config interface{}) *Experiment { diff --git a/internal/engine/experiment/tcpping/tcpping.go b/internal/engine/experiment/tcpping/tcpping.go index 7d4ac35..ef56a01 100644 --- a/internal/engine/experiment/tcpping/tcpping.go +++ b/internal/engine/experiment/tcpping/tcpping.go @@ -135,6 +135,7 @@ func (m *Measurer) tcpPingAsync(ctx context.Context, mxmx *measurex.Measurer, // tcpConnect performs a TCP connect and returns the result to the caller. func (m *Measurer) tcpConnect(ctx context.Context, mxmx *measurex.Measurer, address string) *measurex.EndpointMeasurement { + // TODO(bassosimone): make the timeout user-configurable ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() return mxmx.TCPConnect(ctx, address) diff --git a/internal/engine/experiment/tlsping/tlsping.go b/internal/engine/experiment/tlsping/tlsping.go new file mode 100644 index 0000000..19f5ad2 --- /dev/null +++ b/internal/engine/experiment/tlsping/tlsping.go @@ -0,0 +1,188 @@ +// Package tlsping is the experimental tlsping experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-033-tlsping.md. +package tlsping + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/ooni/probe-cli/v3/internal/measurex" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +const ( + testName = "tlsping" + testVersion = "0.1.0" +) + +// Config contains the experiment configuration. +type Config struct { + // ALPN allows to specify which ALPN or ALPNs to send. + ALPN string `ooni:"space separated list of ALPNs to use"` + + // Delay is the delay between each repetition (in milliseconds). + Delay int64 `ooni:"number of milliseconds to wait before sending each ping"` + + // Repetitions is the number of repetitions for each ping. + Repetitions int64 `ooni:"number of times to repeat the measurement"` + + // SNI is the SNI value to use. + SNI string `ooni:"the SNI value to use"` +} + +func (c *Config) alpn() string { + if c.ALPN != "" { + return c.ALPN + } + return "h2 http/1.1" +} + +func (c *Config) delay() time.Duration { + if c.Delay > 0 { + return time.Duration(c.Delay) * time.Millisecond + } + return time.Second +} + +func (c *Config) repetitions() int64 { + if c.Repetitions > 0 { + return c.Repetitions + } + return 10 +} + +// TestKeys contains the experiment results. +type TestKeys struct { + Pings []*SinglePing `json:"pings"` +} + +// SinglePing contains the results of a single ping. +type SinglePing struct { + NetworkEvents []*measurex.ArchivalNetworkEvent `json:"network_events"` + TCPConnect []*measurex.ArchivalTCPConnect `json:"tcp_connect"` + TLSHandshake []*measurex.ArchivalQUICTLSHandshakeEvent `json:"tls_handshakes"` +} + +// Measurer performs the measurement. +type Measurer struct { + config Config +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +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("not input provided") + + // errInputIsNotAnURL indicates that input is not an URL + errInputIsNotAnURL = errors.New("input is not an URL") + + // errInvalidScheme indicates that the scheme is invalid + errInvalidScheme = errors.New("scheme must be tlshandshake") + + // errMissingPort indicates that there is no port. + errMissingPort = errors.New("the URL must include a port") +) + +// 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 fmt.Errorf("%w: %s", errInputIsNotAnURL, err.Error()) + } + if parsed.Scheme != "tlshandshake" { + return errInvalidScheme + } + if parsed.Port() == "" { + return errMissingPort + } + if m.config.SNI == "" { + sess.Logger().Warn("no -O SNI= specified from command line") + } + tk := new(TestKeys) + measurement.TestKeys = tk + out := make(chan *measurex.EndpointMeasurement) + mxmx := measurex.NewMeasurerWithDefaultSettings() + go m.tlsPingLoop(ctx, mxmx, parsed.Host, out) + for len(tk.Pings) < int(m.config.repetitions()) { + meas := <-out + tk.Pings = append(tk.Pings, &SinglePing{ + NetworkEvents: measurex.NewArchivalNetworkEventList(meas.ReadWrite), + TCPConnect: measurex.NewArchivalTCPConnectList(meas.Connect), + TLSHandshake: measurex.NewArchivalQUICTLSHandshakeEventList(meas.TLSHandshake), + }) + } + return nil // return nil so we always submit the measurement +} + +// tlsPingLoop sends all the ping requests and emits the results onto the out channel. +func (m *Measurer) tlsPingLoop(ctx context.Context, mxmx *measurex.Measurer, + address string, out chan<- *measurex.EndpointMeasurement) { + ticker := time.NewTicker(m.config.delay()) + defer ticker.Stop() + for i := int64(0); i < m.config.repetitions(); i++ { + go m.tlsPingAsync(ctx, mxmx, address, out) + <-ticker.C + } +} + +// tlsPingAsync performs a TLS ping and emits the result onto the out channel. +func (m *Measurer) tlsPingAsync(ctx context.Context, mxmx *measurex.Measurer, + address string, out chan<- *measurex.EndpointMeasurement) { + out <- m.tlsConnectAndHandshake(ctx, mxmx, address) +} + +// tlsConnectAndHandshake performs a TCP connect followed by a TLS handshake +// and returns the results of these operations to the caller. +func (m *Measurer) tlsConnectAndHandshake(ctx context.Context, mxmx *measurex.Measurer, + address string) *measurex.EndpointMeasurement { + // TODO(bassosimone): make the timeout user-configurable + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + return mxmx.TLSConnectAndHandshake(ctx, address, &tls.Config{ + NextProtos: strings.Split(m.config.alpn(), " "), + RootCAs: netxlite.NewDefaultCertPool(), + ServerName: m.config.SNI, + }) +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with ooniprobe +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/tlsping/tlsping_test.go b/internal/engine/experiment/tlsping/tlsping_test.go new file mode 100644 index 0000000..6eaf567 --- /dev/null +++ b/internal/engine/experiment/tlsping/tlsping_test.go @@ -0,0 +1,121 @@ +package tlsping + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/mockable" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func TestConfig_alpn(t *testing.T) { + c := Config{} + if c.alpn() != "h2 http/1.1" { + t.Fatal("invalid default alpn list") + } +} + +func TestConfig_repetitions(t *testing.T) { + c := Config{} + if c.repetitions() != 10 { + t.Fatal("invalid default number of repetitions") + } +} + +func TestConfig_delay(t *testing.T) { + c := Config{} + if c.delay() != time.Second { + t.Fatal("invalid default delay") + } +} + +func TestMeasurer_run(t *testing.T) { + // expectedPings is the expected number of pings + const expectedPings = 4 + + // runHelper is an helper function to run this set of tests. + runHelper := func(input string) (*model.Measurement, model.ExperimentMeasurer, error) { + m := NewExperimentMeasurer(Config{ + ALPN: "http/1.1", + Delay: 1, // millisecond + Repetitions: expectedPings, + }) + if m.ExperimentName() != "tlsping" { + t.Fatal("invalid experiment name") + } + if m.ExperimentVersion() != "0.1.0" { + t.Fatal("invalid experiment version") + } + ctx := context.Background() + meas := &model.Measurement{ + Input: model.MeasurementTarget(input), + } + sess := &mockable.Session{ + MockableLogger: 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("") + if !errors.Is(err, errNoInputProvided) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid URL", func(t *testing.T) { + _, _, err := runHelper("\t") + if !errors.Is(err, errInputIsNotAnURL) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid scheme", func(t *testing.T) { + _, _, err := runHelper("https://8.8.8.8:443/") + if !errors.Is(err, errInvalidScheme) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with missing port", func(t *testing.T) { + _, _, err := runHelper("tlshandshake://8.8.8.8") + if !errors.Is(err, errMissingPort) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with local listener", func(t *testing.T) { + srvr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srvr.Close() + URL, err := url.Parse(srvr.URL) + if err != nil { + t.Fatal(err) + } + URL.Scheme = "tlshandshake" + meas, m, err := runHelper(URL.String()) + if err != nil { + t.Fatal(err) + } + tk := meas.TestKeys.(*TestKeys) + if len(tk.Pings) != expectedPings { + t.Fatal("unexpected number of pings") + } + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + }) +}