83440cf110
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
307 lines
8.0 KiB
Go
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
|
|
}
|