ooni-probe-cli/internal/engine/experiment/hirl/hirl.go
Simone Basso 58adb68b2c
refactor: move tracex outside of engine/netx (#782)
* refactor: move tracex outside of engine/netx

Consistently with https://github.com/ooni/probe/issues/2121 and
https://github.com/ooni/probe/issues/2115, we can now move tracex
outside of engine/netx. The main reason why this makes sense now
is that the package is now changed significantly from the one
that we imported from ooni/probe-engine.

We have improved its implementation, which had not been touched
significantly for quite some time, and converted it to unit
testing. I will document tomorrow some extra work I'd like to
do with this package but likely could not do $soon.

* go fmt

* regen tutorials
2022-06-02 00:50:55 +02:00

324 lines
8.3 KiB
Go

// Package hirl contains the HTTP Invalid Request Line network experiment.
//
// See https://github.com/ooni/spec/blob/master/nettests/ts-007-http-invalid-request-line.md
package hirl
import (
"context"
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/netxlite"
"github.com/ooni/probe-cli/v3/internal/randx"
"github.com/ooni/probe-cli/v3/internal/tracex"
)
const (
testName = "http_invalid_request_line"
testVersion = "0.2.0"
timeout = 5 * time.Second
)
// Config contains the experiment config.
type Config struct{}
// TestKeys contains the experiment test keys.
type TestKeys struct {
FailureList []*string `json:"failure_list"`
Received []tracex.MaybeBinaryValue `json:"received"`
Sent []string `json:"sent"`
TamperingList []bool `json:"tampering_list"`
Tampering bool `json:"tampering"`
}
// NewExperimentMeasurer creates a new ExperimentMeasurer.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return Measurer{
Config: config,
Methods: []Method{
randomInvalidMethod{},
randomInvalidFieldCount{},
randomBigRequestMethod{},
randomInvalidVersionNumber{},
squidCacheManager{},
},
}
}
// Measurer performs the measurement.
type Measurer struct {
Config Config
Methods []Method
}
// ExperimentName implements ExperimentMeasurer.ExperiExperimentName.
func (m Measurer) ExperimentName() string {
return testName
}
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion.
func (m Measurer) ExperimentVersion() string {
return testVersion
}
var (
// ErrNoAvailableTestHelpers is emitted when there are no available test helpers.
ErrNoAvailableTestHelpers = errors.New("no available helpers")
// ErrInvalidHelperType is emitted when the helper type is invalid.
ErrInvalidHelperType = errors.New("invalid helper type")
// ErrNoMeasurementMethod is emitted when Measurer.Methods is empty.
ErrNoMeasurementMethod = errors.New("no configured measurement method")
)
// Run implements ExperimentMeasurer.Run.
func (m Measurer) Run(
ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
tk := new(TestKeys)
measurement.TestKeys = tk
if len(m.Methods) < 1 {
return ErrNoMeasurementMethod
}
const helperName = "tcp-echo"
helpers, ok := sess.GetTestHelpersByName(helperName)
if !ok || len(helpers) < 1 {
return ErrNoAvailableTestHelpers
}
helper := helpers[0]
if helper.Type != "legacy" {
return ErrInvalidHelperType
}
measurement.TestHelpers = map[string]interface{}{
"backend": helper.Address,
}
out := make(chan MethodResult)
for _, method := range m.Methods {
callbacks.OnProgress(0.0, fmt.Sprintf("%s...", method.Name()))
go method.Run(ctx, MethodConfig{
Address: helper.Address,
Logger: sess.Logger(),
Out: out,
})
}
var (
completed int
progress float64
result MethodResult
)
for {
select {
case result = <-out:
case <-time.After(500 * time.Millisecond):
if completed <= 0 {
progress += 0.05
callbacks.OnProgress(progress, "waiting for results...")
}
continue
}
failure := tracex.NewFailure(result.Err)
tk.FailureList = append(tk.FailureList, failure)
tk.Received = append(tk.Received, result.Received)
tk.Sent = append(tk.Sent, result.Sent)
tk.TamperingList = append(tk.TamperingList, result.Tampering)
tk.Tampering = (tk.Tampering || result.Tampering)
completed++
percentage := (float64(completed)/float64(len(m.Methods)))*0.5 + 0.5
callbacks.OnProgress(percentage, fmt.Sprintf("%s... %+v", result.Name, model.ErrorToStringOrOK(result.Err)))
if completed >= len(m.Methods) {
break
}
}
return nil
}
// MethodConfig contains the settings for a specific measuring method.
type MethodConfig struct {
Address string
Logger model.Logger
Out chan<- MethodResult
}
// MethodResult is the result of one of the methods implemented by this experiment.
type MethodResult struct {
Err error
Name string
Received tracex.MaybeBinaryValue
Sent string
Tampering bool
}
// Method is one of the methods implemented by this experiment.
type Method interface {
Name() string
Run(ctx context.Context, config MethodConfig)
}
type randomInvalidMethod struct{}
func (randomInvalidMethod) Name() string {
return "random_invalid_method"
}
func (meth randomInvalidMethod) Run(ctx context.Context, config MethodConfig) {
RunMethod(ctx, RunMethodConfig{
MethodConfig: config,
Name: meth.Name(),
RequestLine: randx.LettersUppercase(4) + " / HTTP/1.1\n\r",
})
}
type randomInvalidFieldCount struct{}
func (randomInvalidFieldCount) Name() string {
return "random_invalid_field_count"
}
func (meth randomInvalidFieldCount) Run(ctx context.Context, config MethodConfig) {
RunMethod(ctx, RunMethodConfig{
MethodConfig: config,
Name: meth.Name(),
RequestLine: strings.Join([]string{
randx.LettersUppercase(5),
" ",
randx.LettersUppercase(5),
" ",
randx.LettersUppercase(5),
" ",
randx.LettersUppercase(5),
"\r\n",
}, ""),
})
}
type randomBigRequestMethod struct{}
func (randomBigRequestMethod) Name() string {
return "random_big_request_method"
}
func (meth randomBigRequestMethod) Run(ctx context.Context, config MethodConfig) {
RunMethod(ctx, RunMethodConfig{
MethodConfig: config,
Name: meth.Name(),
RequestLine: strings.Join([]string{
randx.LettersUppercase(1024),
" / HTTP/1.1\r\n",
}, ""),
})
}
type randomInvalidVersionNumber struct{}
func (randomInvalidVersionNumber) Name() string {
return "random_invalid_version_number"
}
func (meth randomInvalidVersionNumber) Run(ctx context.Context, config MethodConfig) {
RunMethod(ctx, RunMethodConfig{
MethodConfig: config,
Name: meth.Name(),
RequestLine: strings.Join([]string{
"GET / HTTP/",
randx.LettersUppercase(3),
"\r\n",
}, ""),
})
}
type squidCacheManager struct{}
func (squidCacheManager) Name() string {
return "squid_cache_manager"
}
func (meth squidCacheManager) Run(ctx context.Context, config MethodConfig) {
RunMethod(ctx, RunMethodConfig{
MethodConfig: config,
Name: meth.Name(),
RequestLine: "GET cache_object://localhost/ HTTP/1.0\n\r",
})
}
// RunMethodConfig contains the config for RunMethod
type RunMethodConfig struct {
MethodConfig
Name string
NewDialer func(config netx.Config) model.Dialer
RequestLine string
}
// RunMethod runs the specific method using the given config and context
func RunMethod(ctx context.Context, config RunMethodConfig) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
result := MethodResult{Name: config.Name}
defer func() {
config.Out <- result
}()
if config.NewDialer == nil {
config.NewDialer = netx.NewDialer
}
dialer := config.NewDialer(netx.Config{
ContextByteCounting: true,
Logger: config.Logger,
})
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(config.Address, "80"))
if err != nil {
result.Err = err
return
}
deadline := time.Now().Add(timeout)
if err := conn.SetDeadline(deadline); err != nil {
result.Err = err
return
}
if _, err := conn.Write([]byte(config.RequestLine)); err != nil {
result.Err = err
return
}
result.Sent = config.RequestLine
data := make([]byte, 4096)
defer func() {
result.Tampering = (result.Sent != result.Received.Value)
}()
for {
count, err := conn.Read(data)
if err != nil {
// We expect this method to terminate w/ timeout
if err.Error() == netxlite.FailureGenericTimeoutError {
err = nil
}
result.Err = err
return
}
result.Received.Value += string(data[:count])
}
}
// 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) {
sk := SummaryKeys{IsAnomaly: false}
tk, ok := measurement.TestKeys.(*TestKeys)
if !ok {
return sk, errors.New("invalid test keys type")
}
sk.IsAnomaly = tk.Tampering
return sk, nil
}