273b70bacc
## Checklist - [x] I have read the [contribution guidelines](https://github.com/ooni/probe-cli/blob/master/CONTRIBUTING.md) - [x] reference issue for this pull request: https://github.com/ooni/probe/issues/1885 - [x] related ooni/spec pull request: N/A Location of the issue tracker: https://github.com/ooni/probe ## Description This PR contains a set of changes to move important interfaces and data types into the `./internal/model` package. The criteria for including an interface or data type in here is roughly that the type should be important and used by several packages. We are especially interested to move more interfaces here to increase modularity. An additional side effect is that, by reading this package, one should be able to understand more quickly how different parts of the codebase interact with each other. This is what I want to move in `internal/model`: - [x] most important interfaces from `internal/netxlite` - [x] everything that was previously part of `internal/engine/model` - [x] mocks from `internal/netxlite/mocks` should also be moved in here as a subpackage
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/model"
|
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
|
)
|
|
|
|
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 netxlite.FailureConnectionRefused:
|
|
return classAnomalyTestHelperUnreachable
|
|
case netxlite.FailureConnectionReset:
|
|
return classInterferenceReset
|
|
case netxlite.FailureDNSNXDOMAINError:
|
|
return classAnomalyTestHelperUnreachable
|
|
case netxlite.FailureEOFError:
|
|
return classInterferenceClosed
|
|
case netxlite.FailureGenericTimeoutError:
|
|
if tk.Control.Failure != nil {
|
|
return classAnomalyTestHelperUnreachable
|
|
}
|
|
return classAnomalyTimeout
|
|
case netxlite.FailureSSLInvalidCertificate:
|
|
return classInterferenceInvalidCertificate
|
|
case netxlite.FailureSSLInvalidHostname:
|
|
return classSuccessGotServerHello
|
|
case netxlite.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 := netxlite.FailureInterrupted
|
|
failedop := netxlite.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
|
|
}
|