ooni-probe-cli/internal/engine/experiment/sniblocking/sniblocking.go
Simone Basso 83440cf110
refactor: split errorsx in good and legacy (#477)
The legacy part for now is internal/errorsx. It will stay there until
I figure out whether it also needs some extra bug fixing.

The good part is now in internal/netxlite/errorsx and contains all the
logic for mapping errors. We need to further improve upon this logic
by writing more thorough integration tests for QUIC.

We also need to copy the various dialer, conn, etc adapters that set
errors. We will put them inside netxlite and we will generate errors in
a way that is less crazy with respect to the major operation. (The
idea is to always wrap, given that now we measure in an incremental way
and we don't measure every operation together.)

Part of https://github.com/ooni/probe/issues/1591
2021-09-07 17:09:30 +02:00

307 lines
8.0 KiB
Go

// Package sniblocking contains the SNI blocking network experiment.
//
// See https://github.com/ooni/spec/blob/master/nettests/ts-024-sni-blocking.md.
package sniblocking
import (
"context"
"errors"
"fmt"
"math/rand"
"net"
"net/url"
"sync"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/netxlite/errorsx"
)
const (
testName = "sni_blocking"
testVersion = "0.3.0"
)
// Config contains the experiment config.
type Config struct {
// ControlSNI is the SNI to be used for the control.
ControlSNI string
// TestHelperAddress is the address of the test helper.
TestHelperAddress string
}
// Subresult contains the keys of a single measurement
// that targets either the target or the control.
type Subresult struct {
urlgetter.TestKeys
Cached bool `json:"-"`
SNI string `json:"sni"`
THAddress string `json:"th_address"`
}
// TestKeys contains sniblocking test keys.
type TestKeys struct {
Control Subresult `json:"control"`
Result string `json:"result"`
Target Subresult `json:"target"`
}
const (
classAnomalyTestHelperUnreachable = "anomaly.test_helper_unreachable"
classAnomalyTimeout = "anomaly.timeout"
classAnomalyUnexpectedFailure = "anomaly.unexpected_failure"
classInterferenceClosed = "interference.closed"
classInterferenceInvalidCertificate = "interference.invalid_certificate"
classInterferenceReset = "interference.reset"
classInterferenceUnknownAuthority = "interference.unknown_authority"
classSuccessGotServerHello = "success.got_server_hello"
)
func (tk *TestKeys) classify() string {
if tk.Target.Failure == nil {
return classSuccessGotServerHello
}
switch *tk.Target.Failure {
case errorsx.FailureConnectionRefused:
return classAnomalyTestHelperUnreachable
case errorsx.FailureConnectionReset:
return classInterferenceReset
case errorsx.FailureDNSNXDOMAINError:
return classAnomalyTestHelperUnreachable
case errorsx.FailureEOFError:
return classInterferenceClosed
case errorsx.FailureGenericTimeoutError:
if tk.Control.Failure != nil {
return classAnomalyTestHelperUnreachable
}
return classAnomalyTimeout
case errorsx.FailureSSLInvalidCertificate:
return classInterferenceInvalidCertificate
case errorsx.FailureSSLInvalidHostname:
return classSuccessGotServerHello
case errorsx.FailureSSLUnknownAuthority:
return classInterferenceUnknownAuthority
}
return classAnomalyUnexpectedFailure
}
// Measurer performs the measurement.
type Measurer struct {
cache map[string]Subresult
config Config
mu sync.Mutex
}
// ExperimentName implements ExperimentMeasurer.ExperiExperimentName.
func (m *Measurer) ExperimentName() string {
return testName
}
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion.
func (m *Measurer) ExperimentVersion() string {
return testVersion
}
func (m *Measurer) measureone(
ctx context.Context,
sess model.ExperimentSession,
beginning time.Time,
sni string,
thaddr string,
) Subresult {
// slightly delay the measurement
gen := rand.New(rand.NewSource(time.Now().UnixNano()))
sleeptime := time.Duration(gen.Intn(250)) * time.Millisecond
select {
case <-time.After(sleeptime):
case <-ctx.Done():
s := errorsx.FailureInterrupted
failedop := errorsx.TopLevelOperation
return Subresult{
TestKeys: urlgetter.TestKeys{
FailedOperation: &failedop,
Failure: &s,
},
THAddress: thaddr,
SNI: sni,
}
}
// perform the measurement
g := urlgetter.Getter{
Begin: beginning,
Config: urlgetter.Config{TLSServerName: sni},
Session: sess,
Target: fmt.Sprintf("tlshandshake://%s", thaddr),
}
// Ignoring the error because g.Get() sets the tk.Failure field
// to be the OONI equivalent of the error that occurred.
tk, _ := g.Get(ctx)
// assemble and publish the results
smk := Subresult{
SNI: sni,
THAddress: thaddr,
TestKeys: tk,
}
return smk
}
func (m *Measurer) measureonewithcache(
ctx context.Context,
output chan<- Subresult,
sess model.ExperimentSession,
beginning time.Time,
sni string,
thaddr string,
) {
cachekey := sni + thaddr
m.mu.Lock()
smk, okay := m.cache[cachekey]
m.mu.Unlock()
if okay {
output <- smk
return
}
smk = m.measureone(ctx, sess, beginning, sni, thaddr)
output <- smk
smk.Cached = true
m.mu.Lock()
m.cache[cachekey] = smk
m.mu.Unlock()
}
func (m *Measurer) startall(
ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, inputs []string,
) <-chan Subresult {
outputs := make(chan Subresult, len(inputs))
for _, input := range inputs {
go m.measureonewithcache(
ctx, outputs, sess,
measurement.MeasurementStartTimeSaved,
input, m.config.TestHelperAddress,
)
}
return outputs
}
func processall(
outputs <-chan Subresult,
measurement *model.Measurement,
callbacks model.ExperimentCallbacks,
inputs []string,
sess model.ExperimentSession,
controlSNI string,
) *TestKeys {
var (
current int
testkeys = new(TestKeys)
)
for smk := range outputs {
if smk.SNI == controlSNI {
testkeys.Control = smk
} else if smk.SNI == string(measurement.Input) {
testkeys.Target = smk
} else {
panic("unexpected smk.SNI")
}
current++
sess.Logger().Debugf(
"sni_blocking: %s: %s [cached: %+v]", smk.SNI,
asString(smk.Failure), smk.Cached)
if current >= len(inputs) {
break
}
}
testkeys.Result = testkeys.classify()
sess.Logger().Infof("sni_blocking: result: %s", testkeys.Result)
return testkeys
}
// maybeURLToSNI handles the case where the input is from the test-lists
// and hence every input is a URL rather than a domain.
func maybeURLToSNI(input model.MeasurementTarget) (model.MeasurementTarget, error) {
parsed, err := url.Parse(string(input))
if err != nil {
return "", err
}
if parsed.Path == string(input) {
return input, nil
}
return model.MeasurementTarget(parsed.Hostname()), nil
}
// Run implements ExperimentMeasurer.Run.
func (m *Measurer) Run(
ctx context.Context,
sess model.ExperimentSession,
measurement *model.Measurement,
callbacks model.ExperimentCallbacks,
) error {
m.mu.Lock()
if m.cache == nil {
m.cache = make(map[string]Subresult)
}
m.mu.Unlock()
if m.config.ControlSNI == "" {
m.config.ControlSNI = "example.org"
}
if measurement.Input == "" {
return errors.New("Experiment requires measurement.Input")
}
if m.config.TestHelperAddress == "" {
m.config.TestHelperAddress = net.JoinHostPort(
m.config.ControlSNI, "443",
)
}
urlgetter.RegisterExtensions(measurement)
// TODO(bassosimone): if the user has configured DoT or DoH, here we
// probably want to perform the name resolution before the measurements
// or to make sure that the classify logic is robust to that.
//
// See https://github.com/ooni/probe-engine/issues/392.
maybeParsed, err := maybeURLToSNI(measurement.Input)
if err != nil {
return err
}
measurement.Input = maybeParsed
inputs := []string{m.config.ControlSNI}
if string(measurement.Input) != m.config.ControlSNI {
inputs = append(inputs, string(measurement.Input))
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second*time.Duration(len(inputs)))
defer cancel()
outputs := m.startall(ctx, sess, measurement, inputs)
measurement.TestKeys = processall(
outputs, measurement, callbacks, inputs, sess, m.config.ControlSNI,
)
return nil
}
// NewExperimentMeasurer creates a new ExperimentMeasurer.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return &Measurer{config: config}
}
func asString(failure *string) (result string) {
result = "success"
if failure != nil {
result = *failure
}
return
}
// SummaryKeys contains summary keys for this experiment.
//
// Note that this structure is part of the ABI contract with probe-cli
// 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
}