chore: merge probe-engine into probe-cli (#201)

This is how I did it:

1. `git clone https://github.com/ooni/probe-engine internal/engine`

2. ```
(cd internal/engine && git describe --tags)
v0.23.0
```

3. `nvim go.mod` (merging `go.mod` with `internal/engine/go.mod`

4. `rm -rf internal/.git internal/engine/go.{mod,sum}`

5. `git add internal/engine`

6. `find . -type f -name \*.go -exec sed -i 's@/ooni/probe-engine@/ooni/probe-cli/v3/internal/engine@g' {} \;`

7. `go build ./...` (passes)

8. `go test -race ./...` (temporary failure on RiseupVPN)

9. `go mod tidy`

10. this commit message

Once this piece of work is done, we can build a new version of `ooniprobe` that
is using `internal/engine` directly. We need to do more work to ensure all the
other functionality in `probe-engine` (e.g. making mobile packages) are still WAI.

Part of https://github.com/ooni/probe/issues/1335
This commit is contained in:
Simone Basso 2021-02-02 12:05:47 +01:00 committed by GitHub
parent b1ce300c8d
commit d57c78bc71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
535 changed files with 66182 additions and 23 deletions

View File

@ -13,7 +13,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/utils"
"github.com/ooni/probe-engine/cmd/jafar/shellx"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/shellx"
"golang.org/x/sys/unix"
)

View File

@ -11,7 +11,7 @@ var Logger = log.WithFields(log.Fields{
})
// LocationProvider is an interface that returns the current location. The
// github.com/ooni/probe-engine/session.Session implements it.
// github.com/ooni/probe-cli/v3/internal/engine/session.Session implements it.
type LocationProvider interface {
ProbeASN() uint
ProbeASNString() string

View File

@ -10,8 +10,8 @@ import (
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/database"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/ooni"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/output"
engine "github.com/ooni/probe-engine"
"github.com/ooni/probe-engine/model"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/pkg/errors"
)

View File

@ -5,7 +5,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/database"
engine "github.com/ooni/probe-engine"
engine "github.com/ooni/probe-cli/v3/internal/engine"
)
func lookupURLs(ctl *Controller, limit int64, categories []string) ([]string, map[int64]int64, error) {

View File

@ -13,7 +13,7 @@ import (
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/database"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/enginex"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/utils"
engine "github.com/ooni/probe-engine"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/pkg/errors"
"upper.io/db.v3/lib/sqlbuilder"
)

27
go.mod
View File

@ -3,22 +3,45 @@ module github.com/ooni/probe-cli/v3
go 1.14
require (
git.torproject.org/pluggable-transports/goptlib.git v1.1.0
github.com/alecthomas/kingpin v2.2.6+incompatible
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4 // indirect
github.com/apex/log v1.9.0
github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054 // indirect
github.com/cretz/bine v0.1.0
github.com/dchest/siphash v1.2.2 // indirect
github.com/fatih/color v1.10.0
github.com/getsentry/raven-go v0.0.0-20190419175539-919484f041ea
github.com/golang/protobuf v1.4.3 // indirect
github.com/google/go-cmp v0.5.2
github.com/google/martian/v3 v3.1.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.2.0
github.com/gorilla/websocket v1.4.2
github.com/iancoleman/strcase v0.1.3
github.com/lucas-clemente/quic-go v0.19.3
github.com/mattn/go-colorable v0.1.8
github.com/mattn/go-sqlite3 v1.14.6 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ooni/probe-engine v0.22.0
github.com/miekg/dns v1.1.35
github.com/montanaflynn/stats v0.6.4
github.com/ooni/psiphon v0.4.0
github.com/oschwald/geoip2-golang v1.4.0
github.com/oschwald/maxminddb-golang v1.8.0 // indirect
github.com/pborman/getopt/v2 v2.1.0
github.com/pion/stun v0.3.5
github.com/pkg/errors v0.9.1
github.com/rogpeppe/go-internal v1.7.0
github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351
github.com/sirupsen/logrus v1.7.0 // indirect
gitlab.com/yawning/obfs4.git v0.0.0-20201217005658-f638c33f6c6f
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 // indirect
golang.org/x/sys v0.0.0-20210112091331-59c308dcf3cc
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c
golang.org/x/text v0.3.5 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/AlecAivazis/survey.v1 v1.8.8
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect
upper.io/db.v3 v3.8.0+incompatible

32
go.sum
View File

@ -179,7 +179,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
@ -187,7 +189,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.4/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
@ -229,8 +232,8 @@ github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/iancoleman/strcase v0.1.2 h1:gnomlvw9tnV3ITTAxzKSgTF+8kFWcU/f+TgttpXGz1U=
github.com/iancoleman/strcase v0.1.2/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/iancoleman/strcase v0.1.3 h1:dJBk1m2/qjL1twPLf68JND55vvivMupZ4wIzE8CTdBw=
github.com/iancoleman/strcase v0.1.3/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
@ -320,8 +323,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/montanaflynn/stats v0.6.3 h1:F8446DrvIF5V5smZfZ8K9nrmmix0AFgevPdLruGOmzk=
github.com/montanaflynn/stats v0.6.3/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.6.4 h1:ZaPgdYrxEyFUovAKlUKInQHcDhwvjq7HtjwREE7ys68=
github.com/montanaflynn/stats v0.6.4/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
@ -349,10 +352,8 @@ github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/ooni/probe-engine v0.22.0 h1:BVuYqJ4fSAEzordgPLvBG0LFutRv//e1MwAKZyRhELs=
github.com/ooni/probe-engine v0.22.0/go.mod h1:EpHBzce7lJoKIs7s0+AYEzSgkquegmjyJTj7fy6FXo0=
github.com/ooni/psiphon v0.3.0 h1:uFwtf7cIWidrwmljmyX4pIQRQL3H07GEfl00852Atxg=
github.com/ooni/psiphon v0.3.0/go.mod h1:i1v6JweJtxDKaI0i1aEw2/Fr/CUi5BoQ75GYz5KmKwU=
github.com/ooni/psiphon v0.4.0 h1:ZgqkAEJ8cTaP1EzINmn9qQzx39ApUbqjotHj9oSd0xo=
github.com/ooni/psiphon v0.4.0/go.mod h1:i1v6JweJtxDKaI0i1aEw2/Fr/CUi5BoQ75GYz5KmKwU=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
@ -371,6 +372,7 @@ github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph
github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pborman/getopt/v2 v2.1.0 h1:eNfR+r+dWLdWmV8g5OlpyrTYHkhVNxHBdN2cCrJmOEA=
github.com/pborman/getopt/v2 v2.1.0/go.mod h1:4NtW75ny4eBw9fO1bhtNdYTlZKYX5/tBLtsOpwKIKd0=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
@ -413,8 +415,8 @@ github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0=
github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.7.0 h1:3qqXGV8nn7GJT65debw77Dzrx9sfWYgP0DDo7xcMFRk=
github.com/rogpeppe/go-internal v1.7.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351 h1:HXr/qUllAWv9riaI4zh2eXWKmCSDqVS/XH1MRHLKRwk=
github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@ -578,8 +580,8 @@ golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -627,8 +629,8 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112091331-59c308dcf3cc h1:y0Og6AYdwus7SIAnKnDxjc4gJetRiYEWOx4AKbOeyEI=
golang.org/x/sys v0.0.0-20210112091331-59c308dcf3cc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=

View File

@ -0,0 +1,28 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug, triage
assignees: bassosimone
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System information (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,29 @@
---
name: Routine sprint releases
about: Bi-weekly releases of probe-engine, etc.
title: ''
labels: effort/M, priority/medium
assignees: bassosimone
---
- [ ] psiphon: run ./update.bash
- [ ] engine: update dependencies
- [ ] engine: update internal/httpheader/useragent.go
- [ ] engine: update version/version.go
- [ ] engine: update resources/assets.go
- [ ] engine: update bundled certs (using `go generate ./...`)
- [ ] engine: make sure all workflows are green
- [ ] engine: tag a new version
- [ ] engine: update again version.go to be alpha
- [ ] engine: create release at GitHub
- [ ] engine: update mobile-staging branch to create oonimkall
- [ ] cli: pin to latest engine
- [ ] cli: update internal/version/version.go
- [ ] cli: tag a new version
- [ ] cli: update internal/version/version.go again to be alpha
- [ ] android: pin to latest oonimkall
- [ ] ios: pin to latest oonimkall
- [ ] desktop: pin to latest cli
- [ ] engine: create issue for next routine release
- [ ] e2etesting: see whether we can remove legacy checks

View File

@ -0,0 +1,16 @@
name: alltests
on:
push:
branches:
- 'release/**'
schedule:
- cron: "0 7 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: go test -race -tags integration,shaping -coverprofile=probe-engine.cov ./...

View File

@ -0,0 +1,23 @@
name: android
on:
push:
branches:
- mobile-staging
- 'release/**'
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: brew install --cask android-sdk
- run: echo y | sdkmanager --install "platforms;android-29"
- run: echo y | sdkmanager --install "ndk;21.3.6528147"
- run: ./build-android.bash
env:
ANDROID_HOME: /usr/local/Caskroom/android-sdk/4333796
- run: ./publish-android.bash
env:
BINTRAY_API_KEY: ${{ secrets.BINTRAY_API_KEY }}

View File

@ -0,0 +1,58 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
name: "CodeQL"
on:
push:
branches: [ master, mobile-staging ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '35 10 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -0,0 +1,29 @@
name: coverage
on:
pull_request:
push:
branches:
- "master"
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: [ "1.14" ]
steps:
- uses: actions/setup-go@v1
with:
go-version: "${{ matrix.go }}"
- uses: actions/checkout@v2
- run: go test -short -race -tags shaping -coverprofile=probe-engine.cov ./...
- uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: probe-engine.cov
parallel: true
finish:
needs: test
runs-on: ubuntu-latest
steps:
- uses: shogo82148/actions-goveralls@v1
with:
parallel-finished: true

View File

@ -0,0 +1,16 @@
name: generate
on:
push:
branches:
- 'release/**'
schedule:
- cron: "0 0 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: go generate ./...

View File

@ -0,0 +1,18 @@
name: ios
on:
push:
branches:
- mobile-staging
- 'release/**'
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./build-ios.bash
- run: ./publish-ios.bash
env:
BINTRAY_API_KEY: ${{ secrets.BINTRAY_API_KEY }}

View File

@ -0,0 +1,14 @@
name: jafar
on:
schedule:
- cron: "0 5 * * 3"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: go build -v ./cmd/jafar
- run: sudo ./testjafar.bash

View File

@ -0,0 +1,29 @@
name: libooniffi
on:
schedule:
- cron: "0 5 * * 3"
jobs:
darwin:
runs-on: macos-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./libooniffi/buildtest.bash darwin
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./libooniffi/buildtest.bash linux
windows:
runs-on: windows-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: bash.exe ./libooniffi/buildtest.bash windows

View File

@ -0,0 +1,47 @@
name: miniooni
on:
push:
branches:
- 'release/**'
schedule:
- cron: "0 0 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./build-cli.sh linux
- run: ./CLI/linux/amd64/miniooni --yes -nNi https://example.com web_connectivity
- uses: actions/upload-artifact@v1
with:
name: miniooni-linux-386
path: ./CLI/linux/386/miniooni
- uses: actions/upload-artifact@v1
with:
name: miniooni-linux-amd64
path: ./CLI/linux/amd64/miniooni
- uses: actions/upload-artifact@v1
with:
name: miniooni-linux-armv7
path: ./CLI/linux/armv7/miniooni
- uses: actions/upload-artifact@v1
with:
name: miniooni-linux-arm64
path: ./CLI/linux/arm64/miniooni
- run: ./build-cli.sh darwin
- uses: actions/upload-artifact@v1
with:
name: miniooni-darwin-amd64
path: ./CLI/darwin/amd64/miniooni
- run: sudo apt install --yes mingw-w64
- run: ./build-cli.sh windows
- uses: actions/upload-artifact@v1
with:
name: miniooni-windows-amd64.exe
path: ./CLI/windows/amd64/miniooni.exe

View File

@ -0,0 +1,16 @@
name: "qafbmessenger"
on:
push:
branches:
- 'release/**'
schedule:
- cron: "0 3 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./QA/rundocker.bash "fbmessenger"

View File

@ -0,0 +1,13 @@
name: "qahhfm"
on:
schedule:
- cron: "5 3 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./QA/rundocker.bash "hhfm"

View File

@ -0,0 +1,13 @@
name: "qahirl"
on:
schedule:
- cron: "10 3 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./QA/rundocker.bash "hirl"

View File

@ -0,0 +1,13 @@
name: "qatelegram"
on:
schedule:
- cron: "15 3 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./QA/rundocker.bash "telegram"

View File

@ -0,0 +1,16 @@
name: "qawebconnectivity"
on:
push:
branches:
- 'release/**'
schedule:
- cron: "20 3 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./QA/rundocker.bash "webconnectivity"

View File

@ -0,0 +1,13 @@
name: "qawhatsapp"
on:
schedule:
- cron: "25 3 * * */1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./QA/rundocker.bash "whatsapp"

View File

@ -0,0 +1,18 @@
name: shorttests
on:
pull_request:
push:
branches:
- "master"
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: [ "1.14", "1.15" ]
steps:
- uses: actions/setup-go@v1
with:
go-version: "${{ matrix.go }}"
- uses: actions/checkout@v2
- run: go test -short -race -tags shaping ./...

View File

@ -0,0 +1,13 @@
name: using
on:
schedule:
- cron: "0 5 * * 3"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v1
with:
go-version: "1.14"
- uses: actions/checkout@v2
- run: ./testusing.bash

17
internal/engine/.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
/*.jsonl
/.vscode
/apitool
/asn.mmdb
/badproxy.pem
/ca-bundle.pem
/cmd/jafar/badproxy.pem
/country.mmdb
/example.org
/jafar
/jafar.exe
/miniooni
/miniooni.exe
/oohelper
/oohelperd
/oonipsiphon/
.DS_Store

2
internal/engine/AUTHORS Normal file
View File

@ -0,0 +1,2 @@
Simone Basso
Arturo Filastò

View File

@ -0,0 +1,3 @@
# Directory github.com/ooni/probe-engine/CLI
We use this directory for building CLI binaries (e.g. miniooni).

View File

@ -0,0 +1 @@
/miniooni

View File

@ -0,0 +1 @@
/miniooni

View File

@ -0,0 +1 @@
/miniooni

View File

@ -0,0 +1 @@
/miniooni

View File

@ -0,0 +1 @@
/miniooni

View File

@ -0,0 +1 @@
/miniooni.exe

View File

@ -0,0 +1 @@
/miniooni.exe

View File

@ -0,0 +1 @@
* @bassosimone @hellais

View File

@ -0,0 +1,255 @@
# OONI Code of Conduct
Statement of Intent:
OONI is committed to fostering an inclusive environment and
community. OONI is a place where people should feel safe to engage, share their
point of view, and participate.
This code of conduct applies to OONI as a whole. It is intended to
provide guidelines for contributors. Employees and contractors of OONI
are also subject to company policies and procedures. Those
people should feel free to contact HR with questions or concerns.
This code of conduct is not exhaustive or complete. It is an ongoing effort to
summarize our shared understanding. We want to provide a welcoming, safe
environment, so we can work together to pursue powerful solutions. We reserve
the right to deviate from strictly enforcing this code. Any deviations must
produce an outcome which is fairer, and aligned with our values. We understand
that keeping a living document relevant and “patched” involves sustained
effort.
0. Summary: Don't be a jerk. Be awesome instead.
The OONI community should be a good place where people are glad to be.
DO: Be kind, thoughtful, and considerate.
DO: Make OONI a place where people are happy and comfortable.
DO: Remember: We are all contributing; we are all learning. Nobody was born
an expert.
DO: Yield the floor. Listen. Make sure everyone gets heard.
DON'T: Insult, harass, intimidate, or be a jerk.
DON'T: Treat honest mistakes as an excuse to hassle people. Mistakes are for
learning.
DON'T: Hunt for ways to uphold the letter of this code while violating its
spirit.
AND DO: Eagerly email: team@openobservatory.org with
questions or concerns.
1. Purpose
A primary goal of the OONI community is to be inclusive of many different
contributors. We want to include contributors from the most varied and diverse
backgrounds possible. As such, we are committed to providing a friendly, safe
and welcoming environment for all, regardless of their experience, gender
identity or expression, sexual orientation, family, relationships, ability
(whether bodily or mental), personal appearance, socioeconomic status, body
size, race, ethnicity, age, religion, nationality, or membership in a
disadvantaged and/or underrepresented group.
A safe environment means one free from abuse, whether isolated or systemic. We
explicitly acknowledge that tolerating abuse is a security problem. Allowing
abusers and malicious people to disrupt our community puts our software,
developers, and users at risk.
This code of conduct outlines our expectations for all those who participate in
our community, as well as the consequences for unacceptable behavior.
We invite all those who participate in OONI to help us create safe
and positive experiences for everyone.
2. Be your best self
The following behaviors are expected and requested of all community members:
* Participate in an honest and active way. In doing so, you contribute to
the health and longevity of this community.
* Exercise consideration and respect in your speech and actions.
* Attempt collaboration and dialog before engaging in conflict.
* Refrain from demeaning, discriminatory, or harassing behavior and speech.
* Be mindful of your surroundings and of your fellow participants. Alert
community leaders if you notice:
- a dangerous situation
- someone in distress
- violations of this code of conduct, even if they seem minor
* Remember that community event venues may be shared with members of the
public. Please be respectful to everyone using these locations.
* Respect the privacy of your fellow community members.
3. Unacceptable behaviors
The following behaviors are unacceptable within our community:
* Violence, sexual assault, threats of violence, or violent language
directed against another person, especially violence against a person or
group based on a protected characteristic. (Display of weapons may
constitute intimidation or a threat of violence.)
* Sexist, racist, homophobic, transphobic, ableist or otherwise
discriminatory jokes and language.
* Spontaneously posting or displaying sexually explicit or violent
material. (If it is necessary to share such material when working on
OONI's mission, do so with sensitivity. Be aware that many people don't
want to see it.)
* Posting or threatening to post other peoples personally identifying
information ("doxing") without their consent.
* Personal insults or attacks, particularly those related to:
- experience
- gender identity or expression
- sexual orientation
- family
- relationships
- ability (whether bodily or mental)
- personal appearance
- socioeconomic status
- body size
- race
- ethnicity
- age
- religion
- nationality
- membership in a disadvantaged and/or underrepresented group
* Inappropriate photography, audio recording, or recording of personal
information. You should have someone's consent before recording these
things, and before posting them publicly.
* Inappropriate physical contact. You should have someones consent before
touching them.
* Unwelcome sexual attention: this includes sexualized comments or jokes,
inappropriate touching, groping, and unwelcome sexual advances.
* Deliberate intimidation, stalking or following (online or in person).
* Deliberately undermining the spirit of this code while following the
letter.
* Sustained disruption of any community events, including talks,
presentations, and online conversations.
* Deliberately pushing against someone's stated boundaries.
* Advocating for, or encouraging, any of the above behavior.
4. Unacceptable behavior has consequences
We will not tolerate unacceptable behavior from any community member. We will
not make exceptions for sponsors and those with decision-making authority.
People in formal or informal leadership roles must model the highest standards
of behavior.
Anyone asked by another community member to stop unacceptable behavior is
expected to comply immediately. Please don't step in on someone else's behalf
without their consent.
5. Where to go for help
The OONI team can assist with intra-community conflict resolution. You can contact
the whole OONI team:
- if you have questions or concerns about the code of conduct, or
- if you feel that you have witnessed a code of conduct violation
However, if you feel that there is a conflict of interest with any team
member, you may contact members individually. See: https://ooni.org/about/.
6. What to do if you witness unacceptable behavior
If you are subject to or witness unacceptable behavior, or have any other
concerns, please notify the OONI team as soon as possible. You can
contact the OONI team in person, or at
team@openobservatory.org. Current team members are listed
on the [about page](https://ooni.org/about/). The OONI team's incident response will vary on a
case-by-case basis. We will make every effort to respond to the
incident immediately. We will prioritize the safety of the person who
has been harmed, or is at risk of harm. Person(s) who have been harmed or are
at risk of harm can withdraw the incident report at any time. We will never do
anything without the consent of the person who has been harmed or is at risk of
harm, except in situations where there is a threat of imminent danger or harm
to anyone.
7. What the person reporting a violation can expect
The OONI team prioritizes the safety and well-being of any person who
feels that they have been harmed or may be in danger of being harmed. Anyone
reporting an issue to the OONI team can expect that their report will
be taken seriously. Initial reports can be taken in written or verbal form.
The next steps in an incident response will vary on a case-by-case basis.
8. How the OONI team responds to incidents
If a community member engages in unacceptable behavior, the OONI team
may take any action they deem appropriate, including but not limited to a
warning, informal mediation, temporary ban or permanent expulsion from the
community.
9. Scope of this document
This code of conduct covers all community participants:
- paid and unpaid contributors
- sponsors
- other guests
when interacting:
- in all online and in-person community venues
- in one-on-one communications that relate to community work
This code of conduct and its related procedures also applies to unacceptable
behavior occurring outside the scope of community activities when such behavior
has the potential to adversely affect the safety and well-being of community
members. As members of the OONI community, we support and follow this Code while
we are working on OONI, and take care not to undermine it in the rest of our
time.
10. License and attribution
This code of conduct is shared under a Creative Commons CC-BY-SA 4.0
International license.
This code of conduct is adapted from The Tor Project code of conduct and uses
some language and framing from the [Citizen Code of Conduct](https://citizencodeofconduct.org),
which is shared under a CC-BY-SA license.

View File

@ -0,0 +1,74 @@
# Contributing to ooni/probe-engine
This is an open source project, and contributions are welcome! You are welcome
to open pull requests. An open pull request will be reviewed by a core
developer. The review may request you to apply changes. Once the assigned
reviewer is satisfied, they will merge the pull request.
## Opening issues
Please, before opening a new issue, check whether the issue or feature request
you want us to consider has not already been reported by someone else.
## PR requirements
Every pull request that introduces new functionality should feature
comprehensive test coverage. Any pull request that modifies existing
functionality should pass existing tests. What's more, any new pull
request that modifies existing functionality should not decrease the
existing code coverage.
Long-running tests should be skipped when running tests in short mode
using `go test -short`. We prefer external testing to internal
testing. We generally have a file called `foo_test.go` with tests
for every `foo.go` file. Sometimes we separate long running
integration tests in a `foo_integration_test.go` file.
If there is a top-level DESIGN.md document, make sure such document is
kept in sync with code changes you have applied.
Do not submit large PRs. A reviewer can best service your PR if the
code changes are around 200-600 lines. (It is okay to add more changes
afterwards, if the reviewer asks you to do more work; the key point
here is that the PR should be reasonably sized when the review starts.)
In this vein, we'd rather structure a complex issue as a sequence of
small PRs, than have a single large PR address it all.
As a general rule, a PR is reviewed by reading the whole diff. Let us
know if you want us to read each diff individually, if you think that's
functional to better understand your changes.
## Code style requirements
Please, use `go fmt`, `go vet`, and `golint` to check your code
contribution before submitting a pull request. Make sure your code
is documented. At the minimum document all the exported symbols.
Make sure you commit `go.mod` and `go.sum` changes. Make sure you
run `go mod tidy` to minimize such changes.
## Code testing requirements
Make sure all tests pass with `go test -race ./...` run from the
top-level directory of this repository.
## Writing a new OONI experiment
When you are implementing a new experiment (aka nettest), make sure
you have read the relevant spec from the [ooni/spec](
https://github.com/ooni/spec) repository. If the spec is missing,
please help the pull request reviewer to create it. If the spec is
not clear, please let us know during the review.
When you write a new experiment, keep the measurement phase and the
results analysis phases as separate functions. This helps us a lot
to write better unit tests for our code.
To get a sense of what we expect from an experiment, see:
- the experiment/example experiment
- the experiment/webconnectivity experiment
Thank you!

139
internal/engine/DESIGN.md Normal file
View File

@ -0,0 +1,139 @@
# Replacing Measurement Kit
| Author | Simone Basso |
|--------------|--------------|
| Last-Updated | 2020-07-09 |
| Status | historical |
*Abstract* We describe our plan of replacing Measurement Kit for OONI
Probe Android and iOS (in particular) and (also) the CLI.
## Introduction
We want to write experiments in Go. This reduces our burden
compared to writing them using C/C++ code.
Go consumers of probe-engine shall directly use its Go API. We
will discuss the Go API in a future revision of this spec.
For mobile apps, we want to replace these MK APIs:
- [measurement-kit/android-libs](https://github.com/measurement-kit/android-libs)
- [measurement-kit/mkall-ios](https://github.com/measurement-kit/mkall-ios)
We also want consumers of [measurement-kit's FFI API](https://git.io/Jv4Rv)
to be able to replace measurement-kit with probe-engine.
## APIs to replace
### Mobile APIs
We define a Go API that `gomobile` binds to a Java/ObjectiveC
API that is close enough to the MK's mobile APIs.
### FFI API
We define a CGO API such that `go build -buildmode=c-shared`
yields an API reasonably close to MK's FFI API.
## Running experiments
It seems the generic API for enabling running experiments both on
mobile devices and for FFI consumers is like:
```Go
type Task struct{ ... }
func StartTask(input string) (*Task, error)
func (t *Task) Interrupt()
func (t *Task) IsDone() bool
func (t *Task) WaitForNextEvent() string
```
This should be enough to generate a suitable mobile API when
using the `gomobile` Go subcommand.
We can likewise generate a FFI API as follows:
```Go
package main
import (
"C"
"sync"
"github.com/ooni/probe-engine/oonimkall"
)
var (
idx int64 = 1
m = make(map[int64]*oonimkall.Task)
mu sync.Mutex
)
//export ooni_task_start
func ooni_task_start(settings string) int64 {
tp, err := oonimkall.StartTask(settings)
if err != nil {
return 0
}
mu.Lock()
handle := idx
idx++
m[handle] = tp
mu.Unlock()
return handle
}
//export ooni_task_interrupt
func ooni_task_interrupt(handle int64) {
mu.Lock()
if tp := m[handle]; tp != nil {
tp.Interrupt()
}
mu.Unlock()
}
//export ooni_task_is_done
func ooni_task_is_done(handle int64) bool {
isdone := true
mu.Lock()
if tp := m[handle]; tp != nil {
isdone = tp.IsDone()
}
mu.Unlock()
return isdone
}
//export ooni_task_wait_for_next_event
func ooni_task_wait_for_next_event(handle int64) (event string) {
mu.Lock()
tp := m[handle]
mu.Unlock()
if tp != nil {
event = tp.WaitForNextEvent()
}
return
}
func main() {}
```
This is close enough to [measurement-kit's FFI API](https://git.io/Jv4Rv) that
a few lines of C allow to implement an ABI compatible replacement.
## Other APIs of interest
We currently don't have plans for replacing other MK APIs. We will introduce
new APIs specifically tailored for our OONI needs, but they will be out of
scope with respect to the main goal of this design document.
## History
[The initial version of this design document](
https://github.com/measurement-kit/engine/blob/master/DESIGN.md)
lived in the measurement-kit namespace at GitHub. It discussed
a bunch of broad, extra topics, e.g., code bloat that are not
discussed in this document. More details regarding the migration
from MK to probe-engine are at [measurement-kit/measurement-kit#1913](
https://github.com/measurement-kit/measurement-kit/issues/1913).

26
internal/engine/LICENSE Normal file
View File

@ -0,0 +1,26 @@
Copyright 2019 Open Observatory of Network Interference (OONI), The Tor Project
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -0,0 +1,3 @@
# Directory github.com/ooni/probe-engine/MOBILE
This directory is used for building for Android and iOS.

View File

@ -0,0 +1,13 @@
Pod::Spec.new do |s|
s.name = "oonimkall"
s.version = "@VERSION@"
s.summary = "OONI Probe Engine for iOS"
s.author = "Simone Basso"
s.homepage = "https://github.com/ooni/probe-engine"
s.license = { :type => "BSD" }
s.source = {
:http => "https://dl.bintray.com/ooni/ios/oonimkall-@VERSION@.framework.zip"
}
s.platform = :ios, "9.0"
s.ios.vendored_frameworks = "oonimkall.framework"
end

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>org.ooni</groupId>
<artifactId>oonimkall</artifactId>
<version>@VERSION@</version>
<packaging>aar</packaging>
<name>oonimkall</name>
<description>OONI Probe Engine for Android</description>
<url>https://github.com/ooni/probe-engine</url>
<licenses>
<license>
<name>The 3-Clause BSD License</name>
<url>https://opensource.org/licenses/BSD-3-Clause</url>
<distribution>repo</distribution>
</license>
</licenses>
<scm>
<url>https://github.com/ooni/probe-engine</url>
<connection>https://github.com/ooni/probe-engine.git</connection>
</scm>
<developers>
<developer>
<name>Simone Basso</name>
<email>simone@openobservatory.org</email>
<roles>
<role>developer</role>
</roles>
<timezone>Europe/Rome</timezone>
</developer>
</developers>
</project>

View File

@ -0,0 +1 @@
*

3
internal/engine/QA/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/GOPATH
/GOCACHE
/__pycache__

View File

@ -0,0 +1,2 @@
FROM golang:1.14-alpine
RUN apk add go git musl-dev iptables tmux bind-tools curl sudo python3

View File

@ -0,0 +1,54 @@
# Quality Assurance scripts
This directory contains quality assurance scripts that use Jafar to
ensure that OONI implementations behave. These scripts take on the
command line as argument the path to a binary with a OONI Probe v2.x
like command line interface. We do not care about full compatibility
but rather about having enough similar flags that running these tools
in parallel is not too much of a burden for us.
Tools with this shallow-compatible CLI are:
1. `github.com/ooni/probe-legacy`
2. `github.com/measurement-kit/measurement-kit/src/measurement_kit`
3. `github.com/ooni/probe-engine/cmd/miniooni`
## Run QA on a Linux system
These scripts assume you're on a Linux system with `iptables`, `bash`,
`python3`, and possibly a bunch of other tools installed.
To start the QA script, run this command:
```bash
sudo ./QA/$nettest.py $ooni_exe
```
where `$nettest` is the nettest name (e.g. `telegram`) and `$ooni_exe`
is the OONI Probe v2.x compatible binary to test.
The Python script needs to run as root. Note however that sudo will also
be used to run `$ooni_exe` with the privileges of the `nobody` user.
## Run QA using a docker container
Run test in a suitable Docker container using:
```bash
./QA/rundocker.sh $nettest
```
Note that this will run a `--privileged` docker container. This will
eventually run the Python script you would run on Linux.
For now, the docker scripts only perform QA of `miniooni`.
## Diagnosing issues
The Python script that performs the QA runs a specific OONI test under
different failure conditions and stops at the first unexpected value found
in the resulting JSONL report. You can infer what went wrong by reading
the output of the `$ooni_exe` command itself, which should be above the point
where the Python script stopped, as well as by inspecting the JSONL file on
disk. By convention such file is named `$nettest.jsonl` and only contains
the result of the last run of `$nettest`.

View File

@ -0,0 +1,72 @@
""" ./QA/common.py - common code for QA """
import contextlib
import json
import os
import shlex
import shutil
import socket
import subprocess
import sys
import time
import urllib.parse
def execute(args):
""" Execute a specified command """
subprocess.run(args)
def execute_jafar_and_miniooni(ooni_exe, outfile, experiment, tag, args):
""" Executes jafar and miniooni. Returns the test keys. """
tmpoutfile = "/tmp/{}".format(outfile)
with contextlib.suppress(FileNotFoundError):
os.remove(tmpoutfile) # just in case
execute(
[
"./jafar",
"-main-command",
"./QA/minioonilike.py {} -n -o '{}' --home /tmp {}".format(
ooni_exe, tmpoutfile, experiment
),
"-main-user",
"nobody", # should be present on Unix
"-tag",
tag,
]
+ args
)
shutil.copy(tmpoutfile, outfile)
result = read_result(outfile)
assert isinstance(result, dict)
assert isinstance(result["test_keys"], dict)
return result["test_keys"]
def read_result(outfile):
""" Reads the result of an experiment """
return json.load(open(outfile, "rb"))
def test_keys(result):
""" Returns just the test keys of a specific result """
return result["test_keys"]
def check_maybe_binary_value(value):
""" Make sure a maybe binary value is correct """
assert isinstance(value, str) or (
isinstance(value, dict)
and value["format"] == "base64"
and isinstance(value["data"], str)
)
def with_free_port(func):
""" This function executes |func| passing it a port number on localhost
which is bound but not listening for new connections """
# See <https://stackoverflow.com/a/45690594>
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
sock.bind(("127.0.0.1", 0))
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
func(sock.getsockname()[1])

286
internal/engine/QA/fbmessenger.py Executable file
View File

@ -0,0 +1,286 @@
#!/usr/bin/env python3
""" ./QA/fbmessenger.py - main QA script for fbmessenger
This script performs a bunch of fbmessenger tests under censored
network conditions and verifies that the measurement is consistent
with the expectations, by parsing the resulting JSONL. """
import contextlib
import json
import os
import shlex
import socket
import subprocess
import sys
import time
import urllib.parse
sys.path.insert(0, ".")
import common
services = {
"stun": "stun.fbsbx.com",
"b_api": "b-api.facebook.com",
"b_graph": "b-graph.facebook.com",
"edge": "edge-mqtt.facebook.com",
"external_cdn": "external.xx.fbcdn.net",
"scontent_cdn": "scontent.xx.fbcdn.net",
"star": "star.c10r.facebook.com",
}
def execute_jafar_and_return_validated_test_keys(ooni_exe, outfile, tag, args):
""" Executes jafar and returns the validated parsed test keys, or throws
an AssertionError if the result is not valid. """
tk = common.execute_jafar_and_miniooni(
ooni_exe, outfile, "facebook_messenger", tag, args
)
assert tk["requests"] is None
if tk["tcp_connect"] is not None:
assert isinstance(tk["tcp_connect"], list)
assert len(tk["tcp_connect"]) > 0
for entry in tk["tcp_connect"]:
assert isinstance(entry, dict)
assert isinstance(entry["ip"], str)
assert isinstance(entry["port"], int)
assert isinstance(entry["status"], dict)
failure = entry["status"]["failure"]
success = entry["status"]["success"]
assert isinstance(failure, str) or failure is None
assert isinstance(success, bool)
return tk
def helper_for_blocking_services_via_dns(service):
""" Helper for hijacking a service via dns """
args = []
args.append("-iptables-hijack-dns-to")
args.append("127.0.0.1:53")
args.append("-dns-proxy-block")
args.append(service)
return args
def helper_for_hijacking_services_via_dns(service):
""" Helper for hijacking a service via dns """
args = []
args.append("-iptables-hijack-dns-to")
args.append("127.0.0.1:53")
args.append("-dns-proxy-hijack")
args.append(service)
return args
def helper_for_blocking_services_via_tcp(service):
""" Helper for blocking a service via tcp """
args = []
args.append("-iptables-reset-ip")
args.append(service)
return args
def fbmessenger_dns_hijacked_for_all(ooni_exe, outfile):
""" Test case where everything we measure is DNS hijacked """
args = []
for _, value in services.items():
args.extend(helper_for_hijacking_services_via_dns(value))
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "fbmessenger_dns_hijacked_for_all", args,
)
assert tk["facebook_b_api_dns_consistent"] == False
assert tk["facebook_b_api_reachable"] == None
assert tk["facebook_b_graph_dns_consistent"] == False
assert tk["facebook_b_graph_reachable"] == None
assert tk["facebook_edge_dns_consistent"] == False
assert tk["facebook_edge_reachable"] == None
assert tk["facebook_external_cdn_dns_consistent"] == False
assert tk["facebook_external_cdn_reachable"] == None
assert tk["facebook_scontent_cdn_dns_consistent"] == False
assert tk["facebook_scontent_cdn_reachable"] == None
assert tk["facebook_star_dns_consistent"] == False
assert tk["facebook_star_reachable"] == None
assert tk["facebook_stun_dns_consistent"] == False
assert tk["facebook_stun_reachable"] == None
assert tk["facebook_dns_blocking"] == True
assert tk["facebook_tcp_blocking"] == False
def fbmessenger_dns_hijacked_for_some(ooni_exe, outfile):
""" Test case where some endpoints are DNS hijacked """
args = []
args.extend(helper_for_hijacking_services_via_dns(services["star"]))
args.extend(helper_for_hijacking_services_via_dns(services["edge"]))
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "fbmessenger_dns_hijacked_for_some", args,
)
assert tk["facebook_b_api_dns_consistent"] == True
assert tk["facebook_b_api_reachable"] == True
assert tk["facebook_b_graph_dns_consistent"] == True
assert tk["facebook_b_graph_reachable"] == True
assert tk["facebook_edge_dns_consistent"] == False
assert tk["facebook_edge_reachable"] == None
assert tk["facebook_external_cdn_dns_consistent"] == True
assert tk["facebook_external_cdn_reachable"] == True
assert tk["facebook_scontent_cdn_dns_consistent"] == True
assert tk["facebook_scontent_cdn_reachable"] == True
assert tk["facebook_star_dns_consistent"] == False
assert tk["facebook_star_reachable"] == None
assert tk["facebook_stun_dns_consistent"] == True
assert tk["facebook_stun_reachable"] == None
assert tk["facebook_dns_blocking"] == True
assert tk["facebook_tcp_blocking"] == False
def fbmessenger_dns_blocked_for_all(ooni_exe, outfile):
""" Test case where everything we measure is DNS blocked """
args = []
for _, value in services.items():
args.extend(helper_for_blocking_services_via_dns(value))
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "fbmessenger_dns_blocked_for_all", args,
)
assert tk["facebook_b_api_dns_consistent"] == False
assert tk["facebook_b_api_reachable"] == None
assert tk["facebook_b_graph_dns_consistent"] == False
assert tk["facebook_b_graph_reachable"] == None
assert tk["facebook_edge_dns_consistent"] == False
assert tk["facebook_edge_reachable"] == None
assert tk["facebook_external_cdn_dns_consistent"] == False
assert tk["facebook_external_cdn_reachable"] == None
assert tk["facebook_scontent_cdn_dns_consistent"] == False
assert tk["facebook_scontent_cdn_reachable"] == None
assert tk["facebook_star_dns_consistent"] == False
assert tk["facebook_star_reachable"] == None
assert tk["facebook_stun_dns_consistent"] == False
assert tk["facebook_stun_reachable"] == None
assert tk["facebook_dns_blocking"] == True
assert tk["facebook_tcp_blocking"] == False
def fbmessenger_dns_blocked_for_some(ooni_exe, outfile):
""" Test case where some endpoints are DNS blocked """
args = []
args.extend(helper_for_blocking_services_via_dns(services["b_graph"]))
args.extend(helper_for_blocking_services_via_dns(services["stun"]))
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "fbmessenger_dns_blocked_for_some", args,
)
assert tk["facebook_b_api_dns_consistent"] == True
assert tk["facebook_b_api_reachable"] == True
assert tk["facebook_b_graph_dns_consistent"] == False
assert tk["facebook_b_graph_reachable"] == None
assert tk["facebook_edge_dns_consistent"] == True
assert tk["facebook_edge_reachable"] == True
assert tk["facebook_external_cdn_dns_consistent"] == True
assert tk["facebook_external_cdn_reachable"] == True
assert tk["facebook_scontent_cdn_dns_consistent"] == True
assert tk["facebook_scontent_cdn_reachable"] == True
assert tk["facebook_star_dns_consistent"] == True
assert tk["facebook_star_reachable"] == True
assert tk["facebook_stun_dns_consistent"] == False
assert tk["facebook_stun_reachable"] == None
assert tk["facebook_dns_blocking"] == True
assert tk["facebook_tcp_blocking"] == False
def fbmessenger_tcp_blocked_for_all(ooni_exe, outfile):
""" Test case where everything we measure is TCP blocked """
args = []
for _, value in services.items():
args.extend(helper_for_blocking_services_via_tcp(value))
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "fbmessenger_tcp_blocked_for_all", args,
)
assert tk["facebook_b_api_dns_consistent"] == True
assert tk["facebook_b_api_reachable"] == False
assert tk["facebook_b_graph_dns_consistent"] == True
assert tk["facebook_b_graph_reachable"] == False
assert tk["facebook_edge_dns_consistent"] == True
assert tk["facebook_edge_reachable"] == False
assert tk["facebook_external_cdn_dns_consistent"] == True
assert tk["facebook_external_cdn_reachable"] == False
assert tk["facebook_scontent_cdn_dns_consistent"] == True
assert tk["facebook_scontent_cdn_reachable"] == False
assert tk["facebook_star_dns_consistent"] == True
assert tk["facebook_star_reachable"] == False
assert tk["facebook_stun_dns_consistent"] == True
assert tk["facebook_stun_reachable"] == None
assert tk["facebook_dns_blocking"] == False
assert tk["facebook_tcp_blocking"] == True
def fbmessenger_tcp_blocked_for_some(ooni_exe, outfile):
""" Test case where only some endpoints are TCP blocked """
args = []
args.extend(helper_for_blocking_services_via_tcp(services["edge"]))
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "fbmessenger_tcp_blocked_for_some", args,
)
assert tk["facebook_b_api_dns_consistent"] == True
assert tk["facebook_b_api_reachable"] == True
assert tk["facebook_b_graph_dns_consistent"] == True
assert tk["facebook_b_graph_reachable"] == True
assert tk["facebook_edge_dns_consistent"] == True
assert tk["facebook_edge_reachable"] == False
assert tk["facebook_external_cdn_dns_consistent"] == True
assert tk["facebook_external_cdn_reachable"] == True
assert tk["facebook_scontent_cdn_dns_consistent"] == True
assert tk["facebook_scontent_cdn_reachable"] == True
assert tk["facebook_star_dns_consistent"] == True
assert tk["facebook_star_reachable"] == True
assert tk["facebook_stun_dns_consistent"] == True
assert tk["facebook_stun_reachable"] == None
assert tk["facebook_dns_blocking"] == False
assert tk["facebook_tcp_blocking"] == True
def fbmessenger_mixed_results(ooni_exe, outfile):
""" Test case where only some endpoints are TCP blocked """
args = []
args.extend(helper_for_blocking_services_via_tcp(services["edge"]))
args.extend(helper_for_blocking_services_via_dns(services["b_api"]))
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "fbmessenger_tcp_blocked_for_some", args,
)
assert tk["facebook_b_api_dns_consistent"] == False
assert tk["facebook_b_api_reachable"] == None
assert tk["facebook_b_graph_dns_consistent"] == True
assert tk["facebook_b_graph_reachable"] == True
assert tk["facebook_edge_dns_consistent"] == True
assert tk["facebook_edge_reachable"] == False
assert tk["facebook_external_cdn_dns_consistent"] == True
assert tk["facebook_external_cdn_reachable"] == True
assert tk["facebook_scontent_cdn_dns_consistent"] == True
assert tk["facebook_scontent_cdn_reachable"] == True
assert tk["facebook_star_dns_consistent"] == True
assert tk["facebook_star_reachable"] == True
assert tk["facebook_stun_dns_consistent"] == True
assert tk["facebook_stun_reachable"] == None
assert tk["facebook_dns_blocking"] == True
assert tk["facebook_tcp_blocking"] == True
def main():
if len(sys.argv) != 2:
sys.exit("usage: %s /path/to/ooniprobelegacy-like/binary" % sys.argv[0])
outfile = "fbmessenger.jsonl"
ooni_exe = sys.argv[1]
tests = [
fbmessenger_dns_hijacked_for_all,
fbmessenger_dns_hijacked_for_some,
fbmessenger_dns_blocked_for_all,
fbmessenger_dns_blocked_for_some,
fbmessenger_tcp_blocked_for_all,
fbmessenger_tcp_blocked_for_some,
fbmessenger_mixed_results,
]
for test in tests:
test(ooni_exe, outfile)
time.sleep(7)
if __name__ == "__main__":
main()

66
internal/engine/QA/hhfm.py Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
""" ./QA/hhfm.py - main QA script for hhfm
This script performs a bunch of hhfm tests under censored
network conditions and verifies that the measurement is consistent
with the expectations, by parsing the resulting JSONL. """
import contextlib
import json
import os
import shlex
import socket
import subprocess
import sys
import time
import urllib.parse
sys.path.insert(0, ".")
import common
def execute_jafar_and_return_validated_test_keys(ooni_exe, outfile, tag, args):
""" Executes jafar and returns the validated parsed test keys, or throws
an AssertionError if the result is not valid. """
tk = common.execute_jafar_and_miniooni(
ooni_exe, outfile, "http_header_field_manipulation", tag, args
)
# TODO(bassosimone): what checks to put here?
return tk
def hhfm_transparent_proxy(ooni_exe, outfile):
""" Test case where we're passing through a transparent proxy """
args = ["-iptables-hijack-http-to", "127.0.0.1:80"]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "hhfm_transparent_proxy", args,
)
# The proxy sees a domain that does not make any sense and does not
# otherwise know where to connect to. Hence the most likely result is
# a `dns_nxdomain_error` with total tampering.
assert tk["tampering"]["header_field_name"] == False
assert tk["tampering"]["header_field_number"] == False
assert tk["tampering"]["header_field_value"] == False
assert tk["tampering"]["header_name_capitalization"] == False
assert tk["tampering"]["header_name_diff"] == []
assert tk["tampering"]["request_line_capitalization"] == False
assert tk["tampering"]["total"] == True
def main():
if len(sys.argv) != 2:
sys.exit("usage: %s /path/to/ooniprobelegacy-like/binary" % sys.argv[0])
outfile = "hhfm.jsonl"
ooni_exe = sys.argv[1]
tests = [
hhfm_transparent_proxy,
]
for test in tests:
test(ooni_exe, outfile)
time.sleep(7)
if __name__ == "__main__":
main()

67
internal/engine/QA/hirl.py Executable file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
""" ./QA/hirl.py - main QA script for hirl
This script performs a bunch of hirl tests under censored
network conditions and verifies that the measurement is consistent
with the expectations, by parsing the resulting JSONL. """
import contextlib
import json
import os
import shlex
import socket
import subprocess
import sys
import time
import urllib.parse
sys.path.insert(0, ".")
import common
def execute_jafar_and_return_validated_test_keys(ooni_exe, outfile, tag, args):
""" Executes jafar and returns the validated parsed test keys, or throws
an AssertionError if the result is not valid. """
tk = common.execute_jafar_and_miniooni(
ooni_exe, outfile, "http_invalid_request_line", tag, args
)
# TODO(bassosimone): what checks to put here?
return tk
def hirl_transparent_proxy(ooni_exe, outfile):
""" Test case where we're passing through a transparent proxy """
args = ["-iptables-hijack-http-to", "127.0.0.1:80"]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "hirl_transparent_proxy", args,
)
count = 0
for entry in tk["failure_list"]:
if entry is None:
count += 1
elif entry == "eof_error":
count += 1e03
else:
count += 1e06
assert count == 3002
assert tk["tampering_list"] == [True, True, True, True, True]
assert tk["tampering"] == True
def main():
if len(sys.argv) != 2:
sys.exit("usage: %s /path/to/ooniprobelegacy-like/binary" % sys.argv[0])
outfile = "hirl.jsonl"
ooni_exe = sys.argv[1]
tests = [
hirl_transparent_proxy,
]
for test in tests:
test(ooni_exe, outfile)
time.sleep(7)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
""" This script takes in input the name of the tool to run followed by
arguments and followed by the nettest name. The format recognized is
the same of miniooni. Depending on the tool that we want to run, we
reorder arguments so that they make sense for the tool.
This is necessary because, albeit miniooni, MK, and OONI v2.x have
more or less the same arguments, there are some differences. We could
modify other tools to match miniooni, but this seems useless. """
import argparse
import os
import shlex
import sys
sys.path.insert(0, ".")
import common
def file_must_exist(pathname):
""" Throws an exception if the given file does not actually exist. """
if not os.path.isfile(pathname):
raise RuntimeError("missing {}: please run miniooni first".format(pathname))
return pathname
def main():
apa = argparse.ArgumentParser()
apa.add_argument("command", nargs=1, help="command to execute")
# subset of arguments accepted by miniooni
apa.add_argument(
"-n", "--no-collector", action="count", help="don't submit measurement"
)
apa.add_argument("-o", "--reportfile", help="specify report file to use")
apa.add_argument("-i", "--input", help="input for nettests taking an input")
apa.add_argument("--home", help="override home directory")
apa.add_argument("nettest", nargs=1, help="nettest to run")
out = apa.parse_args()
command, nettest = out.command[0], out.nettest[0]
if "miniooni" not in command and "measurement_kit" not in command:
raise RuntimeError("unrecognized tool")
args = []
args.append(command)
if "miniooni" in command:
args.extend(["--yes"]) # make sure we have informed consent
if "measurement_kit" in command:
args.extend(
[
"--ca-bundle-path",
file_must_exist("{}/.miniooni/assets/ca-bundle.pem".format(out.home)),
]
)
args.extend(
[
"--geoip-country-path",
file_must_exist("{}/.miniooni/assets/country.mmdb".format(out.home)),
]
)
args.extend(
[
"--geoip-asn-path",
file_must_exist("{}/.miniooni/assets/asn.mmdb".format(out.home)),
]
)
if out.home and "miniooni" in command:
args.extend(["--home", out.home]) # home applies to miniooni only
if out.input:
if "miniooni" in command:
args.extend(["-i", out.input]) # input is -i for miniooni
if out.no_collector:
args.append("-n")
if out.reportfile:
args.extend(["-o", out.reportfile])
args.append(nettest)
if out.input and "measurement_kit" in command:
if nettest == "web_connectivity":
args.extend(["-u", out.input]) # MK's Web Connectivity uses -u for input
sys.stderr.write("minioonilike.py: {}\n".format(shlex.join(args)))
common.execute(args)
if __name__ == "__main__":
main()

88
internal/engine/QA/probeasn.py Executable file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
""" ./QA/probeasn.py - QA script for the -g miniooni option. """
import contextlib
import json
import os
import shlex
import shutil
import socket
import subprocess
import sys
import time
import urllib.parse
sys.path.insert(0, ".")
import common
def execute_miniooni(ooni_exe, outfile, arguments):
""" Executes miniooni and returns the whole measurement. """
if "miniooni" not in ooni_exe:
return None
tmpoutfile = "/tmp/{}".format(outfile)
with contextlib.suppress(FileNotFoundError):
os.remove(tmpoutfile) # just in case
cmdline = [
ooni_exe,
arguments,
"-o",
tmpoutfile,
"--home",
"/tmp",
"example",
]
print("exec: {}".format(cmdline))
common.execute(cmdline)
shutil.copy(tmpoutfile, outfile)
result = common.read_result(outfile)
assert isinstance(result, dict)
assert isinstance(result["test_keys"], dict)
return result
def probeasn_without_g_option(ooni_exe, outfile):
""" Test case where we're not passing to miniooni the -g option """
m = execute_miniooni(ooni_exe, outfile, "-n")
if m is None:
return
assert m["probe_cc"] != "ZZ"
assert m["probe_ip"] == "127.0.0.1"
assert m["probe_asn"] != "AS0"
assert m["probe_network_name"] != ""
assert m["resolver_ip"] == "127.0.0.2"
assert m["resolver_asn"] != "AS0"
assert m["resolver_network_name"] != ""
def probeasn_with_g_option(ooni_exe, outfile):
""" Test case where we're passing the -g option """
m = execute_miniooni(ooni_exe, outfile, "-gn")
if m is None:
return
assert m["probe_cc"] != "ZZ"
assert m["probe_ip"] == "127.0.0.1"
assert m["probe_asn"] == "AS0"
assert m["probe_network_name"] == ""
assert m["resolver_ip"] == "127.0.0.2"
assert m["resolver_asn"] == "AS0"
assert m["resolver_network_name"] == ""
def main():
if len(sys.argv) != 2:
sys.exit("usage: %s /path/to/ooniprobelegacy-like/binary" % sys.argv[0])
outfile = "probeasn.jsonl"
ooni_exe = sys.argv[1]
tests = [
probeasn_with_g_option,
probeasn_without_g_option,
]
for test in tests:
test(ooni_exe, outfile)
if __name__ == "__main__":
main()

6
internal/engine/QA/pyrun.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
set -ex
export GOPATH=/jafar/QA/GOPATH GOCACHE=/jafar/QA/GOCACHE GO111MODULE=on
go build -v ./cmd/miniooni
go build -v ./cmd/jafar
sudo ./QA/$1.py ./miniooni

View File

@ -0,0 +1,5 @@
#!/bin/sh
set -ex
DOCKER=${DOCKER:-docker}
$DOCKER build -t jafar-qa ./QA/
$DOCKER run --privileged -v`pwd`:/jafar -w/jafar jafar-qa ./QA/pyrun.sh "$@"

218
internal/engine/QA/telegram.py Executable file
View File

@ -0,0 +1,218 @@
#!/usr/bin/env python3
""" ./QA/telegram.py - main QA script for telegram
This script performs a bunch of telegram tests under censored
network conditions and verifies that the measurement is consistent
with the expectations, by parsing the resulting JSONL. """
import contextlib
import json
import os
import shlex
import subprocess
import sys
import time
import urllib.parse
sys.path.insert(0, ".")
import common
ALL_POP_IPS = (
"149.154.175.50",
"149.154.167.51",
"149.154.175.100",
"149.154.167.91",
"149.154.171.5",
)
def execute_jafar_and_return_validated_test_keys(ooni_exe, outfile, tag, args):
""" Executes jafar and returns the validated parsed test keys, or throws
an AssertionError if the result is not valid. """
tk = common.execute_jafar_and_miniooni(ooni_exe, outfile, "telegram", tag, args)
assert isinstance(tk["requests"], list)
assert len(tk["requests"]) > 0
for entry in tk["requests"]:
assert isinstance(entry, dict)
failure = entry["failure"]
assert isinstance(failure, str) or failure is None
assert isinstance(entry["request"], dict)
req = entry["request"]
common.check_maybe_binary_value(req["body"])
assert isinstance(req["headers"], dict)
for key, value in req["headers"].items():
assert isinstance(key, str)
common.check_maybe_binary_value(value)
assert isinstance(req["method"], str)
assert isinstance(entry["response"], dict)
resp = entry["response"]
common.check_maybe_binary_value(resp["body"])
assert isinstance(resp["code"], int)
if resp["headers"] is not None:
for key, value in resp["headers"].items():
assert isinstance(key, str)
common.check_maybe_binary_value(value)
assert isinstance(tk["tcp_connect"], list)
assert len(tk["tcp_connect"]) > 0
for entry in tk["tcp_connect"]:
assert isinstance(entry, dict)
assert isinstance(entry["ip"], str)
assert isinstance(entry["port"], int)
assert isinstance(entry["status"], dict)
failure = entry["status"]["failure"]
success = entry["status"]["success"]
assert isinstance(failure, str) or failure is None
assert isinstance(success, bool)
return tk
def args_for_blocking_all_pop_ips():
""" Returns the arguments useful for blocking all POPs IPs """
args = []
for ip in ALL_POP_IPS:
args.append("-iptables-reset-ip")
args.append(ip)
return args
def args_for_blocking_web_telegram_org_http():
""" Returns arguments for blocking web.telegram.org over http """
return ["-iptables-reset-keyword", "Host: web.telegram.org"]
def args_for_blocking_web_telegram_org_https():
""" Returns arguments for blocking web.telegram.org over https """
#
# 00 00 <SNI extension ID>
# 00 15 <full extension length>
# 00 13 <first entry length>
# 00 <DNS hostname type>
# 00 10 <string length>
# 77 65 ... 67 web.telegram.org
#
return [
"-iptables-reset-keyword-hex",
"|00 00 00 15 00 13 00 00 10 77 65 62 2e 74 65 6c 65 67 72 61 6d 2e 6f 72 67|",
]
def telegram_block_everything(ooni_exe, outfile):
""" Test case where everything we measure is blocked """
args = []
args.extend(args_for_blocking_all_pop_ips())
args.extend(args_for_blocking_web_telegram_org_https())
args.extend(args_for_blocking_web_telegram_org_http())
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "telegram_block_everything", args,
)
assert tk["telegram_tcp_blocking"] == True
assert tk["telegram_http_blocking"] == True
assert tk["telegram_web_failure"] == "connection_reset"
assert tk["telegram_web_status"] == "blocked"
def telegram_tcp_blocking_all(ooni_exe, outfile):
""" Test case where all POPs are TCP/IP blocked """
args = args_for_blocking_all_pop_ips()
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "telegram_tcp_blocking_all", args
)
assert tk["telegram_tcp_blocking"] == True
assert tk["telegram_http_blocking"] == True
assert tk["telegram_web_failure"] == None
assert tk["telegram_web_status"] == "ok"
def telegram_tcp_blocking_some(ooni_exe, outfile):
""" Test case where some POPs are TCP/IP blocked """
args = [
"-iptables-reset-ip",
ALL_POP_IPS[0],
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "telegram_tcp_blocking_some", args
)
assert tk["telegram_tcp_blocking"] == False
assert tk["telegram_http_blocking"] == False
assert tk["telegram_web_failure"] == None
assert tk["telegram_web_status"] == "ok"
def telegram_http_blocking_all(ooni_exe, outfile):
""" Test case where all POPs are HTTP blocked """
args = []
for ip in ALL_POP_IPS:
args.append("-iptables-reset-keyword")
args.append(ip)
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "telegram_http_blocking_all", args,
)
assert tk["telegram_tcp_blocking"] == False
assert tk["telegram_http_blocking"] == True
assert tk["telegram_web_failure"] == None
assert tk["telegram_web_status"] == "ok"
def telegram_http_blocking_some(ooni_exe, outfile):
""" Test case where some POPs are HTTP blocked """
args = [
"-iptables-reset-keyword",
ALL_POP_IPS[0],
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "telegram_http_blocking_some", args,
)
assert tk["telegram_tcp_blocking"] == False
assert tk["telegram_http_blocking"] == False
assert tk["telegram_web_failure"] == None
assert tk["telegram_web_status"] == "ok"
def telegram_web_failure_http(ooni_exe, outfile):
""" Test case where the web HTTP endpoint is blocked """
args = args_for_blocking_web_telegram_org_http()
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "telegram_web_failure_http", args,
)
assert tk["telegram_tcp_blocking"] == False
assert tk["telegram_http_blocking"] == False
assert tk["telegram_web_failure"] == "connection_reset"
assert tk["telegram_web_status"] == "blocked"
def telegram_web_failure_https(ooni_exe, outfile):
""" Test case where the web HTTPS endpoint is blocked """
args = args_for_blocking_web_telegram_org_https()
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "telegram_web_failure_https", args,
)
assert tk["telegram_tcp_blocking"] == False
assert tk["telegram_http_blocking"] == False
assert tk["telegram_web_failure"] == "connection_reset"
assert tk["telegram_web_status"] == "blocked"
def main():
if len(sys.argv) != 2:
sys.exit("usage: %s /path/to/ooniprobelegacy-like/binary" % sys.argv[0])
outfile = "telegram.jsonl"
ooni_exe = sys.argv[1]
tests = [
telegram_block_everything,
telegram_tcp_blocking_all,
telegram_tcp_blocking_some,
telegram_http_blocking_all,
telegram_http_blocking_some,
telegram_web_failure_http,
telegram_web_failure_https,
]
for test in tests:
test(ooni_exe, outfile)
time.sleep(7)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,855 @@
#!/usr/bin/env python3
""" ./QA/webconnectivity.py - main QA script for webconnectivity
This script performs a bunch of webconnectivity tests under censored
network conditions and verifies that the measurement is consistent
with the expectations, by parsing the resulting JSONL. """
import contextlib
import json
import os
import shlex
import socket
import subprocess
import sys
import time
import urllib.parse
sys.path.insert(0, ".")
import common
def execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, experiment_args, tag, args
):
""" Executes jafar and returns the validated parsed test keys, or throws
an AssertionError if the result is not valid. """
tk = common.execute_jafar_and_miniooni(
ooni_exe, outfile, experiment_args, tag, args
)
return tk
def assert_status_flags_are(ooni_exe, tk, desired):
""" Checks whether the status flags are what we expect them to
be when we're running miniooni. This check only makes sense
with miniooni b/c status flags are a miniooni extension. """
if "miniooni" not in ooni_exe:
return
assert tk["x_status"] == desired
def webconnectivity_https_ok_with_control_failure(ooni_exe, outfile):
""" Successful HTTPS measurement but control failure. """
args = [
"-iptables-reset-keyword",
"wcth.ooni.io",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://example.com/ web_connectivity",
"webconnectivity_https_ok_with_control_failure",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == None
assert tk["control_failure"] == "connection_reset"
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
if "miniooni" in ooni_exe:
assert tk["blocking"] == False
assert tk["accessible"] == True
else:
assert tk["blocking"] == None
assert tk["accessible"] == None
assert_status_flags_are(ooni_exe, tk, 1)
def webconnectivity_http_ok_with_control_failure(ooni_exe, outfile):
""" Successful HTTP measurement but control failure. """
args = [
"-iptables-reset-keyword",
"wcth.ooni.io",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://example.org/ web_connectivity",
"webconnectivity_http_ok_with_control_failure",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == None
assert tk["control_failure"] == "connection_reset"
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == None
assert tk["accessible"] == None
assert_status_flags_are(ooni_exe, tk, 8)
def webconnectivity_transparent_http_proxy(ooni_exe, outfile):
""" Test case where we pass through a transparent HTTP proxy """
args = []
args.append("-iptables-hijack-https-to")
args.append("127.0.0.1:443")
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://example.org web_connectivity",
"webconnectivity_transparent_http_proxy",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == True
assert tk["body_proportion"] == 1
assert tk["status_code_match"] == True
assert tk["headers_match"] == True
assert tk["title_match"] == True
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 1)
def webconnectivity_dns_hijacking(ooni_exe, outfile):
""" Test case where there is DNS hijacking towards a transparent proxy. """
args = []
args.append("-iptables-hijack-dns-to")
args.append("127.0.0.1:53")
args.append("-dns-proxy-hijack")
args.append("example.org")
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://example.org web_connectivity",
"webconnectivity_dns_hijacking",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "inconsistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == True
assert tk["body_proportion"] == 1
assert tk["status_code_match"] == True
assert tk["headers_match"] == True
assert tk["title_match"] == True
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 1)
def webconnectivity_control_unreachable_and_using_http(ooni_exe, outfile):
""" Test case where the control is unreachable and we're using the
plaintext HTTP protocol rather than HTTPS """
args = []
args.append("-iptables-reset-keyword")
args.append("wcth.ooni.io")
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://example.org web_connectivity",
"webconnectivity_control_unreachable_and_using_http",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == None
assert tk["control_failure"] == "connection_reset"
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == None
assert tk["accessible"] == None
assert_status_flags_are(ooni_exe, tk, 8)
def webconnectivity_nonexistent_domain(ooni_exe, outfile):
""" Test case where the domain does not exist """
args = []
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://antani.ooni.io web_connectivity",
"webconnectivity_nonexistent_domain",
args,
)
# TODO(bassosimone): Debateable result. We need to do better here.
# See <https://github.com/ooni/probe-engine/issues/579>.
#
# Note that MK is not doing it right here because it's suppressing the
# dns_nxdomain_error that instead is very informative. Yet, it is reporting
# a failure in HTTP, which miniooni does not because it does not make
# sense to perform HTTP when there are no IP addresses.
#
# The following seems indeed a bug in MK where we don't properly record the
# actual error that occurred when performing the DNS experiment.
#
# See <https://github.com/measurement-kit/measurement-kit/issues/1931>.
if "miniooni" in ooni_exe:
assert tk["dns_experiment_failure"] == "dns_nxdomain_error"
else:
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
if "miniooni" in ooni_exe:
assert tk["http_experiment_failure"] == None
else:
assert tk["http_experiment_failure"] == "dns_lookup_error"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 2052)
def webconnectivity_tcpip_blocking_with_consistent_dns(ooni_exe, outfile):
""" Test case where there's TCP/IP blocking w/ consistent DNS """
ip = socket.gethostbyname("nexa.polito.it")
args = [
"-iptables-drop-ip",
ip,
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://nexa.polito.it web_connectivity",
"webconnectivity_tcpip_blocking_with_consistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == "generic_timeout_error"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "tcp_ip"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 4224)
def webconnectivity_tcpip_blocking_with_inconsistent_dns(ooni_exe, outfile):
""" Test case where there's TCP/IP blocking w/ inconsistent DNS """
def runner(port):
args = [
"-dns-proxy-hijack",
"nexa.polito.it",
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-iptables-hijack-http-to",
"127.0.0.1:{}".format(port),
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://nexa.polito.it web_connectivity",
"webconnectivity_tcpip_blocking_with_inconsistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "inconsistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == "connection_refused"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "dns"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 4256)
common.with_free_port(runner)
def webconnectivity_http_connection_refused_with_consistent_dns(ooni_exe, outfile):
""" Test case where there's TCP/IP blocking w/ consistent DNS that occurs
while we're following the chain of redirects. """
# We use a bit.ly link redirecting to nexa.polito.it. We block the IP address
# used by nexa.polito.it. So the error should happen in the redirect chain.
ip = socket.gethostbyname("nexa.polito.it")
args = [
"-iptables-reset-ip",
ip,
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://bit.ly/3h9EJR3 web_connectivity",
"webconnectivity_http_connection_refused_with_consistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == "connection_refused"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "http-failure"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 8320)
def webconnectivity_http_connection_reset_with_consistent_dns(ooni_exe, outfile):
""" Test case where there's RST-based blocking blocking w/ consistent DNS that
occurs while we're following the chain of redirects. """
# We use a bit.ly link redirecting to nexa.polito.it. We block the Host header
# used for nexa.polito.it. So the error should happen in the redirect chain.
args = [
"-iptables-reset-keyword",
"Host: nexa",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://bit.ly/3h9EJR3 web_connectivity",
"webconnectivity_http_connection_reset_with_consistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == "connection_reset"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "http-failure"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 8448)
def webconnectivity_http_nxdomain_with_consistent_dns(ooni_exe, outfile):
""" Test case where there's a redirection and the redirected request cannot
continue because a NXDOMAIN error occurs. """
# We use a bit.ly link redirecting to nexa.polito.it. We block the DNS request
# for nexa.polito.it. So the error should happen in the redirect chain.
args = [
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-dns-proxy-block",
"nexa.polito.it",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://bit.ly/3h9EJR3 web_connectivity",
"webconnectivity_http_nxdomain_with_consistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert (
tk["http_experiment_failure"] == "dns_nxdomain_error" # miniooni
or tk["http_experiment_failure"] == "dns_lookup_error" # MK
)
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "dns"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 8224)
def webconnectivity_http_eof_error_with_consistent_dns(ooni_exe, outfile):
""" Test case where there's a redirection and the redirected request cannot
continue because an eof_error error occurs. """
# We use a bit.ly link redirecting to nexa.polito.it. We block the HTTP request
# for nexa.polito.it using the cleartext bad proxy. So the error should happen in
# the redirect chain and should be EOF.
args = [
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-dns-proxy-hijack",
"nexa.polito.it",
"-iptables-hijack-http-to",
"127.0.0.1:7117", # this is badproxy's cleartext endpoint
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://bit.ly/3h9EJR3 web_connectivity", # bit.ly uses https
"webconnectivity_http_eof_error_with_consistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == "eof_error"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "http-failure"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 8448)
def webconnectivity_http_generic_timeout_error_with_consistent_dns(ooni_exe, outfile):
""" Test case where there's a redirection and the redirected request cannot
continue because a generic_timeout_error error occurs. """
# We use a bit.ly link redirecting to nexa.polito.it. We block the HTTP request
# for nexa.polito.it by dropping packets using DPI. So the error should happen in
# the redirect chain and should be timeout.
args = [
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-dns-proxy-hijack",
"nexa.polito.it",
"-iptables-drop-keyword",
"Host: nexa",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://bit.ly/3h9EJR3 web_connectivity",
"webconnectivity_http_generic_timeout_error_with_consistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == "generic_timeout_error"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "http-failure"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 8704)
def webconnectivity_http_connection_reset_with_inconsistent_dns(ooni_exe, outfile):
""" Test case where there's inconsistent DNS and the connection is RST when
we're executing HTTP code. """
args = [
"-iptables-reset-keyword",
"nexa.polito.it",
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-dns-proxy-hijack",
"polito",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://nexa.polito.it/ web_connectivity",
"webconnectivity_http_connection_reset_with_inconsistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "inconsistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == "connection_reset"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "dns"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 8480)
def webconnectivity_http_successful_website(ooni_exe, outfile):
""" Test case where we succeed with an HTTP only webpage """
args = []
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://example.org/ web_connectivity",
"webconnectivity_http_successful_website",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == True
assert tk["body_proportion"] == 1
assert tk["status_code_match"] == True
assert tk["headers_match"] == True
assert tk["title_match"] == True
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 2)
def webconnectivity_https_successful_website(ooni_exe, outfile):
""" Test case where we succeed with an HTTPS only webpage """
args = []
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://example.com/ web_connectivity",
"webconnectivity_https_successful_website",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == True
assert tk["body_proportion"] == 1
assert tk["status_code_match"] == True
assert tk["headers_match"] == True
assert tk["title_match"] == True
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 1)
def webconnectivity_http_diff_with_inconsistent_dns(ooni_exe, outfile):
""" Test case where we get an http-diff and the DNS is inconsistent """
args = [
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-dns-proxy-hijack",
"example.org",
"-http-proxy-block",
"example.org",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://example.org/ web_connectivity",
"webconnectivity_http_diff_with_inconsistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "inconsistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == False
assert tk["body_proportion"] < 1
assert tk["status_code_match"] == False
assert tk["headers_match"] == False
assert tk["title_match"] == False
assert tk["blocking"] == "dns"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 96)
def webconnectivity_http_diff_with_consistent_dns(ooni_exe, outfile):
""" Test case where we get an http-diff and the DNS is consistent """
args = [
"-iptables-hijack-http-to",
"127.0.0.1:80",
"-http-proxy-block",
"example.org",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i http://example.org/ web_connectivity",
"webconnectivity_http_diff_with_consistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
assert tk["http_experiment_failure"] == None
assert tk["body_length_match"] == False
assert tk["body_proportion"] < 1
assert tk["status_code_match"] == False
assert tk["headers_match"] == False
assert tk["title_match"] == False
assert tk["blocking"] == "http-diff"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 64)
def webconnectivity_https_expired_certificate(ooni_exe, outfile):
""" Test case where the domain's certificate is expired """
args = []
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://expired.badssl.com/ web_connectivity",
"webconnectivity_https_expired_certificate",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
if "miniooni" in ooni_exe:
assert tk["http_experiment_failure"] == "ssl_invalid_certificate"
else:
assert "certificate verify failed" in tk["http_experiment_failure"]
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
# The following strikes me as a measurement_kit bug. We are saying
# that all is good with a domain where actually we don't know why the
# control is failed and that is clearly not accessible according to
# our measurement of the domain (certificate expired).
#
# See <https://github.com/ooni/probe-engine/issues/858>.
if "miniooni" in ooni_exe:
assert tk["blocking"] == None
assert tk["accessible"] == None
else:
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 16)
def webconnectivity_https_wrong_host(ooni_exe, outfile):
""" Test case where the hostname is wrong for the certificate """
args = []
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://wrong.host.badssl.com/ web_connectivity",
"webconnectivity_https_wrong_host",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
if "miniooni" in ooni_exe:
assert tk["http_experiment_failure"] == "ssl_invalid_hostname"
else:
assert "certificate verify failed" in tk["http_experiment_failure"]
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
# The following strikes me as a measurement_kit bug. We are saying
# that all is good with a domain where actually we don't know why the
# control is failed and that is clearly not accessible according to
# our measurement of the domain (wrong host for certificate).
#
# See <https://github.com/ooni/probe-engine/issues/858>.
if "miniooni" in ooni_exe:
assert tk["blocking"] == None
assert tk["accessible"] == None
else:
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 16)
def webconnectivity_https_self_signed(ooni_exe, outfile):
""" Test case where the certificate is self signed """
args = []
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://self-signed.badssl.com/ web_connectivity",
"webconnectivity_https_self_signed",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
if "miniooni" in ooni_exe:
assert tk["http_experiment_failure"] == "ssl_unknown_authority"
else:
assert "certificate verify failed" in tk["http_experiment_failure"]
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
# The following strikes me as a measurement_kit bug. We are saying
# that all is good with a domain where actually we don't know why the
# control is failed and that is clearly not accessible according to
# our measurement of the domain (self signed certificate).
#
# See <https://github.com/ooni/probe-engine/issues/858>.
if "miniooni" in ooni_exe:
assert tk["blocking"] == None
assert tk["accessible"] == None
else:
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 16)
def webconnectivity_https_untrusted_root(ooni_exe, outfile):
""" Test case where the certificate has an untrusted root """
args = []
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://untrusted-root.badssl.com/ web_connectivity",
"webconnectivity_https_untrusted_root",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "consistent"
assert tk["control_failure"] == None
if "miniooni" in ooni_exe:
assert tk["http_experiment_failure"] == "ssl_unknown_authority"
else:
assert "certificate verify failed" in tk["http_experiment_failure"]
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
# The following strikes me as a measurement_kit bug. We are saying
# that all is good with a domain where actually we don't know why the
# control is failed and that is clearly not accessible according to
# our measurement of the domain (untrusted root certificate).
#
# See <https://github.com/ooni/probe-engine/issues/858>.
if "miniooni" in ooni_exe:
assert tk["blocking"] == None
assert tk["accessible"] == None
else:
assert tk["blocking"] == False
assert tk["accessible"] == True
assert_status_flags_are(ooni_exe, tk, 16)
def webconnectivity_dns_blocking_nxdomain(ooni_exe, outfile):
""" Test case where there is blocking using NXDOMAIN """
args = [
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-dns-proxy-block",
"example.com",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://example.com/ web_connectivity",
"webconnectivity_dns_blocking_nxdomain",
args,
)
# The following seems a bug in MK where we don't properly record the
# actual error that occurred when performing the DNS experiment.
#
# See <https://github.com/measurement-kit/measurement-kit/issues/1931>.
if "miniooni" in ooni_exe:
assert tk["dns_experiment_failure"] == "dns_nxdomain_error"
else:
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "inconsistent"
assert tk["control_failure"] == None
if "miniooni" in ooni_exe:
assert tk["http_experiment_failure"] == None
else:
assert tk["http_experiment_failure"] == "dns_lookup_error"
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "dns"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 2080)
def webconnectivity_https_unknown_authority_with_inconsistent_dns(ooni_exe, outfile):
""" Test case where the DNS is sending us towards a website where
we're served an invalid certificate """
args = [
"-iptables-hijack-dns-to",
"127.0.0.1:53",
"-dns-proxy-hijack",
"example.org",
"-bad-proxy-address-tls",
"127.0.0.1:443",
"-tls-proxy-address",
"127.0.0.1:4114",
]
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe,
outfile,
"-i https://example.org/ web_connectivity",
"webconnectivity_https_unknown_authority_with_inconsistent_dns",
args,
)
assert tk["dns_experiment_failure"] == None
assert tk["dns_consistency"] == "inconsistent"
assert tk["control_failure"] == None
if "miniooni" in ooni_exe:
assert tk["http_experiment_failure"] == "ssl_unknown_authority"
else:
assert "certificate verify failed" in tk["http_experiment_failure"]
assert tk["body_length_match"] == None
assert tk["body_proportion"] == 0
assert tk["status_code_match"] == None
assert tk["headers_match"] == None
assert tk["title_match"] == None
assert tk["blocking"] == "dns"
assert tk["accessible"] == False
assert_status_flags_are(ooni_exe, tk, 9248)
def main():
if len(sys.argv) != 2:
sys.exit("usage: %s /path/to/ooniprobelegacy-like/binary" % sys.argv[0])
outfile = "webconnectivity.jsonl"
ooni_exe = sys.argv[1]
tests = [
webconnectivity_https_ok_with_control_failure,
webconnectivity_http_ok_with_control_failure,
webconnectivity_transparent_http_proxy,
webconnectivity_dns_hijacking,
webconnectivity_control_unreachable_and_using_http,
webconnectivity_nonexistent_domain,
webconnectivity_tcpip_blocking_with_consistent_dns,
webconnectivity_tcpip_blocking_with_inconsistent_dns,
webconnectivity_http_connection_refused_with_consistent_dns,
webconnectivity_http_connection_reset_with_consistent_dns,
webconnectivity_http_nxdomain_with_consistent_dns,
webconnectivity_http_eof_error_with_consistent_dns,
webconnectivity_http_generic_timeout_error_with_consistent_dns,
webconnectivity_http_connection_reset_with_inconsistent_dns,
webconnectivity_http_successful_website,
webconnectivity_https_successful_website,
webconnectivity_http_diff_with_inconsistent_dns,
webconnectivity_http_diff_with_consistent_dns,
webconnectivity_https_expired_certificate,
webconnectivity_https_wrong_host,
webconnectivity_https_self_signed,
webconnectivity_https_untrusted_root,
webconnectivity_dns_blocking_nxdomain,
webconnectivity_https_unknown_authority_with_inconsistent_dns,
]
for test in tests:
test(ooni_exe, outfile)
time.sleep(7)
if __name__ == "__main__":
main()

235
internal/engine/QA/whatsapp.py Executable file
View File

@ -0,0 +1,235 @@
#!/usr/bin/env python3
""" ./QA/whatsapp.py - main QA script for whatsapp
This script performs a bunch of whatsapp tests under censored
network conditions and verifies that the measurement is consistent
with the expectations, by parsing the resulting JSONL. """
import contextlib
import json
import os
import shlex
import socket
import subprocess
import sys
import time
import urllib.parse
sys.path.insert(0, ".")
import common
def execute_jafar_and_return_validated_test_keys(ooni_exe, outfile, tag, args):
""" Executes jafar and returns the validated parsed test keys, or throws
an AssertionError if the result is not valid. """
tk = common.execute_jafar_and_miniooni(ooni_exe, outfile, "whatsapp", tag, args)
assert isinstance(tk["requests"], list)
assert len(tk["requests"]) > 0
for entry in tk["requests"]:
assert isinstance(entry, dict)
failure = entry["failure"]
assert isinstance(failure, str) or failure is None
assert isinstance(entry["request"], dict)
req = entry["request"]
common.check_maybe_binary_value(req["body"])
assert isinstance(req["headers"], dict)
for key, value in req["headers"].items():
assert isinstance(key, str)
common.check_maybe_binary_value(value)
assert isinstance(req["method"], str)
assert isinstance(entry["response"], dict)
resp = entry["response"]
common.check_maybe_binary_value(resp["body"])
assert isinstance(resp["code"], int)
if resp["headers"] is not None:
for key, value in resp["headers"].items():
assert isinstance(key, str)
common.check_maybe_binary_value(value)
assert isinstance(tk["tcp_connect"], list)
assert len(tk["tcp_connect"]) > 0
for entry in tk["tcp_connect"]:
assert isinstance(entry, dict)
assert isinstance(entry["ip"], str)
assert isinstance(entry["port"], int)
assert isinstance(entry["status"], dict)
failure = entry["status"]["failure"]
success = entry["status"]["success"]
assert isinstance(failure, str) or failure is None
assert isinstance(success, bool)
return tk
def helper_for_blocking_endpoints(start, stop):
""" Helper function for generating args for blocking endpoints """
args = []
for num in range(start, stop):
args.append("-iptables-reset-ip")
args.append("e{}.whatsapp.net".format(num))
return args
def args_for_blocking_all_endpoints():
""" Returns the arguments useful for blocking all endpoints """
return helper_for_blocking_endpoints(1, 17)
def args_for_blocking_some_endpoints():
""" Returns the arguments useful for blocking some endpoints """
# Implementation note: apparently all the endpoints are now using just
# four IP addresses, hence here we block some endpoints via DNS.
#
# TODO(bassosimone): this fact calls for creating an issue for making
# the whatsapp experiment implementation more efficient.
args = []
args.append("-iptables-hijack-dns-to")
args.append("127.0.0.1:53")
for n in range(1, 7):
args.append("-dns-proxy-block")
args.append("e{}.whatsapp.net".format(n))
return args
def args_for_blocking_v_whatsapp_net_https():
""" Returns arguments for blocking v.whatsapp.net over https """
#
# 00 00 <SNI extension ID>
# 00 13 <full extension length>
# 00 11 <first entry length>
# 00 <DNS hostname type>
# 00 0e <string length>
# 76 2e ... 74 v.whatsapp.net
#
return [
"-iptables-reset-keyword-hex",
"|00 00 00 13 00 11 00 00 0e 76 2e 77 68 61 74 73 61 70 70 2e 6e 65 74|",
]
def args_for_blocking_web_whatsapp_com_http():
""" Returns arguments for blocking web.whatsapp.com over http """
return ["-iptables-reset-keyword", "Host: web.whatsapp.com"]
def args_for_blocking_web_whatsapp_com_https():
""" Returns arguments for blocking web.whatsapp.com over https """
#
# 00 00 <SNI extension ID>
# 00 15 <full extension length>
# 00 13 <first entry length>
# 00 <DNS hostname type>
# 00 10 <string length>
# 77 65 ... 6d web.whatsapp.com
#
return [
"-iptables-reset-keyword-hex",
"|00 00 00 15 00 13 00 00 10 77 65 62 2e 77 68 61 74 73 61 70 70 2e 63 6f 6d|",
]
def whatsapp_block_everything(ooni_exe, outfile):
""" Test case where everything we measure is blocked """
args = []
args.extend(args_for_blocking_all_endpoints())
args.extend(args_for_blocking_v_whatsapp_net_https())
args.extend(args_for_blocking_web_whatsapp_com_https())
args.extend(args_for_blocking_web_whatsapp_com_http())
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "whatsapp_block_everything", args,
)
assert tk["registration_server_failure"] == "connection_reset"
assert tk["registration_server_status"] == "blocked"
assert tk["whatsapp_endpoints_status"] == "blocked"
assert tk["whatsapp_web_failure"] == "connection_reset"
assert tk["whatsapp_web_status"] == "blocked"
def whatsapp_block_all_endpoints(ooni_exe, outfile):
""" Test case where we only block whatsapp endpoints """
args = args_for_blocking_all_endpoints()
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "whatsapp_block_all_endpoints", args
)
assert tk["registration_server_failure"] == None
assert tk["registration_server_status"] == "ok"
assert tk["whatsapp_endpoints_status"] == "blocked"
assert tk["whatsapp_web_failure"] == None
assert tk["whatsapp_web_status"] == "ok"
def whatsapp_block_some_endpoints(ooni_exe, outfile):
""" Test case where we block some whatsapp endpoints """
args = args_for_blocking_some_endpoints()
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "whatsapp_block_some_endpoints", args
)
assert tk["registration_server_failure"] == None
assert tk["registration_server_status"] == "ok"
assert tk["whatsapp_endpoints_status"] == "ok"
assert tk["whatsapp_web_failure"] == None
assert tk["whatsapp_web_status"] == "ok"
def whatsapp_block_registration_server(ooni_exe, outfile):
""" Test case where we block the registration server """
args = []
args.extend(args_for_blocking_v_whatsapp_net_https())
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "whatsapp_block_registration_server", args,
)
assert tk["registration_server_failure"] == "connection_reset"
assert tk["registration_server_status"] == "blocked"
assert tk["whatsapp_endpoints_status"] == "ok"
assert tk["whatsapp_web_failure"] == None
assert tk["whatsapp_web_status"] == "ok"
def whatsapp_block_web_http(ooni_exe, outfile):
""" Test case where we block the HTTP web chat """
args = []
args.extend(args_for_blocking_web_whatsapp_com_http())
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "whatsapp_block_web_http", args,
)
assert tk["registration_server_failure"] == None
assert tk["registration_server_status"] == "ok"
assert tk["whatsapp_endpoints_status"] == "ok"
assert tk["whatsapp_web_failure"] == "connection_reset"
assert tk["whatsapp_web_status"] == "blocked"
def whatsapp_block_web_https(ooni_exe, outfile):
""" Test case where we block the HTTPS web chat """
args = []
args.extend(args_for_blocking_web_whatsapp_com_https())
tk = execute_jafar_and_return_validated_test_keys(
ooni_exe, outfile, "whatsapp_block_web_https", args,
)
assert tk["registration_server_failure"] == None
assert tk["registration_server_status"] == "ok"
assert tk["whatsapp_endpoints_status"] == "ok"
assert tk["whatsapp_web_failure"] == "connection_reset"
assert tk["whatsapp_web_status"] == "blocked"
def main():
if len(sys.argv) != 2:
sys.exit("usage: %s /path/to/ooniprobelegacy-like/binary" % sys.argv[0])
outfile = "whatsapp.jsonl"
ooni_exe = sys.argv[1]
tests = [
whatsapp_block_everything,
whatsapp_block_all_endpoints,
whatsapp_block_some_endpoints,
whatsapp_block_registration_server,
whatsapp_block_web_http,
whatsapp_block_web_https,
]
for test in tests:
test(ooni_exe, outfile)
time.sleep(7)
if __name__ == "__main__":
main()

83
internal/engine/README.md Normal file
View File

@ -0,0 +1,83 @@
# OONI probe measurement engine
[![GoDoc](https://godoc.org/github.com/ooni/probe-engine?status.svg)](https://godoc.org/github.com/ooni/probe-engine) [![Short Tests Status](https://github.com/ooni/probe-engine/workflows/shorttests/badge.svg)](https://github.com/ooni/probe-engine/actions?query=workflow%3Ashorttests) [![All Tests Status](https://github.com/ooni/probe-engine/workflows/alltests/badge.svg)](https://github.com/ooni/probe-engine/actions?query=workflow%3Aalltests) [![Coverage Status](https://coveralls.io/repos/github/ooni/probe-engine/badge.svg?branch=master)](https://coveralls.io/github/ooni/probe-engine?branch=master) [![Go Report Card](https://goreportcard.com/badge/github.com/ooni/probe-engine)](https://goreportcard.com/report/github.com/ooni/probe-engine)
This repository contains OONI probe's [measurement engine](
https://github.com/ooni/spec/tree/master/probe#engine). That is, the
piece of software that implements OONI nettests as well as all the
required functionality to run such nettests.
We expect you to use the Go version indicated in [go.mod](go.mod).
## Integrating ooni/probe-engine
We recommend pinning to a specific version of probe-engine:
```bash
go get -v github.com/ooni/probe-engine@VERSION
```
See also the [workflows/using.yml](.github/workflows/using.yml) test
where we check that the latest commit can be imported by a third party.
We do not provide any API stability guarantee.
## Building miniooni
[miniooni](cmd/miniooni) is a small command line client used for
research and quality assurance testing. Build using:
```bash
go build -v ./cmd/miniooni/
```
See also `./build-cli.bash` for more advanced builds (e.g. to create
statically linked and/or stripped binaries).
We don't provide any `miniooni` command line flags stability guarantee.
See
```bash
./miniooni --help
```
for more help.
## Building Android bindings
```bash
./build-android.bash
```
We automatically build Android bindings whenever commits are pushed to the
`mobile-staging` branch. Such builds could be integrated by using:
```Groovy
implementation "org.ooni:oonimkall:VERSION"
```
Where VERSION is like `2020.03.30-231914` corresponding to the
time when the build occurred.
## Building iOS bindings
```bash
./build-ios.bash
```
We automatically build iOS bindings whenever commits are pushed to the
`mobile-staging` branch. Such builds could be integrated by using:
```ruby
pod 'oonimkall', :podspec => 'https://dl.bintray.com/ooni/ios/oonimkall-VERSION.podspec'
```
Where VERSION is like `2020.03.30-231914` corresponding to the
time when the build occurred.
## Updating dependencies
```
go get -u -v ./... && go mod tidy
```

View File

@ -0,0 +1,323 @@
package engine
import (
"time"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dash"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/example"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/fbmessenger"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hhfm"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hirl"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/httphostheader"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/ndt7"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/psiphon"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/riseupvpn"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/run"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/sniblocking"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/stunreachability"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/telegram"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tor"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/whatsapp"
)
var experimentsByName = map[string]func(*Session) *ExperimentBuilder{
"dash": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, dash.NewExperimentMeasurer(
*config.(*dash.Config),
))
},
config: &dash.Config{},
interruptible: true,
inputPolicy: InputNone,
}
},
"dnscheck": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, dnscheck.NewExperimentMeasurer(
*config.(*dnscheck.Config),
))
},
config: &dnscheck.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"example": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, example.NewExperimentMeasurer(
*config.(*example.Config), "example",
))
},
config: &example.Config{
Message: "Good day from the example experiment!",
SleepTime: int64(time.Second),
},
interruptible: true,
inputPolicy: InputNone,
}
},
"example_with_input": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, example.NewExperimentMeasurer(
*config.(*example.Config), "example_with_input",
))
},
config: &example.Config{
Message: "Good day from the example with input experiment!",
SleepTime: int64(time.Second),
},
interruptible: true,
inputPolicy: InputStrictlyRequired,
}
},
// TODO(bassosimone): when we can set experiment options using the JSON
// we need to get rid of all these multiple experiments.
//
// See https://github.com/ooni/probe-cli/v3/internal/engine/issues/413
"example_with_input_non_interruptible": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, example.NewExperimentMeasurer(
*config.(*example.Config), "example_with_input_non_interruptible",
))
},
config: &example.Config{
Message: "Good day from the example with input experiment!",
SleepTime: int64(time.Second),
},
interruptible: false,
inputPolicy: InputStrictlyRequired,
}
},
"example_with_failure": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, example.NewExperimentMeasurer(
*config.(*example.Config), "example_with_failure",
))
},
config: &example.Config{
Message: "Good day from the example with failure experiment!",
ReturnError: true,
SleepTime: int64(time.Second),
},
interruptible: true,
inputPolicy: InputNone,
}
},
"facebook_messenger": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, fbmessenger.NewExperimentMeasurer(
*config.(*fbmessenger.Config),
))
},
config: &fbmessenger.Config{},
inputPolicy: InputNone,
}
},
"http_header_field_manipulation": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, hhfm.NewExperimentMeasurer(
*config.(*hhfm.Config),
))
},
config: &hhfm.Config{},
inputPolicy: InputNone,
}
},
"http_host_header": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, httphostheader.NewExperimentMeasurer(
*config.(*httphostheader.Config),
))
},
config: &httphostheader.Config{},
inputPolicy: InputOrQueryTestLists,
}
},
"http_invalid_request_line": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, hirl.NewExperimentMeasurer(
*config.(*hirl.Config),
))
},
config: &hirl.Config{},
inputPolicy: InputNone,
}
},
"ndt": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, ndt7.NewExperimentMeasurer(
*config.(*ndt7.Config),
))
},
config: &ndt7.Config{},
interruptible: true,
inputPolicy: InputNone,
}
},
"psiphon": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, psiphon.NewExperimentMeasurer(
*config.(*psiphon.Config),
))
},
config: &psiphon.Config{},
inputPolicy: InputOptional,
}
},
"riseupvpn": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, riseupvpn.NewExperimentMeasurer(
*config.(*riseupvpn.Config),
))
},
config: &riseupvpn.Config{},
inputPolicy: InputNone,
}
},
"run": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, run.NewExperimentMeasurer(
*config.(*run.Config),
))
},
config: &run.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"sni_blocking": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, sniblocking.NewExperimentMeasurer(
*config.(*sniblocking.Config),
))
},
config: &sniblocking.Config{},
inputPolicy: InputOrQueryTestLists,
}
},
"stun_reachability": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, stunreachability.NewExperimentMeasurer(
*config.(*stunreachability.Config),
))
},
config: &stunreachability.Config{},
inputPolicy: InputOptional,
}
},
"telegram": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, telegram.NewExperimentMeasurer(
*config.(*telegram.Config),
))
},
config: &telegram.Config{},
inputPolicy: InputNone,
}
},
"tlstool": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, tlstool.NewExperimentMeasurer(
*config.(*tlstool.Config),
))
},
config: &tlstool.Config{},
inputPolicy: InputOrQueryTestLists,
}
},
"tor": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, tor.NewExperimentMeasurer(
*config.(*tor.Config),
))
},
config: &tor.Config{},
inputPolicy: InputNone,
}
},
"urlgetter": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, urlgetter.NewExperimentMeasurer(
*config.(*urlgetter.Config),
))
},
config: &urlgetter.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"web_connectivity": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, webconnectivity.NewExperimentMeasurer(
*config.(*webconnectivity.Config),
))
},
config: &webconnectivity.Config{},
inputPolicy: InputOrQueryTestLists,
}
},
"whatsapp": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, whatsapp.NewExperimentMeasurer(
*config.(*whatsapp.Config),
))
},
config: &whatsapp.Config{},
inputPolicy: InputNone,
}
},
}
// AllExperiments returns the name of all experiments
func AllExperiments() []string {
var names []string
for key := range experimentsByName {
names = append(names, key)
}
return names
}

View File

@ -0,0 +1,3 @@
# Package github.com/ooni/probe-engine/atomicx
Atomic int64/float64 that works also on 32 bit platforms.

View File

@ -0,0 +1,68 @@
// Package atomicx contains atomic int64/float64 that work also on 32 bit
// platforms. The main reason for rolling out this package is to avoid potential
// crashes when using 32 bit devices where we are atomically accessing a 64 bit
// variable that is not aligned. The solution to this issue is rather crude: use
// a normal variable and protect it using a normal mutex. While this could be
// disappointing in general, it seems fine to be done in our context where
// we mainly use atomic semantics for counting.
package atomicx
import (
"sync"
)
// Int64 is an int64 with atomic semantics.
type Int64 struct {
mu sync.Mutex
v int64
}
// NewInt64 creates a new int64 with atomic semantics.
func NewInt64() *Int64 {
return new(Int64)
}
// Add behaves like atomic.AddInt64
func (i64 *Int64) Add(delta int64) (newvalue int64) {
i64.mu.Lock()
i64.v += delta
newvalue = i64.v
i64.mu.Unlock()
return
}
// Load behaves like atomic.LoadInt64
func (i64 *Int64) Load() (v int64) {
i64.mu.Lock()
v = i64.v
i64.mu.Unlock()
return
}
// Float64 is an float64 with atomic semantics.
type Float64 struct {
mu sync.Mutex
v float64
}
// NewFloat64 creates a new float64 with atomic semantics.
func NewFloat64() *Float64 {
return new(Float64)
}
// Add behaves like AtomicInt64.Add but for float64
func (f64 *Float64) Add(delta float64) (newvalue float64) {
f64.mu.Lock()
f64.v += delta
newvalue = f64.v
f64.mu.Unlock()
return
}
// Load behaves like LoadInt64.Load buf for float64
func (f64 *Float64) Load() (v float64) {
f64.mu.Lock()
v = f64.v
f64.mu.Unlock()
return
}

View File

@ -0,0 +1,50 @@
package atomicx_test
import (
"testing"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/atomicx"
)
func TestInt64(t *testing.T) {
// TODO(bassosimone): how to write tests with race conditions
// and be confident that they're WAI? Here I hope this test is
// run with `-race` and I'm doing something that AFAICT will
// be flagged as race if we were not be using mutexes.
v := atomicx.NewInt64()
go func() {
v.Add(17)
}()
go func() {
v.Add(14)
}()
time.Sleep(1 * time.Second)
if v.Add(3) != 34 {
t.Fatal("unexpected result")
}
if v.Load() != 34 {
t.Fatal("unexpected result")
}
}
func TestFloat64(t *testing.T) {
// TODO(bassosimone): how to write tests with race conditions
// and be confident that they're WAI? Here I hope this test is
// run with `-race` and I'm doing something that AFAICT will
// be flagged as race if we were not be using mutexes.
v := atomicx.NewFloat64()
go func() {
v.Add(17.0)
}()
go func() {
v.Add(14.0)
}()
time.Sleep(1 * time.Second)
if r := v.Add(3); r < 33.9 && r > 34.1 {
t.Fatal("unexpected result")
}
if v.Load() < 33.9 && v.Load() > 34.1 {
t.Fatal("unexpected result")
}
}

View File

@ -0,0 +1,55 @@
#!/bin/bash
set -e
if [ -z "$ANDROID_HOME" -o "$1" = "--help" ]; then
echo ""
echo "usage: $0"
echo ""
echo "Please set ANDROID_HOME. We assume you have installed"
echo "the Android SDK. You can do that on macOS using:"
echo ""
echo " brew install --cask android-sdk"
echo ""
echo "Then make sure you install the required packages:"
echo ""
echo "sdkmanager --install 'build-tools;29.0.3' 'ndk;21.3.6528147'"
echo ""
echo "or, if you already installed, that you're up to date:"
echo ""
echo "sdkmanager --update"
echo ""
echo "Once you have done that, please export ANDROID_HOME to"
echo "point to /usr/local/Caskroom/android-sdk/<version>."
echo ""
exit 1
fi
if [ -d $ANDROID_HOME/ndk-bundle ]; then
echo ""
echo "FATAL: currently we need 'ndk;21.3.6528147' instead of ndk-bundle"
echo ""
echo "See https://github.com/ooni/probe-engine/issues/1179."
echo ""
echo "To fix: sdkmanager --uninstall ndk-bundle"
echo ""
exit 1
fi
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/21.3.6528147
if [ ! -d $ANDROID_NDK_HOME ]; then
echo ""
echo "FATAL: currently we need 'ndk;21.3.6528147'"
echo ""
echo "See https://github.com/ooni/probe-engine/issues/1179."
echo ""
echo "To fix: sdkmanager --install 'ndk;21.3.6528147'"
echo ""
exit 1
fi
topdir=$(cd $(dirname $0) && pwd -P)
set -x
export PATH=$(go env GOPATH)/bin:$PATH
export GO111MODULE=off
go get -u golang.org/x/mobile/cmd/gomobile
gomobile init
export GO111MODULE=on
output=MOBILE/android/oonimkall.aar
gomobile bind -target=android -o $output -ldflags="-s -w" ./oonimkall

31
internal/engine/build-cli.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/sh
set -e
case $1 in
macos|darwin)
export GOOS=darwin GOARCH=amd64
go build -o ./CLI/darwin/amd64 -ldflags="-s -w" ./cmd/miniooni
echo "Binary ready at ./CLI/darwin/amd64/miniooni";;
linux)
export GOOS=linux GOARCH=386
go build -o ./CLI/linux/386 -tags netgo -ldflags='-s -w -extldflags "-static"' ./cmd/miniooni
echo "Binary ready at ./CLI/linux/386/miniooni"
export GOOS=linux GOARCH=amd64
go build -o ./CLI/linux/amd64 -tags netgo -ldflags='-s -w -extldflags "-static"' ./cmd/miniooni
echo "Binary ready at ./CLI/linux/amd64/miniooni"
export GOOS=linux GOARCH=arm GOARM=7
go build -o ./CLI/linux/armv7 -tags netgo -ldflags='-s -w -extldflags "-static"' ./cmd/miniooni
echo "Binary ready at ./CLI/linux/armv7/miniooni"
export GOOS=linux GOARCH=arm64
go build -o ./CLI/linux/arm64 -tags netgo -ldflags='-s -w -extldflags "-static"' ./cmd/miniooni
echo "Binary ready at ./CLI/linux/arm64/miniooni";;
windows)
export GOOS=windows GOARCH=386
go build -o ./CLI/windows/386 -ldflags="-s -w" ./cmd/miniooni
echo "Binary ready at ./CLI/windows/386/miniooni.exe"
export GOOS=windows GOARCH=amd64
go build -o ./CLI/windows/amd64 -ldflags="-s -w" ./cmd/miniooni
echo "Binary ready at ./CLI/windows/amd64/miniooni.exe";;
*)
echo "usage: $0 darwin|linux|windows" 1>&2
exit 1
esac

11
internal/engine/build-ios.bash Executable file
View File

@ -0,0 +1,11 @@
#!/bin/bash
set -e
topdir=$(cd $(dirname $0) && pwd -P)
set -x
export PATH=$(go env GOPATH)/bin:$PATH
export GO111MODULE=off
go get -u golang.org/x/mobile/cmd/gomobile
gomobile init
export GO111MODULE=on
output=MOBILE/ios/oonimkall.framework
gomobile bind -target=ios -o $output -ldflags="-s -w" ./oonimkall

View File

@ -0,0 +1,3 @@
# Directory github.com/ooni/probe-engine/cmd
This directory contains the source code for the CLI tools we build.

View File

@ -0,0 +1,8 @@
# apitool
This directory contains a tool to fetch measurements. This tool is
intended to sporadically fetch measurements, not for batch downloading.
Please, see https://ooni.org/data for information pertaining how to
access OONI data in bulk. Please see https://explorer.ooni.org if your
intent is to navigate and explore OONI data.

View File

@ -0,0 +1,115 @@
// Command apitool is a simple tool to fetch individual OONI measurements.
//
// This tool IS NOT intended for batch downloading.
//
// Please, see https://ooni.org/data for information pertaining how to
// access OONI data in bulk. Please see https://explorer.ooni.org if your
// intent is to navigate and explore OONI data
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
"github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
"github.com/ooni/probe-cli/v3/internal/engine/probeservices"
"github.com/ooni/probe-cli/v3/internal/engine/version"
)
func newclient() probeservices.Client {
txp := netx.NewHTTPTransport(netx.Config{Logger: log.Log})
ua := fmt.Sprintf("apitool/%s ooniprobe-engine/%s", version.Version, version.Version)
return probeservices.Client{
Client: httpx.Client{
BaseURL: "https://ams-pg.ooni.org/",
HTTPClient: &http.Client{Transport: txp},
Logger: log.Log,
UserAgent: ua,
},
LoginCalls: atomicx.NewInt64(),
RegisterCalls: atomicx.NewInt64(),
StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()),
}
}
var osExit = os.Exit
func fatalOnError(err error, message string) {
if err != nil {
log.WithError(err).Error(message)
osExit(1) // overridable from tests
}
}
var (
debug = flag.Bool("v", false, "Enable verbose mode")
input = flag.String("input", "", "Input of the measurement")
mode = flag.String("mode", "", "One of: check, meta, raw")
reportid = flag.String("report-id", "", "Report ID of the measurement")
)
var logmap = map[bool]log.Level{
true: log.DebugLevel,
false: log.InfoLevel,
}
func main() {
flag.Parse()
log.SetLevel(logmap[*debug])
client := newclient()
switch *mode {
case "check":
check(client)
case "meta":
meta(client)
case "raw":
raw(client)
default:
fatalOnError(fmt.Errorf("invalid -mode flag value: %s", *mode), "usage error")
}
}
func check(c probeservices.Client) {
found, err := c.CheckReportID(context.Background(), *reportid)
fatalOnError(err, "c.CheckReportID failed")
fmt.Printf("%+v\n", found)
}
func meta(c probeservices.Client) {
pprint(mmeta(c, false))
}
func raw(c probeservices.Client) {
m := mmeta(c, true)
rm := []byte(m.RawMeasurement)
var opaque interface{}
err := json.Unmarshal(rm, &opaque)
fatalOnError(err, "json.Unmarshal failed")
pprint(opaque)
}
func pprint(opaque interface{}) {
data, err := json.MarshalIndent(opaque, "", " ")
fatalOnError(err, "json.MarshalIndent failed")
fmt.Printf("%s\n", data)
}
func mmeta(c probeservices.Client, full bool) *probeservices.MeasurementMeta {
config := probeservices.MeasurementMetaConfig{
ReportID: *reportid,
Full: full,
Input: *input,
}
ctx := context.Background()
m, err := c.GetMeasurementMeta(ctx, config)
fatalOnError(err, "client.GetMeasurementMeta failed")
return m
}

View File

@ -0,0 +1,39 @@
package main
import (
"fmt"
"testing"
)
func init() {
*reportid = `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU`
*input = `https://www.example.org`
}
func TestCheck(t *testing.T) {
*mode = "check"
main()
}
func TestRaw(t *testing.T) {
*mode = "raw"
main()
}
func TestMeta(t *testing.T) {
*mode = "meta"
main()
}
func TestInvalidMode(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("the code did not panic")
}
}()
osExit = func(code int) {
panic(fmt.Errorf("%d", code))
}
*mode = "antani"
main()
}

View File

@ -0,0 +1,258 @@
# Jafar
> We stepped up the game of simulating censorship upgrading from the
> evil genius to the evil grand vizier.
Jafar is a censorship simulation tool used for testing OONI. It builds on
any system but it really on works on Linux.
## Building
We use Go >= 1.14. Jafar also needs the C library headers,
iptables installed, and root permissions.
With Linux Alpine edge, you can compile Jafar with:
```bash
apk add go git musl-dev iptables
go build -v .
```
Otherwise, using Docker:
```bash
docker build -t jafar-runner .
docker run -it --privileged -v`pwd`:/jafar -w/jafar jafar-runner
go build -v .
```
## Usage
You need to run Jafar as root. You can get a complete list
of all flags using `./jafar -help`. Jafar is composed of modules. Each
module is controllable via flags. We describe modules below.
### main
The main module starts all the other modules. If you don't provide the
`-main-command <command>` flag, the code will run until interrupted. If
instead you use the `-main-command` flag, you can specify a command to
run inside the censored environment. In such case, the main module
will exit when the specified command terminates. Note that the main
module will propagate the child exit code, if the child fails.
The command can also include arguments. Make sure you quote the arguments
such that your shell passes the whole string to the specified option, as
in `-main-command 'ls -lha'`. This will execute the `ls -lha` command line
inside the censored Jafar context. You can also combine that with quoting
and variables interpolation, e.g., `-main-command "echo '$USER is the
walrus'"`. The `$USER` variable will be expanded by your shell. Assuming
your user name is `paul`, then Jafar will lex the main command as `echo
"paul is the walrus"` and will execute it.
Use the `-main-user <username>` flag to select the user to use for
running child commands. By default, we use the `nobody` user for this
purpose. We implement this feature using `sudo`, therefore you need
to make sure that `sudo` is installed.
### iptables
The iptables module is only available on Linux. It exports these flags:
```bash
-iptables-drop-ip value
Drop traffic to the specified IP address
-iptables-drop-keyword-hex value
Drop traffic containing the specified hex keyword
-iptables-drop-keyword value
Drop traffic containing the specified keyword
-iptables-hijack-dns-to string
Hijack all DNS UDP traffic to the specified endpoint
-iptables-hijack-https-to string
Hijack all HTTPS traffic to the specified endpoint
-iptables-hijack-http-to string
Hijack all HTTP traffic to the specified endpoint
-iptables-reset-ip value
Reset TCP/IP traffic to the specified IP address
-iptables-reset-keyword-hex value
Reset TCP/IP traffic containing the specified hex keyword
-iptables-reset-keyword value
Reset TCP/IP traffic containing the specified keyword
```
The difference between `drop` and `reset` is that in the former case
a packet is dropped, in the latter case a RST is sent.
The difference between `ip` and `keyword` flags is that the former
match an outgoing IP, the latter uses DPI.
The `drop` and `reset` rules allow you to simulate, respectively, when
operations timeout and when a connection cannot be established (with
`reset` and `ip`) or is reset after a keyword is seen (with `keyword`).
Hijacking DNS traffic is useful, for example, to redirect all DNS UDP
traffic from the box to the `dns-proxy` module.
Hijacking HTTP and HTTPS traffic actually hijacks based on ports rather
than on DPI. As a known bug, when hijacking HTTP or HTTPS traffic, we
do not hijack traffic owned by root. This is because Jafar runs as root
and therefore its traffic must not match the hijack rule.
When matching keywords, the simplest option is to use ASCII strings as
in `-iptables-drop-keyword ooni`. However, you can also specify a sequence
of hex bytes, as in `-iptables-drop-keyword-hex |6f 6f 6e 69|`.
Note that with `-iptables-drop-keyword`, DNS queries containing such
keyword will fail returning `EPERM`. For a more realistic approach to
dropping specific DNS packets, combine DNS traffic hijacking with
`-dns-proxy-ignore`, to "drop" packets at the DNS proxy.
### dns-proxy (aka resolver)
The DNS proxy or resolver allows to manipulate DNS. Unless you use DNS
hijacking, you will need to configure your application explicitly to use
the proxy with application specific command line flags.
```bash
-dns-proxy-address string
Address where the DNS proxy should listen (default "127.0.0.1:53")
-dns-proxy-block value
Register keyword triggering NXDOMAIN censorship
-dns-proxy-hijack value
Register keyword triggering redirection to 127.0.0.1
-dns-proxy-ignore value
Register keyword causing the proxy to ignore the query
```
The `-dns-proxy-address` flag controls the endpoint where the proxy is
listening.
The `-dns-proxy-block` tells the resolver that every incoming request whose
query contains the specifed string shall receive an `NXDOMAIN` reply.
The `-dns-proxy-hijack` is similar but instead lies and returns to the
client that the requested domain is at `127.0.0.1`. This is an opportunity
to redirect traffic to the HTTP and TLS proxies.
The `-dns-proxy-ignore` is similar but instead just ignores the query.
### http-proxy
The HTTP proxy is an HTTP proxy that may refuse to forward some
specific requests. It's controlled by these flags:
```bash
-http-proxy-address string
Address where the HTTP proxy should listen (default "127.0.0.1:80")
-http-proxy-block value
Register keyword triggering HTTP 451 censorship
```
The `-http-proxy-address` flag has the same semantics it has for the DNS
proxy.
The `-http-proxy-block` flag tells the proxy that it should return a `451`
response for every request whose `Host` contains the specified string.
### tls-proxy
TLS proxy is a proxy that routes traffic to specific servers depending
on their SNI value. It is controlled by the following flags:
```bash
-tls-proxy-address string
Address where the HTTP proxy should listen (default "127.0.0.1:443")
-tls-proxy-block value
Register keyword triggering TLS censorship
```
The `-tls-proxy-address` flags has the same semantics it has for the DNS
proxy.
The `-tls-proxy-block` specifies which string or strings should cause the
proxy to return an internal-erorr alert when the incoming ClientHello's SNI
contains one of the strings provided with this option.
### bad-proxy
```bash
-bad-proxy-address string
Address where to listen for TCP connections (default "127.0.0.1:7117")
-bad-proxy-address-tls string
Address where to listen for TLS connections (default "127.0.0.1:4114")
-bad-proxy-tls-output-ca string
File where to write the CA used by the bad proxy (default "badproxy.pem")
```
The bad proxy is a proxy that reads some bytes from any incoming connection
and then closes the connection without replying anything. This simulates a
proxy that is not working properly, hence the name of the module.
When connecting using TLS, the above behaviour happens after the handshake.
We write the CA on the file specified using `-bad-proxy-tls-output-ca` such that
tools like curl(1) can use such CA to avoid TLS handshake errors. The code will
generate on the fly a certificate for the provided SNI. Not providing any SNI in
the client Hello message will cause the TLS handshake to fail.
### uncensored
```bash
-uncensored-resolver-url string
URL of an hopefully uncensored resolver (default "dot://1.1.1.1:853")
```
The HTTP, DNS, and TLS proxies need to resolve domain names. If you setup DNS
censorship, they may be affected as well. To avoid this issue, we use a different
resolver for them, which by default is `dot://1.1.1.1:853`. You can change such
default by using the `-uncensored-resolver-url` command line flag. The input
URL is `<transport>://<domain>[:<port>][/<path>]`. Here are some examples:
* `system:///` uses the system resolver (i.e. `getaddrinfo`)
* `udp://8.8.8.8:53` uses DNS over UDP
* `tcp://8.8.8.8:53` used DNS over TCP
* `dot://8.8.8.8:853` uses DNS over TLS
* `https://dns.google/dns-query` uses DNS over HTTPS
So, for example, if you are using Jafar to censor `1.1.1.1:853`, then you
most likely want to use `-uncensored-resolver-url`.
## Examples
Block `play.google.com` with RST injection, force DNS traffic to use the our
DNS proxy, and force it to censor `play.google.com` with `NXDOMAIN`.
```bash
# ./jafar -iptables-reset-keyword play.google.com \
-iptables-hijack-dns-to 127.0.0.1:5353 \
-dns-proxy-address 127.0.0.1:5353 \
-dns-proxy-block play.google.com
```
Force all traffic through the HTTP and TLS proxy and use them to censor
`play.google.com` using HTTP 451 and responding with TLS alerts:
```bash
# ./jafar -iptables-hijack-dns-to 127.0.0.1:5353 \
-dns-proxy-address 127.0.0.1:5353 \
-dns-proxy-hijack play.google.com \
-http-proxy-block play.google.com \
-tls-proxy-block play.google.com
```
Run `ping` in a censored environment:
```bash
# ./jafar -iptables-drop-ip 8.8.8.8 -main-command 'ping -c3 8.8.8.8'
```
Run `curl` in a censored environment where it cannot connect to
`play.google.com` using `https`:
```bash
# ./jafar -iptables-hijack-https-to 127.0.0.1:443 \
-tls-proxy-block play.google.com \
-main-command 'curl -Lv http://play.google.com'
```
For more usage examples, see `../../testjafar.bash`.

View File

@ -0,0 +1,113 @@
// Package badproxy implements misbehaving proxies. We have a single
// CensoringProxy that exports two misbehaving endpoints. Each endpoint
// implements a different proxy-censorsing technique. The first one
// reads some bytes from the connection then closes the connection. The
// other instead replies with a self signed x509 certificate.
package badproxy
import (
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"io"
"io/ioutil"
"net"
"strings"
"time"
"github.com/google/martian/v3/mitm"
)
// CensoringProxy is a proxy that does not behave correctly.
type CensoringProxy struct {
mitmNewAuthority func(
name string, organization string,
validity time.Duration,
) (*x509.Certificate, *rsa.PrivateKey, error)
mitmNewConfig func(
ca *x509.Certificate, privateKey interface{},
) (*mitm.Config, error)
tlsListen func(
network string, laddr string, config *tls.Config,
) (net.Listener, error)
}
// NewCensoringProxy creates a new instance of a misbehaving proxy.
func NewCensoringProxy() *CensoringProxy {
return &CensoringProxy{
mitmNewAuthority: mitm.NewAuthority,
mitmNewConfig: mitm.NewConfig,
tlsListen: tls.Listen,
}
}
func (p *CensoringProxy) serve(conn net.Conn) {
deadline := time.Now().Add(250 * time.Millisecond)
conn.SetDeadline(deadline)
// To simulate the case where the proxy isn't willing to forward our
// traffic, we close the connection (1) right after the handshake for
// TLS connections and (2) reasonably after we've received the HTTP
// request for cleartext connections. This may break in several cases
// but is good enough approximation of these bad proxies for now.
if tlsconn, ok := conn.(*tls.Conn); ok {
tlsconn.Handshake()
} else {
const maxread = 1 << 17
reader := io.LimitReader(conn, maxread)
ioutil.ReadAll(reader)
}
conn.Close()
}
func (p *CensoringProxy) run(listener net.Listener) {
for {
conn, err := listener.Accept()
if err != nil && strings.Contains(
err.Error(), "use of closed network connection") {
return
}
if err == nil {
// It's difficult to make accept fail, so restructure
// the code such that we enter into the happy path
go p.serve(conn)
}
}
}
// Start starts the misbehaving proxy for TCP. This endpoint will read some
// bytes from the request and then close the connection. This behaviour is
// implemented by a bunch of censoring proxy around the world. Usually such
// proxies only close the connection with offending SNIs/Host headers.
func (p *CensoringProxy) Start(address string) (net.Listener, error) {
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, err
}
go p.run(listener)
return listener, nil
}
// StartTLS starts the misbehaving proxy for TLS. This endpoint will return
// to the client a self signed certificate. Thus, it models the case where a
// MITM forces users to accept a rogue certificate. After sending such a
// certificate, this proxy will close the TCP connection.
func (p *CensoringProxy) StartTLS(address string) (net.Listener, *x509.Certificate, error) {
cert, privkey, err := p.mitmNewAuthority(
"jafar", "OONI", 24*time.Hour,
)
if err != nil {
return nil, nil, err
}
config, err := p.mitmNewConfig(cert, privkey)
if err != nil {
return nil, nil, err
}
listener, err := p.tlsListen("tcp", address, config.TLS())
if err != nil {
return nil, nil, err
}
go p.run(listener)
return listener, cert, nil
}

View File

@ -0,0 +1,157 @@
package badproxy
import (
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"errors"
"net"
"testing"
"time"
"github.com/google/martian/v3/mitm"
)
func TestCleartext(t *testing.T) {
listener := newproxy(t)
checkdial(t, listener.Addr().String(), nil, net.Dial)
killproxy(t, listener)
}
func TestTLS(t *testing.T) {
listener := newproxytls(t)
checkdial(t, listener.Addr().String(), nil,
func(network, address string) (net.Conn, error) {
conn, err := tls.Dial(network, address, &tls.Config{
InsecureSkipVerify: true,
ServerName: "antani.local",
})
if err != nil {
return nil, err
}
if err = conn.Handshake(); err != nil {
conn.Close()
return nil, err
}
return conn, nil
})
killproxy(t, listener)
}
func TestListenError(t *testing.T) {
proxy := NewCensoringProxy()
listener, err := proxy.Start("8.8.8.8:80")
if err == nil {
t.Fatal("expected an error here")
}
if listener != nil {
t.Fatal("expected nil listener here")
}
}
func TestStarTLS(t *testing.T) {
expected := errors.New("mocked error")
t.Run("when we cannot create a new authority", func(t *testing.T) {
proxy := NewCensoringProxy()
proxy.mitmNewAuthority = func(
name string, organization string,
validity time.Duration,
) (*x509.Certificate, *rsa.PrivateKey, error) {
return nil, nil, expected
}
cert, privkey, err := proxy.StartTLS("127.0.0.1:0")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if cert != nil {
t.Fatal("expected nil cert")
}
if privkey != nil {
t.Fatal("expected nil privkey")
}
})
t.Run("when we cannot create a new config", func(t *testing.T) {
proxy := NewCensoringProxy()
proxy.mitmNewConfig = func(
ca *x509.Certificate, privateKey interface{},
) (*mitm.Config, error) {
return nil, expected
}
cert, privkey, err := proxy.StartTLS("127.0.0.1:0")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if cert != nil {
t.Fatal("expected nil cert")
}
if privkey != nil {
t.Fatal("expected nil privkey")
}
})
t.Run("when we cannot listen", func(t *testing.T) {
proxy := NewCensoringProxy()
proxy.tlsListen = func(
network string, laddr string, config *tls.Config,
) (net.Listener, error) {
return nil, expected
}
cert, privkey, err := proxy.StartTLS("127.0.0.1:0")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if cert != nil {
t.Fatal("expected nil cert")
}
if privkey != nil {
t.Fatal("expected nil privkey")
}
})
}
func newproxy(t *testing.T) net.Listener {
proxy := NewCensoringProxy()
listener, err := proxy.Start("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
return listener
}
func newproxytls(t *testing.T) net.Listener {
proxy := NewCensoringProxy()
listener, _, err := proxy.StartTLS("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
return listener
}
func killproxy(t *testing.T, listener net.Listener) {
err := listener.Close()
if err != nil {
t.Fatal(err)
}
}
func checkdial(
t *testing.T, proxyAddr string, expectErr error,
dial func(network, address string) (net.Conn, error),
) {
conn, err := dial("tcp", proxyAddr)
if err != expectErr {
t.Fatal("not the result we expected")
}
if conn == nil && expectErr == nil {
t.Fatal("expected actionable conn")
}
if conn != nil && expectErr != nil {
t.Fatal("expected nil conn")
}
if conn != nil {
conn.Write([]byte("123454321"))
conn.Close()
}
}

View File

@ -0,0 +1,46 @@
// Package flagx contains extensions for the standard library
// flag package. The code is adapted from github.com/m-lab/go and more
// specifically from <https://git.io/JJ8UA>. This file is licensed under
// version 2.0 of the Apache License <https://git.io/JJ8Ux>.
package flagx
import (
"fmt"
"strings"
)
// StringArray is a new flag type. It appends the flag parameter to an
// `[]string` allowing the parameter to be specified multiple times or using ","
// separated items. Unlike other Flag types, the default argument should almost
// always be the empty array, because there is no way to remove an element, only
// to add one.
type StringArray []string
// Get retrieves the value contained in the flag.
func (sa StringArray) Get() interface{} {
return sa
}
// Set accepts a string parameter and appends it to the associated StringArray.
// Set attempts to split the given string on commas "," and appends each element
// to the StringArray.
func (sa *StringArray) Set(s string) error {
f := strings.Split(s, ",")
*sa = append(*sa, f...)
return nil
}
// String reports the StringArray as a Go value.
func (sa StringArray) String() string {
return fmt.Sprintf("%#v", []string(sa))
}
// Contains returns true when the given value equals one of the StringArray values.
func (sa StringArray) Contains(value string) bool {
for _, v := range sa {
if v == value {
return true
}
}
return false
}

View File

@ -0,0 +1,73 @@
package flagx_test
// The code in this file is adapted from github.com/m-lab/go and more
// specifically from <https://git.io/JJ8UA>. This file is licensed under
// version 2.0 of the Apache License <https://git.io/JJ8Ux>.
import (
"flag"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/flagx"
)
func TestStringArray(t *testing.T) {
tests := []struct {
name string
args []string
expt flagx.StringArray
repr string
contains string
wantErr bool
}{
{
name: "okay",
args: []string{"a", "b"},
expt: flagx.StringArray{"a", "b"},
repr: `[]string{"a", "b"}`,
contains: "b",
},
{
name: "okay-split-commas",
args: []string{"a", "b", "c,d"},
expt: flagx.StringArray{"a", "b", "c", "d"},
repr: `[]string{"a", "b", "c", "d"}`,
contains: "d",
},
{
name: "empty",
args: []string{},
expt: flagx.StringArray{},
repr: `[]string{}`,
contains: "a",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sa := &flagx.StringArray{}
for i := range tt.args {
if err := sa.Set(tt.args[i]); err != nil {
t.Errorf("StringArray.Set() error = %v, want nil", err)
}
}
v := (sa.Get().(flagx.StringArray))
if diff := cmp.Diff(v, tt.expt); diff != "" {
t.Errorf("StringArray.Get() unexpected differences %v", diff)
}
if tt.repr != sa.String() {
t.Errorf("StringArray.String() want = %q, got %q", tt.repr, sa.String())
}
if sa.Contains(tt.contains) == tt.wantErr {
t.Errorf("StringArray.Contains() want = %q, got %t", tt.repr, sa.Contains(tt.contains))
}
})
}
}
// Successful compilation of this function means that StringArray implements the
// flag.Getter interface. The function need not be called.
func assertFlagGetterStringArray(b flagx.StringArray) {
func(in flag.Getter) {}(&b)
}

View File

@ -0,0 +1,80 @@
// Package httpproxy contains a censoring HTTP proxy. This proxy will
// vet all the traffic and reply with 451 responses for a configurable
// set of offending Host headers in incoming requests.
package httpproxy
import (
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
)
const product = "jafar/0.1.0"
// CensoringProxy is a censoring HTTP proxy
type CensoringProxy struct {
keywords []string
transport http.RoundTripper
}
// NewCensoringProxy creates a new CensoringProxy instance using
// the specified list of keywords to censor. keywords is the list
// of keywords that trigger censorship if any of them appears in
// the Host header of a request. dnsNetwork and dnsAddress are
// settings to configure the upstream, non censored DNS.
func NewCensoringProxy(
keywords []string, uncensored netx.HTTPRoundTripper,
) *CensoringProxy {
return &CensoringProxy{keywords: keywords, transport: uncensored}
}
var blockpage = []byte(`<html><head>
<title>451 Unavailable For Legal Reasons</title>
</head><body>
<center><h1>451 Unavailable For Legal Reasons</h1></center>
<p>This content is not available in your jurisdiction.</p>
</body></html>
`)
// ServeHTTP serves HTTP requests
func (p *CensoringProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Implementation note: use Via header to detect in a loose way
// requests originated by us and directed to us
if r.Header.Get("Via") != "" || r.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
for _, pattern := range p.keywords {
if strings.Contains(r.Host, pattern) {
w.WriteHeader(http.StatusUnavailableForLegalReasons)
w.Write(blockpage)
return
}
}
r.Header.Add("Via", product) // see above
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Host: r.Host,
Scheme: "http",
})
proxy.ModifyResponse = func(resp *http.Response) error {
resp.Header.Add("Via", product) // see above
return nil
}
proxy.Transport = p.transport
proxy.ServeHTTP(w, r)
}
// Start starts the censoring proxy.
func (p *CensoringProxy) Start(address string) (*http.Server, net.Addr, error) {
server := &http.Server{Handler: p}
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, nil, err
}
go server.Serve(listener)
return server, listener.Addr(), nil
}

View File

@ -0,0 +1,130 @@
package httpproxy
import (
"bytes"
"context"
"io/ioutil"
"net"
"net/http"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/uncensored"
)
func TestPass(t *testing.T) {
server, addr := newproxy(t, "ooni.io")
// We're filtering ooni.io, so we expect example.com to pass
// through the proxy with 200 and we also expect to see the
// Via header in the responses we receive, of course.
checkrequest(t, addr.String(), "example.com", 200, true)
killproxy(t, server)
}
func TestBlock(t *testing.T) {
server, addr := newproxy(t, "ooni.io")
// Here we're filtering any domain containing ooni.io, so we
// expect the proxy to send 451 without actually proxing, thus
// there should not be any Via header in the output.
checkrequest(t, addr.String(), "mia-ps.ooni.io", 451, false)
killproxy(t, server)
}
func TestLoop(t *testing.T) {
server, addr := newproxy(t, "ooni.io")
// Here we're forcing the proxy to connect to itself. It does
// does that and recognizes itself because of the Via header
// being set in the request generated by the connection to itself,
// which should cause a 400. The response should have the Via
// header set because the 400 is received by the connection that
// this code has made to the proxy.
checkrequest(t, addr.String(), addr.String(), 400, true)
killproxy(t, server)
}
func TestListenError(t *testing.T) {
proxy := NewCensoringProxy([]string{""}, uncensored.DefaultClient)
server, addr, err := proxy.Start("8.8.8.8:80")
if err == nil {
t.Fatal("expected an error here")
}
if server != nil {
t.Fatal("expected nil server here")
}
if addr != nil {
t.Fatal("expected nil addr here")
}
}
func newproxy(t *testing.T, blocked string) (*http.Server, net.Addr) {
proxy := NewCensoringProxy([]string{blocked}, uncensored.DefaultClient)
server, addr, err := proxy.Start("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
return server, addr
}
func killproxy(t *testing.T, server *http.Server) {
err := server.Shutdown(context.Background())
if err != nil {
t.Fatal(err)
}
}
func checkrequest(
t *testing.T, proxyAddr, host string,
expectStatus int, expectVia bool,
) {
req, err := http.NewRequest("GET", "http://"+proxyAddr, nil)
if err != nil {
t.Fatal(err)
}
req.Host = host
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != expectStatus {
t.Fatal("unexpected value of status code")
}
t.Log(resp)
values, _ := resp.Header["Via"]
var foundProduct bool
for _, value := range values {
if value == product {
foundProduct = true
}
}
if foundProduct && !expectVia {
t.Fatal("unexpectedly found Via header")
}
if !foundProduct && expectVia {
t.Fatal("Via header not found")
}
proxiedData, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if expectStatus == 200 {
checkbody(t, proxiedData, host)
}
}
func checkbody(t *testing.T, proxiedData []byte, host string) {
resp, err := http.Get("http://" + host)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatal("unexpected status code")
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(data, proxiedData) == false {
t.Fatal("body mismatch")
}
}

View File

@ -0,0 +1,98 @@
// Package iptables contains code for managing firewall rules. This package
// really only works reliably on Linux. In all other systems the functionality
// in here is just a set of stubs returning errors.
package iptables
import (
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
)
type shell interface {
createChains() error
dropIfDestinationEquals(ip string) error
rstIfDestinationEqualsAndIsTCP(ip string) error
dropIfContainsKeywordHex(keyword string) error
dropIfContainsKeyword(keyword string) error
rstIfContainsKeywordHexAndIsTCP(keyword string) error
rstIfContainsKeywordAndIsTCP(keyword string) error
hijackDNS(address string) error
hijackHTTPS(address string) error
hijackHTTP(address string) error
waive() error
}
// CensoringPolicy implements a censoring policy.
type CensoringPolicy struct {
DropIPs []string // drop IP traffic to these IPs
DropKeywordsHex []string // drop IP packets with these hex keywords
DropKeywords []string // drop IP packets with these keywords
HijackDNSAddress string // where to hijack DNS to
HijackHTTPSAddress string // where to hijack HTTPS to
HijackHTTPAddress string // where to hijack HTTP to
ResetIPs []string // RST TCP/IP traffic to these IPs
ResetKeywordsHex []string // RST TCP/IP flows with these hex keywords
ResetKeywords []string // RST TCP/IP flows with these keywords
sh shell
}
// NewCensoringPolicy returns a new censoring policy.
func NewCensoringPolicy() *CensoringPolicy {
return &CensoringPolicy{
sh: newShell(),
}
}
// Apply applies the censorship policy
func (c *CensoringPolicy) Apply() (err error) {
defer func() {
if recover() != nil {
// JUST KNOW WE'VE BEEN HERE
}
}()
err = c.sh.createChains()
runtimex.PanicOnError(err, "c.sh.createChains failed")
// Implementation note: we want the RST rules to be first such
// that we end up enforcing them before the drop rules.
for _, keyword := range c.ResetKeywordsHex {
err = c.sh.rstIfContainsKeywordHexAndIsTCP(keyword)
runtimex.PanicOnError(err, "c.sh.rstIfContainsKeywordHexAndIsTCP failed")
}
for _, keyword := range c.ResetKeywords {
err = c.sh.rstIfContainsKeywordAndIsTCP(keyword)
runtimex.PanicOnError(err, "c.sh.rstIfContainsKeywordAndIsTCP failed")
}
for _, ip := range c.ResetIPs {
err = c.sh.rstIfDestinationEqualsAndIsTCP(ip)
runtimex.PanicOnError(err, "c.sh.rstIfDestinationEqualsAndIsTCP failed")
}
for _, keyword := range c.DropKeywordsHex {
err = c.sh.dropIfContainsKeywordHex(keyword)
runtimex.PanicOnError(err, "c.sh.dropIfContainsKeywordHex failed")
}
for _, keyword := range c.DropKeywords {
err = c.sh.dropIfContainsKeyword(keyword)
runtimex.PanicOnError(err, "c.sh.dropIfContainsKeyword failed")
}
for _, ip := range c.DropIPs {
err = c.sh.dropIfDestinationEquals(ip)
runtimex.PanicOnError(err, "c.sh.dropIfDestinationEquals failed")
}
if c.HijackDNSAddress != "" {
err = c.sh.hijackDNS(c.HijackDNSAddress)
runtimex.PanicOnError(err, "c.sh.hijackDNS failed")
}
if c.HijackHTTPSAddress != "" {
err = c.sh.hijackHTTPS(c.HijackHTTPSAddress)
runtimex.PanicOnError(err, "c.sh.hijackHTTPS failed")
}
if c.HijackHTTPAddress != "" {
err = c.sh.hijackHTTP(c.HijackHTTPAddress)
runtimex.PanicOnError(err, "c.sh.hijackHTTP failed")
}
return
}
// Waive removes any censorship policy
func (c *CensoringPolicy) Waive() error {
return c.sh.waive()
}

View File

@ -0,0 +1,345 @@
package iptables
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os/exec"
"runtime"
"strings"
"testing"
"time"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/resolver"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/shellx"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/uncensored"
)
func init() {
log.SetLevel(log.ErrorLevel)
}
func newCensoringPolicy() *CensoringPolicy {
policy := NewCensoringPolicy()
policy.Waive() // start over to allow for repeated tests on failure
return policy
}
func TestCannotApplyPolicy(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
policy.DropIPs = []string{"antani"}
if err := policy.Apply(); err == nil {
t.Fatal("expected an error here")
}
}
func TestCreateChainsError(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
// you should not be able to apply the policy when there is
// already a policy, you need to waive it first
if err := policy.Apply(); err == nil {
t.Fatal("expected an error here")
}
}
func TestDropIP(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
policy.DropIPs = []string{"1.1.1.1"}
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", "1.1.1.1:853")
if err == nil {
t.Fatalf("expected an error here")
}
if err.Error() != "dial tcp 1.1.1.1:853: i/o timeout" {
t.Fatal("unexpected error occurred")
}
if conn != nil {
t.Fatal("expected nil connection here")
}
}
func TestDropKeyword(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
policy.DropKeywords = []string{"ooni.io"}
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
req, err := http.NewRequest("GET", "http://www.ooni.io", nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err == nil {
t.Fatal("expected an error here")
}
if !strings.HasSuffix(err.Error(), "context deadline exceeded") {
t.Fatal("unexpected error occurred")
}
if resp != nil {
t.Fatal("expected nil response here")
}
}
func TestDropKeywordHex(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
policy.DropKeywordsHex = []string{"|6f 6f 6e 69|"}
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
req, err := http.NewRequest("GET", "http://www.ooni.io", nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err == nil {
t.Fatal("expected an error here")
}
// the error we see with GitHub Actions is different from the error
// we see when testing locally on Fedora
if !strings.HasSuffix(err.Error(), "operation not permitted") &&
!strings.HasSuffix(err.Error(), "Temporary failure in name resolution") &&
!strings.HasSuffix(err.Error(), "no such host") {
t.Fatalf("unexpected error occurred: %+v", err)
}
if resp != nil {
t.Fatal("expected nil response here")
}
}
func TestResetIP(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
policy.ResetIPs = []string{"1.1.1.1"}
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
conn, err := (&net.Dialer{}).Dial("tcp", "1.1.1.1:853")
if err == nil {
t.Fatalf("expected an error here")
}
if err.Error() != "dial tcp 1.1.1.1:853: connect: connection refused" {
t.Fatal("unexpected error occurred")
}
if conn != nil {
t.Fatal("expected nil connection here")
}
}
func TestResetKeyword(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
policy.ResetKeywords = []string{"ooni.io"}
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
resp, err := http.Get("http://www.ooni.io")
if err == nil {
t.Fatal("expected an error here")
}
if strings.Contains(err.Error(), "read: connection reset by peer") == false {
t.Fatal("unexpected error occurred")
}
if resp != nil {
t.Fatal("expected nil response here")
}
}
func TestResetKeywordHex(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
policy := newCensoringPolicy()
defer policy.Waive()
policy.ResetKeywordsHex = []string{"|6f 6f 6e 69|"}
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
resp, err := http.Get("http://www.ooni.io")
if err == nil {
t.Fatal("expected an error here")
}
if strings.Contains(err.Error(), "read: connection reset by peer") == false {
t.Fatal("unexpected error occurred")
}
if resp != nil {
t.Fatal("expected nil response here")
}
}
func TestHijackDNS(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
resolver := resolver.NewCensoringResolver(
[]string{"ooni.io"}, nil, nil,
uncensored.Must(uncensored.NewClient("dot://1.1.1.1:853")),
)
server, err := resolver.Start("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer server.Shutdown()
policy := newCensoringPolicy()
defer policy.Waive()
policy.HijackDNSAddress = server.PacketConn.LocalAddr().String()
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
addrs, err := net.LookupHost("www.ooni.io")
if err == nil {
t.Fatal("expected an error here")
}
if strings.Contains(err.Error(), "no such host") == false {
t.Fatal("unexpected error occurred")
}
if addrs != nil {
t.Fatal("expected nil addrs here")
}
}
func TestHijackHTTP(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
// Implementation note: this test is complicated by the fact
// that we are running as root and so we're whitelisted.
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(451)
}),
)
defer server.Close()
policy := newCensoringPolicy()
defer policy.Waive()
pu, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
policy.HijackHTTPAddress = pu.Host
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
err = shellx.Run("sudo", "-u", "nobody", "--",
"curl", "-sf", "http://example.com")
if err == nil {
t.Fatal("expected an error here")
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatal("not the error type we expected")
}
if exitErr.ExitCode() != 22 {
t.Fatal("not the exit code we expected")
}
}
func TestHijackHTTPS(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("not implemented on this platform")
}
if testing.Short() {
t.Skip("skip test in short mode")
}
// Implementation note: this test is complicated by the fact
// that we are running as root and so we're whitelisted.
server := httptest.NewTLSServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(451)
}),
)
defer server.Close()
policy := newCensoringPolicy()
defer policy.Waive()
pu, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
policy.HijackHTTPSAddress = pu.Host
if err := policy.Apply(); err != nil {
t.Fatal(err)
}
err = shellx.Run("sudo", "-u", "nobody", "--",
"curl", "-sf", "https://example.com")
if err == nil {
t.Fatal("expected an error here")
}
t.Log(err)
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatal("not the error type we expected")
}
if exitErr.ExitCode() != 60 {
t.Fatal("not the exit code we expected")
}
}

View File

@ -0,0 +1,117 @@
// +build linux
package iptables
import (
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/shellx"
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
)
type linuxShell struct{}
func (s *linuxShell) createChains() (err error) {
defer func() {
if recover() != nil {
// JUST KNOW WE'VE BEEN HERE
}
}()
err = shellx.Run("sudo", "iptables", "-N", "JAFAR_INPUT")
runtimex.PanicOnError(err, "cannot create JAFAR_INPUT chain")
err = shellx.Run("sudo", "iptables", "-N", "JAFAR_OUTPUT")
runtimex.PanicOnError(err, "cannot create JAFAR_OUTPUT chain")
err = shellx.Run("sudo", "iptables", "-t", "nat", "-N", "JAFAR_NAT_OUTPUT")
runtimex.PanicOnError(err, "cannot create JAFAR_NAT_OUTPUT chain")
err = shellx.Run("sudo", "iptables", "-I", "OUTPUT", "-j", "JAFAR_OUTPUT")
runtimex.PanicOnError(err, "cannot insert jump to JAFAR_OUTPUT")
err = shellx.Run("sudo", "iptables", "-I", "INPUT", "-j", "JAFAR_INPUT")
runtimex.PanicOnError(err, "cannot insert jump to JAFAR_INPUT")
err = shellx.Run("sudo", "iptables", "-t", "nat", "-I", "OUTPUT", "-j", "JAFAR_NAT_OUTPUT")
runtimex.PanicOnError(err, "cannot insert jump to JAFAR_NAT_OUTPUT")
return nil
}
func (s *linuxShell) dropIfDestinationEquals(ip string) error {
return shellx.Run("sudo", "iptables", "-A", "JAFAR_OUTPUT", "-d", ip, "-j", "DROP")
}
func (s *linuxShell) rstIfDestinationEqualsAndIsTCP(ip string) error {
return shellx.Run(
"sudo", "iptables", "-A", "JAFAR_OUTPUT", "--proto", "tcp", "-d", ip,
"-j", "REJECT", "--reject-with", "tcp-reset",
)
}
func (s *linuxShell) dropIfContainsKeywordHex(keyword string) error {
return shellx.Run(
"sudo", "iptables", "-A", "JAFAR_OUTPUT", "-m", "string", "--algo", "kmp",
"--hex-string", keyword, "-j", "DROP",
)
}
func (s *linuxShell) dropIfContainsKeyword(keyword string) error {
return shellx.Run(
"sudo", "iptables", "-A", "JAFAR_OUTPUT", "-m", "string", "--algo", "kmp",
"--string", keyword, "-j", "DROP",
)
}
func (s *linuxShell) rstIfContainsKeywordHexAndIsTCP(keyword string) error {
return shellx.Run(
"sudo", "iptables", "-A", "JAFAR_OUTPUT", "-m", "string", "--proto", "tcp", "--algo",
"kmp", "--hex-string", keyword, "-j", "REJECT", "--reject-with", "tcp-reset",
)
}
func (s *linuxShell) rstIfContainsKeywordAndIsTCP(keyword string) error {
return shellx.Run(
"sudo", "iptables", "-A", "JAFAR_OUTPUT", "-m", "string", "--proto", "tcp", "--algo",
"kmp", "--string", keyword, "-j", "REJECT", "--reject-with", "tcp-reset",
)
}
func (s *linuxShell) hijackDNS(address string) error {
// Hijack any DNS query, like the Vodafone station does when using the
// secure network feature. Our transparent proxies will use DoT, in order
// to bypass this restriction and avoid routing loop.
return shellx.Run(
"sudo", "iptables", "-t", "nat", "-A", "JAFAR_NAT_OUTPUT", "-p", "udp",
"--dport", "53", "-j", "DNAT", "--to", address,
)
}
func (s *linuxShell) hijackHTTPS(address string) error {
// We need to whitelist root otherwise the traffic sent by Jafar
// itself will match the rule and loop.
return shellx.Run(
"sudo", "iptables", "-t", "nat", "-A", "JAFAR_NAT_OUTPUT", "-p", "tcp",
"--dport", "443", "-m", "owner", "!", "--uid-owner", "0",
"-j", "DNAT", "--to", address,
)
}
func (s *linuxShell) hijackHTTP(address string) error {
// We need to whitelist root otherwise the traffic sent by Jafar
// itself will match the rule and loop.
return shellx.Run(
"sudo", "iptables", "-t", "nat", "-A", "JAFAR_NAT_OUTPUT", "-p", "tcp",
"--dport", "80", "-m", "owner", "!", "--uid-owner", "0",
"-j", "DNAT", "--to", address,
)
}
func (s *linuxShell) waive() error {
shellx.RunQuiet("sudo", "iptables", "-D", "OUTPUT", "-j", "JAFAR_OUTPUT")
shellx.RunQuiet("sudo", "iptables", "-D", "INPUT", "-j", "JAFAR_INPUT")
shellx.RunQuiet("sudo", "iptables", "-t", "nat", "-D", "OUTPUT", "-j", "JAFAR_NAT_OUTPUT")
shellx.RunQuiet("sudo", "iptables", "-F", "JAFAR_INPUT")
shellx.RunQuiet("sudo", "iptables", "-X", "JAFAR_INPUT")
shellx.RunQuiet("sudo", "iptables", "-F", "JAFAR_OUTPUT")
shellx.RunQuiet("sudo", "iptables", "-X", "JAFAR_OUTPUT")
shellx.RunQuiet("sudo", "iptables", "-t", "nat", "-F", "JAFAR_NAT_OUTPUT")
shellx.RunQuiet("sudo", "iptables", "-t", "nat", "-X", "JAFAR_NAT_OUTPUT")
return nil
}
func newShell() *linuxShell {
return &linuxShell{}
}

View File

@ -0,0 +1,45 @@
// +build !linux
package iptables
import "errors"
type otherwiseShell struct{}
func (*otherwiseShell) createChains() error {
return errors.New("not implemented")
}
func (*otherwiseShell) dropIfDestinationEquals(ip string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) rstIfDestinationEqualsAndIsTCP(ip string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) dropIfContainsKeywordHex(keyword string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) dropIfContainsKeyword(keyword string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) rstIfContainsKeywordHexAndIsTCP(keyword string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) rstIfContainsKeywordAndIsTCP(keyword string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) hijackDNS(address string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) hijackHTTPS(address string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) hijackHTTP(address string) error {
return errors.New("not implemented")
}
func (*otherwiseShell) waive() error {
return errors.New("not implemented")
}
func newShell() *otherwiseShell {
return &otherwiseShell{}
}

View File

@ -0,0 +1,286 @@
// Jafar is a censorship simulation tool used for testing OONI.
package main
import (
"encoding/pem"
"errors"
"flag"
"fmt"
"io/ioutil"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"github.com/apex/log"
"github.com/apex/log/handlers/cli"
"github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/badproxy"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/flagx"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/httpproxy"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/iptables"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/resolver"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/shellx"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/tlsproxy"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/uncensored"
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
)
var (
badProxyAddress *string
badProxyAddressTLS *string
badProxyTLSOutputCA *string
dnsProxyAddress *string
dnsProxyBlock flagx.StringArray
dnsProxyHijack flagx.StringArray
dnsProxyIgnore flagx.StringArray
httpProxyAddress *string
httpProxyBlock flagx.StringArray
iptablesDropIP flagx.StringArray
iptablesDropKeywordHex flagx.StringArray
iptablesDropKeyword flagx.StringArray
iptablesHijackDNSTo *string
iptablesHijackHTTPSTo *string
iptablesHijackHTTPTo *string
iptablesResetIP flagx.StringArray
iptablesResetKeywordHex flagx.StringArray
iptablesResetKeyword flagx.StringArray
mainCh chan os.Signal
mainCommand *string
mainUser *string
tag *string
tlsProxyAddress *string
tlsProxyBlock flagx.StringArray
uncensoredResolverURL *string
)
func init() {
// badProxy
badProxyAddress = flag.String(
"bad-proxy-address", "127.0.0.1:7117",
"Address where to listen for TCP connections",
)
badProxyAddressTLS = flag.String(
"bad-proxy-address-tls", "127.0.0.1:4114",
"Address where to listen for TLS connections",
)
badProxyTLSOutputCA = flag.String(
"bad-proxy-tls-output-ca", "badproxy.pem",
"File where to write the CA used by the bad proxy",
)
// dnsProxy
dnsProxyAddress = flag.String(
"dns-proxy-address", "127.0.0.1:53",
"Address where the DNS proxy should listen",
)
flag.Var(
&dnsProxyBlock, "dns-proxy-block",
"Register keyword triggering NXDOMAIN censorship",
)
flag.Var(
&dnsProxyHijack, "dns-proxy-hijack",
"Register keyword triggering redirection to 127.0.0.1",
)
flag.Var(
&dnsProxyIgnore, "dns-proxy-ignore",
"Register keyword causing the proxy to ignore the query",
)
// httpProxy
httpProxyAddress = flag.String(
"http-proxy-address", "127.0.0.1:80",
"Address where the HTTP proxy should listen",
)
flag.Var(
&httpProxyBlock, "http-proxy-block",
"Register keyword triggering HTTP 451 censorship",
)
// iptables
flag.Var(
&iptablesDropIP, "iptables-drop-ip",
"Drop traffic to the specified IP address",
)
flag.Var(
&iptablesDropKeywordHex, "iptables-drop-keyword-hex",
"Drop traffic containing the specified keyword in hex",
)
flag.Var(
&iptablesDropKeyword, "iptables-drop-keyword",
"Drop traffic containing the specified keyword",
)
iptablesHijackDNSTo = flag.String(
"iptables-hijack-dns-to", "",
"Hijack all DNS UDP traffic to the specified endpoint",
)
iptablesHijackHTTPSTo = flag.String(
"iptables-hijack-https-to", "",
"Hijack all HTTPS traffic to the specified endpoint",
)
iptablesHijackHTTPTo = flag.String(
"iptables-hijack-http-to", "",
"Hijack all HTTP traffic to the specified endpoint",
)
flag.Var(
&iptablesResetIP, "iptables-reset-ip",
"Reset TCP/IP traffic to the specified IP address",
)
flag.Var(
&iptablesResetKeywordHex, "iptables-reset-keyword-hex",
"Reset TCP/IP traffic containing the specified keyword in hex",
)
flag.Var(
&iptablesResetKeyword, "iptables-reset-keyword",
"Reset TCP/IP traffic containing the specified keyword",
)
// main
mainCh = make(chan os.Signal, 1)
signal.Notify(
mainCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT,
)
mainCommand = flag.String("main-command", "", "Optional command to execute")
mainUser = flag.String("main-user", "nobody", "Run command as user")
// tag
tag = flag.String("tag", "", "Add tag to a specific run")
// tlsProxy
tlsProxyAddress = flag.String(
"tls-proxy-address", "127.0.0.1:443",
"Address where the HTTP proxy should listen",
)
flag.Var(
&tlsProxyBlock, "tls-proxy-block",
"Register keyword triggering TLS censorship",
)
// uncensored
uncensoredResolverURL = flag.String(
"uncensored-resolver-url", "dot://1.1.1.1:853",
"URL of an hopefully uncensored resolver",
)
}
func badProxyStart() net.Listener {
proxy := badproxy.NewCensoringProxy()
listener, err := proxy.Start(*badProxyAddress)
runtimex.PanicOnError(err, "proxy.Start failed")
return listener
}
func badProxyStartTLS() net.Listener {
proxy := badproxy.NewCensoringProxy()
listener, cert, err := proxy.StartTLS(*badProxyAddressTLS)
runtimex.PanicOnError(err, "proxy.StartTLS failed")
err = ioutil.WriteFile(*badProxyTLSOutputCA, pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}), 0644)
runtimex.PanicOnError(err, "ioutil.WriteFile failed")
return listener
}
func dnsProxyStart(uncensored *uncensored.Client) *dns.Server {
proxy := resolver.NewCensoringResolver(
dnsProxyBlock, dnsProxyHijack, dnsProxyIgnore, uncensored,
)
server, err := proxy.Start(*dnsProxyAddress)
runtimex.PanicOnError(err, "proxy.Start failed")
return server
}
func httpProxyStart(uncensored *uncensored.Client) *http.Server {
proxy := httpproxy.NewCensoringProxy(httpProxyBlock, uncensored)
server, _, err := proxy.Start(*httpProxyAddress)
runtimex.PanicOnError(err, "proxy.Start failed")
return server
}
func iptablesStart() *iptables.CensoringPolicy {
policy := iptables.NewCensoringPolicy()
// For robustness waive the policy so we start afresh
policy.Waive()
policy.DropIPs = iptablesDropIP
policy.DropKeywordsHex = iptablesDropKeywordHex
policy.DropKeywords = iptablesDropKeyword
policy.HijackDNSAddress = *iptablesHijackDNSTo
policy.HijackHTTPSAddress = *iptablesHijackHTTPSTo
policy.HijackHTTPAddress = *iptablesHijackHTTPTo
policy.ResetIPs = iptablesResetIP
policy.ResetKeywordsHex = iptablesResetKeywordHex
policy.ResetKeywords = iptablesResetKeyword
err := policy.Apply()
runtimex.PanicOnError(err, "policy.Apply failed")
return policy
}
func tlsProxyStart(uncensored *uncensored.Client) net.Listener {
proxy := tlsproxy.NewCensoringProxy(tlsProxyBlock, uncensored)
listener, err := proxy.Start(*tlsProxyAddress)
runtimex.PanicOnError(err, "proxy.Start failed")
return listener
}
func newUncensoredClient() *uncensored.Client {
clnt, err := uncensored.NewClient(*uncensoredResolverURL)
runtimex.PanicOnError(err, "uncensored.NewClient failed")
return clnt
}
func mustx(err error, message string, osExit func(int)) {
if err != nil {
var (
exitcode = 1
exiterr *exec.ExitError
)
if errors.As(err, &exiterr) {
exitcode = exiterr.ExitCode()
}
log.Errorf("%s", message)
osExit(exitcode)
}
}
func main() {
flag.Parse()
// TODO(bassosimone): we may want a verbose flag
log.SetLevel(log.InfoLevel)
log.SetHandler(cli.Default)
log.Infof("jafar command line: [%s]", strings.Join(os.Args, ", "))
log.Infof("jafar tag: %s", *tag)
uncensoredClient := newUncensoredClient()
defer uncensoredClient.CloseIdleConnections()
badlistener := badProxyStart()
defer badlistener.Close()
badtlslistener := badProxyStartTLS()
defer badtlslistener.Close()
dnsproxy := dnsProxyStart(uncensoredClient)
defer dnsproxy.Shutdown()
httpproxy := httpProxyStart(uncensoredClient)
defer httpproxy.Close()
tlslistener := tlsProxyStart(uncensoredClient)
defer tlslistener.Close()
policy := iptablesStart()
var err error
if *mainCommand != "" {
err = shellx.RunCommandline(fmt.Sprintf(
"sudo -u '%s' -- %s", *mainUser, *mainCommand,
))
} else {
<-mainCh
}
policy.Waive()
mustx(err, "subcommand failed", os.Exit)
}

View File

@ -0,0 +1,89 @@
package main
import (
"errors"
"os"
"runtime"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/iptables"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/shellx"
)
func ensureWeStartOverWithIPTables() {
iptables.NewCensoringPolicy().Waive()
}
func TestNoCommand(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("skip test on non Linux systems")
}
ensureWeStartOverWithIPTables()
*dnsProxyAddress = "127.0.0.1:0"
*httpProxyAddress = "127.0.0.1:0"
*tlsProxyAddress = "127.0.0.1:0"
go func() {
mainCh <- os.Interrupt
}()
main()
}
func TestWithCommand(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("skip test on non Linux systems")
}
ensureWeStartOverWithIPTables()
*dnsProxyAddress = "127.0.0.1:0"
*httpProxyAddress = "127.0.0.1:0"
*tlsProxyAddress = "127.0.0.1:0"
*mainCommand = "whoami"
defer func() {
*mainCommand = ""
}()
main()
}
func TestMustx(t *testing.T) {
t.Run("with no error", func(t *testing.T) {
var called int
mustx(nil, "", func(int) {
called++
})
if called != 0 {
t.Fatal("should not happen")
}
})
t.Run("with non-exit-code error", func(t *testing.T) {
var (
called int
exitcode int
)
mustx(errors.New("antani"), "", func(ec int) {
called++
exitcode = ec
})
if called != 1 {
t.Fatal("not called?!")
}
if exitcode != 1 {
t.Fatal("unexpected exitcode value")
}
})
t.Run("with exit-code error", func(t *testing.T) {
var (
called int
exitcode int
)
err := shellx.Run("curl", "-sf", "") // cause exitcode == 3
mustx(err, "", func(ec int) {
called++
exitcode = ec
})
if called != 1 {
t.Fatal("not called?!")
}
if exitcode != 3 {
t.Fatal("unexpected exitcode value")
}
})
}

View File

@ -0,0 +1,130 @@
// Package resolver contains a censoring DNS resolver. Most queries are
// answered without censorship, but selected queries could either be
// discarded or replied to with a bogon or NXDOMAIN answer.
package resolver
import (
"context"
"net"
"strings"
"github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
)
// CensoringResolver is a censoring resolver.
type CensoringResolver struct {
blocked []string
hijacked []string
ignored []string
lookupHost func(ctx context.Context, host string) ([]string, error)
}
// NewCensoringResolver creates a new CensoringResolver instance using
// the specified list of keywords to censor. blocked is the list of
// keywords that trigger NXDOMAIN if they appear in a query. hijacked
// is similar but redirects to 127.0.0.1, where the transparent HTTP
// and TLS proxies will pick them up. dnsNetwork and dnsAddress are the
// settings to configure the upstream, non censored DNS.
func NewCensoringResolver(
blocked, hijacked, ignored []string, uncensored netx.Resolver,
) *CensoringResolver {
return &CensoringResolver{
blocked: blocked,
hijacked: hijacked,
ignored: ignored,
lookupHost: uncensored.LookupHost,
}
}
func (r *CensoringResolver) roundtrip(rw dns.ResponseWriter, req *dns.Msg) {
name := req.Question[0].Name
addrs, err := r.lookupHost(context.Background(), name)
var ips []net.IP
if err == nil {
for _, addr := range addrs {
if ip := net.ParseIP(addr); ip != nil {
ips = append(ips, ip)
}
}
}
r.reply(rw, req, ips)
}
func (r *CensoringResolver) reply(
rw dns.ResponseWriter, req *dns.Msg, ips []net.IP,
) {
m := new(dns.Msg)
m.Compress = true
m.MsgHdr.RecursionAvailable = true
m.SetReply(req)
for _, ip := range ips {
ipv6 := strings.Contains(ip.String(), ":")
if !ipv6 && req.Question[0].Qtype == dns.TypeA {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: req.Question[0].Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 0,
},
A: ip,
})
}
}
if m.Answer == nil {
m.SetRcode(req, dns.RcodeNameError)
}
rw.WriteMsg(m)
}
func (r *CensoringResolver) failure(rw dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.Compress = true
m.MsgHdr.RecursionAvailable = true
m.SetRcode(req, dns.RcodeServerFailure)
rw.WriteMsg(m)
}
// ServeDNS serves a DNS request
func (r *CensoringResolver) ServeDNS(rw dns.ResponseWriter, req *dns.Msg) {
if len(req.Question) < 1 {
r.failure(rw, req)
return
}
name := req.Question[0].Name
for _, pattern := range r.blocked {
if strings.Contains(name, pattern) {
r.reply(rw, req, nil)
return
}
}
for _, pattern := range r.hijacked {
if strings.Contains(name, pattern) {
r.reply(rw, req, []net.IP{net.IPv4(127, 0, 0, 1)})
return
}
}
for _, pattern := range r.ignored {
if strings.Contains(name, pattern) {
return
}
}
r.roundtrip(rw, req)
}
// Start starts the DNS resolver
func (r *CensoringResolver) Start(address string) (*dns.Server, error) {
packetconn, err := net.ListenPacket("udp", address)
if err != nil {
return nil, err
}
server := &dns.Server{
Addr: address,
Handler: r,
Net: "udp",
PacketConn: packetconn,
}
go server.ActivateAndServe()
return server, nil
}

View File

@ -0,0 +1,173 @@
package resolver
import (
"strings"
"testing"
"github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/uncensored"
)
func TestPass(t *testing.T) {
server := newresolver(t, []string{"ooni.io"}, []string{"ooni.nu"}, nil)
checkrequest(t, server, "example.com", "success", nil)
killserver(t, server)
}
func TestBlock(t *testing.T) {
server := newresolver(t, []string{"ooni.io"}, []string{"ooni.nu"}, nil)
checkrequest(t, server, "mia-ps.ooni.io", "blocked", nil)
killserver(t, server)
}
func TestRedirect(t *testing.T) {
server := newresolver(t, []string{"ooni.io"}, []string{"ooni.nu"}, nil)
checkrequest(t, server, "hkgmetadb.ooni.nu", "hijacked", nil)
killserver(t, server)
}
func TestIgnore(t *testing.T) {
server := newresolver(t, nil, nil, []string{"ooni.nu"})
iotimeout := "i/o timeout"
checkrequest(t, server, "hkgmetadb.ooni.nu", "hijacked", &iotimeout)
killserver(t, server)
}
func TestLookupFailure(t *testing.T) {
server := newresolver(t, nil, nil, nil)
// we should receive same response as when we're blocked
checkrequest(t, server, "example.antani", "blocked", nil)
killserver(t, server)
}
func TestFailureNoQuestion(t *testing.T) {
resolver := NewCensoringResolver(
nil, nil, nil, uncensored.DefaultClient,
)
resolver.ServeDNS(&fakeResponseWriter{t: t}, new(dns.Msg))
}
func TestListenFailure(t *testing.T) {
resolver := NewCensoringResolver(
nil, nil, nil, uncensored.DefaultClient,
)
server, err := resolver.Start("8.8.8.8:53")
if err == nil {
t.Fatal("expected an error here")
}
if server != nil {
t.Fatal("expected nil server here")
}
}
func newresolver(t *testing.T, blocked, hijacked, ignored []string) *dns.Server {
resolver := NewCensoringResolver(
blocked, hijacked, ignored,
// using faster dns because dot here causes miekg/dns's
// dns.Exchange to timeout and I don't want more complexity
uncensored.Must(uncensored.NewClient("system:///")),
)
server, err := resolver.Start("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
return server
}
func killserver(t *testing.T, server *dns.Server) {
err := server.Shutdown()
if err != nil {
t.Fatal(err)
}
}
func checkrequest(
t *testing.T, server *dns.Server, host string, expectStatus string,
expectErrorSuffix *string,
) {
address := server.PacketConn.LocalAddr().String()
query := newquery(host)
reply, err := dns.Exchange(query, address)
if err != nil {
if expectErrorSuffix != nil &&
strings.HasSuffix(err.Error(), *expectErrorSuffix) {
return
}
t.Fatal(err)
}
switch expectStatus {
case "success":
checksuccess(t, reply)
case "hijacked":
checkhijacked(t, reply)
case "blocked":
checkblocked(t, reply)
default:
panic("unexpected value")
}
}
func checksuccess(t *testing.T, reply *dns.Msg) {
if reply.Rcode != dns.RcodeSuccess {
t.Fatal("unexpected rcode")
}
if len(reply.Answer) < 1 {
t.Fatal("too few answers")
}
for _, answer := range reply.Answer {
if rr, ok := answer.(*dns.A); ok {
if rr.A.String() == "127.0.0.1" {
t.Fatal("unexpected hijacked response here")
}
}
}
}
func checkhijacked(t *testing.T, reply *dns.Msg) {
if reply.Rcode != dns.RcodeSuccess {
t.Fatal("unexpected rcode")
}
if len(reply.Answer) < 1 {
t.Fatal("too few answers")
}
for _, answer := range reply.Answer {
if rr, ok := answer.(*dns.A); ok {
if rr.A.String() != "127.0.0.1" {
t.Fatal("unexpected non-hijacked response here")
}
}
}
}
func checkblocked(t *testing.T, reply *dns.Msg) {
if reply.Rcode != dns.RcodeNameError {
t.Fatal("unexpected rcode")
}
if len(reply.Answer) >= 1 {
t.Fatal("too many answers")
}
}
func newquery(name string) *dns.Msg {
query := new(dns.Msg)
query.Id = dns.Id()
query.RecursionDesired = true
query.Question = append(query.Question, dns.Question{
Name: dns.Fqdn(name),
Qclass: dns.ClassINET,
Qtype: dns.TypeA,
})
return query
}
type fakeResponseWriter struct {
dns.ResponseWriter
t *testing.T
}
func (rw *fakeResponseWriter) WriteMsg(m *dns.Msg) error {
if m.Rcode != dns.RcodeServerFailure {
rw.t.Fatal("unexpected rcode")
}
return nil
}

View File

@ -0,0 +1,66 @@
// Package shellx contains utilities to run external commands.
package shellx
import (
"errors"
"os"
"os/exec"
"strings"
"github.com/apex/log"
"github.com/google/shlex"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
type runconfig struct {
args []string
loginfof func(format string, v ...interface{})
name string
stdout *os.File
stderr *os.File
}
func run(config runconfig) error {
config.loginfof("exec: %s %s", config.name, strings.Join(config.args, " "))
cmd := exec.Command(config.name, config.args...)
cmd.Stdout = config.stdout
cmd.Stderr = config.stderr
err := cmd.Run()
config.loginfof("exec result: %+v", err)
return err
}
// Run executes the specified command with the specified args
func Run(name string, arg ...string) error {
return run(runconfig{
args: arg,
loginfof: log.Log.Infof,
name: name,
stdout: os.Stdout,
stderr: os.Stderr,
})
}
// RunQuiet is like Run but it does not emit any output.
func RunQuiet(name string, arg ...string) error {
return run(runconfig{
args: arg,
loginfof: model.DiscardLogger.Infof,
name: name,
stdout: nil,
stderr: nil,
})
}
// RunCommandline is like Run but its only argument is a command
// line that will be splitted using the google/shlex package
func RunCommandline(cmdline string) error {
args, err := shlex.Split(cmdline)
if err != nil {
return err
}
if len(args) < 1 {
return errors.New("shellx: no command to execute")
}
return Run(args[0], args[1:]...)
}

View File

@ -0,0 +1,44 @@
package shellx
import "testing"
func TestRun(t *testing.T) {
if err := Run("whoami"); err != nil {
t.Fatal(err)
}
if err := Run("./nonexistent/command"); err == nil {
t.Fatal("expected an error here")
}
}
func TestRunQuiet(t *testing.T) {
if err := RunQuiet("true"); err != nil {
t.Fatal(err)
}
if err := RunQuiet("./nonexistent/command"); err == nil {
t.Fatal("expected an error here")
}
}
func TestRunCommandline(t *testing.T) {
t.Run("when the command does not parse", func(t *testing.T) {
if err := RunCommandline(`"foobar`); err == nil {
t.Fatal("expected an error here")
}
})
t.Run("when we have no arguments", func(t *testing.T) {
if err := RunCommandline(""); err == nil {
t.Fatal("expected an error here")
}
})
t.Run("when we have a single argument", func(t *testing.T) {
if err := RunCommandline("ls"); err != nil {
t.Fatal(err)
}
})
t.Run("when we have more than one argument", func(t *testing.T) {
if err := RunCommandline("ls ."); err != nil {
t.Fatal(err)
}
})
}

View File

@ -0,0 +1,194 @@
// Package tlsproxy contains a censoring TLS proxy. Most traffic is passed
// through using the SNI to choose the hostname to connect to. Specific offending
// SNIs are censored by returning a TLS alert to the client.
package tlsproxy
import (
"context"
"crypto/tls"
"errors"
"net"
"strings"
"sync"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
)
// CensoringProxy is a censoring TLS proxy
type CensoringProxy struct {
keywords []string
dial func(network, address string) (net.Conn, error)
}
// NewCensoringProxy creates a new CensoringProxy instance using
// the specified list of keywords to censor. keywords is the list
// of keywords that trigger censorship if any of them appears in
// the SNII record of a ClientHello. dnsNetwork and dnsAddress are
// settings to configure the upstream, non censored DNS.
func NewCensoringProxy(
keywords []string, uncensored netx.Dialer,
) *CensoringProxy {
return &CensoringProxy{
keywords: keywords,
dial: func(network, address string) (net.Conn, error) {
return uncensored.DialContext(context.Background(), network, address)
},
}
}
// handshakeReader is a hack to perform the initial part of the
// TLS handshake so to know the SNI and then replay the bytes of
// this initial part of the handshake with the server.
type handshakeReader struct {
net.Conn
incoming []byte
}
// Read saves the initial bytes of the handshake such that later
// we can replay the handshake with the real TLS server.
func (c *handshakeReader) Read(b []byte) (int, error) {
count, err := c.Conn.Read(b)
if err == nil {
c.incoming = append(c.incoming, b[:count]...)
}
return count, err
}
// Write prevents writing on the real connection
func (c *handshakeReader) Write(b []byte) (int, error) {
return 0, errors.New("cannot write on this connection")
}
// forward forwards left traffic to right
func forward(wg *sync.WaitGroup, left, right net.Conn) {
data := make([]byte, 1<<18)
for {
n, err := left.Read(data)
if err != nil {
break
}
if _, err = right.Write(data[:n]); err != nil {
break
}
}
wg.Done()
}
// reset closes the connection with a RST segment
func reset(conn net.Conn) {
if tc, ok := conn.(*net.TCPConn); ok {
tc.SetLinger(0)
}
conn.Close()
}
// alertclose sends a TLS alert and then closes the connection
func alertclose(conn net.Conn) {
alertdata := []byte{
21, // alert
3, // version[0]
3, // version[1]
0, // length[0]
2, // length[1]
2, // fatal
80, // internal error
}
conn.Write(alertdata)
conn.Close()
}
// getsni attempts the handshakeReader hack to obtain the SNI by reading
// the beginning of the TLS handshake. On success a nonempty SNI string
// is returned. Otherwise we cannot distinguish between the absence of a
// SNI and any other reading network error that may have occurred.
func getsni(conn *handshakeReader) string {
var (
sni string
mutex sync.Mutex // just for safety
)
tls.Server(conn, &tls.Config{
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
mutex.Lock()
sni = info.ServerName
mutex.Unlock()
return nil, errors.New("tlsproxy: we can't really continue handshake")
},
}).Handshake()
return sni
}
func (p *CensoringProxy) connectingToMyself(conn net.Conn) bool {
local := conn.LocalAddr().String()
localAddr, _, localErr := net.SplitHostPort(local)
remote := conn.RemoteAddr().String()
remoteAddr, _, remoteErr := net.SplitHostPort(remote)
return localErr != nil || remoteErr != nil || localAddr == remoteAddr
}
// handle implements the TLS SNI proxy
func (p *CensoringProxy) handle(clientconn net.Conn) {
hr := &handshakeReader{Conn: clientconn}
sni := getsni(hr)
if sni == "" {
log.Warn("tlsproxy: network failure or SNI not provided")
reset(clientconn)
return
}
for _, pattern := range p.keywords {
if strings.Contains(sni, pattern) {
log.Warnf("tlsproxy: reject SNI by policy: %s", sni)
alertclose(clientconn)
return
}
}
serverconn, err := p.dial("tcp", net.JoinHostPort(sni, "443"))
if err != nil {
log.WithError(err).Warn("tlsproxy: p.dial failed")
alertclose(clientconn)
return
}
if p.connectingToMyself(serverconn) {
log.Warn("tlsproxy: connecting to myself")
alertclose(clientconn)
return
}
if _, err := serverconn.Write(hr.incoming); err != nil {
log.WithError(err).Warn("tlsproxy: serverconn.Write failed")
alertclose(clientconn)
return
}
log.Debugf("tlsproxy: routing for %s", sni)
defer clientconn.Close()
defer serverconn.Close()
var wg sync.WaitGroup
wg.Add(2)
go forward(&wg, clientconn, serverconn)
go forward(&wg, serverconn, clientconn)
wg.Wait()
}
func (p *CensoringProxy) run(listener net.Listener) {
for {
conn, err := listener.Accept()
if err != nil && strings.Contains(
err.Error(), "use of closed network connection") {
return
}
if err == nil {
// It's difficult to make accept fail, so restructure
// the code such that we enter into the happy path
go p.handle(conn)
}
}
}
// Start starts the censoring proxy.
func (p *CensoringProxy) Start(address string) (net.Listener, error) {
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, err
}
go p.run(listener)
return listener, nil
}

View File

@ -0,0 +1,183 @@
package tlsproxy
import (
"crypto/tls"
"errors"
"net"
"sync"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/jafar/uncensored"
)
func TestPass(t *testing.T) {
listener := newproxy(t, "ooni.io")
checkdialtls(t, listener.Addr().String(), true, &tls.Config{
ServerName: "example.com",
})
killproxy(t, listener)
}
func TestBlock(t *testing.T) {
listener := newproxy(t, "ooni.io")
checkdialtls(t, listener.Addr().String(), false, &tls.Config{
ServerName: "mia-ps.ooni.io",
})
killproxy(t, listener)
}
func TestNoSNI(t *testing.T) {
listener := newproxy(t, "ooni.io")
checkdialtls(t, listener.Addr().String(), false, &tls.Config{
ServerName: "",
})
killproxy(t, listener)
}
func TestInvalidDomain(t *testing.T) {
listener := newproxy(t, "ooni.io")
checkdialtls(t, listener.Addr().String(), false, &tls.Config{
ServerName: "antani.local",
})
killproxy(t, listener)
}
func TestFailHandshake(t *testing.T) {
listener := newproxy(t, "ooni.io")
checkdialtls(t, listener.Addr().String(), false, &tls.Config{
ServerName: "expired.badssl.com",
})
killproxy(t, listener)
}
func TestFailConnectingToSelf(t *testing.T) {
proxy := &CensoringProxy{
dial: func(network string, address string) (net.Conn, error) {
return &mockedConnWriteError{}, nil
},
}
listener, err := proxy.Start("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
if listener == nil {
t.Fatal("expected non nil listener here")
}
checkdialtls(t, listener.Addr().String(), false, &tls.Config{
ServerName: "www.google.com",
})
killproxy(t, listener)
}
func TestFailWriteAfterConnect(t *testing.T) {
proxy := &CensoringProxy{
dial: func(network string, address string) (net.Conn, error) {
return &mockedConnWriteError{
// must be different or it refuses connecting to self
localIP: net.IPv4(127, 0, 0, 1),
remoteIP: net.IPv4(127, 0, 0, 2),
}, nil
},
}
listener, err := proxy.Start("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
if listener == nil {
t.Fatal("expected non nil listener here")
}
checkdialtls(t, listener.Addr().String(), false, &tls.Config{
ServerName: "www.google.com",
})
killproxy(t, listener)
}
func TestListenError(t *testing.T) {
proxy := NewCensoringProxy(
[]string{""}, uncensored.DefaultClient,
)
listener, err := proxy.Start("8.8.8.8:80")
if err == nil {
t.Fatal("expected an error here")
}
if listener != nil {
t.Fatal("expected nil listener here")
}
}
func newproxy(t *testing.T, blocked string) net.Listener {
proxy := NewCensoringProxy(
[]string{blocked}, uncensored.DefaultClient,
)
listener, err := proxy.Start("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
return listener
}
func killproxy(t *testing.T, listener net.Listener) {
err := listener.Close()
if err != nil {
t.Fatal(err)
}
}
func checkdialtls(
t *testing.T, proxyAddr string, expectSuccess bool, config *tls.Config,
) {
conn, err := tls.Dial("tcp", proxyAddr, config)
if err != nil && expectSuccess {
t.Fatal(err)
}
if err == nil && !expectSuccess {
t.Fatal("expected failure here")
}
if conn == nil && expectSuccess {
t.Fatal("expected actionable conn")
}
if conn != nil && !expectSuccess {
t.Fatal("expected nil conn")
}
if conn != nil {
conn.Close()
}
}
type mockedConnWriteError struct {
net.Conn
localIP net.IP
remoteIP net.IP
}
func (c *mockedConnWriteError) Write(b []byte) (int, error) {
return 0, errors.New("cannot write sorry")
}
func (c *mockedConnWriteError) LocalAddr() net.Addr {
return &net.TCPAddr{
IP: c.localIP,
}
}
func (c *mockedConnWriteError) RemoteAddr() net.Addr {
return &net.TCPAddr{
IP: c.remoteIP,
}
}
func TestForwardWriteError(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
forward(&wg, &mockedConnReadOkay{}, &mockedConnWriteError{})
}
type mockedConnReadOkay struct {
net.Conn
localIP net.IP
remoteIP net.IP
}
func (c *mockedConnReadOkay) Read(b []byte) (int, error) {
return len(b), nil
}

View File

@ -0,0 +1,86 @@
// Package uncensored contains code used by Jafar to evade its own
// censorship efforts by taking alternate routes.
package uncensored
import (
"context"
"net"
"net/http"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
)
// Client is DNS, HTTP, and TCP client.
type Client struct {
dnsClient *netx.DNSClient
httpTransport netx.HTTPRoundTripper
dialer netx.Dialer
}
// NewClient creates a new Client.
func NewClient(resolverURL string) (*Client, error) {
configuration, err := urlgetter.Configurer{
Config: urlgetter.Config{
ResolverURL: resolverURL,
},
Logger: log.Log,
}.NewConfiguration()
if err != nil {
return nil, err
}
return &Client{
dnsClient: &configuration.DNSClient,
httpTransport: netx.NewHTTPTransport(configuration.HTTPConfig),
dialer: netx.NewDialer(configuration.HTTPConfig),
}, nil
}
// Must panics if it's not possible to create a Client. Usually you should
// use it like `uncensored.Must(uncensored.NewClient(URL))`.
func Must(client *Client, err error) *Client {
runtimex.PanicOnError(err, "cannot create uncensored client")
return client
}
// DefaultClient is the default client for DNS, HTTP, and TCP.
var DefaultClient = Must(NewClient(""))
var _ netx.Resolver = DefaultClient
// Address implements netx.Resolver.Address
func (c *Client) Address() string {
return c.dnsClient.Address()
}
// LookupHost implements netx.Resolver.LookupHost
func (c *Client) LookupHost(ctx context.Context, domain string) ([]string, error) {
return c.dnsClient.LookupHost(ctx, domain)
}
// Network implements netx.Resolver.Network
func (c *Client) Network() string {
return c.dnsClient.Network()
}
var _ netx.Dialer = DefaultClient
// DialContext implements netx.Dialer.DialContext
func (c *Client) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
return c.dialer.DialContext(ctx, network, address)
}
var _ netx.HTTPRoundTripper = DefaultClient
// CloseIdleConnections implement netx.HTTPRoundTripper.CloseIdleConnections
func (c *Client) CloseIdleConnections() {
c.dnsClient.CloseIdleConnections()
c.httpTransport.CloseIdleConnections()
}
// RoundTrip implement netx.HTTPRoundTripper.RoundTrip
func (c *Client) RoundTrip(req *http.Request) (*http.Response, error) {
return c.httpTransport.RoundTrip(req)
}

View File

@ -0,0 +1,75 @@
package uncensored
import (
"bytes"
"context"
"io/ioutil"
"net/http"
"net/url"
"testing"
)
func TestGood(t *testing.T) {
client, err := NewClient("dot://1.1.1.1:853")
if err != nil {
t.Fatal(err)
}
defer client.CloseIdleConnections()
if client.Address() != "1.1.1.1:853" {
t.Fatal("invalid address")
}
if client.Network() != "dot" {
t.Fatal("invalid network")
}
ctx := context.Background()
addrs, err := client.LookupHost(ctx, "dns.google")
if err != nil {
t.Fatal(err)
}
var quad8, two8two4 bool
for _, addr := range addrs {
quad8 = quad8 || (addr == "8.8.8.8")
two8two4 = two8two4 || (addr == "8.8.4.4")
}
if quad8 != true && two8two4 != true {
t.Fatal("invalid response")
}
conn, err := client.DialContext(ctx, "tcp", "8.8.8.8:853")
if err != nil {
t.Fatal(err)
}
defer conn.Close()
resp, err := client.RoundTrip(&http.Request{
Method: "GET",
URL: &url.URL{
Scheme: "https",
Host: "www.google.com",
Path: "/humans.txt",
},
Header: http.Header{},
})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatal("invalid status-code")
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(data, []byte("Google is built by a large team")) {
t.Fatal("not the expected body")
}
}
func TestNewClientFailure(t *testing.T) {
clnt, err := NewClient("antani:///")
if err == nil {
t.Fatal("expected an error here")
}
if clnt != nil {
t.Fatal("expected nil client here")
}
}

View File

@ -0,0 +1,8 @@
# miniooni
This directory contains the source code of a simple CLI client that we
use for research as well as for running QA scripts. We designed this tool
to have a CLI similar to MK and OONI Probe v2.x to ease running Jafar
scripts that check whether these tools behave similarly.
See also libminiooni.

View File

@ -0,0 +1,21 @@
// Command miniooni is a simple binary for research and QA purposes
// with a CLI interface similar to MK and OONI Probe v2.x.
//
// See also libminiooni, which is where we implement this CLI.
package main
import (
"fmt"
"os"
"github.com/ooni/probe-cli/v3/internal/engine/libminiooni"
)
func main() {
defer func() {
if s := recover(); s != nil {
fmt.Fprintf(os.Stderr, "%s", s)
}
}()
libminiooni.Main()
}

View File

@ -0,0 +1,4 @@
# oohelper
This directory contains the source code of a simple client
for the Web Connectivity test helper.

View File

@ -0,0 +1,138 @@
package internal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
"github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader"
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
"github.com/ooni/probe-cli/v3/internal/engine/version"
)
type (
// CtrlResponse is the type of response returned by the test helper.
CtrlResponse = webconnectivity.ControlResponse
// ctrlRequest is the type of the request sent to the test helper.
ctrlRequest = webconnectivity.ControlRequest
)
// The following errors may be returned by this implementation.
var (
ErrHTTPStatusCode = errors.New("oohelper: http status code indicates failure")
ErrUnsupportedURLScheme = errors.New("oohelper: unsupported URL scheme")
ErrUnsupportedExplicitPort = errors.New("oohelper: unsupported explicit port")
ErrEmptyURL = errors.New("oohelper: empty server and/or target URL")
ErrInvalidURL = errors.New("oohelper: cannot parse URL")
ErrCannotCreateRequest = errors.New("oohelper: cannot create HTTP request")
ErrCannotParseJSONReply = errors.New("oohelper: cannot parse JSON reply")
)
// OOClient is a client for the OONI Web Connectivity test helper.
type OOClient struct {
// HTTPClient is the HTTP client to use.
HTTPClient *http.Client
// Resolver is the resolver to user.
Resolver netx.Resolver
}
// OOConfig contains configuration for the client.
type OOConfig struct {
// ServerURL is the URL of the test helper server.
ServerURL string
// TargetURL is the URL that we want to measure.
TargetURL string
}
// MakeTCPEndpoints constructs the list of TCP endpoints to send
// to the Web Connectivity test helper.
func MakeTCPEndpoints(URL *url.URL, addrs []string) ([]string, error) {
var (
port string
out []string
)
if URL.Host != URL.Hostname() {
return nil, ErrUnsupportedExplicitPort
}
switch URL.Scheme {
case "https":
port = "443"
case "http":
port = "80"
default:
return nil, ErrUnsupportedURLScheme
}
for _, addr := range addrs {
out = append(out, net.JoinHostPort(addr, port))
}
return out, nil
}
// Do sends a measurement request to the Web Connectivity test
// helper and receives the corresponding response.
func (oo OOClient) Do(ctx context.Context, config OOConfig) (*CtrlResponse, error) {
if config.TargetURL == "" || config.ServerURL == "" {
return nil, ErrEmptyURL
}
targetURL, err := url.Parse(config.TargetURL)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidURL, err.Error())
}
addrs, err := oo.Resolver.LookupHost(ctx, targetURL.Hostname())
if err != nil {
return nil, err
}
endpoints, err := MakeTCPEndpoints(targetURL, addrs)
if err != nil {
return nil, err
}
creq := ctrlRequest{
HTTPRequest: config.TargetURL,
HTTPRequestHeaders: map[string][]string{
"Accept": {httpheader.Accept()},
"Accept-Language": {httpheader.AcceptLanguage()},
"User-Agent": {httpheader.UserAgent()},
},
TCPConnect: endpoints,
}
data, err := json.Marshal(creq)
runtimex.PanicOnError(err, "oohelper: cannot marshal control request")
log.Debugf("out: %s", string(data))
req, err := http.NewRequestWithContext(ctx, "POST", config.ServerURL, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrCannotCreateRequest, err.Error())
}
req.Header.Add("user-agent", fmt.Sprintf(
"oohelper/%s ooniprobe-engine/%s", version.Version, version.Version,
))
req.Header.Add("content-type", "application/json")
resp, err := oo.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, ErrHTTPStatusCode
}
data, err = ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var cresp CtrlResponse
if err := json.Unmarshal(data, &cresp); err != nil {
return nil, fmt.Errorf("%w: %s", ErrCannotParseJSONReply, err.Error())
}
return &cresp, nil
}

View File

@ -0,0 +1,338 @@
package internal_test
import (
"context"
"errors"
"net/http"
"net/url"
"reflect"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/oohelper/internal"
)
func TestMakeTCPEndpoints(t *testing.T) {
type args struct {
URL *url.URL
addrs []string
}
tests := []struct {
name string
args args
want []string
err error
}{{
name: "with host != hostname",
args: args{URL: &url.URL{Host: "127.0.0.1:8080"}},
err: internal.ErrUnsupportedExplicitPort,
}, {
name: "with unsupported URL scheme",
args: args{URL: &url.URL{Host: "127.0.0.1", Scheme: "imap"}},
err: internal.ErrUnsupportedURLScheme,
}, {
name: "with http scheme",
args: args{
URL: &url.URL{Host: "www.kernel.org", Scheme: "http"},
addrs: []string{"1.1.1.1", "2.2.2.2", "::1"},
},
want: []string{"1.1.1.1:80", "2.2.2.2:80", "[::1]:80"},
}, {
name: "with https scheme",
args: args{
URL: &url.URL{Host: "www.kernel.org", Scheme: "https"},
addrs: []string{"1.1.1.1", "2.2.2.2", "::1"},
},
want: []string{"1.1.1.1:443", "2.2.2.2:443", "[::1]:443"},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := internal.MakeTCPEndpoints(tt.args.URL, tt.args.addrs)
if !errors.Is(err, tt.err) {
t.Errorf("MakeTCPEndpoints() error = %v, wantErr %v", err, tt.err)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MakeTCPEndpoints() = %v, want %v", got, tt.want)
}
})
}
}
func TestOOClientDoWithEmptyTargetURL(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{}
clnt := internal.OOClient{}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrEmptyURL) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithEmptyServerURL(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{TargetURL: "http://www.example.com"}
clnt := internal.OOClient{}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrEmptyURL) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithInvalidTargetURL(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{TargetURL: "\t", ServerURL: "https://wcth.ooni.io"}
clnt := internal.OOClient{}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrInvalidURL) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithResolverFailure(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com",
ServerURL: "https://wcth.ooni.io",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverThatFails(),
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrNotFound) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithUnsupportedExplicitPort(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com:8080",
ServerURL: "https://wcth.ooni.io",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverWithResult([]string{"1.1.1.1"}),
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrUnsupportedExplicitPort) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithInvalidServerURL(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com",
ServerURL: "\t",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverWithResult([]string{"1.1.1.1"}),
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrCannotCreateRequest) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithRoundTripError(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com",
ServerURL: "https://wcth.ooni.io",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverWithResult([]string{"1.1.1.1"}),
HTTPClient: &http.Client{
Transport: internal.FakeTransport{Err: expected},
},
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, expected) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithInvalidStatusCode(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com",
ServerURL: "https://wcth.ooni.io",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverWithResult([]string{"1.1.1.1"}),
HTTPClient: &http.Client{Transport: internal.FakeTransport{
Resp: &http.Response{
StatusCode: 400,
Body: &internal.FakeBody{},
},
}},
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrHTTPStatusCode) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithBodyReadError(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com",
ServerURL: "https://wcth.ooni.io",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverWithResult([]string{"1.1.1.1"}),
HTTPClient: &http.Client{Transport: internal.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: &internal.FakeBody{
Err: expected,
},
},
}},
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, expected) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
func TestOOClientDoWithInvalidJSON(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com",
ServerURL: "https://wcth.ooni.io",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverWithResult([]string{"1.1.1.1"}),
HTTPClient: &http.Client{Transport: internal.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: &internal.FakeBody{
Data: []byte("{"),
},
},
}},
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, internal.ErrCannotParseJSONReply) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp != nil {
t.Fatal("expected nil response")
}
}
const goodresponse = `{
"tcp_connect": {
"172.217.21.68:80": {
"status": true,
"failure": null
}
},
"http_request": {
"body_length": 207878,
"failure": null,
"title": "Google",
"headers": {
"Content-Type": "text/html"
},
"status_code": 200
},
"dns": {
"failure": null,
"addrs": [
"172.217.17.68"
]
}
}`
func TestOOClientDoWithParseableJSON(t *testing.T) {
ctx := context.Background()
config := internal.OOConfig{
TargetURL: "http://www.example.com",
ServerURL: "https://wcth.ooni.io",
}
clnt := internal.OOClient{
Resolver: internal.NewFakeResolverWithResult([]string{"1.1.1.1"}),
HTTPClient: &http.Client{Transport: internal.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: &internal.FakeBody{
Data: []byte(goodresponse),
},
},
}},
}
cresp, err := clnt.Do(ctx, config)
if !errors.Is(err, nil) {
t.Fatalf("not the error we expected: %+v", err)
}
if cresp.DNS.Failure != nil {
t.Fatal("unexpected Failure value")
}
if len(cresp.DNS.Addrs) != 1 {
t.Fatal("unexpected number of DNS entries")
}
if cresp.DNS.Addrs[0] != "172.217.17.68" {
t.Fatal("unexpected DNS addrs [0]")
}
if cresp.HTTPRequest.BodyLength != 207878 {
t.Fatal("invalid http body length")
}
if cresp.HTTPRequest.Failure != nil {
t.Fatal("invalid http failure")
}
if cresp.HTTPRequest.Title != "Google" {
t.Fatal("invalid http title")
}
if len(cresp.HTTPRequest.Headers) != 1 {
t.Fatal("invalid http headers length")
}
if cresp.HTTPRequest.Headers["Content-Type"] != "text/html" {
t.Fatal("invalid http content-type header")
}
if cresp.HTTPRequest.StatusCode != 200 {
t.Fatal("invalid http status code")
}
if len(cresp.TCPConnect) != 1 {
t.Fatal("invalid tcp connect length")
}
entry, ok := cresp.TCPConnect["172.217.21.68:80"]
if !ok {
t.Fatal("cannot find expected TCP connect entry")
}
if entry.Status != true {
t.Fatal("unexpected TCP connect entry status")
}
if entry.Failure != nil {
t.Fatal("unexpected TCP connect entry failure value")
}
}

View File

@ -0,0 +1,102 @@
package internal
import (
"context"
"io"
"io/ioutil"
"net"
"net/http"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
)
type FakeResolver struct {
NumFailures *atomicx.Int64
Err error
Result []string
}
func NewFakeResolverThatFails() FakeResolver {
return FakeResolver{NumFailures: atomicx.NewInt64(), Err: ErrNotFound}
}
func NewFakeResolverWithResult(r []string) FakeResolver {
return FakeResolver{NumFailures: atomicx.NewInt64(), Result: r}
}
var ErrNotFound = &net.DNSError{
Err: "no such host",
}
func (c FakeResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
time.Sleep(10 * time.Microsecond)
if c.Err != nil {
if c.NumFailures != nil {
c.NumFailures.Add(1)
}
return nil, c.Err
}
return c.Result, nil
}
func (c FakeResolver) Network() string {
return "fake"
}
func (c FakeResolver) Address() string {
return ""
}
var _ netx.Resolver = FakeResolver{}
type FakeTransport struct {
Err error
Func func(*http.Request) (*http.Response, error)
Resp *http.Response
}
func (txp FakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
time.Sleep(10 * time.Microsecond)
if txp.Func != nil {
return txp.Func(req)
}
if req.Body != nil {
ioutil.ReadAll(req.Body)
req.Body.Close()
}
if txp.Err != nil {
return nil, txp.Err
}
txp.Resp.Request = req // non thread safe but it doesn't matter
return txp.Resp, nil
}
func (txp FakeTransport) CloseIdleConnections() {}
var _ netx.HTTPRoundTripper = FakeTransport{}
type FakeBody struct {
Data []byte
Err error
}
func (fb *FakeBody) Read(p []byte) (int, error) {
time.Sleep(10 * time.Microsecond)
if fb.Err != nil {
return 0, fb.Err
}
if len(fb.Data) <= 0 {
return 0, io.EOF
}
n := copy(p, fb.Data)
fb.Data = fb.Data[n:]
return n, nil
}
func (fb *FakeBody) Close() error {
return nil
}
var _ io.ReadCloser = &FakeBody{}

View File

@ -0,0 +1,48 @@
// Command oohelper contains a simple command line
// client for the Web Connectivity test helper.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/cmd/oohelper/internal"
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
)
var (
ctx, cancel = context.WithCancel(context.Background())
debug = flag.Bool("debug", false, "Toggle debug mode")
httpClient *http.Client
resolver netx.Resolver
server = flag.String("server", "https://wcth.ooni.io/", "URL of the test helper")
target = flag.String("target", "", "Target URL for the test helper")
)
func init() {
txp := netx.NewHTTPTransport(netx.Config{Logger: log.Log})
httpClient = &http.Client{Transport: txp}
resolver = netx.NewResolver(netx.Config{Logger: log.Log})
}
func main() {
logmap := map[bool]log.Level{
true: log.DebugLevel,
false: log.InfoLevel,
}
flag.Parse()
log.SetLevel(logmap[*debug])
clnt := internal.OOClient{HTTPClient: httpClient, Resolver: resolver}
config := internal.OOConfig{TargetURL: *target, ServerURL: *server}
defer cancel()
cresp, err := clnt.Do(ctx, config)
runtimex.PanicOnError(err, "client.Do failed")
data, err := json.MarshalIndent(cresp, "", " ")
runtimex.PanicOnError(err, "json.MarshalIndent failed")
fmt.Printf("%s\n", string(data))
}

View File

@ -0,0 +1,8 @@
package main
import "testing"
func TestSmoke(t *testing.T) {
*target = "http://www.example.com"
main()
}

Some files were not shown because too many files have changed in this diff Show More