feat(oonirun): implement OONIRun v1 (#843)

This diff adds support for running OONIRun v1 links.

Run with `miniooni` using:

```
./miniooni -i LINK oonirun
```

Part of https://github.com/ooni/probe/issues/2184
This commit is contained in:
Simone Basso 2022-07-08 15:17:52 +02:00 committed by GitHub
parent 0b4a49190a
commit ebb78c2848
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 259 additions and 1 deletions

View File

@ -369,7 +369,14 @@ func MainWithConfiguration(experimentName string, currentOptions Options) {
log.Infof("- resolver's network: %s (%s)", sess.ResolverNetworkName(),
sess.ResolverASNString())
// Run OONI experiments as we normally do.
// We handle the oonirun experiment name specially. The user must specify
// `miniooni -i {OONIRunURL} oonirun` to run a OONI Run URL (v1 or v2).
if experimentName == "oonirun" {
ooniRunMain(ctx, sess, currentOptions, annotations)
return
}
// Otherwise just run OONI experiments as we normally do.
desc := &oonirun.Experiment{
Annotations: annotations,
ExtraOptions: extraOptions,
@ -386,3 +393,36 @@ func MainWithConfiguration(experimentName string, currentOptions Options) {
err = desc.Run(ctx)
runtimex.PanicOnError(err, "cannot run experiment")
}
// ooniRunMain runs the experiments described by the given OONI Run URLs. This
// function works with both v1 and v2 OONI Run URLs.
func ooniRunMain(ctx context.Context,
sess *engine.Session, currentOptions Options, annotations map[string]string) {
runtimex.PanicIfTrue(
len(currentOptions.Inputs) <= 0,
"in oonirun mode you need to specify at least one URL using `-i URL`",
)
runtimex.PanicIfTrue(
len(currentOptions.InputFilePaths) > 0,
"in oonirun mode you cannot specify any `-f FILE` file",
)
logger := sess.Logger()
cfg := &oonirun.LinkConfig{
AcceptChanges: currentOptions.Yes,
Annotations: annotations,
KVStore: sess.KeyValueStore(),
MaxRuntime: currentOptions.MaxRuntime,
NoCollector: currentOptions.NoCollector,
NoJSON: currentOptions.NoJSON,
Random: currentOptions.Random,
ReportFile: currentOptions.ReportFile,
Session: sess,
}
for _, URL := range currentOptions.Inputs {
r := oonirun.NewLinkRunner(cfg, URL)
if err := r.Run(ctx); err != nil {
logger.Warnf("oonirun: running link failed: %s", err.Error())
continue
}
}
}

92
internal/oonirun/link.go Normal file
View File

@ -0,0 +1,92 @@
package oonirun
//
// OONI Run v1 and v2 links
//
import (
"context"
"strings"
"github.com/ooni/probe-cli/v3/internal/model"
)
// LinkConfig contains config for an OONI Run link. You MUST fill all the fields that
// are marked as MANDATORY, or the LinkConfig would cause crashes.
type LinkConfig struct {
// AcceptChanges is OPTIONAL and tells this library that the user is
// okay with running a new or modified OONI Run link without previously
// reviewing what it contains or what has changed.
AcceptChanges bool
// Annotations contains OPTIONAL Annotations for the experiment.
Annotations map[string]string
// KVStore is the MANDATORY key-value store to use to keep track of
// OONI Run links and know when they are new or modified.
KVStore model.KeyValueStore
// MaxRuntime is the OPTIONAL maximum runtime in seconds.
MaxRuntime int64
// NoCollector OPTIONALLY indicates we should not be using any collector.
NoCollector bool
// NoJSON OPTIONALLY indicates we don't want to save measurements to a JSON file.
NoJSON bool
// Random OPTIONALLY indicates we should randomize inputs.
Random bool
// ReportFile is the MANDATORY file in which to save reports, which is only
// used when noJSON is set to false.
ReportFile string
// Session is the MANDATORY Session to use.
Session Session
}
// LinkRunner knows how to run an OONI Run v1 or v2 link.
type LinkRunner interface {
Run(ctx context.Context) error
}
// linkRunner implements LinkRunner.
type linkRunner struct {
config *LinkConfig
f func(ctx context.Context, config *LinkConfig, URL string) error
url string
}
// Run implements LinkRunner.Run.
func (lr *linkRunner) Run(ctx context.Context) error {
return lr.f(ctx, lr.config, lr.url)
}
// NewLinkRunner creates a suitable link runner for the current config
// and the given URL, which is one of the following:
//
// 1. OONI Run v1 link with https scheme (e.g., https://run.ooni.io/nettest?...)
//
// 2. OONI Run v1 link with ooni scheme (e.g., ooni://nettest?...)
//
// 3. arbitrary URL of the OONI Run v2 descriptor.
func NewLinkRunner(c *LinkConfig, URL string) LinkRunner {
// TODO(bassosimone): add support for v2 deeplinks.
out := &linkRunner{
config: c,
f: nil,
url: URL,
}
switch {
case strings.HasPrefix(URL, "https://run.ooni.io/nettest"):
out.f = v1Measure
case strings.HasPrefix(URL, "ooni://nettest"):
out.f = v1Measure
default:
// TODO(bassosimone): this panic will go away when we merge
// the next patch which will implement v2.
panic("unsupported OONI Run link")
}
return out
}

90
internal/oonirun/v1.go Normal file
View File

@ -0,0 +1,90 @@
package oonirun
//
// OONI Run v1 implementation
//
import (
"context"
"encoding/json"
"errors"
"net/url"
)
var (
// ErrInvalidV1URLScheme indicates a v1 OONI Run URL has an invalid scheme.
ErrInvalidV1URLScheme = errors.New("oonirun: invalid v1 URL scheme")
// ErrInvalidV1URLHost indicates a v1 OONI Run URL has an invalid host.
ErrInvalidV1URLHost = errors.New("oonirun: invalid v1 URL host")
// ErrInvalidV1URLPath indicates a v1 OONI Run URL has an invalid path.
ErrInvalidV1URLPath = errors.New("oonirun: invalid v1 URL path")
// ErrInvalidV1URLQueryArgument indicates a v1 OONI Run URL query argument is invalid.
ErrInvalidV1URLQueryArgument = errors.New("oonirun: invalid v1 URL query argument")
)
// v1Arguments contains arguments for a v1 OONI Run URL. These arguments are
// always encoded inside of the "ta" field, which is optional.
type v1Arguments struct {
URLs []string `json:"urls"`
}
// v1Measure performs a measurement using the given v1 OONI Run URL.
func v1Measure(ctx context.Context, config *LinkConfig, URL string) error {
config.Session.Logger().Infof("oonirun/v1: running %s", URL)
pu, err := url.Parse(URL)
if err != nil {
return err
}
switch pu.Scheme {
case "https":
if pu.Host != "run.ooni.io" {
return ErrInvalidV1URLHost
}
if pu.Path != "/nettest" {
return ErrInvalidV1URLPath
}
case "ooni":
if pu.Host != "nettest" {
return ErrInvalidV1URLHost
}
if pu.Path != "" {
return ErrInvalidV1URLPath
}
default:
return ErrInvalidV1URLScheme
}
name := pu.Query().Get("tn")
if name == "" {
return ErrInvalidV1URLQueryArgument
}
var inputs []string
if ra := pu.Query().Get("ta"); ra != "" {
pa, err := url.QueryUnescape(ra)
if err != nil {
return err
}
var arguments v1Arguments
if err := json.Unmarshal([]byte(pa), &arguments); err != nil {
return err
}
inputs = arguments.URLs
}
// TODO(bassosimone): reject mv < 1.2.0
exp := &Experiment{
Annotations: config.Annotations,
ExtraOptions: nil, // no way to specify with v1 URLs
Inputs: inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: name,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
}
return exp.Run(ctx)
}

View File

@ -0,0 +1,36 @@
package oonirun
import (
"context"
"testing"
"github.com/ooni/probe-cli/v3/internal/kvstore"
)
// TODO(bassosimone): it would be cool to write unit tests. However, to do that
// we need to ~redesign the engine package for unit-testability.
func TestOONIRunV1Link(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
AcceptChanges: false,
Annotations: map[string]string{
"platform": "linux",
},
KVStore: &kvstore.Memory{},
MaxRuntime: 0,
NoCollector: true,
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
}
r := NewLinkRunner(config, "https://run.ooni.io/nettest?tn=example&mv=1.2.0")
if err := r.Run(ctx); err != nil {
t.Fatal(err)
}
r = NewLinkRunner(config, "ooni://nettest?tn=example&mv=1.2.0")
if err := r.Run(ctx); err != nil {
t.Fatal(err)
}
}