-
Notifications
You must be signed in to change notification settings - Fork 54
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: introduce the tlsping experiment (#716)
See ooni/probe#2088 (issue) and ooni/spec#236 (spec).
- Loading branch information
1 parent
e983a5c
commit 2917dd6
Showing
4 changed files
with
323 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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=<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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") | ||
} | ||
}) | ||
} |