diff --git a/internal/engine/allexperiments.go b/internal/engine/allexperiments.go index e27d81d..d9e7702 100644 --- a/internal/engine/allexperiments.go +++ b/internal/engine/allexperiments.go @@ -16,6 +16,7 @@ import ( "github.com/ooni/probe-cli/v3/internal/engine/experiment/riseupvpn" "github.com/ooni/probe-cli/v3/internal/engine/experiment/run" "github.com/ooni/probe-cli/v3/internal/engine/experiment/signal" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/simplequicping" "github.com/ooni/probe-cli/v3/internal/engine/experiment/sniblocking" "github.com/ooni/probe-cli/v3/internal/engine/experiment/stunreachability" "github.com/ooni/probe-cli/v3/internal/engine/experiment/tcpping" @@ -181,6 +182,18 @@ var experimentsByName = map[string]func(*Session) *ExperimentBuilder{ } }, + "simplequicping": func(session *Session) *ExperimentBuilder { + return &ExperimentBuilder{ + build: func(config interface{}) *Experiment { + return NewExperiment(session, simplequicping.NewExperimentMeasurer( + *config.(*simplequicping.Config), + )) + }, + config: &simplequicping.Config{}, + inputPolicy: InputStrictlyRequired, + } + }, + "signal": func(session *Session) *ExperimentBuilder { return &ExperimentBuilder{ build: func(config interface{}) *Experiment { diff --git a/internal/engine/experiment/simplequicping/simplequicping.go b/internal/engine/experiment/simplequicping/simplequicping.go new file mode 100644 index 0000000..2d6e041 --- /dev/null +++ b/internal/engine/experiment/simplequicping/simplequicping.go @@ -0,0 +1,185 @@ +// Package simplequicping is the experimental simplequicping experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-034-simplequicping.md. +package simplequicping + +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 = "simplequicping" + 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 "h3" +} + +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"` + QUICHandshakes []*measurex.ArchivalQUICTLSHandshakeEvent `json:"quic_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 quichandshake") + + // 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 != "quichandshake" { + 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.simpleQUICPingLoop(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), + QUICHandshakes: measurex.NewArchivalQUICTLSHandshakeEventList(meas.QUICHandshake), + }) + } + return nil // return nil so we always submit the measurement +} + +// simpleQUICPingLoop sends all the ping requests and emits the results onto the out channel. +func (m *Measurer) simpleQUICPingLoop(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.simpleQUICPingAsync(ctx, mxmx, address, out) + <-ticker.C + } +} + +// simpleQUICPingAsync performs a QUIC ping and emits the result onto the out channel. +func (m *Measurer) simpleQUICPingAsync(ctx context.Context, mxmx *measurex.Measurer, + address string, out chan<- *measurex.EndpointMeasurement) { + out <- m.quicHandshake(ctx, mxmx, address) +} + +// quicHandshake performs a QUIC handshake and returns the results of these operations to the caller. +func (m *Measurer) quicHandshake(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.QUICHandshake(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/simplequicping/simplequicping_test.go b/internal/engine/experiment/simplequicping/simplequicping_test.go new file mode 100644 index 0000000..8750b86 --- /dev/null +++ b/internal/engine/experiment/simplequicping/simplequicping_test.go @@ -0,0 +1,187 @@ +package simplequicping + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "log" + "math/big" + "net/url" + "testing" + "time" + + "github.com/lucas-clemente/quic-go" + "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() != "h3" { + 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: "h3", + Delay: 1, // millisecond + Repetitions: expectedPings, + }) + if m.ExperimentName() != "simplequicping" { + 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("quichandshake://8.8.8.8") + if !errors.Is(err, errMissingPort) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with local listener", func(t *testing.T) { + srvrURL, err := startEchoServer() + if err != nil { + log.Fatal(err) + } + t.Log(srvrURL) + meas, m, err := runHelper(srvrURL) + 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") + } + }) +} + +// Start a server that echos all data on the first stream opened by the client. +// +// SPDX-License-Identifier: MIT +// +// See https://github.com/lucas-clemente/quic-go/blob/v0.27.0/example/echo/echo.go#L34 +func startEchoServer() (string, error) { + listener, err := quic.ListenAddr("127.0.0.1:0", generateTLSConfig(), nil) + if err != nil { + return "", err + } + go echoWorkerMain(listener) + URL := &url.URL{ + Scheme: "quichandshake", + Host: listener.Addr().String(), + Path: "/", + } + return URL.String(), nil +} + +// Worker used by startEchoServer to accept a quic connection. +// +// SPDX-License-Identifier: MIT +// +// See https://github.com/lucas-clemente/quic-go/blob/v0.27.0/example/echo/echo.go#L34 +func echoWorkerMain(listener quic.Listener) { + defer listener.Close() + conn, err := listener.Accept(context.Background()) + if err != nil { + panic(err) + } + stream, err := conn.AcceptStream(context.Background()) + if err != nil { + panic(err) + } + stream.Close() +} + +// Setup a bare-bones TLS config for the server. +// +// SPDX-License-Identifier: MIT +// +// See https://github.com/lucas-clemente/quic-go/blob/v0.27.0/example/echo/echo.go#L91 +func generateTLSConfig() *tls.Config { + key, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + panic(err) + } + template := x509.Certificate{SerialNumber: big.NewInt(1)} + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + panic(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + panic(err) + } + return &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + NextProtos: []string{"quic-echo-example"}, + } +} diff --git a/internal/engine/experiment/tlsping/tlsping.go b/internal/engine/experiment/tlsping/tlsping.go index 19f5ad2..f388df4 100644 --- a/internal/engine/experiment/tlsping/tlsping.go +++ b/internal/engine/experiment/tlsping/tlsping.go @@ -67,7 +67,7 @@ type TestKeys struct { type SinglePing struct { NetworkEvents []*measurex.ArchivalNetworkEvent `json:"network_events"` TCPConnect []*measurex.ArchivalTCPConnect `json:"tcp_connect"` - TLSHandshake []*measurex.ArchivalQUICTLSHandshakeEvent `json:"tls_handshakes"` + TLSHandshakes []*measurex.ArchivalQUICTLSHandshakeEvent `json:"tls_handshakes"` } // Measurer performs the measurement. @@ -132,7 +132,7 @@ func (m *Measurer) Run( tk.Pings = append(tk.Pings, &SinglePing{ NetworkEvents: measurex.NewArchivalNetworkEventList(meas.ReadWrite), TCPConnect: measurex.NewArchivalTCPConnectList(meas.Connect), - TLSHandshake: measurex.NewArchivalQUICTLSHandshakeEventList(meas.TLSHandshake), + TLSHandshakes: measurex.NewArchivalQUICTLSHandshakeEventList(meas.TLSHandshake), }) } return nil // return nil so we always submit the measurement