feat: tutorial on how to write the torsf experiment (#390)

Original tracking issue for Sprint 41: https://github.com/ooni/probe/issues/1507

Follow-up work in Sprint 42 tracked by: https://github.com/ooni/probe/issues/1689
This commit is contained in:
Simone Basso 2021-06-22 00:12:03 +02:00 committed by GitHub
parent a9990ac9da
commit 520398dd8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 2113 additions and 26 deletions

View File

@ -11,7 +11,7 @@ import (
"github.com/apex/log"
"github.com/montanaflynn/stats"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
"github.com/ooni/probe-cli/v3/internal/engine/netx/trace"

View File

@ -8,7 +8,7 @@ import (
"time"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -8,7 +8,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/example"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -9,7 +9,7 @@ import (
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/fbmessenger"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/archival"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"

View File

@ -15,7 +15,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hhfm"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/archival"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"

View File

@ -8,7 +8,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hirl"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
"github.com/ooni/probe-cli/v3/internal/engine/netx/archival"

View File

@ -7,7 +7,7 @@ import (
"testing"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -8,7 +8,7 @@ import (
"testing"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -11,7 +11,7 @@ import (
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/psiphon"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -14,7 +14,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/riseupvpn"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/archival"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"

View File

@ -8,7 +8,7 @@ import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/run"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -7,7 +7,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/signal"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
)

View File

@ -7,7 +7,7 @@ import (
"time"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
)

View File

@ -10,7 +10,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/stunreachability"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
"github.com/pion/stun"

View File

@ -10,7 +10,7 @@ import (
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/telegram"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
)

View File

@ -6,7 +6,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -13,9 +13,9 @@ import (
"github.com/apex/log"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/legacy/oonidatamodel"
"github.com/ooni/probe-cli/v3/internal/engine/legacy/oonitemplates"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
)

View File

@ -7,7 +7,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/torsf"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"golang.org/x/sys/execabs"
)

View File

@ -8,7 +8,7 @@ import (
"time"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/tunnel"
)

View File

@ -9,7 +9,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
)

View File

@ -7,7 +7,7 @@ import (
"testing"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
)
func TestGetterHTTPSWithTunnelCannotCreateTempDir(t *testing.T) {

View File

@ -12,7 +12,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -7,7 +7,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -5,7 +5,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
)
func TestFillASNsEmpty(t *testing.T) {

View File

@ -13,7 +13,7 @@ import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/whatsapp"
"github.com/ooni/probe-cli/v3/internal/engine/internal/httpfailure"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)

View File

@ -12,7 +12,7 @@ import (
"github.com/apex/log"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/probeservices"
"github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra"

View File

@ -0,0 +1,18 @@
# Tutorials: writing OONI nettests
This package contains a living tutorial explaining how to write OONI
nettests. The code in here is based on existing nettests.
Because it's committed to the probe-cli repository and depends on
real OONI code, it should always be up to date.
## Index
- [Rewriting the torsf experiment](experiment/torsf/)
## Regenerating the tutorials
```
(cd ./internal/tutorial && go run ./generator)
```

View File

@ -0,0 +1,20 @@
# Tutorial: rewriting the torsf experiment
This tutorial teaches you how to write a minimal implementation of the
[torsf](https://github.com/ooni/spec/blob/master/nettests/ts-030-torsf.md)
experiment. We will do that in four steps.
In the [first step](chapter01/) we will write a `main.go`
function that runs the existing `torsf` implementation.
In the [second step](chapter02/) we will modify the existing
code to launch an empty experiment instead.
In the [third step](chapter03/) we will start to fill in
the empty experiment to more closely simulate a real implementation
of the `torsf` experiment.
In the [fourth step](chapter04/) we will replace the code
simulating a real `torsf` experiment with a minimal implementation
of such an experiment that uses other code in `ooni/probe-cli` to
attempt to bootstrap `tor` over Snowflake.

View File

@ -0,0 +1,286 @@
# Chapter I: main.go using the real torsf implementation
In this chapter we will write together a `main.go` file that
uses the real `torsf` implementation to run the experiment.
(This file is auto-generated from the corresponding source file,
so make sure you don't edit it manually.)
## The torsf experiment
This experiment attempts to bootstrap the `tor` binary using
Snowflake as the pluggable transport.
You can read the [specification](https://github.com/ooni/spec/blob/master/nettests/ts-030-torsf.md)
of the `torsf` experiment in the [ooni/spec](https://github.com/ooni/spec)
repository. (The `ooni/spec` repository is the repository
containing the specification of all OONI nettests, as well
as of the data formats used by OONI.)
## The main.go file
We define `main.go` file using `package main`.
```Go
package main
```
### Imports
Then we add the required imports.
```Go
import (
```
These are standard library imports.
```Go
"context"
"encoding/json"
"fmt"
"io/ioutil"
```
The apex/log library is the logging library used by OONI Probe.
```Go
"github.com/apex/log"
```
The torsf package contains the implementation of the torsf experiment.
```Go
"github.com/ooni/probe-cli/v3/internal/engine/experiment/torsf"
```
The mockable package contains widely used mocks.
```Go
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
```
The model package contains the data model used by OONI experiments.
```Go
"github.com/ooni/probe-cli/v3/internal/engine/model"
```
We will need the execabs library to check whether there is
a binary called `tor` in the `PATH`.
```Go
"golang.org/x/sys/execabs"
)
```
### Main function
Finally, here's the code of the `main function`.
```Go
func main() {
```
We start by checking whether there is an executable named `"tor"` in
the `PATH`. If there is no such executable, we fail with an error.
```Go
if _, err := execabs.LookPath("tor"); err != nil {
log.Fatal("cannot find the tor executable in path")
}
```
Then, we create a temporary directory to hold any state that may be
required either by the `tor` or by the Snowflake pluggable transport.
```Go
tempdir, err := ioutil.TempDir("", "")
if err != nil {
log.WithError(err).Fatal("cannot create temporary directory")
}
```
### Creating the experiment measurer
All OONI experiments implement a function called
`NewExprimentMeasurer` that allows you to make
an `ExperimentMeasurer` instance. The `ExperimentMeasurer`
is an `interface` defined by the `model` package we
imported above. Because we don't want to configure
any setting (and the experiment does not support any
setting anyway), here we're passing to the
`NewExperimentMeasurer` factory an empty `Config`.
```Go
m := torsf.NewExperimentMeasurer(torsf.Config{})
```
### Creating the measurement
Next, we create an empty `Measurement`. OONI measurements
are JSON data structures that contain generic fields common
to all OONI experiments and experiment-specific data. The
experiment-specific data is contained by a the `test_keys`
field of the `Measurement`.
In the *real* OONI implementation, there is common code
that fills the several fields of a `Measurement`. For
example, it will fill the country code and the autonomous
system number of the network in which the OONI Probe is
running. Because this is just an example to illustrate
how to write experiments, we will not bother with doing
that. Instead, we will pass to the experiment just an
emtpy measurement where no field has been set.
```Go
measurement := &model.Measurement{}
```
### Creating the callbacks
Then, we create an instance of the experiment callbacks. The
experiment callbacks historically groups a set of callbacks
called when the measurer is running. At the moment of writing
this note, the `model.ExperimentCallbacks` contains just a
single method called `OnDataUsage`, which is used to tell the
caller which is the amount of data used by the experiment.
Because this is an example for illustrative purposes, here
we construct an implementation of `ExperimentCallbacks` that
just prints the data usage using the `log.Log` logger.
```Go
callbacks := model.NewPrinterCallbacks(log.Log)
```
### Creating a session
The `ExperimentMeasurer` also wants a `Session`. In normal
OONI code, the `Session` is a data structure containing
information regarding the current measurement session. Since
this is just an illustrative example, rather than creating
a real `Session` instance, we use much-simpler mock.
The interface required by a `Session` is called
`ExperimentSession` and is part of the `model` package.
Here we configure this mockable session to use `log.Log`
as a logger and the previously computed temp dir.
```Go
sess := &mockable.Session{
MockableLogger: log.Log,
MockableTempDir: tempdir,
}
```
# Running the experiment
At last, it's time to run the experiment using all the
previously constructed data structures. The `Run` function
is the main function you need to implement when you are
defining a new OONI experiment.
By convention, the `Run` function only returns an error
when some precondition required by the experiment is
not met. Say that, for example, the experiment needs a
port listening on the local host. If we cannot create
such a port, we will return an error to the caller.
For network errors, instead, we return nil. Consider the
case where we connect to a remote host and the connection
fails. This is not really an error, rather it's a result
that we will include into the measurement.
Apart from the other arguments that we discussed previously,
the `Run` function also wants a `context.Context` as its
first argument. The context is used to interrupt long running
functions early, and our code (mostly) honours contexts.
Since here we are just writing a simple example, we don't
need any fancy context and we pass a `context.Background` to `Run`.
```Go
ctx := context.Background()
if err = m.Run(ctx, sess, measurement, callbacks); err != nil {
log.WithError(err).Fatal("torsf experiment failed")
}
```
### Printing the measurement result
The `Run` function modifies the `TestKeys` (`test_keys` in JSON)
field of the measurement. The real OONI implementation would
now submit this measurement. Because this is an illustrative example,
we will just pretty-print the measurement on the `stdout`.
```Go
data, err := json.Marshal(measurement)
if err != nil {
log.WithError(err).Fatal("json.Marshal failed")
}
fmt.Printf("%s\n", data)
}
```
## Running the code
You can now run this code as follows:
```
$ go run ./experiment/torsf/chapter01 | jq
[snip]
{
"data_format_version": "",
"input": null,
"measurement_start_time": "",
"probe_asn": "",
"probe_cc": "",
"probe_network_name": "",
"report_id": "",
"resolver_asn": "",
"resolver_ip": "",
"resolver_network_name": "",
"software_name": "",
"software_version": "",
"test_keys": {
"bootstrap_time": 68.909067459,
"failure": null
},
"test_name": "",
"test_runtime": 0,
"test_start_time": "",
"test_version": ""
}
```
We have snipped through logs and we have used `jq` to
pretty print the measurement. You see that all the fields
except the `test_keys` are empty.
Let us now analyze the content of the `test_keys`:
- the `bootstrap_time` field contains the time (in seconds) to
bootstrap `tor` using the Snowflake transport;
- the `failure` field contains the error that occurred, if
any, or `null` if no error occurred.
## Concluding remarks
This is all you need to know in terms of minimal code for
running an OONI experiment. In the remainder of this tutorial,
we will show how to reimplement the `torsf` experiment.
Apart from minor changes, the `main.go` file would basically
not change for the remainder of this tutorial.

View File

@ -0,0 +1,287 @@
// -=-=- StartHere -=-=-
//
// # Chapter I: main.go using the real torsf implementation
//
// In this chapter we will write together a `main.go` file that
// uses the real `torsf` implementation to run the experiment.
//
// (This file is auto-generated from the corresponding source file,
// so make sure you don't edit it manually.)
//
// ## The torsf experiment
//
// This experiment attempts to bootstrap the `tor` binary using
// Snowflake as the pluggable transport.
//
// You can read the [specification](https://github.com/ooni/spec/blob/master/nettests/ts-030-torsf.md)
// of the `torsf` experiment in the [ooni/spec](https://github.com/ooni/spec)
// repository. (The `ooni/spec` repository is the repository
// containing the specification of all OONI nettests, as well
// as of the data formats used by OONI.)
//
// ## The main.go file
//
// We define `main.go` file using `package main`.
//
// ```Go
package main
// ```
//
// ### Imports
//
// Then we add the required imports.
//
// ```Go
import (
// ```
//
// These are standard library imports.
//
// ```Go
"context"
"encoding/json"
"fmt"
"io/ioutil"
// ```
//
// The apex/log library is the logging library used by OONI Probe.
//
// ```Go
"github.com/apex/log"
// ```
//
// The torsf package contains the implementation of the torsf experiment.
//
// ```Go
"github.com/ooni/probe-cli/v3/internal/engine/experiment/torsf"
// ```
//
// The mockable package contains widely used mocks.
//
// ```Go
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
// ```
//
// The model package contains the data model used by OONI experiments.
//
// ```Go
"github.com/ooni/probe-cli/v3/internal/engine/model"
// ```
//
// We will need the execabs library to check whether there is
// a binary called `tor` in the `PATH`.
//
// ```Go
"golang.org/x/sys/execabs"
)
// ```
//
// ### Main function
//
// Finally, here's the code of the `main function`.
//
// ```Go
func main() {
// ```
//
// We start by checking whether there is an executable named `"tor"` in
// the `PATH`. If there is no such executable, we fail with an error.
//
// ```Go
if _, err := execabs.LookPath("tor"); err != nil {
log.Fatal("cannot find the tor executable in path")
}
// ```
//
// Then, we create a temporary directory to hold any state that may be
// required either by the `tor` or by the Snowflake pluggable transport.
//
// ```Go
tempdir, err := ioutil.TempDir("", "")
if err != nil {
log.WithError(err).Fatal("cannot create temporary directory")
}
// ```
//
// ### Creating the experiment measurer
//
// All OONI experiments implement a function called
// `NewExprimentMeasurer` that allows you to make
// an `ExperimentMeasurer` instance. The `ExperimentMeasurer`
// is an `interface` defined by the `model` package we
// imported above. Because we don't want to configure
// any setting (and the experiment does not support any
// setting anyway), here we're passing to the
// `NewExperimentMeasurer` factory an empty `Config`.
//
// ```Go
m := torsf.NewExperimentMeasurer(torsf.Config{})
// ```
//
// ### Creating the measurement
//
// Next, we create an empty `Measurement`. OONI measurements
// are JSON data structures that contain generic fields common
// to all OONI experiments and experiment-specific data. The
// experiment-specific data is contained by a the `test_keys`
// field of the `Measurement`.
//
// In the *real* OONI implementation, there is common code
// that fills the several fields of a `Measurement`. For
// example, it will fill the country code and the autonomous
// system number of the network in which the OONI Probe is
// running. Because this is just an example to illustrate
// how to write experiments, we will not bother with doing
// that. Instead, we will pass to the experiment just an
// emtpy measurement where no field has been set.
//
// ```Go
measurement := &model.Measurement{}
// ```
//
// ### Creating the callbacks
//
// Then, we create an instance of the experiment callbacks. The
// experiment callbacks historically groups a set of callbacks
// called when the measurer is running. At the moment of writing
// this note, the `model.ExperimentCallbacks` contains just a
// single method called `OnDataUsage`, which is used to tell the
// caller which is the amount of data used by the experiment.
//
// Because this is an example for illustrative purposes, here
// we construct an implementation of `ExperimentCallbacks` that
// just prints the data usage using the `log.Log` logger.
//
// ```Go
callbacks := model.NewPrinterCallbacks(log.Log)
// ```
//
// ### Creating a session
//
// The `ExperimentMeasurer` also wants a `Session`. In normal
// OONI code, the `Session` is a data structure containing
// information regarding the current measurement session. Since
// this is just an illustrative example, rather than creating
// a real `Session` instance, we use much-simpler mock.
//
// The interface required by a `Session` is called
// `ExperimentSession` and is part of the `model` package.
//
// Here we configure this mockable session to use `log.Log`
// as a logger and the previously computed temp dir.
//
// ```Go
sess := &mockable.Session{
MockableLogger: log.Log,
MockableTempDir: tempdir,
}
// ```
//
// # Running the experiment
//
// At last, it's time to run the experiment using all the
// previously constructed data structures. The `Run` function
// is the main function you need to implement when you are
// defining a new OONI experiment.
//
// By convention, the `Run` function only returns an error
// when some precondition required by the experiment is
// not met. Say that, for example, the experiment needs a
// port listening on the local host. If we cannot create
// such a port, we will return an error to the caller.
//
// For network errors, instead, we return nil. Consider the
// case where we connect to a remote host and the connection
// fails. This is not really an error, rather it's a result
// that we will include into the measurement.
//
// Apart from the other arguments that we discussed previously,
// the `Run` function also wants a `context.Context` as its
// first argument. The context is used to interrupt long running
// functions early, and our code (mostly) honours contexts.
//
// Since here we are just writing a simple example, we don't
// need any fancy context and we pass a `context.Background` to `Run`.
//
// ```Go
ctx := context.Background()
if err = m.Run(ctx, sess, measurement, callbacks); err != nil {
log.WithError(err).Fatal("torsf experiment failed")
}
// ```
//
// ### Printing the measurement result
//
// The `Run` function modifies the `TestKeys` (`test_keys` in JSON)
// field of the measurement. The real OONI implementation would
// now submit this measurement. Because this is an illustrative example,
// we will just pretty-print the measurement on the `stdout`.
//
// ```Go
data, err := json.Marshal(measurement)
if err != nil {
log.WithError(err).Fatal("json.Marshal failed")
}
fmt.Printf("%s\n", data)
}
// ```
//
// ## Running the code
//
// You can now run this code as follows:
//
// ```
// $ go run ./experiment/torsf/chapter01 | jq
// [snip]
// {
// "data_format_version": "",
// "input": null,
// "measurement_start_time": "",
// "probe_asn": "",
// "probe_cc": "",
// "probe_network_name": "",
// "report_id": "",
// "resolver_asn": "",
// "resolver_ip": "",
// "resolver_network_name": "",
// "software_name": "",
// "software_version": "",
// "test_keys": {
// "bootstrap_time": 68.909067459,
// "failure": null
// },
// "test_name": "",
// "test_runtime": 0,
// "test_start_time": "",
// "test_version": ""
//}
// ```
//
// We have snipped through logs and we have used `jq` to
// pretty print the measurement. You see that all the fields
// except the `test_keys` are empty.
//
// Let us now analyze the content of the `test_keys`:
//
// - the `bootstrap_time` field contains the time (in seconds) to
// bootstrap `tor` using the Snowflake transport;
//
// - the `failure` field contains the error that occurred, if
// any, or `null` if no error occurred.
//
// ## Concluding remarks
//
// This is all you need to know in terms of minimal code for
// running an OONI experiment. In the remainder of this tutorial,
// we will show how to reimplement the `torsf` experiment.
//
// Apart from minor changes, the `main.go` file would basically
// not change for the remainder of this tutorial.

View File

@ -0,0 +1,204 @@
# Chapter II: creating an empty experiment
In this chapter we will create an empty experiment and replace
the code calling the real `torsf` experiment in `main.go` to
call our empty experiment instead.
(This file is auto-generated from the corresponding source file,
so make sure you don't edit it manually.)
## Changes in main.go
In `main.go` we will simply replace the call to the
`torsf.NewExperimentMeasurer` function with a call to
a `NewExperimentMeasurer` function that we are going
to implement as part of this chapter.
After you do this, you also need to remove the now-unneded
import of the `torsf` package.
There are no additional changes to `main.go`.
```Go
m := NewExperimentMeasurer(Config{})
```
## The torsf.go file
This file will contain the implementation of the
`NewExperimentMeasurer` function.
As usual we start with the `package` declaration and
with the few imports we need to add.
```Go
package main
import (
"context"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
```
### Data structures
Next, we define data structures.
Config contains config for the torsf experiment. As for the real
`torsf` experiment, we don't have any specific config, so we keep
the structure empty. We still need to define a `Config` struct
here, because, by convention, all OONI experiments have a `Config`.
```Go
type Config struct{}
```
Measurer is the torsf measurer. This structure implements the
`model.ExperimentMeasurer` interface, as we will see below.
Most OONI experiments have a measurer that contains as its unique
field the specific configuration. Here we do the same.
```Go
type Measurer struct {
config Config
}
```
NewExperimentMeasurer creates a new model.ExperimentMeasurer
instance for performing `torsf` measurements. This function
will just assemble a new instance of `Measurer` with the `config`
that was passed as an argument.
```Go
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return &Measurer{config: config}
}
```
### Implementing the model.ExperimentMeasurer.
Now it's time to implement the methods required by the `model`'s
`ExperimentMeasurer` interface.
ExperimentName implements ExperimentMeasurer.ExperimentName. This function
returns the name of the experiment. This code is used by generic code
manipulating the experiment to print the experiment name.
```Go
func (m *Measurer) ExperimentName() string {
return "torsf"
}
```
ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. This
function returns the version of the experiment. This code is also used by
generic code manipulating the experiment to print the experiment version.
```Go
func (m *Measurer) ExperimentVersion() string {
return "0.1.0"
}
```
Run implements ExperimentMeasurer.Run. This is the most interesting
function, where we run the experiment proper. In the previous chapter
we learned how to call this function from a `main.go` file. Here,
instead, we're going to create a minimal stub. In the subsequent
chapters, finally, we will modify this function until it is a
minimal implementation of the `torsf` experiment.
```Go
func (m *Measurer) Run(
ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
```
As you can see, this is just a stub implementation that sleeps
for one second and prints a logging message.
```Go
time.Sleep(time.Second)
sess.Logger().Info("hello from the torsf experiment!")
return nil
}
```
### Summary keys
Before concluding this chapter, we also need to create the `SummaryKeys`
for this experiment. For historical reasons, the `TestKeys` of each
experiment is an `interface{}`. Every experiment also defines a `SummaryKeys`
data structure and a `GetSummaryKeys` method to convert the opaque
result of a measurement to the summary for such an experiment.
The experiment summary is *only* used by the OONI Probe CLI.
SummaryKeys contains summary keys for this experiment. Because this is
just an illustrative tutorial, we will just include a single key, named
`IsAnomaly`. This key is not exported as JSON and is used by the OONI
Probe CLI to inform the user of whether this measurement is ordinary or
anomalous. All OONI experiments' `SummaryKeys` contain such a field.
```Go
type SummaryKeys struct {
IsAnomaly bool `json:"-"`
}
```
GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. This
method just converts the `TestKeys` inside `measurement` to an instance of
the `SummaryKeys` structure. For now, we'll just implement a stub returning
fake `SummaryKeys` declaring there was no anomaly.
```Go
func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) {
return &SummaryKeys{IsAnomaly: false}, nil
}
```
## Running the code
We can run the code written in this chapter as follows:
```
$ go run ./experiment/torsf/chapter02
2021/06/21 20:48:32 info hello from the torsf experiment!
{
"data_format_version": "",
"input": null,
"measurement_start_time": "",
"probe_asn": "",
"probe_cc": "",
"probe_network_name": "",
"report_id": "",
"resolver_asn": "",
"resolver_ip": "",
"resolver_network_name": "",
"software_name": "",
"software_version": "",
"test_keys": null,
"test_name": "",
"test_runtime": 0,
"test_start_time": "",
"test_version": ""
}
```
Here you see that we're printing the log message and
that the `test_keys` are `null`.
The OONI data processing popeline will not be so happy
if we pass it a `null` settings, because there is not
much interesting data in there. We will thus start filling
it in the next chapter.

View File

@ -0,0 +1,65 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"golang.org/x/sys/execabs"
)
func main() {
if _, err := execabs.LookPath("tor"); err != nil {
log.Fatal("cannot find the tor executable in path")
}
tempdir, err := ioutil.TempDir("", "")
if err != nil {
log.WithError(err).Fatal("cannot create temporary directory")
}
// -=-=- StartHere -=-=-
//
// # Chapter II: creating an empty experiment
//
// In this chapter we will create an empty experiment and replace
// the code calling the real `torsf` experiment in `main.go` to
// call our empty experiment instead.
//
// (This file is auto-generated from the corresponding source file,
// so make sure you don't edit it manually.)
//
// ## Changes in main.go
//
// In `main.go` we will simply replace the call to the
// `torsf.NewExperimentMeasurer` function with a call to
// a `NewExperimentMeasurer` function that we are going
// to implement as part of this chapter.
//
// After you do this, you also need to remove the now-unneded
// import of the `torsf` package.
//
// There are no additional changes to `main.go`.
//
// ```Go
m := NewExperimentMeasurer(Config{})
// ```
// -=-=- StopHere -=-=-
ctx := context.Background()
measurement := &model.Measurement{}
callbacks := model.NewPrinterCallbacks(log.Log)
sess := &mockable.Session{
MockableLogger: log.Log,
MockableTempDir: tempdir,
}
if err = m.Run(ctx, sess, measurement, callbacks); err != nil {
log.WithError(err).Fatal("torsf experiment failed")
}
data, err := json.Marshal(measurement)
if err != nil {
log.WithError(err).Fatal("json.Marshal failed")
}
fmt.Printf("%s\n", data)
}

View File

@ -0,0 +1,180 @@
// -=-=- StartHere -=-=-
//
// ## The torsf.go file
//
// This file will contain the implementation of the
// `NewExperimentMeasurer` function.
//
// As usual we start with the `package` declaration and
// with the few imports we need to add.
//
// ```Go
package main
import (
"context"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
// ```
//
// ### Data structures
//
// Next, we define data structures.
// Config contains config for the torsf experiment. As for the real
// `torsf` experiment, we don't have any specific config, so we keep
// the structure empty. We still need to define a `Config` struct
// here, because, by convention, all OONI experiments have a `Config`.
//
// ```Go
type Config struct{}
// ```
//
// Measurer is the torsf measurer. This structure implements the
// `model.ExperimentMeasurer` interface, as we will see below.
//
// Most OONI experiments have a measurer that contains as its unique
// field the specific configuration. Here we do the same.
//
// ```Go
type Measurer struct {
config Config
}
// ```
// NewExperimentMeasurer creates a new model.ExperimentMeasurer
// instance for performing `torsf` measurements. This function
// will just assemble a new instance of `Measurer` with the `config`
// that was passed as an argument.
//
// ```Go
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return &Measurer{config: config}
}
// ```
//
// ### Implementing the model.ExperimentMeasurer.
//
// Now it's time to implement the methods required by the `model`'s
// `ExperimentMeasurer` interface.
// ExperimentName implements ExperimentMeasurer.ExperimentName. This function
// returns the name of the experiment. This code is used by generic code
// manipulating the experiment to print the experiment name.
//
// ```Go
func (m *Measurer) ExperimentName() string {
return "torsf"
}
// ```
//
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. This
// function returns the version of the experiment. This code is also used by
// generic code manipulating the experiment to print the experiment version.
//
// ```Go
func (m *Measurer) ExperimentVersion() string {
return "0.1.0"
}
// ```
//
// Run implements ExperimentMeasurer.Run. This is the most interesting
// function, where we run the experiment proper. In the previous chapter
// we learned how to call this function from a `main.go` file. Here,
// instead, we're going to create a minimal stub. In the subsequent
// chapters, finally, we will modify this function until it is a
// minimal implementation of the `torsf` experiment.
//
// ```Go
func (m *Measurer) Run(
ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
// ```
// As you can see, this is just a stub implementation that sleeps
// for one second and prints a logging message.
//
// ```Go
time.Sleep(time.Second)
sess.Logger().Info("hello from the torsf experiment!")
return nil
}
// ```
// ### Summary keys
//
// Before concluding this chapter, we also need to create the `SummaryKeys`
// for this experiment. For historical reasons, the `TestKeys` of each
// experiment is an `interface{}`. Every experiment also defines a `SummaryKeys`
// data structure and a `GetSummaryKeys` method to convert the opaque
// result of a measurement to the summary for such an experiment.
//
// The experiment summary is *only* used by the OONI Probe CLI.
// SummaryKeys contains summary keys for this experiment. Because this is
// just an illustrative tutorial, we will just include a single key, named
// `IsAnomaly`. This key is not exported as JSON and is used by the OONI
// Probe CLI to inform the user of whether this measurement is ordinary or
// anomalous. All OONI experiments' `SummaryKeys` contain such a field.
//
// ```Go
type SummaryKeys struct {
IsAnomaly bool `json:"-"`
}
// ```
//
// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. This
// method just converts the `TestKeys` inside `measurement` to an instance of
// the `SummaryKeys` structure. For now, we'll just implement a stub returning
// fake `SummaryKeys` declaring there was no anomaly.
//
// ```Go
func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) {
return &SummaryKeys{IsAnomaly: false}, nil
}
// ```
//
// ## Running the code
//
// We can run the code written in this chapter as follows:
//
// ```
// $ go run ./experiment/torsf/chapter02
// 2021/06/21 20:48:32 info hello from the torsf experiment!
// {
// "data_format_version": "",
// "input": null,
// "measurement_start_time": "",
// "probe_asn": "",
// "probe_cc": "",
// "probe_network_name": "",
// "report_id": "",
// "resolver_asn": "",
// "resolver_ip": "",
// "resolver_network_name": "",
// "software_name": "",
// "software_version": "",
// "test_keys": null,
// "test_name": "",
// "test_runtime": 0,
// "test_start_time": "",
// "test_version": ""
// }
// ```
//
// Here you see that we're printing the log message and
// that the `test_keys` are `null`.
//
// The OONI data processing popeline will not be so happy
// if we pass it a `null` settings, because there is not
// much interesting data in there. We will thus start filling
// it in the next chapter.

View File

@ -0,0 +1,166 @@
# Chapter III: starting to simulate a real torsf experiment
In this chapter we will improve upon what we did in the previous
chapter by creating runner code for the `torsf` experiment. We will
not, yet, run the real experiment, but we will instead write
simple code that pretends to run a `tor` bootstrap using snowflake.
(This file is auto-generated from the corresponding source file,
so make sure you don't edit it manually.)
### The TestKeys structure
Let us start by defining the `TestKeys` structure that contains
the experiment specific results. As we have already seen in
Chapter I, this structure must contain two fields. The bootstrap
time for the experiment and the failure.
```Go
type TestKeys struct {
BootstrapTime float64 `json:"bootstrap_time"`
Failure *string `json:"failure"`
}
```
### Rewriting the Run method
Next we will rewrite the Run method. We will arrange for this
method to fill the `measurement`, to setup the timeout, and to
print periodic updates via the `callbacks`. We will defer the
real work to a private function called `run`.
```Go
func (m *Measurer) Run(
ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
```
Let's create an instance of `TestKeys` and let's modify
the `measurement` to refer to such an instance.
```Go
testkeys := &TestKeys{}
measurement.TestKeys = testkeys
```
Next, we record the current time and we modify the
context to have a timeout after 300 seconds. Because
Snowflake *may* take a long time to bootstrap, we
need to specify a generous timeout here.
```Go
start := time.Now()
const maxRuntime = 300 * time.Second
ctx, cancel := context.WithTimeout(ctx, maxRuntime)
defer cancel()
```
Okay, now we are ready to defer the real work to
the internal `run` function. We first create a
channel to receive the result of `run`. Then, we
create a ticker to emit periodic updates. We
emit an update every 250 milliseconds, which is
a reasonably smooth way of increasing a progress
bar (progress is indeed used to move progress bars
both in OONI Probe Desktop and mobile.)
```Go
errch := make(chan error)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
```
Now we defer the real work to `run`, which will
run in a background goroutine.
```Go
go m.run(ctx, sess, testkeys, errch)
```
While `run` is running, we loop and check which
channel has become ready.
If the `errch` channel is ready, it means that `run` is
terminated, so we return to the caller.
Instead, if `ticker.C` is ready, we emit a progress
update using the `callbacks`.
```Go
for {
select {
case err := <-errch:
callbacks.OnProgress(1.0, "torsf experiment is finished")
return err
case <-ticker.C:
progress := time.Since(start).Seconds() / maxRuntime.Seconds()
callbacks.OnProgress(progress, "torsf experiment is running")
}
}
}
```
### The run function
We will now implement the `run` function. For now, this function
will not do any real work, but it will just pretend to do work.
Note how we sleep for some time, set the `BootstrapTime` field
of the `TestKeys`, and then return using `errch`.
```Go
func (m *Measurer) run(ctx context.Context,
sess model.ExperimentSession, testkeys *TestKeys, errch chan<- error) {
fakeBootstrapTime := 10 * time.Second
time.Sleep(fakeBootstrapTime)
testkeys.BootstrapTime = fakeBootstrapTime.Seconds()
errch <- nil
}
```
## Running the code
It's now time to run the new code we've written:
```
$ go run ./experiment/torsf/chapter03 | jq
2021/06/21 21:21:18 info [ 0.1%] torsf experiment is running
2021/06/21 21:21:19 info [ 0.2%] torsf experiment is running
[...]
2021/06/21 21:21:28 info [ 3.3%] torsf experiment is running
2021/06/21 21:21:28 info [100.0%] torsf experiment is finished
{
"data_format_version": "",
"input": null,
"measurement_start_time": "",
"probe_asn": "",
"probe_cc": "",
"probe_network_name": "",
"report_id": "",
"resolver_asn": "",
"resolver_ip": "",
"resolver_network_name": "",
"software_name": "",
"software_version": "",
"test_keys": {
"bootstrap_time": 10,
"failure": null
},
"test_name": "",
"test_runtime": 0,
"test_start_time": "",
"test_version": ""
}
```
You see that now we're filling the bootstrap time and we're
also printing progress using `callbacks`.
In the next chapter, we'll replace the stub `run` implementation
with a real implementation using Snowflake.

View File

@ -0,0 +1,39 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"golang.org/x/sys/execabs"
)
func main() {
if _, err := execabs.LookPath("tor"); err != nil {
log.Fatal("cannot find the tor executable in path")
}
tempdir, err := ioutil.TempDir("", "")
if err != nil {
log.WithError(err).Fatal("cannot create temporary directory")
}
m := NewExperimentMeasurer(Config{})
ctx := context.Background()
measurement := &model.Measurement{}
callbacks := model.NewPrinterCallbacks(log.Log)
sess := &mockable.Session{
MockableLogger: log.Log,
MockableTempDir: tempdir,
}
if err = m.Run(ctx, sess, measurement, callbacks); err != nil {
log.WithError(err).Fatal("torsf experiment failed")
}
data, err := json.Marshal(measurement)
if err != nil {
log.WithError(err).Fatal("json.Marshal failed")
}
fmt.Printf("%s\n", data)
}

View File

@ -0,0 +1,210 @@
package main
import (
"context"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
// Config contains config for the torsf experiment.
type Config struct{}
// Measurer is the torsf measurer.
type Measurer struct {
config Config
}
// newExperimentMeasurer creates a new ExperimentMeasurer for torsf.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return &Measurer{config: config}
}
// ExperimentName implements ExperimentMeasurer.ExperimentName.
func (m *Measurer) ExperimentName() string {
return "torsf"
}
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion.
func (m *Measurer) ExperimentVersion() string {
return "0.1.0"
}
// -=-=- StartHere -=-=-
//
// # Chapter III: starting to simulate a real torsf experiment
//
// In this chapter we will improve upon what we did in the previous
// chapter by creating runner code for the `torsf` experiment. We will
// not, yet, run the real experiment, but we will instead write
// simple code that pretends to run a `tor` bootstrap using snowflake.
//
// (This file is auto-generated from the corresponding source file,
// so make sure you don't edit it manually.)
//
// ### The TestKeys structure
//
// Let us start by defining the `TestKeys` structure that contains
// the experiment specific results. As we have already seen in
// Chapter I, this structure must contain two fields. The bootstrap
// time for the experiment and the failure.
//
// ```Go
type TestKeys struct {
BootstrapTime float64 `json:"bootstrap_time"`
Failure *string `json:"failure"`
}
// ```
//
// ### Rewriting the Run method
//
// Next we will rewrite the Run method. We will arrange for this
// method to fill the `measurement`, to setup the timeout, and to
// print periodic updates via the `callbacks`. We will defer the
// real work to a private function called `run`.
//
// ```Go
func (m *Measurer) Run(
ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
// ```
//
// Let's create an instance of `TestKeys` and let's modify
// the `measurement` to refer to such an instance.
//
// ```Go
testkeys := &TestKeys{}
measurement.TestKeys = testkeys
// ```
//
// Next, we record the current time and we modify the
// context to have a timeout after 300 seconds. Because
// Snowflake *may* take a long time to bootstrap, we
// need to specify a generous timeout here.
//
// ```Go
start := time.Now()
const maxRuntime = 300 * time.Second
ctx, cancel := context.WithTimeout(ctx, maxRuntime)
defer cancel()
// ```
//
// Okay, now we are ready to defer the real work to
// the internal `run` function. We first create a
// channel to receive the result of `run`. Then, we
// create a ticker to emit periodic updates. We
// emit an update every 250 milliseconds, which is
// a reasonably smooth way of increasing a progress
// bar (progress is indeed used to move progress bars
// both in OONI Probe Desktop and mobile.)
//
// ```Go
errch := make(chan error)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
// ```
//
// Now we defer the real work to `run`, which will
// run in a background goroutine.
//
// ```Go
go m.run(ctx, sess, testkeys, errch)
// ```
//
// While `run` is running, we loop and check which
// channel has become ready.
//
// If the `errch` channel is ready, it means that `run` is
// terminated, so we return to the caller.
//
// Instead, if `ticker.C` is ready, we emit a progress
// update using the `callbacks`.
//
// ```Go
for {
select {
case err := <-errch:
callbacks.OnProgress(1.0, "torsf experiment is finished")
return err
case <-ticker.C:
progress := time.Since(start).Seconds() / maxRuntime.Seconds()
callbacks.OnProgress(progress, "torsf experiment is running")
}
}
}
// ```
//
// ### The run function
//
// We will now implement the `run` function. For now, this function
// will not do any real work, but it will just pretend to do work.
//
// Note how we sleep for some time, set the `BootstrapTime` field
// of the `TestKeys`, and then return using `errch`.
//
// ```Go
func (m *Measurer) run(ctx context.Context,
sess model.ExperimentSession, testkeys *TestKeys, errch chan<- error) {
fakeBootstrapTime := 10 * time.Second
time.Sleep(fakeBootstrapTime)
testkeys.BootstrapTime = fakeBootstrapTime.Seconds()
errch <- nil
}
// ```
//
// ## Running the code
//
// It's now time to run the new code we've written:
//
// ```
// $ go run ./experiment/torsf/chapter03 | jq
// 2021/06/21 21:21:18 info [ 0.1%] torsf experiment is running
// 2021/06/21 21:21:19 info [ 0.2%] torsf experiment is running
// [...]
// 2021/06/21 21:21:28 info [ 3.3%] torsf experiment is running
// 2021/06/21 21:21:28 info [100.0%] torsf experiment is finished
// {
// "data_format_version": "",
// "input": null,
// "measurement_start_time": "",
// "probe_asn": "",
// "probe_cc": "",
// "probe_network_name": "",
// "report_id": "",
// "resolver_asn": "",
// "resolver_ip": "",
// "resolver_network_name": "",
// "software_name": "",
// "software_version": "",
// "test_keys": {
// "bootstrap_time": 10,
// "failure": null
// },
// "test_name": "",
// "test_runtime": 0,
// "test_start_time": "",
// "test_version": ""
// }
// ```
//
// You see that now we're filling the bootstrap time and we're
// also printing progress using `callbacks`.
//
// In the next chapter, we'll replace the stub `run` implementation
// with a real implementation using Snowflake.
//
// -=-=- StopHere -=-=-
// SummaryKeys contains summary keys for this experiment.
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
}

View File

@ -0,0 +1,201 @@
# Chapter IV: writing minimal torsf experiment
In this chapter we will replace the code written in the previous
chapter that simulates running the torsf experiment with code that
uses the `ooni/probe-cli` library to run the real experiment.
(This file is auto-generated from the corresponding source file,
so make sure you don't edit it manually.)
## Updating the imports
We need to update the imports of `torsf.go` first to look like this:
```Go
import (
```
These are standard library imports.
```Go
"context"
"path"
"time"
```
As we have already seen, the `model` package defines the
generic data model used by all experiments.
```Go
"github.com/ooni/probe-cli/v3/internal/engine/model"
```
The `archival` package contains code used to format internal
measurements representations to the OONI data format.
```Go
"github.com/ooni/probe-cli/v3/internal/engine/netx/archival"
```
The `ptx` package contains pluggable transport code. It includes
code to dial with obfs4 and snowflake and code to create a
pluggable transport listener.
```Go
"github.com/ooni/probe-cli/v3/internal/ptx"
```
The `tunnel` package contains code to create a tunnel. We will
use this package to start a `tor` tunnel, which executes the `tor`
binary using specified command line arguments.
```Go
"github.com/ooni/probe-cli/v3/internal/tunnel"
)
```
## Rewriting the run method
Let us now rewrite the `run` method to run a real `torsf`
test rather than just pretending to do it.
```Go
func (m *Measurer) run(ctx context.Context,
sess model.ExperimentSession, testkeys *TestKeys, errch chan<- error) {
```
As a first step, we create a dialer for snowflake using the
`ptx` package. This dialer will allow us to create a `net.Conn`-like
network connection where traffic is sent using the Snowflake
pluggable transport. There are several optional fields in
`SnowflakeDialer`, but we don't need to override the default
values, so we can just use a default-initialized struct.
```Go
sfdialer := &ptx.SnowflakeDialer{}
```
Let us now create a listener. The `ptx.Listener` is a listener
that listens on a local port and speaks the SOCKS5 protocol. When
tor connect to this port, the listener will forward the traffic
to the Snowflake dialer we previously created. We will also
use the session's logger to emit logging messages.
```Go
ptl := &ptx.Listener{
PTDialer: sfdialer,
Logger: sess.Logger(),
}
```
Now we start the listener. This entails opening a port on the
local host. If this operation fails, we return an error. In fact,
a failure here means a hard error that prevented us from even
starting the experiment. Therefore, it's consistent with the
`Run`'s expectations to return an error here.
```Go
if err := ptl.Start(); err != nil {
testkeys.Failure = archival.NewFailure(err)
errch <- err
return
}
defer ptl.Stop()
```
Next, we start `tor` using the `tunnel` package. Note how we
pass specific `TorArgs` that cause `tor` to know about the
pluggable transport created by `ptl` and `sfdialer`.
```Go
tun, err := tunnel.Start(ctx, &tunnel.Config{
Name: "tor",
Session: sess,
TunnelDir: path.Join(sess.TempDir(), "torsf"),
Logger: sess.Logger(),
TorArgs: []string{
"UseBridges", "1",
"ClientTransportPlugin", ptl.AsClientTransportPluginArgument(),
"Bridge", sfdialer.AsBridgeArgument(),
},
})
```
In case of error, we convert `err` to a OONI failure using
the `NewFailure` function of `archival`. This function reduces
Go error strings to the error strings used by OONI. You can
read the [errors spec](https://github.com/ooni/spec/blob/master/data-formats/df-007-errors.md)
at the [github.com/ooni/spec repo](https://github.com/ooni/spec).
Note that, in this case, we return `nil` to the caller, because
a failure here is not a fundamental failure in running the
experiment, but rather a possibly interesting anomaly.
```Go
if err != nil {
testkeys.Failure = archival.NewFailure(err)
errch <- nil
return
}
```
Otherwise, we successfully created a tor tunnel using Snowflake,
so we just close the tunnel and record the bootstrap time.
```Go
defer tun.Stop()
testkeys.BootstrapTime = tun.BootstrapTime().Seconds()
errch <- nil
}
```
## Running the code
We can now run the code as follows to obtain:
```
$ go run ./experiment/torsf/chapter04 | jq
[...]
Jun 21 23:40:50.000 [notice] Bootstrapped 100% (done): Done
2021/06/21 23:40:50 info [100.0%] torsf experiment is finished
Jun 21 23:40:50.000 [notice] Catching signal TERM, exiting cleanly
{
"data_format_version": "",
"input": null,
"measurement_start_time": "",
"probe_asn": "",
"probe_cc": "",
"probe_network_name": "",
"report_id": "",
"resolver_asn": "",
"resolver_ip": "",
"resolver_network_name": "",
"software_name": "",
"software_version": "",
"test_keys": {
"bootstrap_time": 48.122813,
"failure": null
},
"test_name": "",
"test_runtime": 0,
"test_start_time": "",
"test_version": ""
}
```
## Concluding remarks
Congratulations, we have now rewritten together (a simplified version of)
the `torsf` experiment! In this journey, we have learned how experiments
interact with the rest of OONI Probe, how they are typically organized,
and how to use lower-level libraries to implement them.

View File

@ -0,0 +1,39 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"golang.org/x/sys/execabs"
)
func main() {
if _, err := execabs.LookPath("tor"); err != nil {
log.Fatal("cannot find the tor executable in path")
}
tempdir, err := ioutil.TempDir("", "")
if err != nil {
log.WithError(err).Fatal("cannot create temporary directory")
}
m := NewExperimentMeasurer(Config{})
ctx := context.Background()
measurement := &model.Measurement{}
callbacks := model.NewPrinterCallbacks(log.Log)
sess := &mockable.Session{
MockableLogger: log.Log,
MockableTempDir: tempdir,
}
if err = m.Run(ctx, sess, measurement, callbacks); err != nil {
log.WithError(err).Fatal("torsf experiment failed")
}
data, err := json.Marshal(measurement)
if err != nil {
log.WithError(err).Fatal("json.Marshal failed")
}
fmt.Printf("%s\n", data)
}

View File

@ -0,0 +1,277 @@
package main
// -=-=- StartHere -=-=-
//
// # Chapter IV: writing minimal torsf experiment
//
// In this chapter we will replace the code written in the previous
// chapter that simulates running the torsf experiment with code that
// uses the `ooni/probe-cli` library to run the real experiment.
//
// (This file is auto-generated from the corresponding source file,
// so make sure you don't edit it manually.)
//
// ## Updating the imports
//
// We need to update the imports of `torsf.go` first to look like this:
//
// ```Go
import (
// ```
//
// These are standard library imports.
//
// ```Go
"context"
"path"
"time"
// ```
//
// As we have already seen, the `model` package defines the
// generic data model used by all experiments.
//
// ```Go
"github.com/ooni/probe-cli/v3/internal/engine/model"
// ```
//
// The `archival` package contains code used to format internal
// measurements representations to the OONI data format.
//
// ```Go
"github.com/ooni/probe-cli/v3/internal/engine/netx/archival"
// ```
//
// The `ptx` package contains pluggable transport code. It includes
// code to dial with obfs4 and snowflake and code to create a
// pluggable transport listener.
//
// ```Go
"github.com/ooni/probe-cli/v3/internal/ptx"
// ```
//
// The `tunnel` package contains code to create a tunnel. We will
// use this package to start a `tor` tunnel, which executes the `tor`
// binary using specified command line arguments.
//
// ```Go
"github.com/ooni/probe-cli/v3/internal/tunnel"
)
// ```
//
// -=-=- StopHere -=-=-
// Config contains config for the torsf experiment.
type Config struct{}
// Measurer is the torsf measurer.
type Measurer struct {
config Config
}
// newExperimentMeasurer creates a new ExperimentMeasurer for torsf.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return &Measurer{config: config}
}
// ExperimentName implements ExperimentMeasurer.ExperimentName.
func (m *Measurer) ExperimentName() string {
return "torsf"
}
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion.
func (m *Measurer) ExperimentVersion() string {
return "0.1.0"
}
// TestKeys contains the experiment results.
type TestKeys struct {
// BootstrapTime is the time required to bootstrap.
BootstrapTime float64 `json:"bootstrap_time"`
// Failure is the failure that occurred, or nil.
Failure *string `json:"failure"`
}
// Run implements ExperimentMeasurer.Run.
func (m *Measurer) Run(
ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
testkeys := &TestKeys{}
measurement.TestKeys = testkeys
start := time.Now()
const maxRuntime = 300 * time.Second
ctx, cancel := context.WithTimeout(ctx, maxRuntime)
defer cancel()
errch := make(chan error)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
go m.run(ctx, sess, testkeys, errch)
for {
select {
case err := <-errch:
callbacks.OnProgress(1.0, "torsf experiment is finished")
return err
case <-ticker.C:
progress := time.Since(start).Seconds() / maxRuntime.Seconds()
callbacks.OnProgress(progress, "torsf experiment is running")
}
}
}
// -=-=- StartHere -=-=-
//
// ## Rewriting the run method
//
// Let us now rewrite the `run` method to run a real `torsf`
// test rather than just pretending to do it.
//
// ```Go
func (m *Measurer) run(ctx context.Context,
sess model.ExperimentSession, testkeys *TestKeys, errch chan<- error) {
// ```
//
// As a first step, we create a dialer for snowflake using the
// `ptx` package. This dialer will allow us to create a `net.Conn`-like
// network connection where traffic is sent using the Snowflake
// pluggable transport. There are several optional fields in
// `SnowflakeDialer`, but we don't need to override the default
// values, so we can just use a default-initialized struct.
//
// ```Go
sfdialer := &ptx.SnowflakeDialer{}
// ```
//
// Let us now create a listener. The `ptx.Listener` is a listener
// that listens on a local port and speaks the SOCKS5 protocol. When
// tor connect to this port, the listener will forward the traffic
// to the Snowflake dialer we previously created. We will also
// use the session's logger to emit logging messages.
//
// ```Go
ptl := &ptx.Listener{
PTDialer: sfdialer,
Logger: sess.Logger(),
}
// ```
//
// Now we start the listener. This entails opening a port on the
// local host. If this operation fails, we return an error. In fact,
// a failure here means a hard error that prevented us from even
// starting the experiment. Therefore, it's consistent with the
// `Run`'s expectations to return an error here.
//
// ```Go
if err := ptl.Start(); err != nil {
testkeys.Failure = archival.NewFailure(err)
errch <- err
return
}
defer ptl.Stop()
// ```
//
// Next, we start `tor` using the `tunnel` package. Note how we
// pass specific `TorArgs` that cause `tor` to know about the
// pluggable transport created by `ptl` and `sfdialer`.
//
// ```Go
tun, err := tunnel.Start(ctx, &tunnel.Config{
Name: "tor",
Session: sess,
TunnelDir: path.Join(sess.TempDir(), "torsf"),
Logger: sess.Logger(),
TorArgs: []string{
"UseBridges", "1",
"ClientTransportPlugin", ptl.AsClientTransportPluginArgument(),
"Bridge", sfdialer.AsBridgeArgument(),
},
})
// ```
//
// In case of error, we convert `err` to a OONI failure using
// the `NewFailure` function of `archival`. This function reduces
// Go error strings to the error strings used by OONI. You can
// read the [errors spec](https://github.com/ooni/spec/blob/master/data-formats/df-007-errors.md)
// at the [github.com/ooni/spec repo](https://github.com/ooni/spec).
//
// Note that, in this case, we return `nil` to the caller, because
// a failure here is not a fundamental failure in running the
// experiment, but rather a possibly interesting anomaly.
//
// ```Go
if err != nil {
testkeys.Failure = archival.NewFailure(err)
errch <- nil
return
}
// ```
//
// Otherwise, we successfully created a tor tunnel using Snowflake,
// so we just close the tunnel and record the bootstrap time.
//
// ```Go
defer tun.Stop()
testkeys.BootstrapTime = tun.BootstrapTime().Seconds()
errch <- nil
}
// ```
//
// ## Running the code
//
// We can now run the code as follows to obtain:
//
// ```
// $ go run ./experiment/torsf/chapter04 | jq
// [...]
// Jun 21 23:40:50.000 [notice] Bootstrapped 100% (done): Done
// 2021/06/21 23:40:50 info [100.0%] torsf experiment is finished
// Jun 21 23:40:50.000 [notice] Catching signal TERM, exiting cleanly
// {
// "data_format_version": "",
// "input": null,
// "measurement_start_time": "",
// "probe_asn": "",
// "probe_cc": "",
// "probe_network_name": "",
// "report_id": "",
// "resolver_asn": "",
// "resolver_ip": "",
// "resolver_network_name": "",
// "software_name": "",
// "software_version": "",
// "test_keys": {
// "bootstrap_time": 48.122813,
// "failure": null
// },
// "test_name": "",
// "test_runtime": 0,
// "test_start_time": "",
// "test_version": ""
// }
// ```
//
// ## Concluding remarks
//
// Congratulations, we have now rewritten together (a simplified version of)
// the `torsf` experiment! In this journey, we have learned how experiments
// interact with the rest of OONI Probe, how they are typically organized,
// and how to use lower-level libraries to implement them.
//
// -=-=- StopHere -=-=-
// SummaryKeys contains summary keys for this experiment.
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
}

View File

@ -0,0 +1,95 @@
// Command generator generates or re-generates the tutorial chapters. You
// should run this command like `go run ./generator`.
package main
import (
"bufio"
"io"
"log"
"os"
"path"
"strings"
)
// writeString writes a string on the given writer. If there
// is a write error, this function will call log.Fatal.
func writeString(w io.Writer, s string) {
if _, err := io.WriteString(w, s); err != nil {
log.Fatal(err)
}
}
// gen1 generates a single file within a chapter.
func gen1(destfile io.Writer, filepath string) {
srcfile, err := os.Open(filepath)
if err != nil {
log.Fatal(err)
}
defer srcfile.Close()
scanner := bufio.NewScanner(srcfile)
var started bool
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.Trim(line, " \t\r\n")
if trimmed == "// -=-=- StopHere -=-=-" {
started = false
continue
}
if trimmed == "// -=-=- StartHere -=-=-" {
started = true
continue
}
if !started {
continue
}
if strings.HasPrefix(trimmed, "//") {
if strings.HasPrefix(trimmed, "// ") {
trimmed = trimmed[3:]
} else {
trimmed = trimmed[2:]
}
writeString(destfile, trimmed+"\n")
continue
}
writeString(destfile, line+"\n")
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
}
// gen generates or re-generates a chapter. The dirpath argument
// is the path to the directory that contains a chapter. The files
// arguments contains the source file names to process. We will process
// files using the specified order. Note that files names are not
// paths, just file names, e.g.,
//
// gen("./experiment/torsf/chapter01", "main.go")
func gen(dirpath string, files ...string) {
readme := path.Join(dirpath, "README.md")
destfile, err := os.Create(path.Join(readme))
if err != nil {
log.Fatal(err)
}
defer func() {
if err := destfile.Close(); err != nil {
log.Fatal(err)
}
}()
for _, file := range files {
gen1(destfile, path.Join(dirpath, file))
}
}
// gentorsf generates the torsf chapters.
func gentorsf() {
prefix := path.Join(".", "experiment", "torsf")
gen(path.Join(prefix, "chapter01"), "main.go")
gen(path.Join(prefix, "chapter02"), "main.go", "torsf.go")
gen(path.Join(prefix, "chapter03"), "torsf.go")
gen(path.Join(prefix, "chapter04"), "torsf.go")
}
func main() {
gentorsf()
}