diff --git a/cmd/ooniprobe/internal/autorun/autorun_darwin.go b/cmd/ooniprobe/internal/autorun/autorun_darwin.go index 8d5aa73..805a32a 100644 --- a/cmd/ooniprobe/internal/autorun/autorun_darwin.go +++ b/cmd/ooniprobe/internal/autorun/autorun_darwin.go @@ -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" ) diff --git a/cmd/ooniprobe/internal/enginex/enginex.go b/cmd/ooniprobe/internal/enginex/enginex.go index f0b551b..8071334 100644 --- a/cmd/ooniprobe/internal/enginex/enginex.go +++ b/cmd/ooniprobe/internal/enginex/enginex.go @@ -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 diff --git a/cmd/ooniprobe/internal/nettests/nettests.go b/cmd/ooniprobe/internal/nettests/nettests.go index 9171517..539eb8b 100644 --- a/cmd/ooniprobe/internal/nettests/nettests.go +++ b/cmd/ooniprobe/internal/nettests/nettests.go @@ -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" ) diff --git a/cmd/ooniprobe/internal/nettests/web_connectivity.go b/cmd/ooniprobe/internal/nettests/web_connectivity.go index 7bedc45..923fab4 100644 --- a/cmd/ooniprobe/internal/nettests/web_connectivity.go +++ b/cmd/ooniprobe/internal/nettests/web_connectivity.go @@ -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) { diff --git a/cmd/ooniprobe/internal/ooni/ooni.go b/cmd/ooniprobe/internal/ooni/ooni.go index 5ba3489..676ccef 100644 --- a/cmd/ooniprobe/internal/ooni/ooni.go +++ b/cmd/ooniprobe/internal/ooni/ooni.go @@ -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" ) diff --git a/go.mod b/go.mod index e0742b8..8991302 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index bbdb5a6..e7d45cb 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/engine/.github/ISSUE_TEMPLATE/bug_report.md b/internal/engine/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b55f471 --- /dev/null +++ b/internal/engine/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/internal/engine/.github/ISSUE_TEMPLATE/routine-sprint-releases.md b/internal/engine/.github/ISSUE_TEMPLATE/routine-sprint-releases.md new file mode 100644 index 0000000..16bd1da --- /dev/null +++ b/internal/engine/.github/ISSUE_TEMPLATE/routine-sprint-releases.md @@ -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 diff --git a/internal/engine/.github/workflows/alltests.yml b/internal/engine/.github/workflows/alltests.yml new file mode 100644 index 0000000..2f29554 --- /dev/null +++ b/internal/engine/.github/workflows/alltests.yml @@ -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 ./... diff --git a/internal/engine/.github/workflows/android.yml b/internal/engine/.github/workflows/android.yml new file mode 100644 index 0000000..9ccfda9 --- /dev/null +++ b/internal/engine/.github/workflows/android.yml @@ -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 }} diff --git a/internal/engine/.github/workflows/codeql-analysis.yml b/internal/engine/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..2af96e1 --- /dev/null +++ b/internal/engine/.github/workflows/codeql-analysis.yml @@ -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 diff --git a/internal/engine/.github/workflows/coverage.yml b/internal/engine/.github/workflows/coverage.yml new file mode 100644 index 0000000..9e58a7d --- /dev/null +++ b/internal/engine/.github/workflows/coverage.yml @@ -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 diff --git a/internal/engine/.github/workflows/generate.yml b/internal/engine/.github/workflows/generate.yml new file mode 100644 index 0000000..a22f7c1 --- /dev/null +++ b/internal/engine/.github/workflows/generate.yml @@ -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 ./... diff --git a/internal/engine/.github/workflows/ios.yml b/internal/engine/.github/workflows/ios.yml new file mode 100644 index 0000000..a3d998e --- /dev/null +++ b/internal/engine/.github/workflows/ios.yml @@ -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 }} diff --git a/internal/engine/.github/workflows/jafar.yml b/internal/engine/.github/workflows/jafar.yml new file mode 100644 index 0000000..9f5c2f4 --- /dev/null +++ b/internal/engine/.github/workflows/jafar.yml @@ -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 diff --git a/internal/engine/.github/workflows/libooniffi.yml b/internal/engine/.github/workflows/libooniffi.yml new file mode 100644 index 0000000..57be6c1 --- /dev/null +++ b/internal/engine/.github/workflows/libooniffi.yml @@ -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 diff --git a/internal/engine/.github/workflows/miniooni.yml b/internal/engine/.github/workflows/miniooni.yml new file mode 100644 index 0000000..dd3d702 --- /dev/null +++ b/internal/engine/.github/workflows/miniooni.yml @@ -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 diff --git a/internal/engine/.github/workflows/qafbmessenger.yml b/internal/engine/.github/workflows/qafbmessenger.yml new file mode 100644 index 0000000..39e8dd6 --- /dev/null +++ b/internal/engine/.github/workflows/qafbmessenger.yml @@ -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" diff --git a/internal/engine/.github/workflows/qahhfm.yml b/internal/engine/.github/workflows/qahhfm.yml new file mode 100644 index 0000000..e7669bb --- /dev/null +++ b/internal/engine/.github/workflows/qahhfm.yml @@ -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" diff --git a/internal/engine/.github/workflows/qahirl.yml b/internal/engine/.github/workflows/qahirl.yml new file mode 100644 index 0000000..c2d832e --- /dev/null +++ b/internal/engine/.github/workflows/qahirl.yml @@ -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" diff --git a/internal/engine/.github/workflows/qatelegram.yml b/internal/engine/.github/workflows/qatelegram.yml new file mode 100644 index 0000000..eb1af11 --- /dev/null +++ b/internal/engine/.github/workflows/qatelegram.yml @@ -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" diff --git a/internal/engine/.github/workflows/qawebconnectivity.yml b/internal/engine/.github/workflows/qawebconnectivity.yml new file mode 100644 index 0000000..a14c579 --- /dev/null +++ b/internal/engine/.github/workflows/qawebconnectivity.yml @@ -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" diff --git a/internal/engine/.github/workflows/qawhatsapp.yml b/internal/engine/.github/workflows/qawhatsapp.yml new file mode 100644 index 0000000..4e49cf1 --- /dev/null +++ b/internal/engine/.github/workflows/qawhatsapp.yml @@ -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" diff --git a/internal/engine/.github/workflows/shorttests.yml b/internal/engine/.github/workflows/shorttests.yml new file mode 100644 index 0000000..322b8f6 --- /dev/null +++ b/internal/engine/.github/workflows/shorttests.yml @@ -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 ./... diff --git a/internal/engine/.github/workflows/using.yml b/internal/engine/.github/workflows/using.yml new file mode 100644 index 0000000..688a5a4 --- /dev/null +++ b/internal/engine/.github/workflows/using.yml @@ -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 diff --git a/internal/engine/.gitignore b/internal/engine/.gitignore new file mode 100644 index 0000000..3757255 --- /dev/null +++ b/internal/engine/.gitignore @@ -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 diff --git a/internal/engine/AUTHORS b/internal/engine/AUTHORS new file mode 100644 index 0000000..82b2b61 --- /dev/null +++ b/internal/engine/AUTHORS @@ -0,0 +1,2 @@ +Simone Basso +Arturo Filastò diff --git a/internal/engine/CLI/README.md b/internal/engine/CLI/README.md new file mode 100644 index 0000000..e3c4ef1 --- /dev/null +++ b/internal/engine/CLI/README.md @@ -0,0 +1,3 @@ +# Directory github.com/ooni/probe-engine/CLI + +We use this directory for building CLI binaries (e.g. miniooni). diff --git a/internal/engine/CLI/darwin/amd64/.gitignore b/internal/engine/CLI/darwin/amd64/.gitignore new file mode 100644 index 0000000..a47ad4c --- /dev/null +++ b/internal/engine/CLI/darwin/amd64/.gitignore @@ -0,0 +1 @@ +/miniooni diff --git a/internal/engine/CLI/linux/386/.gitignore b/internal/engine/CLI/linux/386/.gitignore new file mode 100644 index 0000000..a47ad4c --- /dev/null +++ b/internal/engine/CLI/linux/386/.gitignore @@ -0,0 +1 @@ +/miniooni diff --git a/internal/engine/CLI/linux/amd64/.gitignore b/internal/engine/CLI/linux/amd64/.gitignore new file mode 100644 index 0000000..a47ad4c --- /dev/null +++ b/internal/engine/CLI/linux/amd64/.gitignore @@ -0,0 +1 @@ +/miniooni diff --git a/internal/engine/CLI/linux/arm64/.gitignore b/internal/engine/CLI/linux/arm64/.gitignore new file mode 100644 index 0000000..a47ad4c --- /dev/null +++ b/internal/engine/CLI/linux/arm64/.gitignore @@ -0,0 +1 @@ +/miniooni diff --git a/internal/engine/CLI/linux/armv7/.gitignore b/internal/engine/CLI/linux/armv7/.gitignore new file mode 100644 index 0000000..a47ad4c --- /dev/null +++ b/internal/engine/CLI/linux/armv7/.gitignore @@ -0,0 +1 @@ +/miniooni diff --git a/internal/engine/CLI/windows/386/.gitignore b/internal/engine/CLI/windows/386/.gitignore new file mode 100644 index 0000000..2953a7a --- /dev/null +++ b/internal/engine/CLI/windows/386/.gitignore @@ -0,0 +1 @@ +/miniooni.exe diff --git a/internal/engine/CLI/windows/amd64/.gitignore b/internal/engine/CLI/windows/amd64/.gitignore new file mode 100644 index 0000000..2953a7a --- /dev/null +++ b/internal/engine/CLI/windows/amd64/.gitignore @@ -0,0 +1 @@ +/miniooni.exe diff --git a/internal/engine/CODEOWNERS b/internal/engine/CODEOWNERS new file mode 100644 index 0000000..448124f --- /dev/null +++ b/internal/engine/CODEOWNERS @@ -0,0 +1 @@ +* @bassosimone @hellais diff --git a/internal/engine/CODE_OF_CONDUCT.md b/internal/engine/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..01fdee0 --- /dev/null +++ b/internal/engine/CODE_OF_CONDUCT.md @@ -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 people’s 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 someone’s 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. diff --git a/internal/engine/CONTRIBUTING.md b/internal/engine/CONTRIBUTING.md new file mode 100644 index 0000000..9cd81a7 --- /dev/null +++ b/internal/engine/CONTRIBUTING.md @@ -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! diff --git a/internal/engine/DESIGN.md b/internal/engine/DESIGN.md new file mode 100644 index 0000000..02a4ff8 --- /dev/null +++ b/internal/engine/DESIGN.md @@ -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). diff --git a/internal/engine/LICENSE b/internal/engine/LICENSE new file mode 100644 index 0000000..9249ef4 --- /dev/null +++ b/internal/engine/LICENSE @@ -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. diff --git a/internal/engine/MOBILE/README.md b/internal/engine/MOBILE/README.md new file mode 100644 index 0000000..6b36c8c --- /dev/null +++ b/internal/engine/MOBILE/README.md @@ -0,0 +1,3 @@ +# Directory github.com/ooni/probe-engine/MOBILE + +This directory is used for building for Android and iOS. diff --git a/internal/engine/MOBILE/template.podspec b/internal/engine/MOBILE/template.podspec new file mode 100644 index 0000000..cc74bae --- /dev/null +++ b/internal/engine/MOBILE/template.podspec @@ -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 diff --git a/internal/engine/MOBILE/template.pom b/internal/engine/MOBILE/template.pom new file mode 100644 index 0000000..086196b --- /dev/null +++ b/internal/engine/MOBILE/template.pom @@ -0,0 +1,33 @@ + + + 4.0.0 + org.ooni + oonimkall + @VERSION@ + aar + oonimkall + OONI Probe Engine for Android + https://github.com/ooni/probe-engine + + + The 3-Clause BSD License + https://opensource.org/licenses/BSD-3-Clause + repo + + + + https://github.com/ooni/probe-engine + https://github.com/ooni/probe-engine.git + + + + Simone Basso + simone@openobservatory.org + + developer + + Europe/Rome + + + diff --git a/internal/engine/QA/.dockerignore b/internal/engine/QA/.dockerignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/internal/engine/QA/.dockerignore @@ -0,0 +1 @@ +* diff --git a/internal/engine/QA/.gitignore b/internal/engine/QA/.gitignore new file mode 100644 index 0000000..4d78627 --- /dev/null +++ b/internal/engine/QA/.gitignore @@ -0,0 +1,3 @@ +/GOPATH +/GOCACHE +/__pycache__ diff --git a/internal/engine/QA/Dockerfile b/internal/engine/QA/Dockerfile new file mode 100644 index 0000000..dcb3ab0 --- /dev/null +++ b/internal/engine/QA/Dockerfile @@ -0,0 +1,2 @@ +FROM golang:1.14-alpine +RUN apk add go git musl-dev iptables tmux bind-tools curl sudo python3 diff --git a/internal/engine/QA/README.md b/internal/engine/QA/README.md new file mode 100644 index 0000000..9417939 --- /dev/null +++ b/internal/engine/QA/README.md @@ -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`. diff --git a/internal/engine/QA/common.py b/internal/engine/QA/common.py new file mode 100644 index 0000000..0c64959 --- /dev/null +++ b/internal/engine/QA/common.py @@ -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 + 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]) diff --git a/internal/engine/QA/fbmessenger.py b/internal/engine/QA/fbmessenger.py new file mode 100755 index 0000000..33162bb --- /dev/null +++ b/internal/engine/QA/fbmessenger.py @@ -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() diff --git a/internal/engine/QA/hhfm.py b/internal/engine/QA/hhfm.py new file mode 100755 index 0000000..75047f5 --- /dev/null +++ b/internal/engine/QA/hhfm.py @@ -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() diff --git a/internal/engine/QA/hirl.py b/internal/engine/QA/hirl.py new file mode 100755 index 0000000..5257754 --- /dev/null +++ b/internal/engine/QA/hirl.py @@ -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() diff --git a/internal/engine/QA/minioonilike.py b/internal/engine/QA/minioonilike.py new file mode 100755 index 0000000..a4b9dda --- /dev/null +++ b/internal/engine/QA/minioonilike.py @@ -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() diff --git a/internal/engine/QA/probeasn.py b/internal/engine/QA/probeasn.py new file mode 100755 index 0000000..f86438d --- /dev/null +++ b/internal/engine/QA/probeasn.py @@ -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() diff --git a/internal/engine/QA/pyrun.sh b/internal/engine/QA/pyrun.sh new file mode 100755 index 0000000..6dd32bb --- /dev/null +++ b/internal/engine/QA/pyrun.sh @@ -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 diff --git a/internal/engine/QA/rundocker.bash b/internal/engine/QA/rundocker.bash new file mode 100755 index 0000000..8ce7320 --- /dev/null +++ b/internal/engine/QA/rundocker.bash @@ -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 "$@" diff --git a/internal/engine/QA/telegram.py b/internal/engine/QA/telegram.py new file mode 100755 index 0000000..717a0e1 --- /dev/null +++ b/internal/engine/QA/telegram.py @@ -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 + # 00 15 + # 00 13 + # 00 + # 00 10 + # 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() diff --git a/internal/engine/QA/webconnectivity.py b/internal/engine/QA/webconnectivity.py new file mode 100755 index 0000000..a3d8403 --- /dev/null +++ b/internal/engine/QA/webconnectivity.py @@ -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 . + # + # 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 . + 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 . + 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 . + 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 . + 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 . + 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 . + 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() diff --git a/internal/engine/QA/whatsapp.py b/internal/engine/QA/whatsapp.py new file mode 100755 index 0000000..35b2dd7 --- /dev/null +++ b/internal/engine/QA/whatsapp.py @@ -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 + # 00 13 + # 00 11 + # 00 + # 00 0e + # 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 + # 00 15 + # 00 13 + # 00 + # 00 10 + # 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() diff --git a/internal/engine/README.md b/internal/engine/README.md new file mode 100644 index 0000000..058f124 --- /dev/null +++ b/internal/engine/README.md @@ -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 +``` diff --git a/internal/engine/allexperiments.go b/internal/engine/allexperiments.go new file mode 100644 index 0000000..7138ada --- /dev/null +++ b/internal/engine/allexperiments.go @@ -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 +} diff --git a/internal/engine/atomicx/README.md b/internal/engine/atomicx/README.md new file mode 100644 index 0000000..8a8fb95 --- /dev/null +++ b/internal/engine/atomicx/README.md @@ -0,0 +1,3 @@ +# Package github.com/ooni/probe-engine/atomicx + +Atomic int64/float64 that works also on 32 bit platforms. diff --git a/internal/engine/atomicx/atomicx.go b/internal/engine/atomicx/atomicx.go new file mode 100644 index 0000000..3c02d88 --- /dev/null +++ b/internal/engine/atomicx/atomicx.go @@ -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 +} diff --git a/internal/engine/atomicx/atomicx_test.go b/internal/engine/atomicx/atomicx_test.go new file mode 100644 index 0000000..baab7ff --- /dev/null +++ b/internal/engine/atomicx/atomicx_test.go @@ -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") + } +} diff --git a/internal/engine/build-android.bash b/internal/engine/build-android.bash new file mode 100755 index 0000000..f62be25 --- /dev/null +++ b/internal/engine/build-android.bash @@ -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/." + 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 diff --git a/internal/engine/build-cli.sh b/internal/engine/build-cli.sh new file mode 100755 index 0000000..4eb9703 --- /dev/null +++ b/internal/engine/build-cli.sh @@ -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 diff --git a/internal/engine/build-ios.bash b/internal/engine/build-ios.bash new file mode 100755 index 0000000..71700ff --- /dev/null +++ b/internal/engine/build-ios.bash @@ -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 diff --git a/internal/engine/cmd/README.md b/internal/engine/cmd/README.md new file mode 100644 index 0000000..1e27b73 --- /dev/null +++ b/internal/engine/cmd/README.md @@ -0,0 +1,3 @@ +# Directory github.com/ooni/probe-engine/cmd + +This directory contains the source code for the CLI tools we build. diff --git a/internal/engine/cmd/apitool/README.md b/internal/engine/cmd/apitool/README.md new file mode 100644 index 0000000..9b16d3e --- /dev/null +++ b/internal/engine/cmd/apitool/README.md @@ -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. diff --git a/internal/engine/cmd/apitool/main.go b/internal/engine/cmd/apitool/main.go new file mode 100644 index 0000000..8db01af --- /dev/null +++ b/internal/engine/cmd/apitool/main.go @@ -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 +} diff --git a/internal/engine/cmd/apitool/main_test.go b/internal/engine/cmd/apitool/main_test.go new file mode 100644 index 0000000..4d9a299 --- /dev/null +++ b/internal/engine/cmd/apitool/main_test.go @@ -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() +} diff --git a/internal/engine/cmd/jafar/README.md b/internal/engine/cmd/jafar/README.md new file mode 100644 index 0000000..4cda5a8 --- /dev/null +++ b/internal/engine/cmd/jafar/README.md @@ -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 ` 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 ` 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 `://[:][/]`. 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`. diff --git a/internal/engine/cmd/jafar/badproxy/badproxy.go b/internal/engine/cmd/jafar/badproxy/badproxy.go new file mode 100644 index 0000000..e3060c0 --- /dev/null +++ b/internal/engine/cmd/jafar/badproxy/badproxy.go @@ -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 +} diff --git a/internal/engine/cmd/jafar/badproxy/badproxy_test.go b/internal/engine/cmd/jafar/badproxy/badproxy_test.go new file mode 100644 index 0000000..badeeab --- /dev/null +++ b/internal/engine/cmd/jafar/badproxy/badproxy_test.go @@ -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() + } +} diff --git a/internal/engine/cmd/jafar/flagx/stringarray.go b/internal/engine/cmd/jafar/flagx/stringarray.go new file mode 100644 index 0000000..674ffe6 --- /dev/null +++ b/internal/engine/cmd/jafar/flagx/stringarray.go @@ -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 . This file is licensed under +// version 2.0 of the Apache License . +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 +} diff --git a/internal/engine/cmd/jafar/flagx/stringarray_test.go b/internal/engine/cmd/jafar/flagx/stringarray_test.go new file mode 100644 index 0000000..f605ca6 --- /dev/null +++ b/internal/engine/cmd/jafar/flagx/stringarray_test.go @@ -0,0 +1,73 @@ +package flagx_test + +// The code in this file is adapted from github.com/m-lab/go and more +// specifically from . This file is licensed under +// version 2.0 of the Apache License . + +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) +} diff --git a/internal/engine/cmd/jafar/httpproxy/httpproxy.go b/internal/engine/cmd/jafar/httpproxy/httpproxy.go new file mode 100644 index 0000000..5a7aa6b --- /dev/null +++ b/internal/engine/cmd/jafar/httpproxy/httpproxy.go @@ -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(` + 451 Unavailable For Legal Reasons + +

451 Unavailable For Legal Reasons

+

This content is not available in your jurisdiction.

+ +`) + +// 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 +} diff --git a/internal/engine/cmd/jafar/httpproxy/httpproxy_test.go b/internal/engine/cmd/jafar/httpproxy/httpproxy_test.go new file mode 100644 index 0000000..db1849b --- /dev/null +++ b/internal/engine/cmd/jafar/httpproxy/httpproxy_test.go @@ -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") + } +} diff --git a/internal/engine/cmd/jafar/iptables/iptables.go b/internal/engine/cmd/jafar/iptables/iptables.go new file mode 100644 index 0000000..1184489 --- /dev/null +++ b/internal/engine/cmd/jafar/iptables/iptables.go @@ -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() +} diff --git a/internal/engine/cmd/jafar/iptables/iptables_integration_test.go b/internal/engine/cmd/jafar/iptables/iptables_integration_test.go new file mode 100644 index 0000000..afdef2a --- /dev/null +++ b/internal/engine/cmd/jafar/iptables/iptables_integration_test.go @@ -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") + } +} diff --git a/internal/engine/cmd/jafar/iptables/iptables_linux.go b/internal/engine/cmd/jafar/iptables/iptables_linux.go new file mode 100644 index 0000000..01917b8 --- /dev/null +++ b/internal/engine/cmd/jafar/iptables/iptables_linux.go @@ -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{} +} diff --git a/internal/engine/cmd/jafar/iptables/iptables_unsupported.go b/internal/engine/cmd/jafar/iptables/iptables_unsupported.go new file mode 100644 index 0000000..0ffd0c8 --- /dev/null +++ b/internal/engine/cmd/jafar/iptables/iptables_unsupported.go @@ -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{} +} diff --git a/internal/engine/cmd/jafar/main.go b/internal/engine/cmd/jafar/main.go new file mode 100644 index 0000000..44f106e --- /dev/null +++ b/internal/engine/cmd/jafar/main.go @@ -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) +} diff --git a/internal/engine/cmd/jafar/main_test.go b/internal/engine/cmd/jafar/main_test.go new file mode 100644 index 0000000..49c6f29 --- /dev/null +++ b/internal/engine/cmd/jafar/main_test.go @@ -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") + } + }) +} diff --git a/internal/engine/cmd/jafar/resolver/resolver.go b/internal/engine/cmd/jafar/resolver/resolver.go new file mode 100644 index 0000000..2b4baf9 --- /dev/null +++ b/internal/engine/cmd/jafar/resolver/resolver.go @@ -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 +} diff --git a/internal/engine/cmd/jafar/resolver/resolver_test.go b/internal/engine/cmd/jafar/resolver/resolver_test.go new file mode 100644 index 0000000..254aaa3 --- /dev/null +++ b/internal/engine/cmd/jafar/resolver/resolver_test.go @@ -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 +} diff --git a/internal/engine/cmd/jafar/shellx/shellx.go b/internal/engine/cmd/jafar/shellx/shellx.go new file mode 100644 index 0000000..e540e14 --- /dev/null +++ b/internal/engine/cmd/jafar/shellx/shellx.go @@ -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:]...) +} diff --git a/internal/engine/cmd/jafar/shellx/shellx_test.go b/internal/engine/cmd/jafar/shellx/shellx_test.go new file mode 100644 index 0000000..607649a --- /dev/null +++ b/internal/engine/cmd/jafar/shellx/shellx_test.go @@ -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) + } + }) +} diff --git a/internal/engine/cmd/jafar/tlsproxy/tlsproxy.go b/internal/engine/cmd/jafar/tlsproxy/tlsproxy.go new file mode 100644 index 0000000..52915cc --- /dev/null +++ b/internal/engine/cmd/jafar/tlsproxy/tlsproxy.go @@ -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 +} diff --git a/internal/engine/cmd/jafar/tlsproxy/tlsproxy_test.go b/internal/engine/cmd/jafar/tlsproxy/tlsproxy_test.go new file mode 100644 index 0000000..f7b1e38 --- /dev/null +++ b/internal/engine/cmd/jafar/tlsproxy/tlsproxy_test.go @@ -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 +} diff --git a/internal/engine/cmd/jafar/uncensored/uncensored.go b/internal/engine/cmd/jafar/uncensored/uncensored.go new file mode 100644 index 0000000..aa9e378 --- /dev/null +++ b/internal/engine/cmd/jafar/uncensored/uncensored.go @@ -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) +} diff --git a/internal/engine/cmd/jafar/uncensored/uncensored_test.go b/internal/engine/cmd/jafar/uncensored/uncensored_test.go new file mode 100644 index 0000000..b58c055 --- /dev/null +++ b/internal/engine/cmd/jafar/uncensored/uncensored_test.go @@ -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") + } +} diff --git a/internal/engine/cmd/miniooni/README.md b/internal/engine/cmd/miniooni/README.md new file mode 100644 index 0000000..ae2b3fd --- /dev/null +++ b/internal/engine/cmd/miniooni/README.md @@ -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. diff --git a/internal/engine/cmd/miniooni/main.go b/internal/engine/cmd/miniooni/main.go new file mode 100644 index 0000000..6ee48f8 --- /dev/null +++ b/internal/engine/cmd/miniooni/main.go @@ -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() +} diff --git a/internal/engine/cmd/oohelper/README.md b/internal/engine/cmd/oohelper/README.md new file mode 100644 index 0000000..fe0b285 --- /dev/null +++ b/internal/engine/cmd/oohelper/README.md @@ -0,0 +1,4 @@ +# oohelper + +This directory contains the source code of a simple client +for the Web Connectivity test helper. diff --git a/internal/engine/cmd/oohelper/internal/client.go b/internal/engine/cmd/oohelper/internal/client.go new file mode 100644 index 0000000..2ba2d49 --- /dev/null +++ b/internal/engine/cmd/oohelper/internal/client.go @@ -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 +} diff --git a/internal/engine/cmd/oohelper/internal/client_test.go b/internal/engine/cmd/oohelper/internal/client_test.go new file mode 100644 index 0000000..bace44a --- /dev/null +++ b/internal/engine/cmd/oohelper/internal/client_test.go @@ -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") + } +} diff --git a/internal/engine/cmd/oohelper/internal/fake_test.go b/internal/engine/cmd/oohelper/internal/fake_test.go new file mode 100644 index 0000000..dd09aaa --- /dev/null +++ b/internal/engine/cmd/oohelper/internal/fake_test.go @@ -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{} diff --git a/internal/engine/cmd/oohelper/oohelper.go b/internal/engine/cmd/oohelper/oohelper.go new file mode 100644 index 0000000..1d73bd2 --- /dev/null +++ b/internal/engine/cmd/oohelper/oohelper.go @@ -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)) +} diff --git a/internal/engine/cmd/oohelper/oohelper_test.go b/internal/engine/cmd/oohelper/oohelper_test.go new file mode 100644 index 0000000..05518f0 --- /dev/null +++ b/internal/engine/cmd/oohelper/oohelper_test.go @@ -0,0 +1,8 @@ +package main + +import "testing" + +func TestSmoke(t *testing.T) { + *target = "http://www.example.com" + main() +} diff --git a/internal/engine/cmd/oohelperd/README.md b/internal/engine/cmd/oohelperd/README.md new file mode 100644 index 0000000..bc7d2a8 --- /dev/null +++ b/internal/engine/cmd/oohelperd/README.md @@ -0,0 +1,4 @@ +# oohelperd + +This directory contains the source code of the Web +Connectivity test helper written in Go. diff --git a/internal/engine/cmd/oohelperd/internal/dns.go b/internal/engine/cmd/oohelperd/internal/dns.go new file mode 100644 index 0000000..6277f19 --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/dns.go @@ -0,0 +1,32 @@ +package internal + +import ( + "context" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" +) + +// newfailure is a convenience shortcut to save typing +var newfailure = archival.NewFailure + +// CtrlDNSResult is the result of the DNS check performed by +// the Web Connectivity test helper. +type CtrlDNSResult = webconnectivity.ControlDNSResult + +// DNSConfig configures the DNS check. +type DNSConfig struct { + Domain string + Out chan CtrlDNSResult + Resolver netx.Resolver + Wg *sync.WaitGroup +} + +// DNSDo performs the DNS check. +func DNSDo(ctx context.Context, config *DNSConfig) { + defer config.Wg.Done() + addrs, err := config.Resolver.LookupHost(ctx, config.Domain) + config.Out <- CtrlDNSResult{Failure: newfailure(err), Addrs: addrs} +} diff --git a/internal/engine/cmd/oohelperd/internal/fake_test.go b/internal/engine/cmd/oohelperd/internal/fake_test.go new file mode 100644 index 0000000..4eb77aa --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/fake_test.go @@ -0,0 +1,127 @@ +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{} + +type FakeResponseWriter struct { + Body [][]byte + HeaderMap http.Header + StatusCode int +} + +func NewFakeResponseWriter() *FakeResponseWriter { + return &FakeResponseWriter{HeaderMap: make(http.Header)} +} + +func (frw *FakeResponseWriter) Header() http.Header { + return frw.HeaderMap +} + +func (frw *FakeResponseWriter) Write(b []byte) (int, error) { + frw.Body = append(frw.Body, b) + return len(b), nil +} + +func (frw *FakeResponseWriter) WriteHeader(statusCode int) { + frw.StatusCode = statusCode +} + +var _ http.ResponseWriter = &FakeResponseWriter{} diff --git a/internal/engine/cmd/oohelperd/internal/http.go b/internal/engine/cmd/oohelperd/internal/http.go new file mode 100644 index 0000000..3985c44 --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/http.go @@ -0,0 +1,67 @@ +package internal + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "strings" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" +) + +// CtrlHTTPResponse is the result of the HTTP check performed by +// the Web Connectivity test helper. +type CtrlHTTPResponse = webconnectivity.ControlHTTPRequestResult + +// HTTPConfig configures the HTTP check. +type HTTPConfig struct { + Client *http.Client + Headers map[string][]string + MaxAcceptableBody int64 + Out chan CtrlHTTPResponse + URL string + Wg *sync.WaitGroup +} + +// HTTPDo performs the HTTP check. +func HTTPDo(ctx context.Context, config *HTTPConfig) { + defer config.Wg.Done() + req, err := http.NewRequestWithContext(ctx, "GET", config.URL, nil) + if err != nil { + config.Out <- CtrlHTTPResponse{Failure: newfailure(err)} + return + } + // The original test helper failed with extra headers while here + // we're implementing (for now?) a more liberal approach. + for k, vs := range config.Headers { + switch strings.ToLower(k) { + case "user-agent": + case "accept": + case "accept-language": + for _, v := range vs { + req.Header.Add(k, v) + } + } + } + resp, err := config.Client.Do(req) + if err != nil { + config.Out <- CtrlHTTPResponse{Failure: newfailure(err)} + return + } + defer resp.Body.Close() + headers := make(map[string]string) + for k := range resp.Header { + headers[k] = resp.Header.Get(k) + } + reader := &io.LimitedReader{R: resp.Body, N: config.MaxAcceptableBody} + data, err := ioutil.ReadAll(reader) + config.Out <- CtrlHTTPResponse{ + BodyLength: int64(len(data)), + Failure: newfailure(err), + StatusCode: int64(resp.StatusCode), + Headers: headers, + Title: webconnectivity.GetTitle(string(data)), + } +} diff --git a/internal/engine/cmd/oohelperd/internal/http_test.go b/internal/engine/cmd/oohelperd/internal/http_test.go new file mode 100644 index 0000000..8022079 --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/http_test.go @@ -0,0 +1,59 @@ +package internal_test + +import ( + "context" + "errors" + "net/http" + "strings" + "sync" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/cmd/oohelperd/internal" +) + +func TestHTTPDoWithInvalidURL(t *testing.T) { + ctx := context.Background() + wg := new(sync.WaitGroup) + httpch := make(chan internal.CtrlHTTPResponse, 1) + wg.Add(1) + go internal.HTTPDo(ctx, &internal.HTTPConfig{ + Client: http.DefaultClient, + Headers: nil, + MaxAcceptableBody: 1 << 24, + Out: httpch, + URL: "http://[::1]aaaa", + Wg: wg, + }) + // wait for measurement steps to complete + wg.Wait() + resp := <-httpch + if resp.Failure == nil || !strings.HasSuffix(*resp.Failure, `invalid port "aaaa" after host`) { + t.Fatal("not the failure we expected") + } +} + +func TestHTTPDoWithHTTPTransportFailure(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + wg := new(sync.WaitGroup) + httpch := make(chan internal.CtrlHTTPResponse, 1) + wg.Add(1) + go internal.HTTPDo(ctx, &internal.HTTPConfig{ + Client: &http.Client{ + Transport: internal.FakeTransport{ + Err: expected, + }, + }, + Headers: nil, + MaxAcceptableBody: 1 << 24, + Out: httpch, + URL: "http://www.x.org", + Wg: wg, + }) + // wait for measurement steps to complete + wg.Wait() + resp := <-httpch + if resp.Failure == nil || !strings.HasSuffix(*resp.Failure, "mocked error") { + t.Fatal("not the error we expected") + } +} diff --git a/internal/engine/cmd/oohelperd/internal/internal.go b/internal/engine/cmd/oohelperd/internal/internal.go new file mode 100644 index 0000000..70b5f45 --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/internal.go @@ -0,0 +1,61 @@ +package internal + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/version" +) + +// Handler implements the Web Connectivity test helper HTTP API. +type Handler struct { + Client *http.Client + Dialer netx.Dialer + MaxAcceptableBody int64 + Resolver netx.Resolver +} + +func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + w.Header().Add("Server", fmt.Sprintf( + "oohelperd/%s ooniprobe-engine/%s", version.Version, version.Version, + )) + if req.Method != "POST" { + w.WriteHeader(400) + return + } + if req.Header.Get("content-type") != "application/json" { + w.WriteHeader(400) + return + } + reader := &io.LimitedReader{R: req.Body, N: h.MaxAcceptableBody} + data, err := ioutil.ReadAll(reader) + if err != nil { + w.WriteHeader(400) + return + } + var creq CtrlRequest + if err := json.Unmarshal(data, &creq); err != nil { + w.WriteHeader(400) + return + } + measureConfig := MeasureConfig{ + Client: h.Client, + Dialer: h.Dialer, + MaxAcceptableBody: h.MaxAcceptableBody, + Resolver: h.Resolver, + } + cresp, err := Measure(req.Context(), measureConfig, &creq) + if err != nil { + w.WriteHeader(400) + return + } + // We assume that the following call cannot fail because it's a + // clearly serializable data structure. + data, _ = json.Marshal(cresp) + w.Header().Add("Content-Type", "application/json") + w.Write(data) +} diff --git a/internal/engine/cmd/oohelperd/internal/internal_test.go b/internal/engine/cmd/oohelperd/internal/internal_test.go new file mode 100644 index 0000000..b7bdecb --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/internal_test.go @@ -0,0 +1,160 @@ +package internal_test + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/cmd/oohelperd/internal" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +const simplerequest = `{ + "http_request": "https://dns.google", + "http_request_headers": { + "Accept": [ + "*/*" + ], + "Accept-Language": [ + "en-US;q=0.8,en;q=0.5" + ], + "User-Agent": [ + "Mozilla/5.0" + ] + }, + "tcp_connect": [ + "8.8.8.8:443" + ] +}` + +const requestWithoutDomainName = `{ + "http_request": "https://8.8.8.8", + "http_request_headers": { + "Accept": [ + "*/*" + ], + "Accept-Language": [ + "en-US;q=0.8,en;q=0.5" + ], + "User-Agent": [ + "Mozilla/5.0" + ] + }, + "tcp_connect": [ + "8.8.8.8:443" + ] +}` + +func TestWorkingAsIntended(t *testing.T) { + handler := internal.Handler{ + Client: http.DefaultClient, + Dialer: new(net.Dialer), + MaxAcceptableBody: 1 << 24, + Resolver: resolver.SystemResolver{}, + } + srv := httptest.NewServer(handler) + defer srv.Close() + type expectationSpec struct { + name string + reqMethod string + reqContentType string + reqBody string + respStatusCode int + respContentType string + parseBody bool + } + expectations := []expectationSpec{{ + name: "check for invalid method", + reqMethod: "GET", + respStatusCode: 400, + }, { + name: "check for invalid content-type", + reqMethod: "POST", + respStatusCode: 400, + }, { + name: "check for invalid request body", + reqMethod: "POST", + reqContentType: "application/json", + reqBody: "{", + respStatusCode: 400, + }, { + name: "with measurement failure", + reqMethod: "POST", + reqContentType: "application/json", + reqBody: `{"http_request": "http://[::1]aaaa"}`, + respStatusCode: 400, + }, { + name: "with reasonably good request", + reqMethod: "POST", + reqContentType: "application/json", + reqBody: simplerequest, + respStatusCode: 200, + respContentType: "application/json", + parseBody: true, + }, { + name: "when there's no domain name in the request", + reqMethod: "POST", + reqContentType: "application/json", + reqBody: requestWithoutDomainName, + respStatusCode: 200, + respContentType: "application/json", + parseBody: true, + }} + for _, expect := range expectations { + t.Run(expect.name, func(t *testing.T) { + body := strings.NewReader(expect.reqBody) + req, err := http.NewRequest(expect.reqMethod, srv.URL, body) + if err != nil { + t.Fatalf("%s: %+v", expect.name, err) + } + if expect.reqContentType != "" { + req.Header.Add("content-type", expect.reqContentType) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s: %+v", expect.name, err) + } + defer resp.Body.Close() + if resp.StatusCode != expect.respStatusCode { + t.Fatalf("unexpected status code: %+v", resp.StatusCode) + } + if v := resp.Header.Get("content-type"); v != expect.respContentType { + t.Fatalf("unexpected content-type: %s", v) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !expect.parseBody { + return + } + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestHandlerWithRequestBodyReadingError(t *testing.T) { + expected := errors.New("mocked error") + handler := internal.Handler{MaxAcceptableBody: 1 << 24} + rw := internal.NewFakeResponseWriter() + req := &http.Request{ + Method: "POST", + Header: map[string][]string{ + "Content-Type": {"application/json"}, + "Content-Length": {"2048"}, + }, + Body: &internal.FakeBody{Err: expected}, + } + handler.ServeHTTP(rw, req) + if rw.StatusCode != 400 { + t.Fatal("unexpected status code") + } +} diff --git a/internal/engine/cmd/oohelperd/internal/measure.go b/internal/engine/cmd/oohelperd/internal/measure.go new file mode 100644 index 0000000..506e5f2 --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/measure.go @@ -0,0 +1,88 @@ +package internal + +import ( + "context" + "net" + "net/http" + "net/url" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +type ( + // CtrlRequest is the request sent to the test helper + CtrlRequest = webconnectivity.ControlRequest + + // CtrlResponse is the response from the test helper + CtrlResponse = webconnectivity.ControlResponse +) + +// MeasureConfig contains configuration for Measure. +type MeasureConfig struct { + Client *http.Client + Dialer netx.Dialer + MaxAcceptableBody int64 + Resolver netx.Resolver +} + +// Measure performs the measurement described by the request and +// returns the corresponding response or an error. +func Measure(ctx context.Context, config MeasureConfig, creq *CtrlRequest) (*CtrlResponse, error) { + // parse input for correctness + URL, err := url.Parse(creq.HTTPRequest) + if err != nil { + return nil, err + } + // dns: start + wg := new(sync.WaitGroup) + dnsch := make(chan CtrlDNSResult, 1) + if net.ParseIP(URL.Hostname()) == nil { + wg.Add(1) + go DNSDo(ctx, &DNSConfig{ + Domain: URL.Hostname(), + Out: dnsch, + Resolver: config.Resolver, + Wg: wg, + }) + } + // tcpconnect: start + tcpconnch := make(chan TCPResultPair, len(creq.TCPConnect)) + for _, endpoint := range creq.TCPConnect { + wg.Add(1) + go TCPDo(ctx, &TCPConfig{ + Dialer: config.Dialer, + Endpoint: endpoint, + Out: tcpconnch, + Wg: wg, + }) + } + // http: start + httpch := make(chan CtrlHTTPResponse, 1) + wg.Add(1) + go HTTPDo(ctx, &HTTPConfig{ + Client: config.Client, + Headers: creq.HTTPRequestHeaders, + MaxAcceptableBody: config.MaxAcceptableBody, + Out: httpch, + URL: creq.HTTPRequest, + Wg: wg, + }) + // wait for measurement steps to complete + wg.Wait() + // assemble response + cresp := new(CtrlResponse) + select { + case cresp.DNS = <-dnsch: + default: + // we land here when there's no domain name + } + cresp.HTTPRequest = <-httpch + cresp.TCPConnect = make(map[string]CtrlTCPResult) + for len(cresp.TCPConnect) < len(creq.TCPConnect) { + tcpconn := <-tcpconnch + cresp.TCPConnect[tcpconn.Endpoint] = tcpconn.Result + } + return cresp, nil +} diff --git a/internal/engine/cmd/oohelperd/internal/tcpconnect.go b/internal/engine/cmd/oohelperd/internal/tcpconnect.go new file mode 100644 index 0000000..6115a61 --- /dev/null +++ b/internal/engine/cmd/oohelperd/internal/tcpconnect.go @@ -0,0 +1,42 @@ +package internal + +import ( + "context" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +// CtrlTCPResult is the result of the TCP check performed by the test helper. +type CtrlTCPResult = webconnectivity.ControlTCPConnectResult + +// TCPResultPair contains the endpoint and the corresponding result. +type TCPResultPair struct { + Endpoint string + Result CtrlTCPResult +} + +// TCPConfig configures the TCP connect check. +type TCPConfig struct { + Dialer netx.Dialer + Endpoint string + Out chan TCPResultPair + Wg *sync.WaitGroup +} + +// TCPDo performs the TCP check. +func TCPDo(ctx context.Context, config *TCPConfig) { + defer config.Wg.Done() + conn, err := config.Dialer.DialContext(ctx, "tcp", config.Endpoint) + if conn != nil { + conn.Close() + } + config.Out <- TCPResultPair{ + Endpoint: config.Endpoint, + Result: CtrlTCPResult{ + Failure: newfailure(err), + Status: err == nil, + }, + } +} diff --git a/internal/engine/cmd/oohelperd/oohelperd.go b/internal/engine/cmd/oohelperd/oohelperd.go new file mode 100644 index 0000000..48bc3c7 --- /dev/null +++ b/internal/engine/cmd/oohelperd/oohelperd.go @@ -0,0 +1,67 @@ +// Command oohelperd contains the Web Connectivity test helper. +package main + +import ( + "context" + "flag" + "net/http" + "sync" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/cmd/oohelperd/internal" + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +const maxAcceptableBody = 1 << 24 + +var ( + dialer netx.Dialer + endpoint = flag.String("endpoint", ":8080", "Endpoint where to listen") + httpx *http.Client + resolver netx.Resolver + srvcancel context.CancelFunc + srvctx context.Context + srvwg = new(sync.WaitGroup) +) + +func init() { + srvctx, srvcancel = context.WithCancel(context.Background()) + dialer = netx.NewDialer(netx.Config{Logger: log.Log}) + txp := netx.NewHTTPTransport(netx.Config{Logger: log.Log}) + httpx = &http.Client{Transport: txp} + resolver = netx.NewResolver(netx.Config{Logger: log.Log}) +} + +func shutdown(srv *http.Server) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + srv.Shutdown(ctx) +} + +func main() { + logmap := map[bool]log.Level{ + true: log.DebugLevel, + false: log.InfoLevel, + } + debug := flag.Bool("debug", false, "Toggle debug mode") + flag.Parse() + log.SetLevel(logmap[*debug]) + testableMain() +} + +func testableMain() { + mux := http.NewServeMux() + mux.Handle("/", internal.Handler{ + Client: httpx, + Dialer: dialer, + MaxAcceptableBody: maxAcceptableBody, + Resolver: resolver, + }) + srv := &http.Server{Addr: *endpoint, Handler: mux} + srvwg.Add(1) + go srv.ListenAndServe() + <-srvctx.Done() + shutdown(srv) + srvwg.Done() +} diff --git a/internal/engine/cmd/oohelperd/oohelperd_test.go b/internal/engine/cmd/oohelperd/oohelperd_test.go new file mode 100644 index 0000000..9a6d486 --- /dev/null +++ b/internal/engine/cmd/oohelperd/oohelperd_test.go @@ -0,0 +1,15 @@ +package main + +import ( + "testing" +) + +func TestSmoke(t *testing.T) { + // Just check whether we can start and then tear down the server, so + // we have coverage of this code and when we see that some lines aren't + // covered we know these are genuine places where we're not testing + // the code rather than just places like this simple main. + go testableMain() + srvcancel() // kills the listener + srvwg.Wait() // joined +} diff --git a/internal/engine/experiment.go b/internal/engine/experiment.go new file mode 100644 index 0000000..891f3be --- /dev/null +++ b/internal/engine/experiment.go @@ -0,0 +1,242 @@ +// Package engine contains the engine API +package engine + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "strconv" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/geolocate" + "github.com/ooni/probe-cli/v3/internal/engine/internal/platform" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/resources" + "github.com/ooni/probe-cli/v3/internal/engine/version" +) + +const dateFormat = "2006-01-02 15:04:05" + +func formatTimeNowUTC() string { + return time.Now().UTC().Format(dateFormat) +} + +// Experiment is an experiment instance. +type Experiment struct { + byteCounter *bytecounter.Counter + callbacks model.ExperimentCallbacks + measurer model.ExperimentMeasurer + report probeservices.ReportChannel + session *Session + testName string + testStartTime string + testVersion string +} + +// NewExperiment creates a new experiment given a measurer. The preferred +// way to create an experiment is the ExperimentBuilder. Though this function +// allows the programmer to create a custom, external experiment. +func NewExperiment(sess *Session, measurer model.ExperimentMeasurer) *Experiment { + return &Experiment{ + byteCounter: bytecounter.New(), + callbacks: model.NewPrinterCallbacks(sess.Logger()), + measurer: measurer, + session: sess, + testName: measurer.ExperimentName(), + testStartTime: formatTimeNowUTC(), + testVersion: measurer.ExperimentVersion(), + } +} + +// KibiBytesReceived accounts for the KibiBytes received by the HTTP clients +// managed by this session so far, including experiments. +func (e *Experiment) KibiBytesReceived() float64 { + return e.byteCounter.KibiBytesReceived() +} + +// KibiBytesSent is like KibiBytesReceived but for the bytes sent. +func (e *Experiment) KibiBytesSent() float64 { + return e.byteCounter.KibiBytesSent() +} + +// Name returns the experiment name. +func (e *Experiment) Name() string { + return e.testName +} + +// GetSummaryKeys returns a data structure containing a +// summary of the test keys for probe-cli. +func (e *Experiment) GetSummaryKeys(m *model.Measurement) (interface{}, error) { + return e.measurer.GetSummaryKeys(m) +} + +// OpenReport is an idempotent method to open a report. We assume that +// you have configured the available probe services, either manually or +// through using the session's MaybeLookupBackends method. +func (e *Experiment) OpenReport() (err error) { + return e.OpenReportContext(context.Background()) +} + +// ReportID returns the open reportID, if we have opened a report +// successfully before, or an empty string, otherwise. +func (e *Experiment) ReportID() string { + if e.report == nil { + return "" + } + return e.report.ReportID() +} + +// Measure performs a measurement with input. We assume that you have +// configured the available test helpers, either manually or by calling +// the session's MaybeLookupBackends() method. +func (e *Experiment) Measure(input string) (*model.Measurement, error) { + return e.MeasureWithContext(context.Background(), input) +} + +// MeasureWithContext is like Measure but with context. +func (e *Experiment) MeasureWithContext( + ctx context.Context, input string, +) (measurement *model.Measurement, err error) { + err = e.session.MaybeLookupLocationContext(ctx) // this already tracks session bytes + if err != nil { + return + } + ctx = dialer.WithSessionByteCounter(ctx, e.session.byteCounter) + ctx = dialer.WithExperimentByteCounter(ctx, e.byteCounter) + measurement = e.newMeasurement(input) + start := time.Now() + err = e.measurer.Run(ctx, e.session, measurement, e.callbacks) + stop := time.Now() + measurement.MeasurementRuntime = stop.Sub(start).Seconds() + scrubErr := measurement.Scrub(e.session.ProbeIP()) + if err == nil { + err = scrubErr + } + return +} + +// SaveMeasurement saves a measurement on the specified file path. +func (e *Experiment) SaveMeasurement(measurement *model.Measurement, filePath string) error { + return e.saveMeasurement( + measurement, filePath, json.Marshal, os.OpenFile, + func(fp *os.File, b []byte) (int, error) { + return fp.Write(b) + }, + ) +} + +// SubmitAndUpdateMeasurement submits a measurement and updates the +// fields whose value has changed as part of the submission. +func (e *Experiment) SubmitAndUpdateMeasurement(measurement *model.Measurement) error { + return e.SubmitAndUpdateMeasurementContext(context.Background(), measurement) +} + +// SubmitAndUpdateMeasurementContext submits a measurement and updates the +// fields whose value has changed as part of the submission. +func (e *Experiment) SubmitAndUpdateMeasurementContext( + ctx context.Context, measurement *model.Measurement) error { + if e.report == nil { + return errors.New("Report is not open") + } + return e.report.SubmitMeasurement(ctx, measurement) +} + +func (e *Experiment) newMeasurement(input string) *model.Measurement { + utctimenow := time.Now().UTC() + m := &model.Measurement{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Input: model.MeasurementTarget(input), + MeasurementStartTime: utctimenow.Format(dateFormat), + MeasurementStartTimeSaved: utctimenow, + ProbeIP: geolocate.DefaultProbeIP, + ProbeASN: e.session.ProbeASNString(), + ProbeCC: e.session.ProbeCC(), + ProbeNetworkName: e.session.ProbeNetworkName(), + ReportID: e.ReportID(), + ResolverASN: e.session.ResolverASNString(), + ResolverIP: e.session.ResolverIP(), + ResolverNetworkName: e.session.ResolverNetworkName(), + SoftwareName: e.session.SoftwareName(), + SoftwareVersion: e.session.SoftwareVersion(), + TestName: e.testName, + TestStartTime: e.testStartTime, + TestVersion: e.testVersion, + } + m.AddAnnotation("assets_version", strconv.FormatInt(resources.Version, 10)) + m.AddAnnotation("engine_name", "ooniprobe-engine") + m.AddAnnotation("engine_version", version.Version) + m.AddAnnotation("platform", platform.Name()) + return m +} + +// OpenReportContext will open a report using the given context +// to possibly limit the lifetime of this operation. +func (e *Experiment) OpenReportContext(ctx context.Context) error { + if e.report != nil { + return nil // already open + } + // use custom client to have proper byte accounting + httpClient := &http.Client{ + Transport: &httptransport.ByteCountingTransport{ + RoundTripper: e.session.httpDefaultTransport, // proxy is OK + Counter: e.byteCounter, + }, + } + if e.session.selectedProbeService == nil { + return errors.New("no probe services selected") + } + client, err := probeservices.NewClient(e.session, *e.session.selectedProbeService) + if err != nil { + e.session.logger.Debugf("%+v", err) + return err + } + client.HTTPClient = httpClient // patch HTTP client to use + template := e.newReportTemplate() + e.report, err = client.OpenReport(ctx, template) + if err != nil { + e.session.logger.Debugf("experiment: probe services error: %s", err.Error()) + return err + } + return nil +} + +func (e *Experiment) newReportTemplate() probeservices.ReportTemplate { + return probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: e.session.ProbeASNString(), + ProbeCC: e.session.ProbeCC(), + SoftwareName: e.session.SoftwareName(), + SoftwareVersion: e.session.SoftwareVersion(), + TestName: e.testName, + TestStartTime: e.testStartTime, + TestVersion: e.testVersion, + } +} + +func (e *Experiment) saveMeasurement( + measurement *model.Measurement, filePath string, + marshal func(v interface{}) ([]byte, error), + openFile func(name string, flag int, perm os.FileMode) (*os.File, error), + write func(fp *os.File, b []byte) (n int, err error), +) error { + data, err := marshal(measurement) + if err != nil { + return err + } + data = append(data, byte('\n')) + filep, err := openFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + if _, err := write(filep, data); err != nil { + return err + } + return filep.Close() +} diff --git a/internal/engine/experiment/README.md b/internal/engine/experiment/README.md new file mode 100644 index 0000000..a440d92 --- /dev/null +++ b/internal/engine/experiment/README.md @@ -0,0 +1,24 @@ +# Directory github.com/ooni/probe-engine/experiment + +This directory contains the implementation of all the supported +experiments, one for each directory. The [OONI spec repository +contains a description of all the specified experiments]( +https://github.com/ooni/spec/tree/master/nettests). + +Note that in the OONI spec repository experiments are called +nettests. Originally, they were also called nettests here but +that created confusion with nettests in [ooni/probe-cli]( +https://github.com/ooni/probe-cli). Therefore, we now use the +term experiment to indicate the implementation and the term +nettest to indicate the user facing view of such implementation. + +Note that some experiments implemented here are not part of +the OONI specification. For example, the [urlgetter](urlgetter) +experiment is not in the OONI spec repository. The reason why +this happens is that `urlgetter` is an experiment "library" that +other experiments use to implement their functionality. + +Likewise, the [example](example) experiment is a minimal +experiment that does nothing and you could use to bootstrap +the implementation of a new experiment. Of course, this +experiment is not part of the OONI specification. diff --git a/internal/engine/experiment/dash/collect.go b/internal/engine/experiment/dash/collect.go new file mode 100644 index 0000000..9e15dcc --- /dev/null +++ b/internal/engine/experiment/dash/collect.go @@ -0,0 +1,57 @@ +package dash + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type collectDeps interface { + HTTPClient() *http.Client + JSONMarshal(v interface{}) ([]byte, error) + Logger() model.Logger + NewHTTPRequest(method string, url string, body io.Reader) (*http.Request, error) + ReadAll(r io.Reader) ([]byte, error) + Scheme() string + UserAgent() string +} + +func collect(ctx context.Context, fqdn, authorization string, + results []clientResults, deps collectDeps) error { + data, err := deps.JSONMarshal(results) + if err != nil { + return err + } + deps.Logger().Debugf("dash: body: %s", string(data)) + var URL url.URL + URL.Scheme = deps.Scheme() + URL.Host = fqdn + URL.Path = collectPath + req, err := deps.NewHTTPRequest("POST", URL.String(), bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("User-Agent", deps.UserAgent()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", authorization) + resp, err := deps.HTTPClient().Do(req.WithContext(ctx)) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return errHTTPRequestFailed + } + defer resp.Body.Close() + data, err = deps.ReadAll(resp.Body) + if err != nil { + return err + } + deps.Logger().Debugf("dash: body: %s", string(data)) + var serverResults []serverResults + return json.Unmarshal(data, &serverResults) +} diff --git a/internal/engine/experiment/dash/collect_test.go b/internal/engine/experiment/dash/collect_test.go new file mode 100644 index 0000000..f5dbd59 --- /dev/null +++ b/internal/engine/experiment/dash/collect_test.go @@ -0,0 +1,113 @@ +package dash + +import ( + "bytes" + "context" + "errors" + "io/ioutil" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestCollectJSONMarshalError(t *testing.T) { + expected := errors.New("mocked error") + deps := FakeDeps{jsonMarshalErr: expected} + err := collect(context.Background(), "", "", nil, deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestCollectNewHTTPRequestFailure(t *testing.T) { + expected := errors.New("mocked error") + deps := FakeDeps{newHTTPRequestErr: expected} + err := collect(context.Background(), "", "", nil, deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestCollectHTTPClientDoFailure(t *testing.T) { + expected := errors.New("mocked error") + txp := FakeHTTPTransport{err: expected} + deps := FakeDeps{httpTransport: txp, newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }} + err := collect(context.Background(), "", "", nil, deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestCollectInternalError(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{StatusCode: 500}} + deps := FakeDeps{httpTransport: txp, newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }} + err := collect(context.Background(), "", "", nil, deps) + if !errors.Is(err, errHTTPRequestFailed) { + t.Fatal("not the error we expected") + } +} + +func TestCollectReadAllFailure(t *testing.T) { + expected := errors.New("mocked error") + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllErr: expected, + } + err := collect(context.Background(), "", "", nil, deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestCollectInvalidJSON(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllResult: []byte("["), + } + err := collect(context.Background(), "", "", nil, deps) + if err == nil || !strings.HasSuffix(err.Error(), "unexpected end of JSON input") { + t.Fatal("not the error we expected") + } +} + +func TestCollectSuccess(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllResult: []byte("[]"), + } + err := collect(context.Background(), "", "", nil, deps) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/engine/experiment/dash/dash.go b/internal/engine/experiment/dash/dash.go new file mode 100644 index 0000000..ae01c22 --- /dev/null +++ b/internal/engine/experiment/dash/dash.go @@ -0,0 +1,307 @@ +// Package dash implements the DASH network experiment. +// +// Spec: https://github.com/ooni/spec/blob/master/nettests/ts-021-dash.md +package dash + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "runtime" + "time" + + "github.com/montanaflynn/stats" + "github.com/ooni/probe-cli/v3/internal/engine/internal/humanizex" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +const ( + defaultTimeout = 120 * time.Second + magicVersion = "0.008000000" + testName = "dash" + testVersion = "0.12.0" + totalStep = 15.0 +) + +var ( + errServerBusy = errors.New("dash: server busy; try again later") + errHTTPRequestFailed = errors.New("dash: request failed") +) + +// Config contains the experiment config. +type Config struct{} + +// Simple contains the experiment total summary +type Simple struct { + ConnectLatency float64 `json:"connect_latency"` + MedianBitrate int64 `json:"median_bitrate"` + MinPlayoutDelay float64 `json:"min_playout_delay"` +} + +// ServerInfo contains information on the selected server +// +// This is currently an extension to the DASH specification +// until the data format of the new mlab locate is clear. +type ServerInfo struct { + Hostname string `json:"hostname"` + Site string `json:"site,omitempty"` +} + +// TestKeys contains the test keys +type TestKeys struct { + Server ServerInfo `json:"server"` + Simple Simple `json:"simple"` + Failure *string `json:"failure"` + ReceiverData []clientResults `json:"receiver_data"` +} + +type runner struct { + callbacks model.ExperimentCallbacks + httpClient *http.Client + saver *trace.Saver + sess model.ExperimentSession + tk *TestKeys +} + +func (r runner) HTTPClient() *http.Client { + return r.httpClient +} + +func (r runner) JSONMarshal(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +func (r runner) Logger() model.Logger { + return r.sess.Logger() +} + +func (r runner) NewHTTPRequest(meth, url string, body io.Reader) (*http.Request, error) { + return http.NewRequest(meth, url, body) +} + +func (r runner) ReadAll(reader io.Reader) ([]byte, error) { + return ioutil.ReadAll(reader) +} + +func (r runner) Scheme() string { + return "https" +} + +func (r runner) UserAgent() string { + return r.sess.UserAgent() +} + +func (r runner) loop(ctx context.Context, numIterations int64) error { + locateResult, err := locate(ctx, r) + if err != nil { + return err + } + r.tk.Server = ServerInfo{ + Hostname: locateResult.FQDN, + Site: locateResult.Site, + } + fqdn := locateResult.FQDN + r.callbacks.OnProgress(0.0, fmt.Sprintf("streaming: server: %s", fqdn)) + negotiateResp, err := negotiate(ctx, fqdn, r) + if err != nil { + return err + } + if err := r.measure(ctx, fqdn, negotiateResp, numIterations); err != nil { + return err + } + // TODO(bassosimone): it seems we're not saving the server data? + err = collect(ctx, fqdn, negotiateResp.Authorization, r.tk.ReceiverData, r) + if err != nil { + return err + } + return r.tk.analyze() +} + +func (r runner) measure( + ctx context.Context, fqdn string, negotiateResp negotiateResponse, + numIterations int64) error { + // Note: according to a comment in MK sources 3000 kbit/s was the + // minimum speed recommended by Netflix for SD quality in 2017. + // + // See: . + const initialBitrate = 3000 + current := clientResults{ + ElapsedTarget: 2, + Platform: runtime.GOOS, + Rate: initialBitrate, + RealAddress: negotiateResp.RealAddress, + Version: magicVersion, + } + var ( + begin = time.Now() + connectTime float64 + total int64 + ) + for current.Iteration < numIterations { + result, err := download(ctx, downloadConfig{ + authorization: negotiateResp.Authorization, + begin: begin, + currentRate: current.Rate, + deps: r, + elapsedTarget: current.ElapsedTarget, + fqdn: fqdn, + }) + if err != nil { + // Implementation note: ndt7 controls the connection much + // more than us and it can tell whether an error occurs when + // connecting or later. We cannot say that very precisely + // because, in principle, we may reconnect. So we always + // return error here. This comment is being introduced so + // that we don't do https://github.com/ooni/probe-cli/v3/internal/engine/pull/526 + // again, because that isn't accurate. + return err + } + current.Elapsed = result.elapsed + current.Received = result.received + current.RequestTicks = result.requestTicks + current.Timestamp = result.timestamp + current.ServerURL = result.serverURL + // Read the events so far and possibly update our measurement + // of the latest connect time. We should have one sample in most + // cases, because the connection should be persistent. + for _, ev := range r.saver.Read() { + if ev.Name == errorx.ConnectOperation { + connectTime = ev.Duration.Seconds() + } + } + current.ConnectTime = connectTime + r.tk.ReceiverData = append(r.tk.ReceiverData, current) + total += current.Received + avgspeed := 8 * float64(total) / time.Now().Sub(begin).Seconds() + percentage := float64(current.Iteration) / float64(numIterations) + message := fmt.Sprintf("streaming: speed: %s", humanizex.SI(avgspeed, "bit/s")) + r.callbacks.OnProgress(percentage, message) + current.Iteration++ + speed := float64(current.Received) / float64(current.Elapsed) + speed *= 8.0 // to bits per second + speed /= 1000.0 // to kbit/s + current.Rate = int64(speed) + } + return nil +} + +func (tk *TestKeys) analyze() error { + var ( + rates []float64 + frameReadyTime float64 + playTime float64 + ) + for _, results := range tk.ReceiverData { + rates = append(rates, float64(results.Rate)) + // Same in all samples if we're using a single connection + tk.Simple.ConnectLatency = results.ConnectTime + // Rationale: first segment plays when it arrives. Subsequent segments + // would play in ElapsedTarget seconds. However, will play when they + // arrive. Stall is the time we need to wait for a frame to arrive with + // the video stopped and the spinning icon. + frameReadyTime += results.Elapsed + if playTime == 0.0 { + playTime += frameReadyTime + } else { + playTime += float64(results.ElapsedTarget) + } + stall := frameReadyTime - playTime + if stall > tk.Simple.MinPlayoutDelay { + tk.Simple.MinPlayoutDelay = stall + } + } + median, err := stats.Median(rates) + tk.Simple.MedianBitrate = int64(median) + return err +} + +func (r runner) do(ctx context.Context) error { + defer r.callbacks.OnProgress(1, "streaming: done") + const numIterations = 15 + err := r.loop(ctx, numIterations) + if err != nil { + s := err.Error() + r.tk.Failure = &s + // fallthrough + } + return err +} + +// Measurer performs the measurement. +type Measurer struct { + config Config +} + +// ExperimentName implements model.ExperimentMeasurer.ExperimentName. +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements model.ExperimentMeasurer.ExperimentVersion. +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements model.ExperimentMeasurer.Run. +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + tk := new(TestKeys) + measurement.TestKeys = tk + saver := &trace.Saver{} + httpClient := &http.Client{ + Transport: netx.NewHTTPTransport(netx.Config{ + ContextByteCounting: true, + DialSaver: saver, + Logger: sess.Logger(), + }), + } + defer httpClient.CloseIdleConnections() + r := runner{ + callbacks: callbacks, + httpClient: httpClient, + saver: saver, + sess: sess, + tk: tk, + } + ctx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + return r.do(ctx) +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + Latency float64 `json:"connect_latency"` + Bitrate float64 `json:"median_bitrate"` + Delay float64 `json:"min_playout_delay"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + sk.Latency = tk.Simple.ConnectLatency + sk.Bitrate = float64(tk.Simple.MedianBitrate) + sk.Delay = tk.Simple.MinPlayoutDelay + return sk, nil +} diff --git a/internal/engine/experiment/dash/dash_test.go b/internal/engine/experiment/dash/dash_test.go new file mode 100644 index 0000000..efe0908 --- /dev/null +++ b/internal/engine/experiment/dash/dash_test.go @@ -0,0 +1,328 @@ +package dash + +import ( + "context" + "errors" + "io/ioutil" + "net/http" + "strings" + "testing" + "time" + + "github.com/apex/log" + "github.com/montanaflynn/stats" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestRunnerLoopLocateFailure(t *testing.T) { + expected := errors.New("mocked error") + r := runner{ + callbacks: model.NewPrinterCallbacks(log.Log), + httpClient: &http.Client{ + Transport: FakeHTTPTransport{ + err: expected, + }, + }, + saver: new(trace.Saver), + sess: &mockable.Session{ + MockableLogger: log.Log, + }, + tk: new(TestKeys), + } + err := r.loop(context.Background(), 1) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerLoopNegotiateFailure(t *testing.T) { + expected := errors.New("mocked error") + r := runner{ + callbacks: model.NewPrinterCallbacks(log.Log), + httpClient: &http.Client{ + Transport: &FakeHTTPTransportStack{ + all: []FakeHTTPTransport{ + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader( + `{"fqdn": "ams01.measurementlab.net"}`)), + StatusCode: 200, + }, + }, + {err: expected}, + }, + }, + }, + saver: new(trace.Saver), + sess: &mockable.Session{ + MockableLogger: log.Log, + }, + tk: new(TestKeys), + } + err := r.loop(context.Background(), 1) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerLoopMeasureFailure(t *testing.T) { + expected := errors.New("mocked error") + r := runner{ + callbacks: model.NewPrinterCallbacks(log.Log), + httpClient: &http.Client{ + Transport: &FakeHTTPTransportStack{ + all: []FakeHTTPTransport{ + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader( + `{"fqdn": "ams01.measurementlab.net"}`)), + StatusCode: 200, + }, + }, + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader( + `{"authorization": "xx", "unchoked": 1}`)), + StatusCode: 200, + }, + }, + {err: expected}, + }, + }, + }, + saver: new(trace.Saver), + sess: &mockable.Session{ + MockableLogger: log.Log, + }, + tk: new(TestKeys), + } + err := r.loop(context.Background(), 1) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerLoopCollectFailure(t *testing.T) { + expected := errors.New("mocked error") + saver := new(trace.Saver) + saver.Write(trace.Event{Name: errorx.ConnectOperation, Duration: 150 * time.Millisecond}) + r := runner{ + callbacks: model.NewPrinterCallbacks(log.Log), + httpClient: &http.Client{ + Transport: &FakeHTTPTransportStack{ + all: []FakeHTTPTransport{ + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader( + `{"fqdn": "ams01.measurementlab.net"}`)), + StatusCode: 200, + }, + }, + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader( + `{"authorization": "xx", "unchoked": 1}`)), + StatusCode: 200, + }, + }, + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader(`1234567`)), + StatusCode: 200, + }, + }, + {err: expected}, + }, + }, + }, + saver: saver, + sess: &mockable.Session{ + MockableLogger: log.Log, + }, + tk: new(TestKeys), + } + err := r.loop(context.Background(), 1) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerLoopSuccess(t *testing.T) { + saver := new(trace.Saver) + saver.Write(trace.Event{Name: errorx.ConnectOperation, Duration: 150 * time.Millisecond}) + r := runner{ + callbacks: model.NewPrinterCallbacks(log.Log), + httpClient: &http.Client{ + Transport: &FakeHTTPTransportStack{ + all: []FakeHTTPTransport{ + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader( + `{"fqdn": "ams01.measurementlab.net"}`)), + StatusCode: 200, + }, + }, + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader( + `{"authorization": "xx", "unchoked": 1}`)), + StatusCode: 200, + }, + }, + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader(`1234567`)), + StatusCode: 200, + }, + }, + { + resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader(`[]`)), + StatusCode: 200, + }, + }, + }, + }, + }, + saver: saver, + sess: &mockable.Session{ + MockableLogger: log.Log, + }, + tk: new(TestKeys), + } + err := r.loop(context.Background(), 1) + if err != nil { + t.Fatal(err) + } +} + +func TestTestKeysAnalyzeWithNoData(t *testing.T) { + tk := &TestKeys{} + err := tk.analyze() + if !errors.Is(err, stats.EmptyInputErr) { + t.Fatal("expected an error here") + } +} + +func TestTestKeysAnalyzeMedian(t *testing.T) { + tk := &TestKeys{ + ReceiverData: []clientResults{ + { + Rate: 1, + }, + { + Rate: 2, + }, + { + Rate: 3, + }, + }, + } + err := tk.analyze() + if err != nil { + t.Fatal(err) + } + if tk.Simple.MedianBitrate != 2 { + t.Fatal("unexpected median value") + } +} + +func TestTestKeysAnalyzeMinPlayoutDelay(t *testing.T) { + tk := &TestKeys{ + ReceiverData: []clientResults{ + { + ElapsedTarget: 2, + Elapsed: 1.4, + }, + { + ElapsedTarget: 2, + Elapsed: 3.0, + }, + { + ElapsedTarget: 2, + Elapsed: 1.8, + }, + }, + } + err := tk.analyze() + if err != nil { + t.Fatal(err) + } + if tk.Simple.MinPlayoutDelay < 0.99 || tk.Simple.MinPlayoutDelay > 1.01 { + t.Fatal("unexpected min-playout-delay value") + } +} + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "dash" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.12.0" { + t.Fatal("unexpected version") + } +} + +func TestMeasureWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cause failure + measurement := new(model.Measurement) + m := &Measurer{} + err := m.Run( + ctx, + &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, context.Canceled) { + t.Fatal("unexpected error value") + } + sk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysGood(t *testing.T) { + measurement := &model.Measurement{TestKeys: &TestKeys{Simple: Simple{ + ConnectLatency: 1234, + MedianBitrate: 123, + MinPlayoutDelay: 12, + }}} + m := &Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(SummaryKeys) + if sk.Latency != 1234 { + t.Fatal("invalid latency") + } + if sk.Bitrate != 123 { + t.Fatal("invalid bitrate") + } + if sk.Delay != 12 { + t.Fatal("invalid delay") + } + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/dash/download.go b/internal/engine/experiment/dash/download.go new file mode 100644 index 0000000..1d860d2 --- /dev/null +++ b/internal/engine/experiment/dash/download.go @@ -0,0 +1,74 @@ +package dash + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +type downloadDeps interface { + HTTPClient() *http.Client + NewHTTPRequest(method string, url string, body io.Reader) (*http.Request, error) + ReadAll(r io.Reader) ([]byte, error) + Scheme() string + UserAgent() string +} + +type downloadConfig struct { + authorization string + begin time.Time + currentRate int64 + deps downloadDeps + elapsedTarget int64 + fqdn string +} + +type downloadResult struct { + elapsed float64 + received int64 + requestTicks float64 + serverURL string + timestamp int64 +} + +func download(ctx context.Context, config downloadConfig) (downloadResult, error) { + nbytes := (config.currentRate * 1000 * config.elapsedTarget) >> 3 + var URL url.URL + URL.Scheme = config.deps.Scheme() + URL.Host = config.fqdn + URL.Path = fmt.Sprintf("%s%d", downloadPath, nbytes) + req, err := config.deps.NewHTTPRequest("GET", URL.String(), nil) + var result downloadResult + if err != nil { + return result, err + } + result.serverURL = URL.String() + req.Header.Set("User-Agent", config.deps.UserAgent()) + req.Header.Set("Authorization", config.authorization) + savedTicks := time.Now() + resp, err := config.deps.HTTPClient().Do(req.WithContext(ctx)) + if err != nil { + return result, err + } + if resp.StatusCode != 200 { + return result, errHTTPRequestFailed + } + defer resp.Body.Close() + data, err := config.deps.ReadAll(resp.Body) + if err != nil { + return result, err + } + // Implementation note: MK contains a comment that says that Neubot uses + // the elapsed time since when we start receiving the response but it + // turns out that Neubot and MK do the same. So, we do what they do. At + // the same time, we are currently not able to include the overhead that + // is caused by HTTP headers etc. So, we're a bit less precise. + result.elapsed = time.Now().Sub(savedTicks).Seconds() + result.received = int64(len(data)) + result.requestTicks = savedTicks.Sub(config.begin).Seconds() + result.timestamp = time.Now().Unix() + return result, nil +} diff --git a/internal/engine/experiment/dash/download_test.go b/internal/engine/experiment/dash/download_test.go new file mode 100644 index 0000000..69a5bf4 --- /dev/null +++ b/internal/engine/experiment/dash/download_test.go @@ -0,0 +1,104 @@ +package dash + +import ( + "bytes" + "context" + "errors" + "io/ioutil" + "net/http" + "net/url" + "testing" +) + +func TestDownloadNewHTTPRequestFailure(t *testing.T) { + expected := errors.New("mocked error") + _, err := download(context.Background(), downloadConfig{ + deps: FakeDeps{newHTTPRequestErr: expected}, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestDownloadHTTPClientDoFailure(t *testing.T) { + expected := errors.New("mocked error") + txp := FakeHTTPTransport{err: expected} + _, err := download(context.Background(), downloadConfig{ + deps: FakeDeps{httpTransport: txp, newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }}, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestDownloadInternalError(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{StatusCode: 500}} + _, err := download(context.Background(), downloadConfig{ + deps: FakeDeps{httpTransport: txp, newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }}, + }) + if !errors.Is(err, errHTTPRequestFailed) { + t.Fatal("not the error we expected") + } +} + +func TestDownloadReadAllFailure(t *testing.T) { + expected := errors.New("mocked error") + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + _, err := download(context.Background(), downloadConfig{ + deps: FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllErr: expected, + }, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestDownloadSuccess(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + result, err := download(context.Background(), downloadConfig{ + deps: FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllResult: []byte("[]"), + }, + }) + if err != nil { + t.Fatal(err) + } + if result.elapsed <= 0 { + t.Fatal("invalid elapsed") + } + if result.received <= 0 { + t.Fatal("invalid received") + } + if result.requestTicks <= 0 { + t.Fatal("invalid requestTicks") + } + if result.serverURL == "" { + t.Fatal("invalid serverURL") + } + if result.timestamp <= 0 { + t.Fatal("invalid timestamp") + } +} diff --git a/internal/engine/experiment/dash/fake_test.go b/internal/engine/experiment/dash/fake_test.go new file mode 100644 index 0000000..baa9b5c --- /dev/null +++ b/internal/engine/experiment/dash/fake_test.go @@ -0,0 +1,69 @@ +package dash + +import ( + "io" + "net/http" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type FakeDeps struct { + httpTransport http.RoundTripper + jsonMarshalErr error + jsonMarshalResult []byte + newHTTPRequestErr error + newHTTPRequestResult *http.Request + readAllErr error + readAllResult []byte +} + +func (d FakeDeps) HTTPClient() *http.Client { + return &http.Client{Transport: d.httpTransport} +} + +func (d FakeDeps) JSONMarshal(v interface{}) ([]byte, error) { + return d.jsonMarshalResult, d.jsonMarshalErr +} + +func (d FakeDeps) Logger() model.Logger { + return log.Log +} + +func (d FakeDeps) NewHTTPRequest( + method string, url string, body io.Reader) (*http.Request, error) { + return d.newHTTPRequestResult, d.newHTTPRequestErr +} + +func (d FakeDeps) ReadAll(r io.Reader) ([]byte, error) { + return d.readAllResult, d.readAllErr +} + +func (d FakeDeps) Scheme() string { + return "https" +} + +func (d FakeDeps) UserAgent() string { + return "miniooni/0.1.0-dev" +} + +type FakeHTTPTransport struct { + err error + resp *http.Response +} + +func (txp FakeHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + time.Sleep(10 * time.Microsecond) + return txp.resp, txp.err +} + +type FakeHTTPTransportStack struct { + all []FakeHTTPTransport +} + +func (txp *FakeHTTPTransportStack) RoundTrip(req *http.Request) (*http.Response, error) { + frame := txp.all[0] + txp.all = txp.all[1:] + return frame.RoundTrip(req) +} diff --git a/internal/engine/experiment/dash/locate.go b/internal/engine/experiment/dash/locate.go new file mode 100644 index 0000000..3542b14 --- /dev/null +++ b/internal/engine/experiment/dash/locate.go @@ -0,0 +1,20 @@ +package dash + +import ( + "context" + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/mlablocate" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type locateDeps interface { + HTTPClient() *http.Client + Logger() model.Logger + UserAgent() string +} + +func locate(ctx context.Context, deps locateDeps) (mlablocate.Result, error) { + return mlablocate.NewClient( + deps.HTTPClient(), deps.Logger(), deps.UserAgent()).Query(ctx, "neubot") +} diff --git a/internal/engine/experiment/dash/model.go b/internal/engine/experiment/dash/model.go new file mode 100644 index 0000000..fb6df0b --- /dev/null +++ b/internal/engine/experiment/dash/model.go @@ -0,0 +1,47 @@ +package dash + +// clientResults contains the results measured by the client. This data +// structure is sent to the server in the collection phase. +// +// All the fields listed here are part of the original specification +// of DASH, except ServerURL, added in MK v0.10.6. +type clientResults struct { + ConnectTime float64 `json:"connect_time"` + DeltaSysTime float64 `json:"delta_sys_time"` + DeltaUserTime float64 `json:"delta_user_time"` + Elapsed float64 `json:"elapsed"` + ElapsedTarget int64 `json:"elapsed_target"` + InternalAddress string `json:"internal_address"` + Iteration int64 `json:"iteration"` + Platform string `json:"platform"` + Rate int64 `json:"rate"` + RealAddress string `json:"real_address"` + Received int64 `json:"received"` + RemoteAddress string `json:"remote_address"` + RequestTicks float64 `json:"request_ticks"` + ServerURL string `json:"server_url"` + Timestamp int64 `json:"timestamp"` + UUID string `json:"uuid"` + Version string `json:"version"` +} + +// serverResults contains the server results. This data structure is sent +// to the client during the collection phase of DASH. +type serverResults struct { + Iteration int64 `json:"iteration"` + Ticks float64 `json:"ticks"` + Timestamp int64 `json:"timestamp"` +} + +// negotiateRequest contains the request of negotiation +type negotiateRequest struct { + DASHRates []int64 `json:"dash_rates"` +} + +// negotiateResponse contains the response of negotiation +type negotiateResponse struct { + Authorization string `json:"authorization"` + QueuePos int64 `json:"queue_pos"` + RealAddress string `json:"real_address"` + Unchoked int `json:"unchoked"` +} diff --git a/internal/engine/experiment/dash/negotiate.go b/internal/engine/experiment/dash/negotiate.go new file mode 100644 index 0000000..71968c4 --- /dev/null +++ b/internal/engine/experiment/dash/negotiate.go @@ -0,0 +1,68 @@ +package dash + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type negotiateDeps interface { + HTTPClient() *http.Client + JSONMarshal(v interface{}) ([]byte, error) + Logger() model.Logger + NewHTTPRequest(method string, url string, body io.Reader) (*http.Request, error) + ReadAll(r io.Reader) ([]byte, error) + Scheme() string + UserAgent() string +} + +func negotiate( + ctx context.Context, fqdn string, deps negotiateDeps) (negotiateResponse, error) { + var negotiateResp negotiateResponse + data, err := deps.JSONMarshal(negotiateRequest{DASHRates: defaultRates}) + if err != nil { + return negotiateResp, err + } + deps.Logger().Debugf("dash: body: %s", string(data)) + var URL url.URL + URL.Scheme = deps.Scheme() + URL.Host = fqdn + URL.Path = negotiatePath + req, err := deps.NewHTTPRequest("POST", URL.String(), bytes.NewReader(data)) + if err != nil { + return negotiateResp, err + } + req.Header.Set("User-Agent", deps.UserAgent()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "") + resp, err := deps.HTTPClient().Do(req.WithContext(ctx)) + if err != nil { + return negotiateResp, err + } + if resp.StatusCode != 200 { + return negotiateResp, errHTTPRequestFailed + } + defer resp.Body.Close() + data, err = deps.ReadAll(resp.Body) + if err != nil { + return negotiateResp, err + } + deps.Logger().Debugf("dash: body: %s", string(data)) + err = json.Unmarshal(data, &negotiateResp) + if err != nil { + return negotiateResp, err + } + // Implementation oddity: Neubot is using an integer rather than a + // boolean for the unchoked, with obvious semantics. I wonder why + // I choose an integer over a boolean, given that Python does have + // support for booleans. I don't remember 🤷. + if negotiateResp.Authorization == "" || negotiateResp.Unchoked == 0 { + return negotiateResp, errServerBusy + } + return negotiateResp, nil +} diff --git a/internal/engine/experiment/dash/negotiate_test.go b/internal/engine/experiment/dash/negotiate_test.go new file mode 100644 index 0000000..295a752 --- /dev/null +++ b/internal/engine/experiment/dash/negotiate_test.go @@ -0,0 +1,178 @@ +package dash + +import ( + "bytes" + "context" + "errors" + "io/ioutil" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestNegotiateJSONMarshalError(t *testing.T) { + expected := errors.New("mocked error") + deps := FakeDeps{jsonMarshalErr: expected} + result, err := negotiate(context.Background(), "", deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateNewHTTPRequestFailure(t *testing.T) { + expected := errors.New("mocked error") + deps := FakeDeps{newHTTPRequestErr: expected} + result, err := negotiate(context.Background(), "", deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateHTTPClientDoFailure(t *testing.T) { + expected := errors.New("mocked error") + txp := FakeHTTPTransport{err: expected} + deps := FakeDeps{httpTransport: txp, newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }} + result, err := negotiate(context.Background(), "", deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateInternalError(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{StatusCode: 500}} + deps := FakeDeps{httpTransport: txp, newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }} + result, err := negotiate(context.Background(), "", deps) + if !errors.Is(err, errHTTPRequestFailed) { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateReadAllFailure(t *testing.T) { + expected := errors.New("mocked error") + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllErr: expected, + } + result, err := negotiate(context.Background(), "", deps) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateInvalidJSON(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllResult: []byte("["), + } + result, err := negotiate(context.Background(), "", deps) + if err == nil || !strings.HasSuffix(err.Error(), "unexpected end of JSON input") { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateServerBusyFirstCase(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllResult: []byte(`{"authorization": ""}`), + } + result, err := negotiate(context.Background(), "", deps) + if !errors.Is(err, errServerBusy) { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateServerBusyThirdCase(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllResult: []byte(`{}`), + } + result, err := negotiate(context.Background(), "", deps) + if !errors.Is(err, errServerBusy) { + t.Fatal("not the error we expected") + } + if result.Authorization != "" || result.Unchoked != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegotiateSuccess(t *testing.T) { + txp := FakeHTTPTransport{resp: &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader(nil)), + StatusCode: 200, + }} + deps := FakeDeps{ + httpTransport: txp, + newHTTPRequestResult: &http.Request{ + Header: http.Header{}, + URL: &url.URL{}, + }, + readAllResult: []byte(`{"authorization": "xx", "unchoked": 1}`), + } + result, err := negotiate(context.Background(), "", deps) + if err != nil { + t.Fatal(err) + } + if result.Authorization != "xx" || result.Unchoked != 1 { + t.Fatal("invalid result") + } +} diff --git a/internal/engine/experiment/dash/spec.go b/internal/engine/experiment/dash/spec.go new file mode 100644 index 0000000..b689b69 --- /dev/null +++ b/internal/engine/experiment/dash/spec.go @@ -0,0 +1,26 @@ +package dash + +const ( + // currentServerSchemaVersion is the version of the server schema that + // will be adopted by this implementation. Version 3 is the one that is + // Neubot uses. We needed to bump the version because Web100 is not on + // M-Lab anymore and hence we need to make a breaking change. + currentServerSchemaVersion = 4 + + // negotiatePath is the URL path used to negotiate + negotiatePath = "/negotiate/dash" + + // downloadPath is the URL path used to request DASH segments. You can + // append to this path an integer indicating how many bytes you would like + // the server to send you as part of the next chunk. + downloadPath = "/dash/download/" + + // collectPath is the URL path used to collect + collectPath = "/collect/dash" +) + +// defaultRates contains the default DASH rates in kbit/s. +var defaultRates = []int64{ + 100, 150, 200, 250, 300, 400, 500, 700, 900, 1200, 1500, 2000, + 2500, 3000, 4000, 5000, 6000, 7000, 10000, 20000, +} diff --git a/internal/engine/experiment/dnscheck/dnscheck.go b/internal/engine/experiment/dnscheck/dnscheck.go new file mode 100644 index 0000000..f4df2ca --- /dev/null +++ b/internal/engine/experiment/dnscheck/dnscheck.go @@ -0,0 +1,323 @@ +// Package dnscheck contains the DNS check experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-028-dnscheck.md. +package dnscheck + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "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/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +const ( + testName = "dnscheck" + testVersion = "0.9.0" + defaultDomain = "example.org" +) + +// Endpoints keeps track of repeatedly measured endpoints. +type Endpoints struct { + WaitTime time.Duration + count uint32 + nextVisit map[string]time.Time + mu sync.Mutex +} + +func (e *Endpoints) maybeSleep(resolverURL string, logger model.Logger) { + if e == nil { + return + } + defer e.mu.Unlock() + e.mu.Lock() + nextTime, found := e.nextVisit[resolverURL] + now := time.Now() + if !found || now.After(nextTime) { + return + } + sleepTime := nextTime.Sub(now) + atomic.AddUint32(&e.count, 1) + logger.Infof("waiting %v before testing %s again", sleepTime, resolverURL) + time.Sleep(sleepTime) +} + +func (e *Endpoints) maybeRegister(resolverURL string) { + if e != nil && !strings.HasPrefix(resolverURL, "udp://") { + defer e.mu.Unlock() + e.mu.Lock() + if e.nextVisit == nil { + e.nextVisit = make(map[string]time.Time) + } + waitTime := 180 * time.Second + if e.WaitTime > 0 { + waitTime = e.WaitTime + } + e.nextVisit[resolverURL] = time.Now().Add(waitTime) + } +} + +// Config contains the experiment's configuration. +type Config struct { + DefaultAddrs string `json:"default_addrs" ooni:"default addresses for domain"` + Domain string `json:"domain" ooni:"domain to resolve using the specified resolver"` + HTTP3Enabled bool `json:"http3_enabled" ooni:"use http3 instead of http/1.1 or http2"` + HTTPHost string `json:"http_host" ooni:"force using specific HTTP Host header"` + TLSServerName string `json:"tls_server_name" ooni:"force TLS to using a specific SNI in Client Hello"` + TLSVersion string `json:"tls_version" ooni:"Force specific TLS version (e.g. 'TLSv1.3')"` +} + +// TestKeys contains the results of the dnscheck experiment. +type TestKeys struct { + DefaultAddrs string `json:"x_default_addrs"` + Domain string `json:"domain"` + HTTP3Enabled bool `json:"x_http3_enabled,omitempty"` + HTTPHost string `json:"x_http_host,omitempty"` + TLSServerName string `json:"x_tls_server_name,omitempty"` + TLSVersion string `json:"x_tls_version,omitempty"` + Bootstrap *urlgetter.TestKeys `json:"bootstrap"` + BootstrapFailure *string `json:"bootstrap_failure"` + Lookups map[string]urlgetter.TestKeys `json:"lookups"` +} + +// Measurer performs the measurement. +type Measurer struct { + Config + Endpoints *Endpoints +} + +// ExperimentName implements model.ExperimentSession.ExperimentName +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements model.ExperimentSession.ExperimentVersion +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +// The following errors may be returned by this experiment. Of course these +// errors are in addition to any other errors returned by the low level packages +// that are used by this experiment to implement its functionality. +var ( + ErrInputRequired = errors.New("this experiment needs input") + ErrInvalidURL = errors.New("the input URL is invalid") + ErrUnsupportedURLScheme = errors.New("unsupported URL scheme") +) + +// Run implements model.ExperimentSession.Run +func (m *Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + // 1. fill the measurement with test keys + tk := new(TestKeys) + tk.Lookups = make(map[string]urlgetter.TestKeys) + measurement.TestKeys = tk + urlgetter.RegisterExtensions(measurement) + + // 2. select the domain to resolve or use default and, while there, also + // ensure that we register all the other options we're using. + domain := m.Config.Domain + if domain == "" { + domain = defaultDomain + } + tk.DefaultAddrs = m.Config.DefaultAddrs + tk.Domain = domain + tk.HTTP3Enabled = m.Config.HTTP3Enabled + tk.HTTPHost = m.Config.HTTPHost + tk.TLSServerName = m.Config.TLSServerName + tk.TLSVersion = m.Config.TLSVersion + + // 3. parse the input URL describing the resolver to use + input := string(measurement.Input) + if input == "" { + return ErrInputRequired + } + URL, err := url.Parse(input) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidURL, err.Error()) + } + switch URL.Scheme { + case "https", "dot", "udp", "tcp": + // all good + default: + return ErrUnsupportedURLScheme + } + + // 4. possibly expand a domain to a list of IP addresses. + // + // Implementation note: because the resolver we constructed also deals + // with IP addresses successfully, we just get back the IPs when we are + // passing as input an IP address rather than a domain name. + begin := measurement.MeasurementStartTimeSaved + evsaver := new(trace.Saver) + resolver := netx.NewResolver(netx.Config{ + BogonIsError: true, + Logger: sess.Logger(), + ResolveSaver: evsaver, + }) + addrs, err := m.lookupHost(ctx, URL.Hostname(), resolver) + queries := archival.NewDNSQueriesList(begin, evsaver.Read(), sess.ASNDatabasePath()) + tk.BootstrapFailure = archival.NewFailure(err) + if len(queries) > 0 { + // We get no queries in case we are resolving an IP address, since + // the address resolver doesn't generate events + tk.Bootstrap = &urlgetter.TestKeys{Queries: queries} + } + + // 5. merge default addresses for the domain with the ones that + // we did discover here and measure them all. + allAddrs := make(map[string]bool) + for _, addr := range addrs { + allAddrs[addr] = true + } + for _, addr := range strings.Split(m.Config.DefaultAddrs, " ") { + if addr != "" { + allAddrs[addr] = true + } + } + + // 6. determine all the domain lookups we need to perform + const maxParallelism = 10 + parallelism := maxParallelism + if parallelism > len(allAddrs) { + parallelism = len(allAddrs) + } + var inputs []urlgetter.MultiInput + multi := urlgetter.Multi{Begin: begin, Parallelism: parallelism, Session: sess} + for addr := range allAddrs { + inputs = append(inputs, urlgetter.MultiInput{ + Config: urlgetter.Config{ + DNSHTTPHost: m.httpHost(URL.Host), + DNSTLSServerName: m.tlsServerName(URL.Hostname()), + DNSTLSVersion: m.Config.TLSVersion, + HTTP3Enabled: m.Config.HTTP3Enabled, + RejectDNSBogons: true, // bogons are errors in this context + ResolverURL: makeResolverURL(URL, addr), + Timeout: 45 * time.Second, + }, + Target: fmt.Sprintf("dnslookup://%s", domain), // urlgetter wants a URL + }) + } + + // 7. make sure we don't test the same endpoint too frequently + // because this may cause residual censorship. + for _, input := range inputs { + resolverURL := input.Config.ResolverURL + m.Endpoints.maybeSleep(resolverURL, sess.Logger()) + } + + // 8. perform all the required resolutions + for output := range Collect(ctx, multi, inputs, callbacks) { + resolverURL := output.Input.Config.ResolverURL + tk.Lookups[resolverURL] = output.TestKeys + m.Endpoints.maybeRegister(resolverURL) + } + return nil +} + +func (m *Measurer) lookupHost(ctx context.Context, hostname string, r netx.Resolver) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + return r.LookupHost(ctx, hostname) +} + +// httpHost returns the configured HTTP host, if set, otherwise +// it will return the host provide as argument. +func (m *Measurer) httpHost(httpHost string) string { + if m.Config.HTTPHost != "" { + return m.Config.HTTPHost + } + return httpHost +} + +// tlsServerName is like httpHost for the TLS server name. +func (m *Measurer) tlsServerName(tlsServerName string) string { + if m.Config.TLSServerName != "" { + return m.Config.TLSServerName + } + return tlsServerName +} + +// Collect prints on the output channel the result of running dnscheck +// on every provided input. It closes the output channel when done. +func Collect(ctx context.Context, multi urlgetter.Multi, inputs []urlgetter.MultiInput, + callbacks model.ExperimentCallbacks) <-chan urlgetter.MultiOutput { + outputch := make(chan urlgetter.MultiOutput) + expect := len(inputs) + inputch := multi.Run(ctx, inputs) + go func() { + var count int + defer close(outputch) + for count < expect { + entry := <-inputch + count++ + percentage := float64(count) / float64(expect) + callbacks.OnProgress(percentage, fmt.Sprintf( + "dnscheck: measure %s: %+v", entry.Input.Config.ResolverURL, entry.Err, + )) + outputch <- entry + } + }() + return outputch +} + +// makeResolverURL rewrites the input URL to replace the domain in +// the input URL with the given addr. When the input URL already contains +// an addr, this operation will return the same URL. +func makeResolverURL(URL *url.URL, addr string) string { + // 1. determine the hostname in the resulting URL + hostname := URL.Hostname() + if net.ParseIP(hostname) == nil { + hostname = addr + } + // 2. adjust hostname if we also have a port + if hasPort := URL.Port() != ""; hasPort { + _, port, err := net.SplitHostPort(URL.Host) + // We say this cannot fail because we already parsed the URL to validate + // its scheme and hence the URL hostname should be well formed. + runtimex.PanicOnError(err, "net.SplitHostPort should not fail here") + hostname = net.JoinHostPort(hostname, port) + } else if idx := strings.Index(addr, ":"); idx >= 0 { + // Make sure an IPv6 address hostname without a port is properly + // quoted to avoid breaking the URL parser down the line. + hostname = "[" + addr + "]" + } + // 3. reassemble the URL + return (&url.URL{ + Scheme: URL.Scheme, + Host: hostname, + Path: URL.Path, + RawQuery: URL.RawQuery, + }).String() +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/dnscheck/dnscheck_test.go b/internal/engine/experiment/dnscheck/dnscheck_test.go new file mode 100644 index 0000000..09de9d5 --- /dev/null +++ b/internal/engine/experiment/dnscheck/dnscheck_test.go @@ -0,0 +1,227 @@ +package dnscheck + +import ( + "context" + "errors" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestHTTPHostWithOverride(t *testing.T) { + m := Measurer{Config: Config{HTTPHost: "antani"}} + result := m.httpHost("mascetti") + if result != "antani" { + t.Fatal("not the result we expected") + } +} + +func TestHTTPHostWithoutOverride(t *testing.T) { + m := Measurer{Config: Config{}} + result := m.httpHost("mascetti") + if result != "mascetti" { + t.Fatal("not the result we expected") + } +} + +func TestTLSServerNameWithOverride(t *testing.T) { + m := Measurer{Config: Config{TLSServerName: "antani"}} + result := m.tlsServerName("mascetti") + if result != "antani" { + t.Fatal("not the result we expected") + } +} + +func TestTLSServerNameWithoutOverride(t *testing.T) { + m := Measurer{Config: Config{}} + result := m.tlsServerName("mascetti") + if result != "mascetti" { + t.Fatal("not the result we expected") + } +} + +func TestExperimentNameAndVersion(t *testing.T) { + measurer := NewExperimentMeasurer(Config{Domain: "example.com"}) + if measurer.ExperimentName() != "dnscheck" { + t.Error("unexpected experiment name") + } + if measurer.ExperimentVersion() != "0.9.0" { + t.Error("unexpected experiment version") + } +} + +func TestDNSCheckFailsWithoutInput(t *testing.T) { + measurer := NewExperimentMeasurer(Config{Domain: "example.com"}) + err := measurer.Run( + context.Background(), + newsession(), + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, ErrInputRequired) { + t.Fatal("expected no input error") + } +} + +func TestDNSCheckFailsWithInvalidURL(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + err := measurer.Run( + context.Background(), + newsession(), + &model.Measurement{Input: "Not a valid URL \x7f"}, + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, ErrInvalidURL) { + t.Fatal("expected invalid input error") + } +} + +func TestDNSCheckFailsWithUnsupportedProtocol(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + err := measurer.Run( + context.Background(), + newsession(), + &model.Measurement{Input: "file://1.1.1.1"}, + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, ErrUnsupportedURLScheme) { + t.Fatal("expected unsupported scheme error") + } +} + +func TestWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel the context + measurer := NewExperimentMeasurer(Config{ + DefaultAddrs: "1.1.1.1 1.0.0.1", + }) + measurement := &model.Measurement{Input: "dot://one.one.one.one"} + err := measurer.Run( + ctx, + newsession(), + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestMakeResolverURL(t *testing.T) { + // test address substitution + addr := "255.255.255.0" + resolver := makeResolverURL(&url.URL{Host: "example.com"}, addr) + resolverURL, err := url.Parse(resolver) + if err != nil { + t.Fatal(err) + } + if resolverURL.Host != addr { + t.Fatal("expected address to be set as host") + } + + // test IPv6 URLs are quoted + addr = "2001:db8:85a3:8d3:1319:8a2e:370" + resolver = makeResolverURL(&url.URL{Host: "example.com"}, addr) + resolverURL, err = url.Parse(resolver) + if err != nil { + t.Fatal(err) + } + if resolverURL.Host != "["+addr+"]" { + t.Fatal("expected URL host to be quoted") + } +} + +func TestDNSCheckValid(t *testing.T) { + measurer := NewExperimentMeasurer(Config{ + DefaultAddrs: "1.1.1.1 1.0.0.1", + }) + measurement := model.Measurement{Input: "dot://one.one.one.one:853"} + err := measurer.Run( + context.Background(), + newsession(), + &measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + tk := measurement.TestKeys.(*TestKeys) + if tk.Domain != defaultDomain { + t.Fatal("unexpected default value for domain") + } + if tk.Bootstrap == nil { + t.Fatal("unexpected value for bootstrap") + } + if tk.BootstrapFailure != nil { + t.Fatal("unexpected value for bootstrap_failure") + } + if len(tk.Lookups) <= 0 { + t.Fatal("unexpected value for lookups") + } +} + +func newsession() model.ExperimentSession { + return &mockable.Session{MockableLogger: log.Log} +} + +func TestSummaryKeysGeneric(t *testing.T) { + measurement := &model.Measurement{TestKeys: &TestKeys{}} + m := &Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} + +func TestDNSCheckWait(t *testing.T) { + endpoints := &Endpoints{ + WaitTime: 1 * time.Second, + } + measurer := &Measurer{Endpoints: endpoints} + run := func(input string) { + measurement := model.Measurement{Input: model.MeasurementTarget(input)} + err := measurer.Run( + context.Background(), + newsession(), + &measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + tk := measurement.TestKeys.(*TestKeys) + if tk.Domain != defaultDomain { + t.Fatal("unexpected default value for domain") + } + if tk.Bootstrap == nil { + t.Fatalf("unexpected value for bootstrap: %+v", tk.Bootstrap) + } + if tk.BootstrapFailure != nil { + t.Fatal("unexpected value for bootstrap_failure") + } + if len(tk.Lookups) <= 0 { + t.Fatal("unexpected value for lookups") + } + } + run("dot://one.one.one.one") + run("dot://1dot1dot1dot1.cloudflare-dns.com") + if atomic.LoadUint32(&endpoints.count) < 1 { + t.Fatal("did not sleep") + } +} diff --git a/internal/engine/experiment/example/example.go b/internal/engine/experiment/example/example.go new file mode 100644 index 0000000..658bc77 --- /dev/null +++ b/internal/engine/experiment/example/example.go @@ -0,0 +1,95 @@ +// Package example contains a simple example experiment. +// +// You could use this code to boostrap the implementation of +// a new experiment that you are working on. +package example + +import ( + "context" + "errors" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const testVersion = "0.1.0" + +// Config contains the experiment config. +// +// This contains all the settings that user can set to modify the behaviour +// of this experiment. By tagging these variables with `ooni:"..."`, we allow +// miniooni's -O flag to find them and set them. +type Config struct { + Message string `ooni:"Message to emit at test completion"` + ReturnError bool `ooni:"Toogle to return a mocked error"` + SleepTime int64 `ooni:"Amount of time to sleep for"` +} + +// TestKeys contains the experiment's result. +// +// This is what will end up into the Measurement.TestKeys field +// when you run this experiment. +// +// In other words, the variables in this struct will be +// the specific results of this experiment. +type TestKeys struct { + Success bool `json:"success"` +} + +// Measurer performs the measurement. +type Measurer struct { + config Config + testName string +} + +// ExperimentName implements model.ExperimentMeasurer.ExperimentName. +func (m Measurer) ExperimentName() string { + return m.testName +} + +// ExperimentVersion implements model.ExperimentMeasurer.ExperimentVersion. +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +// ErrFailure is the error returned when you set the +// config.ReturnError field to true. +var ErrFailure = errors.New("mocked error") + +// Run implements model.ExperimentMeasurer.Run. +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + var err error + if m.config.ReturnError { + err = ErrFailure + } + testkeys := &TestKeys{Success: err == nil} + measurement.TestKeys = testkeys + sess.Logger().Warnf("%s", "Follow the white rabbit.") + ctx, cancel := context.WithTimeout(ctx, time.Duration(m.config.SleepTime)) + defer cancel() + <-ctx.Done() + sess.Logger().Infof("%s", "Knock, knock, Neo.") + callbacks.OnProgress(1.0, m.config.Message) + return err +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config, testName string) model.ExperimentMeasurer { + return Measurer{config: config, testName: testName} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/example/example_test.go b/internal/engine/experiment/example/example_test.go new file mode 100644 index 0000000..5f2e39b --- /dev/null +++ b/internal/engine/experiment/example/example_test.go @@ -0,0 +1,67 @@ +package example_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/example" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestSuccess(t *testing.T) { + m := example.NewExperimentMeasurer(example.Config{ + SleepTime: int64(2 * time.Millisecond), + }, "example") + if m.ExperimentName() != "example" { + t.Fatal("invalid ExperimentName") + } + if m.ExperimentVersion() != "0.1.0" { + t.Fatal("invalid ExperimentVersion") + } + ctx := context.Background() + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(sess.Logger()) + measurement := new(model.Measurement) + err := m.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + sk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(example.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestFailure(t *testing.T) { + m := example.NewExperimentMeasurer(example.Config{ + SleepTime: int64(2 * time.Millisecond), + ReturnError: true, + }, "example") + ctx := context.Background() + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(sess.Logger()) + err := m.Run(ctx, sess, new(model.Measurement), callbacks) + if !errors.Is(err, example.ErrFailure) { + t.Fatal("expected an error here") + } +} + +func TestSummaryKeysGeneric(t *testing.T) { + measurement := &model.Measurement{TestKeys: &example.TestKeys{}} + m := &example.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(example.SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/fbmessenger/fbmessenger.go b/internal/engine/experiment/fbmessenger/fbmessenger.go new file mode 100644 index 0000000..77e3c2f --- /dev/null +++ b/internal/engine/experiment/fbmessenger/fbmessenger.go @@ -0,0 +1,227 @@ +// Package fbmessenger contains the Facebook Messenger network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-019-facebook-messenger.md +package fbmessenger + +import ( + "context" + "errors" + "math/rand" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +const ( + // FacebookASN is Facebook's ASN + FacebookASN = 32934 + + // ServiceSTUN is the STUN service + ServiceSTUN = "dnslookup://stun.fbsbx.com" + + // ServiceBAPI is the b-api service + ServiceBAPI = "tcpconnect://b-api.facebook.com:443" + + // ServiceBGraph is the b-graph service + ServiceBGraph = "tcpconnect://b-graph.facebook.com:443" + + // ServiceEdge is the edge service + ServiceEdge = "tcpconnect://edge-mqtt.facebook.com:443" + + // ServiceExternalCDN is the external CDN service + ServiceExternalCDN = "tcpconnect://external.xx.fbcdn.net:443" + + // ServiceScontentCDN is the scontent CDN service + ServiceScontentCDN = "tcpconnect://scontent.xx.fbcdn.net:443" + + // ServiceStar is the star service + ServiceStar = "tcpconnect://star.c10r.facebook.com:443" + + testName = "facebook_messenger" + testVersion = "0.2.0" +) + +// Config contains the experiment config. +type Config struct{} + +// TestKeys contains the experiment results +type TestKeys struct { + urlgetter.TestKeys + FacebookBAPIDNSConsistent *bool `json:"facebook_b_api_dns_consistent"` + FacebookBAPIReachable *bool `json:"facebook_b_api_reachable"` + FacebookBGraphDNSConsistent *bool `json:"facebook_b_graph_dns_consistent"` + FacebookBGraphReachable *bool `json:"facebook_b_graph_reachable"` + FacebookEdgeDNSConsistent *bool `json:"facebook_edge_dns_consistent"` + FacebookEdgeReachable *bool `json:"facebook_edge_reachable"` + FacebookExternalCDNDNSConsistent *bool `json:"facebook_external_cdn_dns_consistent"` + FacebookExternalCDNReachable *bool `json:"facebook_external_cdn_reachable"` + FacebookScontentCDNDNSConsistent *bool `json:"facebook_scontent_cdn_dns_consistent"` + FacebookScontentCDNReachable *bool `json:"facebook_scontent_cdn_reachable"` + FacebookStarDNSConsistent *bool `json:"facebook_star_dns_consistent"` + FacebookStarReachable *bool `json:"facebook_star_reachable"` + FacebookSTUNDNSConsistent *bool `json:"facebook_stun_dns_consistent"` + FacebookSTUNReachable *bool `json:"facebook_stun_reachable"` + FacebookDNSBlocking *bool `json:"facebook_dns_blocking"` + FacebookTCPBlocking *bool `json:"facebook_tcp_blocking"` +} + +// Update updates the TestKeys using the given MultiOutput result. +func (tk *TestKeys) Update(v urlgetter.MultiOutput) { + // Update the easy to update entries first + tk.NetworkEvents = append(tk.NetworkEvents, v.TestKeys.NetworkEvents...) + tk.Queries = append(tk.Queries, v.TestKeys.Queries...) + tk.Requests = append(tk.Requests, v.TestKeys.Requests...) + tk.TCPConnect = append(tk.TCPConnect, v.TestKeys.TCPConnect...) + tk.TLSHandshakes = append(tk.TLSHandshakes, v.TestKeys.TLSHandshakes...) + // Set the status of endpoints + switch v.Input.Target { + case ServiceSTUN: + var ignored *bool + tk.ComputeEndpointStatus(v, &tk.FacebookSTUNDNSConsistent, &ignored) + case ServiceBAPI: + tk.ComputeEndpointStatus( + v, &tk.FacebookBAPIDNSConsistent, &tk.FacebookBAPIReachable) + case ServiceBGraph: + tk.ComputeEndpointStatus( + v, &tk.FacebookBGraphDNSConsistent, &tk.FacebookBGraphReachable) + case ServiceEdge: + tk.ComputeEndpointStatus( + v, &tk.FacebookEdgeDNSConsistent, &tk.FacebookEdgeReachable) + case ServiceExternalCDN: + tk.ComputeEndpointStatus( + v, &tk.FacebookExternalCDNDNSConsistent, &tk.FacebookExternalCDNReachable) + case ServiceScontentCDN: + tk.ComputeEndpointStatus( + v, &tk.FacebookScontentCDNDNSConsistent, &tk.FacebookScontentCDNReachable) + case ServiceStar: + tk.ComputeEndpointStatus( + v, &tk.FacebookStarDNSConsistent, &tk.FacebookStarReachable) + } +} + +var ( + trueValue = true + falseValue = false +) + +// ComputeEndpointStatus computes the DNS and TCP status of a specific endpoint. +func (tk *TestKeys) ComputeEndpointStatus(v urlgetter.MultiOutput, dns, tcp **bool) { + // start where all is unknown + *dns, *tcp = nil, nil + // process DNS first + if v.TestKeys.FailedOperation != nil && *v.TestKeys.FailedOperation == errorx.ResolveOperation { + tk.FacebookDNSBlocking = &trueValue + *dns = &falseValue + return // we know that the DNS has failed + } + for _, query := range v.TestKeys.Queries { + for _, ans := range query.Answers { + if ans.ASN != FacebookASN { + tk.FacebookDNSBlocking = &trueValue + *dns = &falseValue + return // because DNS is lying + } + } + } + *dns = &trueValue + // now process connect + if v.TestKeys.FailedOperation != nil && *v.TestKeys.FailedOperation == errorx.ConnectOperation { + tk.FacebookTCPBlocking = &trueValue + *tcp = &falseValue + return // because connect failed + } + // all good + *tcp = &trueValue +} + +// Measurer performs the measurement +type Measurer struct { + // Config contains the experiment settings. If empty we + // will be using default settings. + Config Config + + // Getter is an optional getter to be used for testing. + Getter urlgetter.MultiGetter +} + +// ExperimentName implements ExperimentMeasurer.ExperimentName +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements ExperimentMeasurer.Run +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + urlgetter.RegisterExtensions(measurement) + // generate targets + services := []string{ + ServiceSTUN, ServiceBAPI, ServiceBGraph, ServiceEdge, ServiceExternalCDN, + ServiceScontentCDN, ServiceStar, + } + var inputs []urlgetter.MultiInput + for _, service := range services { + inputs = append(inputs, urlgetter.MultiInput{Target: service}) + } + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + rnd.Shuffle(len(inputs), func(i, j int) { + inputs[i], inputs[j] = inputs[j], inputs[i] + }) + // measure in parallel + multi := urlgetter.Multi{Begin: time.Now(), Getter: m.Getter, Session: sess} + testkeys := new(TestKeys) + testkeys.Agent = "redirect" + measurement.TestKeys = testkeys + for entry := range multi.Collect(ctx, inputs, "facebook_messenger", callbacks) { + testkeys.Update(entry) + } + // if we haven't yet determined the status of DNS blocking and TCP blocking + // then no blocking has been detected and we can set them + if testkeys.FacebookDNSBlocking == nil { + testkeys.FacebookDNSBlocking = &falseValue + } + if testkeys.FacebookTCPBlocking == nil { + testkeys.FacebookTCPBlocking = &falseValue + } + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + DNSBlocking bool `json:"facebook_dns_blocking"` + TCPBlocking bool `json:"facebook_tcp_blocking"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + dnsBlocking := tk.FacebookDNSBlocking != nil && *tk.FacebookDNSBlocking + tcpBlocking := tk.FacebookTCPBlocking != nil && *tk.FacebookTCPBlocking + sk.DNSBlocking = dnsBlocking + sk.TCPBlocking = tcpBlocking + sk.IsAnomaly = dnsBlocking || tcpBlocking + return sk, nil +} diff --git a/internal/engine/experiment/fbmessenger/fbmessenger_test.go b/internal/engine/experiment/fbmessenger/fbmessenger_test.go new file mode 100644 index 0000000..e6640bc --- /dev/null +++ b/internal/engine/experiment/fbmessenger/fbmessenger_test.go @@ -0,0 +1,363 @@ +package fbmessenger_test + +import ( + "context" + "io" + "testing" + + "github.com/apex/log" + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/fbmessenger" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := fbmessenger.NewExperimentMeasurer(fbmessenger.Config{}) + if measurer.ExperimentName() != "facebook_messenger" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.2.0" { + t.Fatal("unexpected version") + } +} + +func TestSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := fbmessenger.NewExperimentMeasurer(fbmessenger.Config{}) + ctx := context.Background() + // we need a real session because we need the ASN database + sess := newsession(t) + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*fbmessenger.TestKeys) + if *tk.FacebookBAPIDNSConsistent != true { + t.Fatal("invalid FacebookBAPIDNSConsistent") + } + if *tk.FacebookBAPIReachable != true { + t.Fatal("invalid FacebookBAPIReachable") + } + if *tk.FacebookBGraphDNSConsistent != true { + t.Fatal("invalid FacebookBGraphDNSConsistent") + } + if *tk.FacebookBGraphReachable != true { + t.Fatal("invalid FacebookBGraphReachable") + } + if *tk.FacebookEdgeDNSConsistent != true { + t.Fatal("invalid FacebookEdgeDNSConsistent") + } + if *tk.FacebookEdgeReachable != true { + t.Fatal("invalid FacebookEdgeReachable") + } + if *tk.FacebookExternalCDNDNSConsistent != true { + t.Fatal("invalid FacebookExternalCDNDNSConsistent") + } + if *tk.FacebookExternalCDNReachable != true { + t.Fatal("invalid FacebookExternalCDNReachable") + } + if *tk.FacebookScontentCDNDNSConsistent != true { + t.Fatal("invalid FacebookScontentCDNDNSConsistent") + } + if *tk.FacebookScontentCDNReachable != true { + t.Fatal("invalid FacebookScontentCDNReachable") + } + if *tk.FacebookStarDNSConsistent != true { + t.Fatal("invalid FacebookStarDNSConsistent") + } + if *tk.FacebookStarReachable != true { + t.Fatal("invalid FacebookStarReachable") + } + if *tk.FacebookSTUNDNSConsistent != true { + t.Fatal("invalid FacebookSTUNDNSConsistent") + } + if tk.FacebookSTUNReachable != nil { + t.Fatal("invalid FacebookSTUNReachable") + } + if *tk.FacebookDNSBlocking != false { + t.Fatal("invalid FacebookDNSBlocking") + } + if *tk.FacebookTCPBlocking != false { + t.Fatal("invalid FacebookTCPBlocking") + } +} + +func TestWithCancelledContext(t *testing.T) { + measurer := fbmessenger.NewExperimentMeasurer(fbmessenger.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // so we fail immediately + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*fbmessenger.TestKeys) + if *tk.FacebookBAPIDNSConsistent != false { + t.Fatal("invalid FacebookBAPIDNSConsistent") + } + if tk.FacebookBAPIReachable != nil { + t.Fatal("invalid FacebookBAPIReachable") + } + if *tk.FacebookBGraphDNSConsistent != false { + t.Fatal("invalid FacebookBGraphDNSConsistent") + } + if tk.FacebookBGraphReachable != nil { + t.Fatal("invalid FacebookBGraphReachable") + } + if *tk.FacebookEdgeDNSConsistent != false { + t.Fatal("invalid FacebookEdgeDNSConsistent") + } + if tk.FacebookEdgeReachable != nil { + t.Fatal("invalid FacebookEdgeReachable") + } + if *tk.FacebookExternalCDNDNSConsistent != false { + t.Fatal("invalid FacebookExternalCDNDNSConsistent") + } + if tk.FacebookExternalCDNReachable != nil { + t.Fatal("invalid FacebookExternalCDNReachable") + } + if *tk.FacebookScontentCDNDNSConsistent != false { + t.Fatal("invalid FacebookScontentCDNDNSConsistent") + } + if tk.FacebookScontentCDNReachable != nil { + t.Fatal("invalid FacebookScontentCDNReachable") + } + if *tk.FacebookStarDNSConsistent != false { + t.Fatal("invalid FacebookStarDNSConsistent") + } + if tk.FacebookStarReachable != nil { + t.Fatal("invalid FacebookStarReachable") + } + if *tk.FacebookSTUNDNSConsistent != false { + t.Fatal("invalid FacebookSTUNDNSConsistent") + } + if tk.FacebookSTUNReachable != nil { + t.Fatal("invalid FacebookSTUNReachable") + } + if *tk.FacebookDNSBlocking != true { + t.Fatal("invalid FacebookDNSBlocking") + } + // no TCP blocking because we didn't ever reach TCP connect + if *tk.FacebookTCPBlocking != false { + t.Fatal("invalid FacebookTCPBlocking") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(fbmessenger.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestComputeEndpointStatsTCPBlocking(t *testing.T) { + failure := io.EOF.Error() + operation := errorx.ConnectOperation + tk := fbmessenger.TestKeys{} + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: fbmessenger.ServiceEdge}, + TestKeys: urlgetter.TestKeys{ + Failure: &failure, + FailedOperation: &operation, + Queries: []archival.DNSQueryEntry{{ + Answers: []archival.DNSAnswerEntry{{ + ASN: fbmessenger.FacebookASN, + }}, + }}, + }, + }) + if *tk.FacebookEdgeDNSConsistent != true { + t.Fatal("invalid FacebookEdgeDNSConsistent") + } + if *tk.FacebookEdgeReachable != false { + t.Fatal("invalid FacebookEdgeReachable") + } + if tk.FacebookDNSBlocking != nil { // meaning: not determined yet + t.Fatal("invalid FacebookDNSBlocking") + } + if *tk.FacebookTCPBlocking != true { + t.Fatal("invalid FacebookTCPBlocking") + } +} + +func TestComputeEndpointStatsDNSIsLying(t *testing.T) { + failure := io.EOF.Error() + operation := errorx.ConnectOperation + tk := fbmessenger.TestKeys{} + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: fbmessenger.ServiceEdge}, + TestKeys: urlgetter.TestKeys{ + Failure: &failure, + FailedOperation: &operation, + Queries: []archival.DNSQueryEntry{{ + Answers: []archival.DNSAnswerEntry{{ + ASN: 0, + }}, + }}, + }, + }) + if *tk.FacebookEdgeDNSConsistent != false { + t.Fatal("invalid FacebookEdgeDNSConsistent") + } + if tk.FacebookEdgeReachable != nil { + t.Fatal("invalid FacebookEdgeReachable") + } + if *tk.FacebookDNSBlocking != true { + t.Fatal("invalid FacebookDNSBlocking") + } + if tk.FacebookTCPBlocking != nil { // meaning: not determined yet + t.Fatal("invalid FacebookTCPBlocking") + } +} + +func newsession(t *testing.T) model.ExperimentSession { + sess, err := engine.NewSession(engine.SessionConfig{ + AssetsDir: "../../testdata", + AvailableProbeServices: []model.Service{{ + Address: "https://ams-pg-test.ooni.org", + Type: "https", + }}, + Logger: log.Log, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + if err := sess.MaybeLookupLocation(); err != nil { + t.Fatal(err) + } + return sess +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &fbmessenger.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysWithNils(t *testing.T) { + measurement := &model.Measurement{TestKeys: &fbmessenger.TestKeys{}} + m := &fbmessenger.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(fbmessenger.SummaryKeys) + if sk.DNSBlocking { + t.Fatal("invalid dnsBlocking") + } + if sk.TCPBlocking { + t.Fatal("invalid tcpBlocking") + } + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} + +func TestSummaryKeysWithFalseFalse(t *testing.T) { + falsy := false + measurement := &model.Measurement{TestKeys: &fbmessenger.TestKeys{ + FacebookTCPBlocking: &falsy, + FacebookDNSBlocking: &falsy, + }} + m := &fbmessenger.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(fbmessenger.SummaryKeys) + if sk.DNSBlocking { + t.Fatal("invalid dnsBlocking") + } + if sk.TCPBlocking { + t.Fatal("invalid tcpBlocking") + } + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} + +func TestSummaryKeysWithFalseTrue(t *testing.T) { + falsy := false + truy := true + measurement := &model.Measurement{TestKeys: &fbmessenger.TestKeys{ + FacebookTCPBlocking: &falsy, + FacebookDNSBlocking: &truy, + }} + m := &fbmessenger.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(fbmessenger.SummaryKeys) + if sk.DNSBlocking == false { + t.Fatal("invalid dnsBlocking") + } + if sk.TCPBlocking { + t.Fatal("invalid tcpBlocking") + } + if sk.IsAnomaly == false { + t.Fatal("invalid isAnomaly") + } +} + +func TestSummaryKeysWithTrueFalse(t *testing.T) { + falsy := false + truy := true + measurement := &model.Measurement{TestKeys: &fbmessenger.TestKeys{ + FacebookTCPBlocking: &truy, + FacebookDNSBlocking: &falsy, + }} + m := &fbmessenger.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(fbmessenger.SummaryKeys) + if sk.DNSBlocking { + t.Fatal("invalid dnsBlocking") + } + if sk.TCPBlocking == false { + t.Fatal("invalid tcpBlocking") + } + if sk.IsAnomaly == false { + t.Fatal("invalid isAnomaly") + } +} + +func TestSummaryKeysWithTrueTrue(t *testing.T) { + truy := true + measurement := &model.Measurement{TestKeys: &fbmessenger.TestKeys{ + FacebookTCPBlocking: &truy, + FacebookDNSBlocking: &truy, + }} + m := &fbmessenger.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(fbmessenger.SummaryKeys) + if sk.DNSBlocking == false { + t.Fatal("invalid dnsBlocking") + } + if sk.TCPBlocking == false { + t.Fatal("invalid tcpBlocking") + } + if sk.IsAnomaly == false { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/hhfm/fake_test.go b/internal/engine/experiment/hhfm/fake_test.go new file mode 100644 index 0000000..3138c97 --- /dev/null +++ b/internal/engine/experiment/hhfm/fake_test.go @@ -0,0 +1,56 @@ +package hhfm_test + +import ( + "context" + "io/ioutil" + "net" + "net/http" + "time" +) + +type FakeDialer struct { + Conn net.Conn + Err error +} + +func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return d.Conn, d.Err +} + +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() {} + +type FakeBody struct { + Err error +} + +func (fb FakeBody) Read(p []byte) (int, error) { + time.Sleep(10 * time.Microsecond) + return 0, fb.Err +} + +func (fb FakeBody) Close() error { + return nil +} diff --git a/internal/engine/experiment/hhfm/hhfm.go b/internal/engine/experiment/hhfm/hhfm.go new file mode 100644 index 0000000..464362e --- /dev/null +++ b/internal/engine/experiment/hhfm/hhfm.go @@ -0,0 +1,370 @@ +// Package hhfm contains the HTTP Header Field Manipulation network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-006-header-field-manipulation.md +package hhfm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "sort" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" + "github.com/ooni/probe-cli/v3/internal/engine/internal/randx" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" +) + +const ( + testName = "http_header_field_manipulation" + testVersion = "0.2.0" +) + +// Config contains the experiment config. +type Config struct{} + +// TestKeys contains the experiment test keys. +// +// Here we are emitting for the same set of test keys that are +// produced by the MK implementation. +type TestKeys struct { + Agent string `json:"agent"` + Failure *string `json:"failure"` + Requests []archival.RequestEntry `json:"requests"` + SOCKSProxy *string `json:"socksproxy"` + Tampering Tampering `json:"tampering"` +} + +// Tampering describes the detected forms of tampering. +// +// The meaning of these fields is described in the specification. +type Tampering struct { + HeaderFieldName bool `json:"header_field_name"` + HeaderFieldNumber bool `json:"header_field_number"` + HeaderFieldValue bool `json:"header_field_value"` + HeaderNameCapitalization bool `json:"header_name_capitalization"` + HeaderNameDiff []string `json:"header_name_diff"` + RequestLineCapitalization bool `json:"request_line_capitalization"` + Total bool `json:"total"` +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// Transport is the definition of http.RoundTripper used by this package. +type Transport interface { + RoundTrip(req *http.Request) (*http.Response, error) + CloseIdleConnections() +} + +// Measurer performs the measurement. +type Measurer struct { + Config Config + Transport Transport // for testing +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +var ( + // ErrNoAvailableTestHelpers is emitted when there are no available test helpers. + ErrNoAvailableTestHelpers = errors.New("no available helpers") + + // ErrInvalidHelperType is emitted when the helper type is invalid. + ErrInvalidHelperType = errors.New("invalid helper type") +) + +// Run implements ExperimentMeasurer.Run. +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + urlgetter.RegisterExtensions(measurement) + tk := new(TestKeys) + tk.Agent = "agent" + tk.Tampering.HeaderNameDiff = []string{} + measurement.TestKeys = tk + // parse helper + const helperName = "http-return-json-headers" + helpers, ok := sess.GetTestHelpersByName(helperName) + if !ok || len(helpers) < 1 { + return ErrNoAvailableTestHelpers + } + helper := helpers[0] + if helper.Type != "legacy" { + return ErrInvalidHelperType + } + measurement.TestHelpers = map[string]interface{}{ + "backend": helper.Address, + } + // prepare request + req, err := http.NewRequest("GeT", helper.Address, nil) + if err != nil { + return err + } + headers := map[string]string{ + randx.ChangeCapitalization("Accept"): httpheader.Accept(), + randx.ChangeCapitalization("Accept-Charset"): "ISO-8859-1,utf-8;q=0.7,*;q=0.3", + randx.ChangeCapitalization("Accept-Encoding"): "gzip,deflate,sdch", + randx.ChangeCapitalization("Accept-Language"): httpheader.AcceptLanguage(), + randx.ChangeCapitalization("Host"): randx.Letters(15) + ".com", + randx.ChangeCapitalization("User-Agent"): httpheader.UserAgent(), + } + for key, value := range headers { + // Implementation note: Golang will normalize the header names. We will use + // a custom dialer to restore the random capitalisation. + req.Header.Set(key, value) + } + req.Host = req.Header.Get("Host") + // fill tk.Requests[0] + tk.Requests = NewRequestEntryList(req, headers) + // prepare transport + txp := m.Transport + if txp == nil { + ht := http.DefaultTransport.(*http.Transport).Clone() // basically: use defaults + ht.DisableCompression = true // disable sending Accept: gzip + ht.ForceAttemptHTTP2 = false + ht.DialContext = Dialer{Headers: headers}.DialContext + txp = ht + } + defer txp.CloseIdleConnections() + // round trip and read body + // TODO(bassosimone): this implementation will lead to false positives if the + // network is really bad. Yet, this seems what MK does, so I'd rather start + // from that and then see to improve the robustness in the future. + resp, data, err := Transact(txp, req.WithContext(ctx), callbacks) + if err != nil { + tk.Failure = archival.NewFailure(err) + tk.Requests[0].Failure = tk.Failure + tk.Tampering.Total = true + return nil // measurement did not fail, we measured tampering + } + // fill tk.Requests[0].Response + tk.Requests[0].Response = NewHTTPResponse(resp, data) + // parse response body + var jsonHeaders JSONHeaders + if err := json.Unmarshal(data, &jsonHeaders); err != nil { + failure := errorx.FailureJSONParseError + tk.Failure = &failure + tk.Tampering.Total = true + return nil // measurement did not fail, we measured tampering + } + // fill tampering + tk.FillTampering(req, jsonHeaders, headers) + return nil +} + +// Transact performs the HTTP transaction which consists of performing +// the HTTP round trip and then reading the body. +func Transact(txp Transport, req *http.Request, + callbacks model.ExperimentCallbacks) (*http.Response, []byte, error) { + // make sure that we return a wrapped error here + resp, data, err := transact(txp, req, callbacks) + err = errorx.SafeErrWrapperBuilder{ + Error: err, Operation: errorx.TopLevelOperation}.MaybeBuild() + return resp, data, err +} + +func transact(txp Transport, req *http.Request, + callbacks model.ExperimentCallbacks) (*http.Response, []byte, error) { + callbacks.OnProgress(0.25, "sending request...") + resp, err := txp.RoundTrip(req) + callbacks.OnProgress(0.50, fmt.Sprintf("got reseponse headers... %+v", err)) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, nil, urlgetter.ErrHTTPRequestFailed + } + callbacks.OnProgress(0.75, "reading response body...") + data, err := ioutil.ReadAll(resp.Body) + callbacks.OnProgress(1.00, fmt.Sprintf("got reseponse body... %+v", err)) + if err != nil { + return nil, nil, err + } + return resp, data, nil +} + +// FillTampering fills the tampering structure in the TestKeys +// based on the value of other fields of the TestKeys, the original +// HTTP request, the response from the test helper, and the +// headers with modified capitalisation. +func (tk *TestKeys) FillTampering( + req *http.Request, jsonHeaders JSONHeaders, headers map[string]string) { + tk.Tampering.RequestLineCapitalization = (fmt.Sprintf( + "%s / HTTP/1.1", req.Method) != jsonHeaders.RequestLine) + tk.Tampering.HeaderFieldNumber = len(headers) != len(jsonHeaders.HeadersDict) + expectedHeaderKeys := make(map[string]string) + for key := range headers { + expectedHeaderKeys[http.CanonicalHeaderKey(key)] = key + } + receivedHeaderKeys := make(map[string]string) + for key := range jsonHeaders.HeadersDict { + receivedHeaderKeys[http.CanonicalHeaderKey(key)] = key + } + commonHeaderKeys := make(map[string]int) + for key := range expectedHeaderKeys { + commonHeaderKeys[key]++ + } + for key := range receivedHeaderKeys { + commonHeaderKeys[key]++ + } + for key, count := range commonHeaderKeys { + if count != 2 { + continue // not in common + } + expectedKey, receivedKey := expectedHeaderKeys[key], receivedHeaderKeys[key] + if expectedKey != receivedKey { + tk.Tampering.HeaderNameCapitalization = true + tk.Tampering.HeaderNameDiff = append(tk.Tampering.HeaderNameDiff, expectedKey) + tk.Tampering.HeaderNameDiff = append(tk.Tampering.HeaderNameDiff, receivedKey) + } + expectedValue := headers[expectedKey] + receivedValue := jsonHeaders.HeadersDict[receivedKey] + if len(receivedValue) != 1 || expectedValue != receivedValue[0] { + tk.Tampering.HeaderFieldValue = true + } + } +} + +// NewRequestEntryList creates a new []archival.RequestEntry given a +// specific *http.Request and headers with random case. +func NewRequestEntryList(req *http.Request, headers map[string]string) (out []archival.RequestEntry) { + out = []archival.RequestEntry{{ + Request: archival.HTTPRequest{ + Headers: make(map[string]archival.MaybeBinaryValue), + HeadersList: []archival.HTTPHeader{}, + Method: req.Method, + URL: req.URL.String(), + }, + }} + for key, value := range headers { + // Using the random capitalization headers here + mbv := archival.MaybeBinaryValue{Value: value} + out[0].Request.Headers[key] = mbv + out[0].Request.HeadersList = append(out[0].Request.HeadersList, + archival.HTTPHeader{Key: key, Value: mbv}) + } + sort.Slice(out[0].Request.HeadersList, func(i, j int) bool { + return out[0].Request.HeadersList[i].Key < out[0].Request.HeadersList[j].Key + }) + return +} + +// NewHTTPResponse creates a new archival.HTTPResponse given a +// specific *http.Response instance and its body. +func NewHTTPResponse(resp *http.Response, data []byte) (out archival.HTTPResponse) { + out = archival.HTTPResponse{ + Body: archival.HTTPBody{Value: string(data)}, + Code: int64(resp.StatusCode), + Headers: make(map[string]archival.MaybeBinaryValue), + HeadersList: []archival.HTTPHeader{}, + } + for key := range resp.Header { + mbv := archival.MaybeBinaryValue{Value: resp.Header.Get(key)} + out.Headers[key] = mbv + out.HeadersList = append(out.HeadersList, archival.HTTPHeader{Key: key, Value: mbv}) + } + sort.Slice(out.HeadersList, func(i, j int) bool { + return out.HeadersList[i].Key < out.HeadersList[j].Key + }) + return +} + +// JSONHeaders contains the response from the backend server. +// +// Here we're defining only the fields we care about. +type JSONHeaders struct { + HeadersDict map[string][]string `json:"headers_dict"` + RequestLine string `json:"request_line"` +} + +// Dialer is a dialer that performs headers transformations. +// +// Because Golang will canonicalize header names, we need to reintroduce +// the random capitalization when emitting the request. +// +// This implementation rests on the assumption that we shall use the +// same connection just once, which is guarantee by the implementation +// of HHFM above. If using this code elsewhere, make sure that you +// guarantee that the connection is used for a single request and that +// such a request does not contain any body. +type Dialer struct { + Dialer netx.Dialer // used for testing + Headers map[string]string +} + +// DialContext dials a specific connection and arranges such that +// headers in the outgoing request are transformed. +func (d Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + dialer := d.Dialer + if dialer == nil { + dialer = selfcensor.DefaultDialer + } + conn, err := dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return Conn{Conn: conn, Headers: d.Headers}, nil +} + +// Conn is a connection where headers in the outgoing request +// are transformed according to a transform table. +type Conn struct { + net.Conn + Headers map[string]string +} + +// Write implements Conn.Write. +func (c Conn) Write(b []byte) (int, error) { + for key := range c.Headers { + b = bytes.Replace(b, []byte(http.CanonicalHeaderKey(key)+":"), []byte(key+":"), 1) + } + return c.Conn.Write(b) +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + sk.IsAnomaly = (tk.Tampering.HeaderFieldName || + tk.Tampering.HeaderFieldNumber || + tk.Tampering.HeaderFieldValue || + tk.Tampering.HeaderNameCapitalization || + tk.Tampering.RequestLineCapitalization || + tk.Tampering.Total) + return sk, nil +} diff --git a/internal/engine/experiment/hhfm/hhfm_test.go b/internal/engine/experiment/hhfm/hhfm_test.go new file mode 100644 index 0000000..63ab715 --- /dev/null +++ b/internal/engine/experiment/hhfm/hhfm_test.go @@ -0,0 +1,906 @@ +package hhfm_test + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/hhfm" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + if measurer.ExperimentName() != "http_header_field_manipulation" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.2.0" { + t.Fatal("unexpected version") + } +} + +func TestSuccess(t *testing.T) { + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "http-return-json-headers": {{ + Address: "http://37.218.241.94:80", + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*hhfm.TestKeys) + if tk.Agent != "agent" { + t.Fatal("invalid Agent") + } + if tk.Failure != nil { + t.Fatal("invalid Failure") + } + if len(tk.Requests) != 1 { + t.Fatal("invalid Requests") + } + request := tk.Requests[0] + if request.Failure != nil { + t.Fatal("invalid Requests[0].Failure") + } + if request.Request.Body.Value != "" { + t.Fatal("invalid Requests[0].Request.Body.Value") + } + if request.Request.BodyIsTruncated != false { + t.Fatal("invalid Requests[0].Request.BodyIsTruncated") + } + if len(request.Request.HeadersList) != 6 { + t.Fatal("invalid Requests[0].Request.HeadersList length") + } + if len(request.Request.Headers) != 6 { + t.Fatal("invalid Requests[0].Request.Headers length") + } + if strings.ToUpper(request.Request.Method) != "GET" { + t.Fatal("invalid Requests[0].Request.Method") + } + if request.Request.Tor.ExitIP != nil { + t.Fatal("invalid Requests[0].Request.Tor.ExitIP") + } + if request.Request.Tor.ExitName != nil { + t.Fatal("invalid Requests[0].Request.Tor.ExitName") + } + if request.Request.Tor.IsTor != false { + t.Fatal("invalid Requests[0].Request.Tor.IsTor") + } + ths, ok := sess.GetTestHelpersByName("http-return-json-headers") + if !ok || len(ths) < 1 || ths[0].Type != "legacy" { + t.Fatal("cannot get the test helper") + } + if request.Request.URL != ths[0].Address { + t.Fatal("invalid Requests[0].Request.URL") + } + if len(request.Response.Body.Value) < 1 { + t.Fatal("invalid Requests[0].Response.Body.Value length") + } + if request.Response.BodyIsTruncated != false { + t.Fatal("invalid Requests[0].Response.BodyIsTruncated") + } + if request.Response.Code != 200 { + t.Fatal("invalid Requests[0].Code") + } + if len(request.Response.HeadersList) != 0 { + t.Fatal("invalid Requests[0].HeadersList length") + } + if len(request.Response.Headers) != 0 { + t.Fatal("invalid Requests[0].Headers length") + } + if request.T != 0 { + t.Fatal("invalid Requests[0].T") + } + if request.TransactionID != 0 { + t.Fatal("invalid Requests[0].TransactionID") + } + if tk.SOCKSProxy != nil { + t.Fatal("invalid SOCKSProxy") + } + if tk.Tampering.HeaderFieldName != false { + t.Fatal("invalid Tampering.HeaderFieldName") + } + if tk.Tampering.HeaderFieldNumber != false { + t.Fatal("invalid Tampering.HeaderFieldNumber") + } + if tk.Tampering.HeaderFieldValue != false { + t.Fatal("invalid Tampering.HeaderFieldValue") + } + if tk.Tampering.HeaderNameCapitalization != false { + t.Fatal("invalid Tampering.HeaderNameCapitalization") + } + if len(tk.Tampering.HeaderNameDiff) != 0 { + t.Fatal("invalid Tampering.HeaderNameDiff") + } + if tk.Tampering.RequestLineCapitalization != false { + t.Fatal("invalid Tampering.RequestLineCapitalization") + } + if tk.Tampering.Total != false { + t.Fatal("invalid Tampering.Total") + } +} + +func TestCancelledContext(t *testing.T) { + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "http-return-json-headers": {{ + Address: "http://37.218.241.94:80", + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*hhfm.TestKeys) + if tk.Agent != "agent" { + t.Fatal("invalid Agent") + } + if *tk.Failure != errorx.FailureInterrupted { + t.Fatal("invalid Failure") + } + if len(tk.Requests) != 1 { + t.Fatal("invalid Requests") + } + request := tk.Requests[0] + if *request.Failure != errorx.FailureInterrupted { + t.Fatal("invalid Requests[0].Failure") + } + if request.Request.Body.Value != "" { + t.Fatal("invalid Requests[0].Request.Body.Value") + } + if request.Request.BodyIsTruncated != false { + t.Fatal("invalid Requests[0].Request.BodyIsTruncated") + } + if len(request.Request.HeadersList) != 6 { + t.Fatal("invalid Requests[0].Request.HeadersList length") + } + if len(request.Request.Headers) != 6 { + t.Fatal("invalid Requests[0].Request.Headers length") + } + if strings.ToUpper(request.Request.Method) != "GET" { + t.Fatal("invalid Requests[0].Request.Method") + } + if request.Request.Tor.ExitIP != nil { + t.Fatal("invalid Requests[0].Request.Tor.ExitIP") + } + if request.Request.Tor.ExitName != nil { + t.Fatal("invalid Requests[0].Request.Tor.ExitName") + } + if request.Request.Tor.IsTor != false { + t.Fatal("invalid Requests[0].Request.Tor.IsTor") + } + ths, ok := sess.GetTestHelpersByName("http-return-json-headers") + if !ok || len(ths) < 1 || ths[0].Type != "legacy" { + t.Fatal("cannot get the test helper") + } + if request.Request.URL != ths[0].Address { + t.Fatal("invalid Requests[0].Request.URL") + } + if len(request.Response.Body.Value) != 0 { + t.Fatal("invalid Requests[0].Response.Body.Value length") + } + if request.Response.BodyIsTruncated != false { + t.Fatal("invalid Requests[0].Response.BodyIsTruncated") + } + if request.Response.Code != 0 { + t.Fatal("invalid Requests[0].Code") + } + if len(request.Response.HeadersList) != 0 { + t.Fatal("invalid Requests[0].HeadersList length") + } + if len(request.Response.Headers) != 0 { + t.Fatal("invalid Requests[0].Headers length") + } + if request.T != 0 { + t.Fatal("invalid Requests[0].T") + } + if request.TransactionID != 0 { + t.Fatal("invalid Requests[0].TransactionID") + } + if tk.SOCKSProxy != nil { + t.Fatal("invalid SOCKSProxy") + } + if tk.Tampering.HeaderFieldName != false { + t.Fatal("invalid Tampering.HeaderFieldName") + } + if tk.Tampering.HeaderFieldNumber != false { + t.Fatal("invalid Tampering.HeaderFieldNumber") + } + if tk.Tampering.HeaderFieldValue != false { + t.Fatal("invalid Tampering.HeaderFieldValue") + } + if tk.Tampering.HeaderNameCapitalization != false { + t.Fatal("invalid Tampering.HeaderNameCapitalization") + } + if len(tk.Tampering.HeaderNameDiff) != 0 { + t.Fatal("invalid Tampering.HeaderNameDiff") + } + if tk.Tampering.RequestLineCapitalization != false { + t.Fatal("invalid Tampering.RequestLineCapitalization") + } + if tk.Tampering.Total != true { + t.Fatal("invalid Tampering.Total") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(hhfm.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestNoHelpers(t *testing.T) { + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + ctx := context.Background() + sess := &mockable.Session{} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, hhfm.ErrNoAvailableTestHelpers) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hhfm.TestKeys) + if tk.Agent != "agent" { + t.Fatal("invalid Agent") + } + if tk.Failure != nil { + t.Fatal("invalid Failure") + } + if len(tk.Requests) != 0 { + t.Fatal("invalid Requests") + } + if tk.SOCKSProxy != nil { + t.Fatal("invalid SOCKSProxy") + } + if tk.Tampering.HeaderFieldName != false { + t.Fatal("invalid Tampering.HeaderFieldName") + } + if tk.Tampering.HeaderFieldNumber != false { + t.Fatal("invalid Tampering.HeaderFieldNumber") + } + if tk.Tampering.HeaderFieldValue != false { + t.Fatal("invalid Tampering.HeaderFieldValue") + } + if tk.Tampering.HeaderNameCapitalization != false { + t.Fatal("invalid Tampering.HeaderNameCapitalization") + } + if len(tk.Tampering.HeaderNameDiff) != 0 { + t.Fatal("invalid Tampering.HeaderNameDiff") + } + if tk.Tampering.RequestLineCapitalization != false { + t.Fatal("invalid Tampering.RequestLineCapitalization") + } + if tk.Tampering.Total != false { + t.Fatal("invalid Tampering.Total") + } +} + +func TestNoActualHelpersInList(t *testing.T) { + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "http-return-json-headers": nil, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, hhfm.ErrNoAvailableTestHelpers) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hhfm.TestKeys) + if tk.Agent != "agent" { + t.Fatal("invalid Agent") + } + if tk.Failure != nil { + t.Fatal("invalid Failure") + } + if len(tk.Requests) != 0 { + t.Fatal("invalid Requests") + } + if tk.SOCKSProxy != nil { + t.Fatal("invalid SOCKSProxy") + } + if tk.Tampering.HeaderFieldName != false { + t.Fatal("invalid Tampering.HeaderFieldName") + } + if tk.Tampering.HeaderFieldNumber != false { + t.Fatal("invalid Tampering.HeaderFieldNumber") + } + if tk.Tampering.HeaderFieldValue != false { + t.Fatal("invalid Tampering.HeaderFieldValue") + } + if tk.Tampering.HeaderNameCapitalization != false { + t.Fatal("invalid Tampering.HeaderNameCapitalization") + } + if len(tk.Tampering.HeaderNameDiff) != 0 { + t.Fatal("invalid Tampering.HeaderNameDiff") + } + if tk.Tampering.RequestLineCapitalization != false { + t.Fatal("invalid Tampering.RequestLineCapitalization") + } + if tk.Tampering.Total != false { + t.Fatal("invalid Tampering.Total") + } +} + +func TestWrongTestHelperType(t *testing.T) { + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "http-return-json-headers": {{ + Address: "http://127.0.0.1", + Type: "antani", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, hhfm.ErrInvalidHelperType) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hhfm.TestKeys) + if tk.Agent != "agent" { + t.Fatal("invalid Agent") + } + if tk.Failure != nil { + t.Fatal("invalid Failure") + } + if len(tk.Requests) != 0 { + t.Fatal("invalid Requests") + } + if tk.SOCKSProxy != nil { + t.Fatal("invalid SOCKSProxy") + } + if tk.Tampering.HeaderFieldName != false { + t.Fatal("invalid Tampering.HeaderFieldName") + } + if tk.Tampering.HeaderFieldNumber != false { + t.Fatal("invalid Tampering.HeaderFieldNumber") + } + if tk.Tampering.HeaderFieldValue != false { + t.Fatal("invalid Tampering.HeaderFieldValue") + } + if tk.Tampering.HeaderNameCapitalization != false { + t.Fatal("invalid Tampering.HeaderNameCapitalization") + } + if len(tk.Tampering.HeaderNameDiff) != 0 { + t.Fatal("invalid Tampering.HeaderNameDiff") + } + if tk.Tampering.RequestLineCapitalization != false { + t.Fatal("invalid Tampering.RequestLineCapitalization") + } + if tk.Tampering.Total != false { + t.Fatal("invalid Tampering.Total") + } +} + +func TestNewRequestFailure(t *testing.T) { + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "http-return-json-headers": {{ + Address: "http://127.0.0.1\t\t\t", // invalid + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hhfm.TestKeys) + if tk.Agent != "agent" { + t.Fatal("invalid Agent") + } + if tk.Failure != nil { + t.Fatal("invalid Failure") + } + if len(tk.Requests) != 0 { + t.Fatal("invalid Requests") + } + if tk.SOCKSProxy != nil { + t.Fatal("invalid SOCKSProxy") + } + if tk.Tampering.HeaderFieldName != false { + t.Fatal("invalid Tampering.HeaderFieldName") + } + if tk.Tampering.HeaderFieldNumber != false { + t.Fatal("invalid Tampering.HeaderFieldNumber") + } + if tk.Tampering.HeaderFieldValue != false { + t.Fatal("invalid Tampering.HeaderFieldValue") + } + if tk.Tampering.HeaderNameCapitalization != false { + t.Fatal("invalid Tampering.HeaderNameCapitalization") + } + if len(tk.Tampering.HeaderNameDiff) != 0 { + t.Fatal("invalid Tampering.HeaderNameDiff") + } + if tk.Tampering.RequestLineCapitalization != false { + t.Fatal("invalid Tampering.RequestLineCapitalization") + } + if tk.Tampering.Total != false { + t.Fatal("invalid Tampering.Total") + } +} + +func TestInvalidJSONBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Hello, client") // not valid JSON + })) + defer server.Close() + measurer := hhfm.NewExperimentMeasurer(hhfm.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "http-return-json-headers": {{ + Address: server.URL, + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*hhfm.TestKeys) + if tk.Agent != "agent" { + t.Fatal("invalid Agent") + } + if *tk.Failure != errorx.FailureJSONParseError { + t.Fatal("invalid Failure") + } + if len(tk.Requests) != 1 { + t.Fatal("invalid Requests") + } + // we already check the content of Requests in other tests + if tk.SOCKSProxy != nil { + t.Fatal("invalid SOCKSProxy") + } + if tk.Tampering.HeaderFieldName != false { + t.Fatal("invalid Tampering.HeaderFieldName") + } + if tk.Tampering.HeaderFieldNumber != false { + t.Fatal("invalid Tampering.HeaderFieldNumber") + } + if tk.Tampering.HeaderFieldValue != false { + t.Fatal("invalid Tampering.HeaderFieldValue") + } + if tk.Tampering.HeaderNameCapitalization != false { + t.Fatal("invalid Tampering.HeaderNameCapitalization") + } + if len(tk.Tampering.HeaderNameDiff) != 0 { + t.Fatal("invalid Tampering.HeaderNameDiff") + } + if tk.Tampering.RequestLineCapitalization != false { + t.Fatal("invalid Tampering.RequestLineCapitalization") + } + if tk.Tampering.Total != true { + t.Fatal("invalid Tampering.Total") + } +} + +func TestTransactStatusCodeFailure(t *testing.T) { + txp := FakeTransport{Resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader("")), + StatusCode: 500, + }} + resp, body, err := hhfm.Transact(txp, &http.Request{}, + model.NewPrinterCallbacks(log.Log)) + if !errors.Is(err, urlgetter.ErrHTTPRequestFailed) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("resp is not nil") + } + if body != nil { + t.Fatal("body is not nil") + } +} + +func TestTransactCannotReadBody(t *testing.T) { + expected := errors.New("mocked error") + txp := FakeTransport{Resp: &http.Response{ + Body: &FakeBody{Err: expected}, + StatusCode: 200, + }} + resp, body, err := hhfm.Transact(txp, &http.Request{}, + model.NewPrinterCallbacks(log.Log)) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("resp is not nil") + } + if body != nil { + t.Fatal("body is not nil") + } +} + +func newsession(t *testing.T) model.ExperimentSession { + sess, err := engine.NewSession(engine.SessionConfig{ + AssetsDir: "../../testdata", + AvailableProbeServices: []model.Service{{ + Address: "https://ams-pg-test.ooni.org", + Type: "https", + }}, + Logger: log.Log, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + if err := sess.MaybeLookupBackends(); err != nil { + t.Fatal(err) + } + return sess +} + +func TestTestKeys_FillTampering(t *testing.T) { + type fields struct { + Agent string + Failure *string + Requests []archival.RequestEntry + SOCKSProxy *string + Tampering hhfm.Tampering + } + type args struct { + req *http.Request + jsonHeaders hhfm.JSONHeaders + headers map[string]string + } + tests := []struct { + name string + fields fields + args args + }{{ + name: "Request line capitalisation", + fields: fields{ + Tampering: hhfm.Tampering{ + RequestLineCapitalization: true, + }, + }, + args: args{ + req: &http.Request{ + Method: "GeT", + }, + jsonHeaders: hhfm.JSONHeaders{ + RequestLine: "GET / HTTP/1.1", + }, + }, + }, { + name: "Header field number", + fields: fields{ + Tampering: hhfm.Tampering{ + HeaderFieldNumber: true, + }, + }, + args: args{ + req: &http.Request{ + Method: "GeT", + }, + jsonHeaders: hhfm.JSONHeaders{ + RequestLine: "GeT / HTTP/1.1", + }, + headers: map[string]string{ + "UsEr-AgENt": "miniooni/0.1.0-dev", + }, + }, + }, { + name: "Header name diff", + fields: fields{ + Tampering: hhfm.Tampering{ + HeaderNameCapitalization: true, + HeaderNameDiff: []string{"UsEr-AgENt", "User-Agent"}, + }, + }, + args: args{ + req: &http.Request{ + Method: "GeT", + }, + jsonHeaders: hhfm.JSONHeaders{ + RequestLine: "GeT / HTTP/1.1", + HeadersDict: map[string][]string{ + "User-Agent": {"miniooni/0.1.0-dev"}, + }, + }, + headers: map[string]string{ + "UsEr-AgENt": "miniooni/0.1.0-dev", + }, + }, + }, { + name: "Header value diff", + fields: fields{ + Tampering: hhfm.Tampering{ + HeaderFieldValue: true, + }, + }, + args: args{ + req: &http.Request{ + Method: "GeT", + }, + jsonHeaders: hhfm.JSONHeaders{ + RequestLine: "GeT / HTTP/1.1", + HeadersDict: map[string][]string{ + "UsEr-AgENt": {"MINIOONI/0.1.0-dev"}, + }, + }, + headers: map[string]string{ + "UsEr-AgENt": "miniooni/0.1.0-dev", + }, + }, + }, { + name: "Number of headers per key diffs", + fields: fields{ + Tampering: hhfm.Tampering{ + HeaderFieldValue: true, + }, + }, + args: args{ + req: &http.Request{ + Method: "GeT", + }, + jsonHeaders: hhfm.JSONHeaders{ + RequestLine: "GeT / HTTP/1.1", + HeadersDict: map[string][]string{ + "UsEr-AgENt": {"miniooni/0.1.0-dev", "ooniprobe-engine/0.1.0-dev"}, + }, + }, + headers: map[string]string{ + "UsEr-AgENt": "miniooni/0.1.0-dev", + }, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tk := &hhfm.TestKeys{ + Agent: tt.fields.Agent, + Failure: tt.fields.Failure, + Requests: tt.fields.Requests, + SOCKSProxy: tt.fields.SOCKSProxy, + } + tk.FillTampering(tt.args.req, tt.args.jsonHeaders, tt.args.headers) + if diff := cmp.Diff(tt.fields.Tampering, tk.Tampering); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestNewRequestEntryList(t *testing.T) { + type args struct { + req *http.Request + headers map[string]string + } + tests := []struct { + name string + args args + wantOut []archival.RequestEntry + }{{ + name: "common case", + args: args{ + req: &http.Request{ + Method: "GeT", + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1", + Path: "/", + }, + }, + headers: map[string]string{ + "ContENt-tYPE": "text/plain", + "User-aGENT": "foo/1.0", + }, + }, + wantOut: []archival.RequestEntry{{ + Request: archival.HTTPRequest{ + HeadersList: []archival.HTTPHeader{{ + Key: "ContENt-tYPE", + Value: archival.MaybeBinaryValue{Value: "text/plain"}, + }, { + Key: "User-aGENT", + Value: archival.MaybeBinaryValue{Value: "foo/1.0"}, + }}, + Headers: map[string]archival.MaybeBinaryValue{ + "ContENt-tYPE": {Value: "text/plain"}, + "User-aGENT": {Value: "foo/1.0"}, + }, + Method: "GeT", + URL: "http://10.0.0.1/", + }, + }}, + }, { + name: "without headers", + args: args{ + req: &http.Request{ + Method: "GeT", + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1", + Path: "/", + }, + }, + }, + wantOut: []archival.RequestEntry{{ + Request: archival.HTTPRequest{ + Method: "GeT", + Headers: make(map[string]archival.MaybeBinaryValue), + HeadersList: []archival.HTTPHeader{}, + URL: "http://10.0.0.1/", + }, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := hhfm.NewRequestEntryList(tt.args.req, tt.args.headers) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestNewHTTPResponse(t *testing.T) { + type args struct { + resp *http.Response + data []byte + } + tests := []struct { + name string + args args + wantOut archival.HTTPResponse + }{{ + name: "common case", + args: args{ + resp: &http.Response{ + StatusCode: 200, + Header: http.Header{ + "Content-Type": []string{"text/plain"}, + "User-Agent": []string{"foo/1.0"}, + }, + }, + data: []byte("deadbeef"), + }, + wantOut: archival.HTTPResponse{ + Body: archival.MaybeBinaryValue{Value: "deadbeef"}, + Code: 200, + HeadersList: []archival.HTTPHeader{{ + Key: "Content-Type", + Value: archival.MaybeBinaryValue{Value: "text/plain"}, + }, { + Key: "User-Agent", + Value: archival.MaybeBinaryValue{Value: "foo/1.0"}, + }}, + Headers: map[string]archival.MaybeBinaryValue{ + "Content-Type": {Value: "text/plain"}, + "User-Agent": {Value: "foo/1.0"}, + }, + }, + }, { + name: "with no HTTP header and body", + args: args{ + resp: &http.Response{StatusCode: 200}, + }, + wantOut: archival.HTTPResponse{ + Body: archival.MaybeBinaryValue{Value: ""}, + Code: 200, + HeadersList: []archival.HTTPHeader{}, + Headers: map[string]archival.MaybeBinaryValue{}, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := hhfm.NewHTTPResponse(tt.args.resp, tt.args.data) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestDialerDialContext(t *testing.T) { + expected := errors.New("mocked error") + d := hhfm.Dialer{Dialer: FakeDialer{Err: expected}} + conn, err := d.DialContext(context.Background(), "tcp", "127.0.0.1:80") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("conn is not nil") + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &hhfm.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysWorksAsIntended(t *testing.T) { + tests := []struct { + tampering hhfm.Tampering + isAnomaly bool + }{{ + tampering: hhfm.Tampering{}, + isAnomaly: false, + }, { + tampering: hhfm.Tampering{HeaderFieldName: true}, + isAnomaly: true, + }, { + tampering: hhfm.Tampering{HeaderFieldNumber: true}, + isAnomaly: true, + }, { + tampering: hhfm.Tampering{HeaderFieldValue: true}, + isAnomaly: true, + }, { + tampering: hhfm.Tampering{HeaderNameCapitalization: true}, + isAnomaly: true, + }, { + tampering: hhfm.Tampering{RequestLineCapitalization: true}, + isAnomaly: true, + }, { + tampering: hhfm.Tampering{Total: true}, + isAnomaly: true, + }} + for idx, tt := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + m := &hhfm.Measurer{} + measurement := &model.Measurement{TestKeys: &hhfm.TestKeys{ + Tampering: tt.tampering, + }} + got, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + return + } + sk := got.(hhfm.SummaryKeys) + if sk.IsAnomaly != tt.isAnomaly { + t.Fatal("unexpected isAnomaly value") + } + }) + } +} diff --git a/internal/engine/experiment/hirl/fake_test.go b/internal/engine/experiment/hirl/fake_test.go new file mode 100644 index 0000000..e6ee632 --- /dev/null +++ b/internal/engine/experiment/hirl/fake_test.go @@ -0,0 +1,71 @@ +package hirl_test + +import ( + "context" + "io" + "net" + "time" +) + +type FakeDialer struct { + Conn net.Conn + Err error +} + +func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return d.Conn, d.Err +} + +type FakeConn struct { + ReadError error + ReadData []byte + SetDeadlineError error + SetReadDeadlineError error + SetWriteDeadlineError error + WriteError error +} + +func (c *FakeConn) Read(b []byte) (int, error) { + if len(c.ReadData) > 0 { + n := copy(b, c.ReadData) + c.ReadData = c.ReadData[n:] + return n, nil + } + if c.ReadError != nil { + return 0, c.ReadError + } + return 0, io.EOF +} + +func (c *FakeConn) Write(b []byte) (n int, err error) { + if c.WriteError != nil { + return 0, c.WriteError + } + n = len(b) + return +} + +func (*FakeConn) Close() (err error) { + return +} + +func (*FakeConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*FakeConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (c *FakeConn) SetDeadline(t time.Time) (err error) { + return c.SetDeadlineError +} + +func (c *FakeConn) SetReadDeadline(t time.Time) (err error) { + return c.SetReadDeadlineError +} + +func (c *FakeConn) SetWriteDeadline(t time.Time) (err error) { + return c.SetWriteDeadlineError +} diff --git a/internal/engine/experiment/hirl/hirl.go b/internal/engine/experiment/hirl/hirl.go new file mode 100644 index 0000000..6fa3f5c --- /dev/null +++ b/internal/engine/experiment/hirl/hirl.go @@ -0,0 +1,323 @@ +// Package hirl contains the HTTP Invalid Request Line network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-007-http-invalid-request-line.md +package hirl + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/randx" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +const ( + testName = "http_invalid_request_line" + testVersion = "0.2.0" + timeout = 5 * time.Second +) + +// Config contains the experiment config. +type Config struct{} + +// TestKeys contains the experiment test keys. +type TestKeys struct { + FailureList []*string `json:"failure_list"` + Received []archival.MaybeBinaryValue `json:"received"` + Sent []string `json:"sent"` + TamperingList []bool `json:"tampering_list"` + Tampering bool `json:"tampering"` +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{ + Config: config, + Methods: []Method{ + randomInvalidMethod{}, + randomInvalidFieldCount{}, + randomBigRequestMethod{}, + randomInvalidVersionNumber{}, + squidCacheManager{}, + }, + } +} + +// Measurer performs the measurement. +type Measurer struct { + Config Config + Methods []Method +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +var ( + // ErrNoAvailableTestHelpers is emitted when there are no available test helpers. + ErrNoAvailableTestHelpers = errors.New("no available helpers") + + // ErrInvalidHelperType is emitted when the helper type is invalid. + ErrInvalidHelperType = errors.New("invalid helper type") + + // ErrNoMeasurementMethod is emitted when Measurer.Methods is empty. + ErrNoMeasurementMethod = errors.New("no configured measurement method") +) + +// Run implements ExperimentMeasurer.Run. +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + tk := new(TestKeys) + measurement.TestKeys = tk + if len(m.Methods) < 1 { + return ErrNoMeasurementMethod + } + const helperName = "tcp-echo" + helpers, ok := sess.GetTestHelpersByName(helperName) + if !ok || len(helpers) < 1 { + return ErrNoAvailableTestHelpers + } + helper := helpers[0] + if helper.Type != "legacy" { + return ErrInvalidHelperType + } + measurement.TestHelpers = map[string]interface{}{ + "backend": helper.Address, + } + out := make(chan MethodResult) + for _, method := range m.Methods { + callbacks.OnProgress(0.0, fmt.Sprintf("%s...", method.Name())) + go method.Run(ctx, MethodConfig{ + Address: helper.Address, + Logger: sess.Logger(), + Out: out, + }) + } + var ( + completed int + progress float64 + result MethodResult + ) + for { + select { + case result = <-out: + case <-time.After(500 * time.Millisecond): + if completed <= 0 { + progress += 0.05 + callbacks.OnProgress(progress, "waiting for results...") + } + continue + } + failure := archival.NewFailure(result.Err) + tk.FailureList = append(tk.FailureList, failure) + tk.Received = append(tk.Received, result.Received) + tk.Sent = append(tk.Sent, result.Sent) + tk.TamperingList = append(tk.TamperingList, result.Tampering) + tk.Tampering = (tk.Tampering || result.Tampering) + completed++ + percentage := (float64(completed)/float64(len(m.Methods)))*0.5 + 0.5 + callbacks.OnProgress(percentage, fmt.Sprintf("%s... %+v", result.Name, result.Err)) + if completed >= len(m.Methods) { + break + } + } + return nil +} + +// MethodConfig contains the settings for a specific measuring method. +type MethodConfig struct { + Address string + Logger model.Logger + Out chan<- MethodResult +} + +// MethodResult is the result of one of the methods implemented by this experiment. +type MethodResult struct { + Err error + Name string + Received archival.MaybeBinaryValue + Sent string + Tampering bool +} + +// Method is one of the methods implemented by this experiment. +type Method interface { + Name() string + Run(ctx context.Context, config MethodConfig) +} + +type randomInvalidMethod struct{} + +func (randomInvalidMethod) Name() string { + return "random_invalid_method" +} + +func (meth randomInvalidMethod) Run(ctx context.Context, config MethodConfig) { + RunMethod(ctx, RunMethodConfig{ + MethodConfig: config, + Name: meth.Name(), + RequestLine: randx.LettersUppercase(4) + " / HTTP/1.1\n\r", + }) +} + +type randomInvalidFieldCount struct{} + +func (randomInvalidFieldCount) Name() string { + return "random_invalid_field_count" +} + +func (meth randomInvalidFieldCount) Run(ctx context.Context, config MethodConfig) { + RunMethod(ctx, RunMethodConfig{ + MethodConfig: config, + Name: meth.Name(), + RequestLine: strings.Join([]string{ + randx.LettersUppercase(5), + " ", + randx.LettersUppercase(5), + " ", + randx.LettersUppercase(5), + " ", + randx.LettersUppercase(5), + "\r\n", + }, ""), + }) +} + +type randomBigRequestMethod struct{} + +func (randomBigRequestMethod) Name() string { + return "random_big_request_method" +} + +func (meth randomBigRequestMethod) Run(ctx context.Context, config MethodConfig) { + RunMethod(ctx, RunMethodConfig{ + MethodConfig: config, + Name: meth.Name(), + RequestLine: strings.Join([]string{ + randx.LettersUppercase(1024), + " / HTTP/1.1\r\n", + }, ""), + }) +} + +type randomInvalidVersionNumber struct{} + +func (randomInvalidVersionNumber) Name() string { + return "random_invalid_version_number" +} + +func (meth randomInvalidVersionNumber) Run(ctx context.Context, config MethodConfig) { + RunMethod(ctx, RunMethodConfig{ + MethodConfig: config, + Name: meth.Name(), + RequestLine: strings.Join([]string{ + "GET / HTTP/", + randx.LettersUppercase(3), + "\r\n", + }, ""), + }) +} + +type squidCacheManager struct{} + +func (squidCacheManager) Name() string { + return "squid_cache_manager" +} + +func (meth squidCacheManager) Run(ctx context.Context, config MethodConfig) { + RunMethod(ctx, RunMethodConfig{ + MethodConfig: config, + Name: meth.Name(), + RequestLine: "GET cache_object://localhost/ HTTP/1.0\n\r", + }) +} + +// RunMethodConfig contains the config for RunMethod +type RunMethodConfig struct { + MethodConfig + Name string + NewDialer func(config netx.Config) netx.Dialer + RequestLine string +} + +// RunMethod runs the specific method using the given config and context +func RunMethod(ctx context.Context, config RunMethodConfig) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + result := MethodResult{Name: config.Name} + defer func() { + config.Out <- result + }() + if config.NewDialer == nil { + config.NewDialer = netx.NewDialer + } + dialer := config.NewDialer(netx.Config{ + ContextByteCounting: true, + Logger: config.Logger, + }) + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(config.Address, "80")) + if err != nil { + result.Err = err + return + } + deadline := time.Now().Add(timeout) + if err := conn.SetDeadline(deadline); err != nil { + result.Err = err + return + } + if _, err := conn.Write([]byte(config.RequestLine)); err != nil { + result.Err = err + return + } + result.Sent = config.RequestLine + data := make([]byte, 4096) + defer func() { + result.Tampering = (result.Sent != result.Received.Value) + }() + for { + count, err := conn.Read(data) + if err != nil { + // We expect this method to terminate w/ timeout + if err.Error() == errorx.FailureGenericTimeoutError { + err = nil + } + result.Err = err + return + } + result.Received.Value += string(data[:count]) + } +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + sk.IsAnomaly = tk.Tampering + return sk, nil +} diff --git a/internal/engine/experiment/hirl/hirl_test.go b/internal/engine/experiment/hirl/hirl_test.go new file mode 100644 index 0000000..47279b9 --- /dev/null +++ b/internal/engine/experiment/hirl/hirl_test.go @@ -0,0 +1,598 @@ +package hirl_test + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/hirl" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := hirl.NewExperimentMeasurer(hirl.Config{}) + if measurer.ExperimentName() != "http_invalid_request_line" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.2.0" { + t.Fatal("unexpected version") + } +} + +func TestSuccess(t *testing.T) { + measurer := hirl.NewExperimentMeasurer(hirl.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "37.218.241.93", + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*hirl.TestKeys) + if len(tk.FailureList) != len(tk.Received) { + t.Fatal("FailureList and Received have different lengths") + } + if len(tk.Received) != len(tk.Sent) { + t.Fatal("Received and Sent have different lengths") + } + if len(tk.Sent) != len(tk.TamperingList) { + t.Fatal("Sent and TamperingList have different lengths") + } + for _, failure := range tk.FailureList { + if failure != nil { + t.Fatal(*failure) + } + } + for idx, received := range tk.Received { + if received.Value != tk.Sent[idx] { + t.Fatal("mismatch between received and sent") + } + } + for _, entry := range tk.TamperingList { + if entry != false { + t.Fatal("found entry with tampering") + } + } + if tk.Tampering != false { + t.Fatal("overall there is tampering?!") + } +} + +func TestCancelledContext(t *testing.T) { + measurer := hirl.NewExperimentMeasurer(hirl.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "37.218.241.93", + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*hirl.TestKeys) + if len(tk.FailureList) != 5 { + t.Fatal("unexpected FailureList length") + } + for _, failure := range tk.FailureList { + if *failure != errorx.FailureInterrupted { + t.Fatal("unexpected failure") + } + } + if len(tk.Received) != 5 { + t.Fatal("unexpected Received length") + } + for _, entry := range tk.Received { + if entry.Value != "" { + t.Fatal("unexpected received entry") + } + } + if len(tk.Sent) != 5 { + t.Fatal("unexpected Sent length") + } + for _, entry := range tk.Sent { + if entry != "" { + t.Fatal("unexpected sent entry") + } + } + if len(tk.TamperingList) != 5 { + t.Fatal("unexpected TamperingList length") + } + for _, entry := range tk.TamperingList { + if entry != false { + t.Fatal("unexpected tampering entry") + } + } + if tk.Tampering != false { + t.Fatal("overall there is tampering?!") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(hirl.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +type FakeMethodSuccessful struct{} + +func (FakeMethodSuccessful) Name() string { + return "success" +} + +func (meth FakeMethodSuccessful) Run(ctx context.Context, config hirl.MethodConfig) { + config.Out <- hirl.MethodResult{ + Name: meth.Name(), + Received: archival.MaybeBinaryValue{Value: "antani"}, + Sent: "antani", + Tampering: false, + } +} + +type FakeMethodFailure struct{} + +func (FakeMethodFailure) Name() string { + return "failure" +} + +func (meth FakeMethodFailure) Run(ctx context.Context, config hirl.MethodConfig) { + config.Out <- hirl.MethodResult{ + Name: meth.Name(), + Received: archival.MaybeBinaryValue{Value: "antani"}, + Sent: "melandri", + Tampering: true, + } +} + +func TestWithFakeMethods(t *testing.T) { + measurer := hirl.Measurer{ + Config: hirl.Config{}, + Methods: []hirl.Method{ + FakeMethodSuccessful{}, + FakeMethodFailure{}, + FakeMethodSuccessful{}, + }, + } + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "127.0.0.1", + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*hirl.TestKeys) + if len(tk.FailureList) != len(tk.Received) { + t.Fatal("FailureList and Received have different lengths") + } + if len(tk.Received) != len(tk.Sent) { + t.Fatal("Received and Sent have different lengths") + } + if len(tk.Sent) != len(tk.TamperingList) { + t.Fatal("Sent and TamperingList have different lengths") + } + for _, failure := range tk.FailureList { + if failure != nil { + t.Fatal(*failure) + } + } + for _, received := range tk.Received { + if received.Value != "antani" { + t.Fatal("unexpected received value") + } + } + for _, sent := range tk.Sent { + if sent != "antani" && sent != "melandri" { + t.Fatal("unexpected sent value") + } + } + var falses, trues int + for _, entry := range tk.TamperingList { + if entry { + trues++ + } else { + falses++ + } + } + if falses != 2 && trues != 1 { + t.Fatal("not the right values in tampering list") + } + if tk.Tampering != true { + t.Fatal("overall there is no tampering?!") + } +} + +func TestWithNoMethods(t *testing.T) { + measurer := hirl.Measurer{ + Config: hirl.Config{}, + Methods: []hirl.Method{}, + } + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "127.0.0.1", + Type: "legacy", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, hirl.ErrNoMeasurementMethod) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hirl.TestKeys) + if len(tk.FailureList) != 0 { + t.Fatal("unexpected FailureList length") + } + if len(tk.Received) != 0 { + t.Fatal("unexpected Received length") + } + if len(tk.Sent) != 0 { + t.Fatal("unexpected Sent length") + } + if len(tk.TamperingList) != 0 { + t.Fatal("unexpected TamperingList length") + } + if tk.Tampering != false { + t.Fatal("overall there is tampering?!") + } +} + +func TestNoHelpers(t *testing.T) { + measurer := hirl.NewExperimentMeasurer(hirl.Config{}) + ctx := context.Background() + sess := &mockable.Session{} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, hirl.ErrNoAvailableTestHelpers) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hirl.TestKeys) + if len(tk.FailureList) != 0 { + t.Fatal("expected an empty FailureList") + } + if len(tk.FailureList) != len(tk.Received) { + t.Fatal("FailureList and Received have different lengths") + } + if len(tk.Received) != len(tk.Sent) { + t.Fatal("Received and Sent have different lengths") + } + if len(tk.Sent) != len(tk.TamperingList) { + t.Fatal("Sent and TamperingList have different lengths") + } + if tk.Tampering != false { + t.Fatal("overall there is tampering?!") + } +} + +func TestNoActualHelperInList(t *testing.T) { + measurer := hirl.NewExperimentMeasurer(hirl.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": nil, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, hirl.ErrNoAvailableTestHelpers) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hirl.TestKeys) + if len(tk.FailureList) != 0 { + t.Fatal("expected an empty FailureList") + } + if len(tk.FailureList) != len(tk.Received) { + t.Fatal("FailureList and Received have different lengths") + } + if len(tk.Received) != len(tk.Sent) { + t.Fatal("Received and Sent have different lengths") + } + if len(tk.Sent) != len(tk.TamperingList) { + t.Fatal("Sent and TamperingList have different lengths") + } + if tk.Tampering != false { + t.Fatal("overall there is tampering?!") + } +} + +func TestWrongTestHelperType(t *testing.T) { + measurer := hirl.NewExperimentMeasurer(hirl.Config{}) + ctx := context.Background() + sess := &mockable.Session{ + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "127.0.0.1", + Type: "antani", + }}, + }, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, hirl.ErrInvalidHelperType) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*hirl.TestKeys) + if len(tk.FailureList) != 0 { + t.Fatal("expected an empty FailureList") + } + if len(tk.FailureList) != len(tk.Received) { + t.Fatal("FailureList and Received have different lengths") + } + if len(tk.Received) != len(tk.Sent) { + t.Fatal("Received and Sent have different lengths") + } + if len(tk.Sent) != len(tk.TamperingList) { + t.Fatal("Sent and TamperingList have different lengths") + } + if tk.Tampering != false { + t.Fatal("overall there is tampering?!") + } +} + +func TestRunMethodDialFailure(t *testing.T) { + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "37.218.241.93", + Type: "legacy", + }}, + }, + } + helpers, ok := sess.GetTestHelpersByName("tcp-echo") + if len(helpers) < 1 || !ok { + t.Fatal("cannot get helper") + } + expected := errors.New("mocked error") + out := make(chan hirl.MethodResult) + config := hirl.RunMethodConfig{ + MethodConfig: hirl.MethodConfig{ + Address: helpers[0].Address, + Logger: log.Log, + Out: out, + }, + Name: "random_invalid_version_number", + NewDialer: func(config netx.Config) netx.Dialer { + return FakeDialer{Err: expected} + }, + RequestLine: "GET / HTTP/ABC", + } + go hirl.RunMethod(context.Background(), config) + result := <-out + if !errors.Is(result.Err, expected) { + t.Fatal("not the error we expected") + } + if result.Name != "random_invalid_version_number" { + t.Fatal("unexpected Name") + } + if result.Received.Value != "" { + t.Fatal("unexpected Received.Value") + } + if result.Sent != "" { + t.Fatal("unexpected Sent") + } + if result.Tampering != false { + t.Fatal("unexpected Tampering") + } +} + +func TestRunMethodSetDeadlineFailure(t *testing.T) { + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "37.218.241.93", + Type: "legacy", + }}, + }, + } + helpers, ok := sess.GetTestHelpersByName("tcp-echo") + if len(helpers) < 1 || !ok { + t.Fatal("cannot get helper") + } + expected := errors.New("mocked error") + out := make(chan hirl.MethodResult) + config := hirl.RunMethodConfig{ + MethodConfig: hirl.MethodConfig{ + Address: helpers[0].Address, + Logger: log.Log, + Out: out, + }, + Name: "random_invalid_version_number", + NewDialer: func(config netx.Config) netx.Dialer { + return FakeDialer{Conn: &FakeConn{ + SetDeadlineError: expected, + }} + }, + RequestLine: "GET / HTTP/ABC", + } + go hirl.RunMethod(context.Background(), config) + result := <-out + if !errors.Is(result.Err, expected) { + t.Fatal("not the error we expected") + } + if result.Name != "random_invalid_version_number" { + t.Fatal("unexpected Name") + } + if result.Received.Value != "" { + t.Fatal("unexpected Received.Value") + } + if result.Sent != "" { + t.Fatal("unexpected Sent") + } + if result.Tampering != false { + t.Fatal("unexpected Tampering") + } +} + +func TestRunMethodWriteFailure(t *testing.T) { + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "37.218.241.93", + Type: "legacy", + }}, + }, + } + helpers, ok := sess.GetTestHelpersByName("tcp-echo") + if len(helpers) < 1 || !ok { + t.Fatal("cannot get helper") + } + expected := errors.New("mocked error") + out := make(chan hirl.MethodResult) + config := hirl.RunMethodConfig{ + MethodConfig: hirl.MethodConfig{ + Address: helpers[0].Address, + Logger: log.Log, + Out: out, + }, + Name: "random_invalid_version_number", + NewDialer: func(config netx.Config) netx.Dialer { + return FakeDialer{Conn: &FakeConn{ + WriteError: expected, + }} + }, + RequestLine: "GET / HTTP/ABC", + } + go hirl.RunMethod(context.Background(), config) + result := <-out + if !errors.Is(result.Err, expected) { + t.Fatal("not the error we expected") + } + if result.Name != "random_invalid_version_number" { + t.Fatal("unexpected Name") + } + if result.Received.Value != "" { + t.Fatal("unexpected Received.Value") + } + if result.Sent != "" { + t.Fatal("unexpected Sent") + } + if result.Tampering != false { + t.Fatal("unexpected Tampering") + } +} + +func TestRunMethodReadEOFWithWrongData(t *testing.T) { + sess := &mockable.Session{ + MockableLogger: log.Log, + MockableTestHelpers: map[string][]model.Service{ + "tcp-echo": {{ + Address: "37.218.241.93", + Type: "legacy", + }}, + }, + } + helpers, ok := sess.GetTestHelpersByName("tcp-echo") + if len(helpers) < 1 || !ok { + t.Fatal("cannot get helper") + } + out := make(chan hirl.MethodResult) + config := hirl.RunMethodConfig{ + MethodConfig: hirl.MethodConfig{ + Address: helpers[0].Address, + Logger: log.Log, + Out: out, + }, + Name: "random_invalid_version_number", + NewDialer: func(config netx.Config) netx.Dialer { + return FakeDialer{Conn: &FakeConn{ + ReadData: []byte("0xdeadbeef"), + }} + }, + RequestLine: "GET / HTTP/ABC", + } + go hirl.RunMethod(context.Background(), config) + result := <-out + if !errors.Is(result.Err, io.EOF) { + t.Fatal("not the error we expected") + } + if result.Name != "random_invalid_version_number" { + t.Fatal("unexpected Name") + } + if result.Received.Value != "0xdeadbeef" { + t.Fatal("unexpected Received.Value") + } + if result.Sent != "GET / HTTP/ABC" { + t.Fatal("unexpected Sent") + } + if result.Tampering != true { + t.Fatal("unexpected Tampering") + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &hirl.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysFalse(t *testing.T) { + measurement := &model.Measurement{TestKeys: &hirl.TestKeys{ + Tampering: false, + }} + m := &hirl.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(hirl.SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} + +func TestSummaryKeysTrue(t *testing.T) { + measurement := &model.Measurement{TestKeys: &hirl.TestKeys{ + Tampering: true, + }} + m := &hirl.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(hirl.SummaryKeys) + if sk.IsAnomaly == false { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/httphostheader/httphostheader.go b/internal/engine/experiment/httphostheader/httphostheader.go new file mode 100644 index 0000000..62380da --- /dev/null +++ b/internal/engine/experiment/httphostheader/httphostheader.go @@ -0,0 +1,94 @@ +// Package httphostheader contains the HTTP host header network experiment. +// +// This experiment has not been specified yet. It is nonetheless available for testing +// and as a building block that other experiments could reuse. +package httphostheader + +import ( + "context" + "errors" + "fmt" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const ( + testName = "http_host_header" + testVersion = "0.3.0" +) + +// Config contains the experiment config. +type Config struct { + // TestHelperURL is the address of the test helper. + TestHelperURL string +} + +// TestKeys contains httphost test keys. +type TestKeys struct { + urlgetter.TestKeys + THAddress string `json:"th_address"` +} + +// Measurer performs the measurement. +type Measurer struct { + config Config +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements ExperimentMeasurer.Run. +func (m *Measurer) Run( + ctx context.Context, + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, +) error { + if measurement.Input == "" { + return errors.New("experiment requires input") + } + if m.config.TestHelperURL == "" { + m.config.TestHelperURL = "http://www.example.org" + } + urlgetter.RegisterExtensions(measurement) + g := urlgetter.Getter{ + Begin: measurement.MeasurementStartTimeSaved, + Config: urlgetter.Config{ + HTTPHost: string(measurement.Input), + }, + Session: sess, + Target: fmt.Sprintf(m.config.TestHelperURL), + } + tk, _ := g.Get(ctx) + measurement.TestKeys = &TestKeys{ + TestKeys: tk, + THAddress: m.config.TestHelperURL, + } + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/httphostheader/httphostheader_test.go b/internal/engine/experiment/httphostheader/httphostheader_test.go new file mode 100644 index 0000000..6de991c --- /dev/null +++ b/internal/engine/experiment/httphostheader/httphostheader_test.go @@ -0,0 +1,107 @@ +package httphostheader + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const ( + softwareName = "ooniprobe-example" + softwareVersion = "0.0.1" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "http_host_header" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.3.0" { + t.Fatal("unexpected version") + } +} + +func TestMeasurerMeasureNoMeasurementInput(t *testing.T) { + measurer := NewExperimentMeasurer(Config{ + TestHelperURL: "http://www.google.com", + }) + err := measurer.Run( + context.Background(), + newsession(), + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if err == nil || err.Error() != "experiment requires input" { + t.Fatal("not the error we expected") + } +} + +func TestMeasurerMeasureNoTestHelper(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + measurement := &model.Measurement{Input: "x.org"} + err := measurer.Run( + context.Background(), + newsession(), + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestRunnerHTTPSetHostHeader(t *testing.T) { + var host string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host = r.Host + w.WriteHeader(200) + })) + defer server.Close() + measurer := NewExperimentMeasurer(Config{ + TestHelperURL: server.URL, + }) + measurement := &model.Measurement{ + Input: "x.org", + } + err := measurer.Run( + context.Background(), + newsession(), + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if host != "x.org" { + t.Fatal("not the host we expected") + } + if err != nil { + t.Fatal(err) + } +} + +func newsession() model.ExperimentSession { + return &mockable.Session{MockableLogger: log.Log} +} + +func TestSummaryKeysGeneric(t *testing.T) { + measurement := &model.Measurement{TestKeys: &TestKeys{}} + m := &Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/ndt7/callback.go b/internal/engine/experiment/ndt7/callback.go new file mode 100644 index 0000000..00f72f3 --- /dev/null +++ b/internal/engine/experiment/ndt7/callback.go @@ -0,0 +1,8 @@ +package ndt7 + +import "time" + +type ( + callbackJSON func(data []byte) error + callbackPerformance func(elapsed time.Duration, count int64) +) diff --git a/internal/engine/experiment/ndt7/callback_test.go b/internal/engine/experiment/ndt7/callback_test.go new file mode 100644 index 0000000..1a78613 --- /dev/null +++ b/internal/engine/experiment/ndt7/callback_test.go @@ -0,0 +1,10 @@ +package ndt7 + +import "time" + +func defaultCallbackJSON(data []byte) error { + return nil +} + +func defaultCallbackPerformance(elapsed time.Duration, count int64) { +} diff --git a/internal/engine/experiment/ndt7/dial.go b/internal/engine/experiment/ndt7/dial.go new file mode 100644 index 0000000..cff75f2 --- /dev/null +++ b/internal/engine/experiment/ndt7/dial.go @@ -0,0 +1,91 @@ +package ndt7 + +import ( + "context" + "crypto/tls" + "net/http" + "net/url" + + "github.com/gorilla/websocket" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" +) + +type dialManager struct { + ndt7URL string + logger model.Logger + proxyURL *url.URL + readBufferSize int + tlsConfig *tls.Config + userAgent string + writeBufferSize int +} + +func newDialManager(ndt7URL string, logger model.Logger, userAgent string) dialManager { + return dialManager{ + ndt7URL: ndt7URL, + logger: logger, + readBufferSize: paramMaxBufferSize, + userAgent: userAgent, + writeBufferSize: paramMaxBufferSize, + } +} + +func (mgr dialManager) dialWithTestName(ctx context.Context, testName string) (*websocket.Conn, error) { + var reso resolver.Resolver = resolver.SystemResolver{} + reso = resolver.LoggingResolver{Resolver: reso, Logger: mgr.logger} + var dlr dialer.Dialer = selfcensor.SystemDialer{} + dlr = dialer.TimeoutDialer{Dialer: dlr} + dlr = dialer.ErrorWrapperDialer{Dialer: dlr} + dlr = dialer.LoggingDialer{Dialer: dlr, Logger: mgr.logger} + dlr = dialer.DNSDialer{Dialer: dlr, Resolver: reso} + dlr = dialer.ProxyDialer{Dialer: dlr, ProxyURL: mgr.proxyURL} + dlr = dialer.ByteCounterDialer{Dialer: dlr} + dlr = dialer.ShapingDialer{Dialer: dlr} + dialer := websocket.Dialer{ + NetDialContext: dlr.DialContext, + ReadBufferSize: mgr.readBufferSize, + TLSClientConfig: mgr.tlsConfig, + WriteBufferSize: mgr.writeBufferSize, + } + headers := http.Header{} + headers.Add("Sec-WebSocket-Protocol", "net.measurementlab.ndt.v7") + headers.Add("User-Agent", mgr.userAgent) + mgr.logrequest(mgr.ndt7URL, headers) + conn, _, err := dialer.DialContext(ctx, mgr.ndt7URL, headers) + mgr.logresponse(err) + return conn, err +} + +func (mgr dialManager) logrequest(url string, headers http.Header) { + mgr.logger.Debugf("> GET %s", url) + for key, values := range headers { + for _, v := range values { + mgr.logger.Debugf("> %s: %s", key, v) + } + } + mgr.logger.Debug("> Connection: upgrade") + mgr.logger.Debug("> Upgrade: websocket") + mgr.logger.Debug(">") +} + +func (mgr dialManager) logresponse(err error) { + if err != nil { + mgr.logger.Debugf("< %+v", err) + return + } + mgr.logger.Debug("< 101") + mgr.logger.Debug("< Connection: upgrade") + mgr.logger.Debug("< Upgrade: websocket") + mgr.logger.Debug("<") +} + +func (mgr dialManager) dialDownload(ctx context.Context) (*websocket.Conn, error) { + return mgr.dialWithTestName(ctx, "download") +} + +func (mgr dialManager) dialUpload(ctx context.Context) (*websocket.Conn, error) { + return mgr.dialWithTestName(ctx, "upload") +} diff --git a/internal/engine/experiment/ndt7/dial_test.go b/internal/engine/experiment/ndt7/dial_test.go new file mode 100644 index 0000000..c809953 --- /dev/null +++ b/internal/engine/experiment/ndt7/dial_test.go @@ -0,0 +1,70 @@ +package ndt7 + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/apex/log" + "github.com/gorilla/websocket" +) + +func TestDialDownloadWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately halt + mgr := newDialManager("wss://hostname.fake", log.Log, "miniooni/0.1.0-dev") + conn, err := mgr.dialDownload(ctx) + if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} + +func TestDialUploadWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately halt + mgr := newDialManager("wss://hostname.fake", log.Log, "miniooni/0.1.0-dev") + conn, err := mgr.dialUpload(ctx) + if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} + +func TestDialIncludesUserAgent(t *testing.T) { + do := func(testName string) { + var userAgent string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userAgent = r.UserAgent() + w.WriteHeader(500) + }) + server := httptest.NewServer(handler) + defer server.Close() + url, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + url.Scheme = "ws" + mgr := newDialManager(url.String(), log.Log, "miniooni/0.1.0-dev") + conn, err := mgr.dialWithTestName(context.Background(), testName) + if !errors.Is(err, websocket.ErrBadHandshake) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } + if userAgent != "miniooni/0.1.0-dev" { + t.Fatal("User-Agent not sent") + } + } + do("download") + do("upload") +} diff --git a/internal/engine/experiment/ndt7/download.go b/internal/engine/experiment/ndt7/download.go new file mode 100644 index 0000000..5adc0bb --- /dev/null +++ b/internal/engine/experiment/ndt7/download.go @@ -0,0 +1,73 @@ +package ndt7 + +import ( + "context" + "io" + "io/ioutil" + "time" + + "github.com/gorilla/websocket" +) + +type downloadManager struct { + conn mockableConn + maxMessageSize int64 + maxRuntime time.Duration + measureInterval time.Duration + onJSON callbackJSON + onPerformance callbackPerformance +} + +func newDownloadManager( + conn mockableConn, onPerformance callbackPerformance, + onJSON callbackJSON, +) downloadManager { + return downloadManager{ + conn: conn, + maxMessageSize: paramMaxMessageSize, + maxRuntime: paramMaxRuntime, + measureInterval: paramMeasureInterval, + onJSON: onJSON, + onPerformance: onPerformance, + } +} + +func (mgr downloadManager) run(ctx context.Context) error { + var total int64 + start := time.Now() + if err := mgr.conn.SetReadDeadline(start.Add(mgr.maxRuntime)); err != nil { + return err + } + mgr.conn.SetReadLimit(mgr.maxMessageSize) + ticker := time.NewTicker(mgr.measureInterval) + defer ticker.Stop() + for ctx.Err() == nil { + kind, reader, err := mgr.conn.NextReader() + if err != nil { + return err + } + if kind == websocket.TextMessage { + data, err := ioutil.ReadAll(reader) + if err != nil { + return err + } + total += int64(len(data)) + if err := mgr.onJSON(data); err != nil { + return err + } + continue + } + n, err := io.Copy(ioutil.Discard, reader) + if err != nil { + return err + } + total += int64(n) + select { + case now := <-ticker.C: + mgr.onPerformance(now.Sub(start), total) + default: + // NOTHING + } + } + return nil +} diff --git a/internal/engine/experiment/ndt7/download_test.go b/internal/engine/experiment/ndt7/download_test.go new file mode 100644 index 0000000..bc393a3 --- /dev/null +++ b/internal/engine/experiment/ndt7/download_test.go @@ -0,0 +1,145 @@ +package ndt7 + +import ( + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestDownloadSetReadDeadlineFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newDownloadManager( + &mockableConnMock{ + ReadDeadlineErr: expected, + }, + defaultCallbackPerformance, + defaultCallbackJSON, + ) + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestDownloadNextReaderFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newDownloadManager( + &mockableConnMock{ + NextReaderErr: expected, + }, + defaultCallbackPerformance, + defaultCallbackJSON, + ) + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestDownloadTextMessageReadAllFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newDownloadManager( + &mockableConnMock{ + NextReaderMsgType: websocket.TextMessage, + NextReaderReader: func() io.Reader { + return &alwaysFailingReader{ + Err: expected, + } + }, + }, + defaultCallbackPerformance, + defaultCallbackJSON, + ) + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +type alwaysFailingReader struct { + Err error +} + +func (r *alwaysFailingReader) Read(p []byte) (int, error) { + return 0, r.Err +} + +func TestDownloadBinaryMessageReadAllFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newDownloadManager( + &mockableConnMock{ + NextReaderMsgType: websocket.BinaryMessage, + NextReaderReader: func() io.Reader { + return &alwaysFailingReader{ + Err: expected, + } + }, + }, + defaultCallbackPerformance, + defaultCallbackJSON, + ) + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestDownloadOnJSONCallbackError(t *testing.T) { + mgr := newDownloadManager( + &mockableConnMock{ + NextReaderMsgType: websocket.TextMessage, + NextReaderReader: func() io.Reader { + return &invalidJSONReader{} + }, + }, + defaultCallbackPerformance, + func(data []byte) error { + var v interface{} + return json.Unmarshal(data, &v) + }, + ) + err := mgr.run(context.Background()) + if err == nil || !strings.HasSuffix(err.Error(), "unexpected end of JSON input") { + t.Fatal("not the error we expected") + } +} + +type invalidJSONReader struct{} + +func (r *invalidJSONReader) Read(p []byte) (int, error) { + return copy(p, []byte(`{`)), io.EOF +} + +func TestDownloadOnJSONLoop(t *testing.T) { + mgr := newDownloadManager( + &mockableConnMock{ + NextReaderMsgType: websocket.TextMessage, + NextReaderReader: func() io.Reader { + return &goodJSONReader{} + }, + }, + defaultCallbackPerformance, + func(data []byte) error { + var v interface{} + return json.Unmarshal(data, &v) + }, + ) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := mgr.run(ctx) + if err != nil { + t.Fatal(err) + } +} + +type goodJSONReader struct{} + +func (r *goodJSONReader) Read(p []byte) (int, error) { + return copy(p, []byte(`{}`)), io.EOF +} diff --git a/internal/engine/experiment/ndt7/mockable.go b/internal/engine/experiment/ndt7/mockable.go new file mode 100644 index 0000000..4c0d6d7 --- /dev/null +++ b/internal/engine/experiment/ndt7/mockable.go @@ -0,0 +1,16 @@ +package ndt7 + +import ( + "io" + "time" + + "github.com/gorilla/websocket" +) + +type mockableConn interface { + NextReader() (int, io.Reader, error) + SetReadDeadline(time.Time) error + SetReadLimit(int64) + SetWriteDeadline(time.Time) error + WritePreparedMessage(*websocket.PreparedMessage) error +} diff --git a/internal/engine/experiment/ndt7/mockable_test.go b/internal/engine/experiment/ndt7/mockable_test.go new file mode 100644 index 0000000..7e0d61c --- /dev/null +++ b/internal/engine/experiment/ndt7/mockable_test.go @@ -0,0 +1,39 @@ +package ndt7 + +import ( + "io" + "time" + + "github.com/gorilla/websocket" +) + +type mockableConnMock struct { + NextReaderMsgType int + NextReaderErr error + NextReaderReader func() io.Reader + ReadDeadlineErr error + WriteDeadlineErr error + WritePreparedMessageErr error +} + +func (c *mockableConnMock) NextReader() (int, io.Reader, error) { + var reader io.Reader + if c.NextReaderReader != nil { + reader = c.NextReaderReader() + } + return c.NextReaderMsgType, reader, c.NextReaderErr +} + +func (c *mockableConnMock) SetReadDeadline(time.Time) error { + return c.ReadDeadlineErr +} + +func (c *mockableConnMock) SetReadLimit(int64) {} + +func (c *mockableConnMock) SetWriteDeadline(time.Time) error { + return c.WriteDeadlineErr +} + +func (c *mockableConnMock) WritePreparedMessage(*websocket.PreparedMessage) error { + return c.WritePreparedMessageErr +} diff --git a/internal/engine/experiment/ndt7/ndt7.go b/internal/engine/experiment/ndt7/ndt7.go new file mode 100644 index 0000000..ef268d9 --- /dev/null +++ b/internal/engine/experiment/ndt7/ndt7.go @@ -0,0 +1,301 @@ +// Package ndt7 contains the ndt7 network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-022-ndt.md +package ndt7 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/humanizex" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mlablocatev2" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +const ( + testName = "ndt" + testVersion = "0.8.0" +) + +// Config contains the experiment settings +type Config struct { + noDownload bool + noUpload bool +} + +// Summary is the measurement summary +type Summary struct { + AvgRTT float64 `json:"avg_rtt"` // Average RTT [ms] + Download float64 `json:"download"` // download speed [kbit/s] + MSS int64 `json:"mss"` // MSS + MaxRTT float64 `json:"max_rtt"` // Max AvgRTT sample seen [ms] + MinRTT float64 `json:"min_rtt"` // Min RTT according to kernel [ms] + Ping float64 `json:"ping"` // Equivalent to MinRTT [ms] + RetransmitRate float64 `json:"retransmit_rate"` // bytes_retrans/bytes_sent [0..1] + Upload float64 `json:"upload"` // upload speed [kbit/s] +} + +// ServerInfo contains information on the selected server +// +// Site is currently an extension to the NDT specification +// until the data format of the new mlab locate is clear. +type ServerInfo struct { + Hostname string `json:"hostname"` + Site string `json:"site,omitempty"` +} + +// TestKeys contains the test keys +type TestKeys struct { + // Download contains download results + Download []Measurement `json:"download"` + + // Failure is the failure string + Failure *string `json:"failure"` + + // Protocol contains the version of the ndt protocol + Protocol int64 `json:"protocol"` + + // Server contains information on the selected server + Server ServerInfo `json:"server"` + + // Summary contains the measurement summary + Summary Summary `json:"summary"` + + // Upload contains upload results + Upload []Measurement `json:"upload"` +} + +// Measurer performs the measurement. +type Measurer struct { + config Config + jsonUnmarshal func(data []byte, v interface{}) error + preDownloadHook func() + preUploadHook func() +} + +func (m *Measurer) discover( + ctx context.Context, sess model.ExperimentSession) (mlablocatev2.NDT7Result, error) { + httpClient := &http.Client{ + Transport: netx.NewHTTPTransport(netx.Config{ + Logger: sess.Logger(), + }), + } + defer httpClient.CloseIdleConnections() + client := mlablocatev2.NewClient(httpClient, sess.Logger(), sess.UserAgent()) + out, err := client.QueryNDT7(ctx) + if err != nil { + return mlablocatev2.NDT7Result{}, err + } + return out[0], nil // same as with locate services v1 +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +func (m *Measurer) doDownload( + ctx context.Context, sess model.ExperimentSession, + callbacks model.ExperimentCallbacks, tk *TestKeys, + URL string, +) error { + if m.config.noDownload == true { + return nil // useful to make tests faster + } + conn, err := newDialManager(URL, + sess.Logger(), sess.UserAgent()).dialDownload(ctx) + if err != nil { + return err + } + defer callbacks.OnProgress(0.5, " download: done") + defer conn.Close() + mgr := newDownloadManager( + conn, + func(timediff time.Duration, count int64) { + elapsed := timediff.Seconds() + // The percentage of completion of download goes from 0 to + // 50% of the whole experiment, hence the `/2.0`. + percentage := elapsed / paramMaxRuntimeUpperBound / 2.0 + speed := float64(count) * 8.0 / elapsed + message := fmt.Sprintf(" download: speed %s", humanizex.SI( + float64(speed), "bit/s")) + tk.Summary.Download = speed / 1e03 /* bit/s => kbit/s */ + callbacks.OnProgress(percentage, message) + tk.Download = append(tk.Download, Measurement{ + AppInfo: &AppInfo{ + ElapsedTime: int64(timediff / time.Microsecond), + NumBytes: count, + }, + Origin: "client", + Test: "download", + }) + }, + func(data []byte) error { + sess.Logger().Debugf("%s", string(data)) + var measurement Measurement + if err := m.jsonUnmarshal(data, &measurement); err != nil { + return err + } + if measurement.TCPInfo != nil { + rtt := float64(measurement.TCPInfo.RTT) / 1e03 /* us => ms */ + tk.Summary.AvgRTT = rtt + tk.Summary.MSS = int64(measurement.TCPInfo.AdvMSS) + if tk.Summary.MaxRTT < rtt { + tk.Summary.MaxRTT = rtt + } + tk.Summary.MinRTT = float64(measurement.TCPInfo.MinRTT) / 1e03 /* us => ms */ + tk.Summary.Ping = tk.Summary.MinRTT + if measurement.TCPInfo.BytesSent > 0 { + tk.Summary.RetransmitRate = (float64(measurement.TCPInfo.BytesRetrans) / + float64(measurement.TCPInfo.BytesSent)) + } + measurement.BBRInfo = nil // don't encourage people to use it + measurement.ConnectionInfo = nil // do we need to save it? + measurement.Origin = "server" + measurement.Test = "download" + tk.Download = append(tk.Download, measurement) + } + return nil + }, + ) + if err := mgr.run(ctx); err != nil && err.Error() != "generic_timeout_error" { + sess.Logger().Warnf("download: %s", err) + } + return nil // failure is only when we cannot connect +} + +func (m *Measurer) doUpload( + ctx context.Context, sess model.ExperimentSession, + callbacks model.ExperimentCallbacks, tk *TestKeys, + URL string, +) error { + if m.config.noUpload == true { + return nil // useful to make tests faster + } + conn, err := newDialManager(URL, + sess.Logger(), sess.UserAgent()).dialUpload(ctx) + if err != nil { + return err + } + defer callbacks.OnProgress(1, " upload: done") + defer conn.Close() + mgr := newUploadManager( + conn, + func(timediff time.Duration, count int64) { + elapsed := timediff.Seconds() + // The percentage of completion of upload goes from 50% to 100% of + // the whole experiment, hence `0.5 +` and `/2.0`. + percentage := 0.5 + elapsed/paramMaxRuntimeUpperBound/2.0 + speed := float64(count) * 8.0 / elapsed + message := fmt.Sprintf(" upload: speed %s", humanizex.SI( + float64(speed), "bit/s")) + tk.Summary.Upload = speed / 1e03 /* bit/s => kbit/s */ + callbacks.OnProgress(percentage, message) + tk.Upload = append(tk.Upload, Measurement{ + AppInfo: &AppInfo{ + ElapsedTime: int64(timediff / time.Microsecond), + NumBytes: count, + }, + Origin: "client", + Test: "upload", + }) + }, + ) + if err := mgr.run(ctx); err != nil && err.Error() != "generic_timeout_error" { + sess.Logger().Warnf("upload: %s", err) + } + return nil // failure is only when we cannot connect +} + +// Run implements ExperimentMeasurer.Run. +func (m *Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + tk := new(TestKeys) + tk.Protocol = 7 + measurement.TestKeys = tk + locateResult, err := m.discover(ctx, sess) + if err != nil { + tk.Failure = failureFromError(err) + return err + } + tk.Server = ServerInfo{ + Hostname: locateResult.Hostname, + Site: locateResult.Site, + } + callbacks.OnProgress(0, fmt.Sprintf(" download: url: %s", locateResult.WSSDownloadURL)) + if m.preDownloadHook != nil { + m.preDownloadHook() + } + if err := m.doDownload(ctx, sess, callbacks, tk, locateResult.WSSDownloadURL); err != nil { + tk.Failure = failureFromError(err) + return err + } + callbacks.OnProgress(0.5, fmt.Sprintf(" upload: url: %s", locateResult.WSSUploadURL)) + if m.preUploadHook != nil { + m.preUploadHook() + } + if err := m.doUpload(ctx, sess, callbacks, tk, locateResult.WSSUploadURL); err != nil { + tk.Failure = failureFromError(err) + return err + } + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{config: config, jsonUnmarshal: json.Unmarshal} +} + +func failureFromError(err error) (failure *string) { + if err != nil { + s := err.Error() + failure = &s + } + return +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + Upload float64 `json:"upload"` + Download float64 `json:"download"` + Ping float64 `json:"ping"` + MaxRTT float64 `json:"max_rtt"` + AvgRTT float64 `json:"avg_rtt"` + MinRTT float64 `json:"min_rtt"` + MSS float64 `json:"mss"` + RetransmitRate float64 `json:"retransmit_rate"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + sk.Upload = tk.Summary.Upload + sk.Download = tk.Summary.Download + sk.Ping = tk.Summary.Ping + sk.MaxRTT = tk.Summary.MaxRTT + sk.AvgRTT = tk.Summary.AvgRTT + sk.MinRTT = tk.Summary.MinRTT + sk.MSS = float64(tk.Summary.MSS) + sk.RetransmitRate = tk.Summary.RetransmitRate + return sk, nil +} diff --git a/internal/engine/experiment/ndt7/ndt7_test.go b/internal/engine/experiment/ndt7/ndt7_test.go new file mode 100644 index 0000000..c209d14 --- /dev/null +++ b/internal/engine/experiment/ndt7/ndt7_test.go @@ -0,0 +1,250 @@ +package ndt7 + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "ndt" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.8.0" { + t.Fatal("unexpected version") + } +} + +func TestDiscoverCancelledContext(t *testing.T) { + m := new(Measurer) + sess := &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + MockableUserAgent: "miniooni/0.1.0-dev", + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel + locateResult, err := m.discover(ctx, sess) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if locateResult.Hostname != "" { + t.Fatal("not the Hostname we expected") + } +} + +type verifyRequestTransport struct { + ExpectedError error +} + +func (txp *verifyRequestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.RawQuery != "ip=1.2.3.4" { + return nil, errors.New("invalid req.URL.RawQuery") + } + return nil, txp.ExpectedError +} + +func TestDoDownloadWithCancelledContext(t *testing.T) { + m := new(Measurer) + sess := &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + MockableUserAgent: "miniooni/0.1.0-dev", + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel + err := m.doDownload( + ctx, sess, model.NewPrinterCallbacks(log.Log), new(TestKeys), + "ws://host.name") + if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") { + t.Fatal("not the error we expected") + } +} + +func TestDoUploadWithCancelledContext(t *testing.T) { + m := new(Measurer) + sess := &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + MockableUserAgent: "miniooni/0.1.0-dev", + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel + err := m.doUpload( + ctx, sess, model.NewPrinterCallbacks(log.Log), new(TestKeys), + "ws://host.name") + if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") { + t.Fatal("not the error we expected") + } +} + +func TestRunWithCancelledContext(t *testing.T) { + m := new(Measurer) + sess := &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + MockableUserAgent: "miniooni/0.1.0-dev", + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel + err := m.Run(ctx, sess, new(model.Measurement), model.NewPrinterCallbacks(log.Log)) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } +} + +func TestGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurement := new(model.Measurement) + measurer := NewExperimentMeasurer(Config{}) + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestFailDownload(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + measurer := NewExperimentMeasurer(Config{}).(*Measurer) + measurer.preDownloadHook = func() { + cancel() + } + err := measurer.Run( + ctx, + &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") { + t.Fatal(err) + } +} + +func TestFailUpload(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + measurer := NewExperimentMeasurer(Config{noDownload: true}).(*Measurer) + measurer.preUploadHook = func() { + cancel() + } + err := measurer.Run( + ctx, + &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") { + t.Fatal(err) + } +} + +func TestDownloadJSONUnmarshalFail(t *testing.T) { + measurer := NewExperimentMeasurer(Config{noUpload: true}).(*Measurer) + var seenError bool + expected := errors.New("expected error") + measurer.jsonUnmarshal = func(data []byte, v interface{}) error { + seenError = true + return expected + } + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + if !seenError { + t.Fatal("did not see expected error") + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysGood(t *testing.T) { + measurement := &model.Measurement{TestKeys: &TestKeys{Summary: Summary{ + RetransmitRate: 1, + MSS: 2, + MinRTT: 3, + AvgRTT: 4, + MaxRTT: 5, + Ping: 6, + Download: 7, + Upload: 8, + }}} + m := &Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(SummaryKeys) + if sk.RetransmitRate != 1 { + t.Fatal("invalid retransmitRate") + } + if sk.MSS != 2 { + t.Fatal("invalid mss") + } + if sk.MinRTT != 3 { + t.Fatal("invalid minRTT") + } + if sk.AvgRTT != 4 { + t.Fatal("invalid minRTT") + } + if sk.MaxRTT != 5 { + t.Fatal("invalid minRTT") + } + if sk.Ping != 6 { + t.Fatal("invalid minRTT") + } + if sk.Download != 7 { + t.Fatal("invalid minRTT") + } + if sk.Upload != 8 { + t.Fatal("invalid minRTT") + } + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/ndt7/param.go b/internal/engine/experiment/ndt7/param.go new file mode 100644 index 0000000..afc87db --- /dev/null +++ b/internal/engine/experiment/ndt7/param.go @@ -0,0 +1,14 @@ +package ndt7 + +import "time" + +const ( + paramFractionForScaling = 16 + paramMinMessageSize = 1 << 10 + paramMaxBufferSize = 1 << 20 + paramMaxScaledMessageSize = 1 << 20 + paramMaxMessageSize = 1 << 24 + paramMaxRuntimeUpperBound = 15.0 // seconds + paramMaxRuntime = 10 * time.Second + paramMeasureInterval = 250 * time.Millisecond +) diff --git a/internal/engine/experiment/ndt7/spec.go b/internal/engine/experiment/ndt7/spec.go new file mode 100644 index 0000000..311ee07 --- /dev/null +++ b/internal/engine/experiment/ndt7/spec.go @@ -0,0 +1,177 @@ +package ndt7 + +// This file vendors data structures from the following repositories: +// +// - github.com/m-lab/ndt7-client-go +// - github.com/m-lab/ndt-server +// - github.com/m-lab/tcp-info +// +// It is available under the Apache License v2.0. +// +// Because m-lab uses mainly Linux as a development platform, they may +// unwillingly break our Windows builds. Also, they use lots of depdencies +// that we don't actually need. Hence, vendoring FTW. +// +// The data structures are supposed to stay constant in time or to not +// change dramatically, hence this vendoring shouldn't be too bad. + +type ( + // OriginKind indicates the origin of a measurement. + OriginKind string + + // TestKind indicates the direction of a measurement. + TestKind string +) + +const ( + // OriginClient indicates that the measurement origin is the client. + OriginClient = OriginKind("client") + + // OriginServer indicates that the measurement origin is the server. + OriginServer = OriginKind("server") + + // TestDownload indicates that this is a download. + TestDownload = TestKind("download") + + // TestUpload indicates that this is an upload. + TestUpload = TestKind("upload") +) + +// LinuxTCPInfo is the linux defined structure returned in RouteAttr DIAG_INFO messages. +// It corresponds to the struct tcp_info in include/uapi/linux/tcp.h +type LinuxTCPInfo struct { + State uint8 `csv:"TCP.State"` + CAState uint8 `csv:"TCP.CAState"` + Retransmits uint8 `csv:"TCP.Retransmits"` + Probes uint8 `csv:"TCP.Probes"` + Backoff uint8 `csv:"TCP.Backoff"` + Options uint8 `csv:"TCP.Options"` + WScale uint8 `csv:"TCP.WScale"` //snd_wscale : 4, tcpi_rcv_wscale : 4; + AppLimited uint8 `csv:"TCP.AppLimited"` //delivery_rate_app_limited:1; + + RTO uint32 `csv:"TCP.RTO"` // offset 8 + ATO uint32 `csv:"TCP.ATO"` + SndMSS uint32 `csv:"TCP.SndMSS"` + RcvMSS uint32 `csv:"TCP.RcvMSS"` + + Unacked uint32 `csv:"TCP.Unacked"` // offset 24 + Sacked uint32 `csv:"TCP.Sacked"` + Lost uint32 `csv:"TCP.Lost"` + Retrans uint32 `csv:"TCP.Retrans"` + Fackets uint32 `csv:"TCP.Fackets"` + + /* Times. */ + // These seem to be elapsed time, so they increase on almost every sample. + // We can probably use them to get more info about intervals between samples. + LastDataSent uint32 `csv:"TCP.LastDataSent"` // offset 44 + LastAckSent uint32 `csv:"TCP.LastAckSent"` /* Not remembered, sorry. */ // offset 48 + LastDataRecv uint32 `csv:"TCP.LastDataRecv"` // offset 52 + LastAckRecv uint32 `csv:"TCP.LastDataRecv"` // offset 56 + + /* Metrics. */ + PMTU uint32 `csv:"TCP.PMTU"` + RcvSsThresh uint32 `csv:"TCP.RcvSsThresh"` + RTT uint32 `csv:"TCP.RTT"` + RTTVar uint32 `csv:"TCP.RTTVar"` + SndSsThresh uint32 `csv:"TCP.SndSsThresh"` + SndCwnd uint32 `csv:"TCP.SndCwnd"` + AdvMSS uint32 `csv:"TCP.AdvMSS"` + Reordering uint32 `csv:"TCP.Reordering"` + + RcvRTT uint32 `csv:"TCP.RcvRTT"` + RcvSpace uint32 `csv:"TCP.RcvSpace"` + + TotalRetrans uint32 `csv:"TCP.TotalRetrans"` + + PacingRate int64 `csv:"TCP.PacingRate"` // This is often -1, so better for it to be signed + MaxPacingRate int64 `csv:"TCP.MaxPacingRate"` // This is often -1, so better to be signed. + + // NOTE: In linux, these are uint64, but we make them int64 here for compatibility with BigQuery + BytesAcked int64 `csv:"TCP.BytesAcked"` /* RFC4898 tcpEStatsAppHCThruOctetsAcked */ + BytesReceived int64 `csv:"TCP.BytesReceived"` /* RFC4898 tcpEStatsAppHCThruOctetsReceived */ + SegsOut int32 `csv:"TCP.SegsOut"` /* RFC4898 tcpEStatsPerfSegsOut */ + SegsIn int32 `csv:"TCP.SegsIn"` /* RFC4898 tcpEStatsPerfSegsIn */ + + NotsentBytes uint32 `csv:"TCP.NotsentBytes"` + MinRTT uint32 `csv:"TCP.MinRTT"` + DataSegsIn uint32 `csv:"TCP.DataSegsIn"` /* RFC4898 tcpEStatsDataSegsIn */ + DataSegsOut uint32 `csv:"TCP.DataSegsOut"` /* RFC4898 tcpEStatsDataSegsOut */ + + // NOTE: In linux, this is uint64, but we make it int64 here for compatibility with BigQuery + DeliveryRate int64 `csv:"TCP.DeliveryRate"` + + BusyTime int64 `csv:"TCP.BusyTime"` /* Time (usec) busy sending data */ + RWndLimited int64 `csv:"TCP.RWndLimited"` /* Time (usec) limited by receive window */ + SndBufLimited int64 `csv:"TCP.SndBufLimited"` /* Time (usec) limited by send buffer */ + + Delivered uint32 `csv:"TCP.Delivered"` + DeliveredCE uint32 `csv:"TCP.DeliveredCE"` + + // NOTE: In linux, these are uint64, but we make them int64 here for compatibility with BigQuery + BytesSent int64 `csv:"TCP.BytesSent"` /* RFC4898 tcpEStatsPerfHCDataOctetsOut */ + BytesRetrans int64 `csv:"TCP.BytesRetrans"` /* RFC4898 tcpEStatsPerfOctetsRetrans */ + + DSackDups uint32 `csv:"TCP.DSackDups"` /* RFC4898 tcpEStatsStackDSACKDups */ + ReordSeen uint32 `csv:"TCP.ReordSeen"` /* reordering events seen */ +} + +// AppInfo contains an application level measurement. This structure is +// described in the ndt7 specification. +type AppInfo struct { + NumBytes int64 + ElapsedTime int64 +} + +// ConnectionInfo contains connection info. This structure is described +// in the ndt7 specification. +type ConnectionInfo struct { + Client string + Server string + UUID string `json:",omitempty"` +} + +// InetDiagBBRInfo implements the struct associated with INET_DIAG_BBRINFO attribute, corresponding with +// linux struct tcp_bbr_info in uapi/linux/inet_diag.h. +type InetDiagBBRInfo struct { + BW int64 `csv:"BBR.BW"` // Max-filtered BW (app throughput) estimate in bytes/second + MinRTT uint32 `csv:"BBR.MinRTT"` // Min-filtered RTT in uSec + PacingGain uint32 `csv:"BBR.PacingGain"` // Pacing gain shifted left 8 bits + CwndGain uint32 `csv:"BBR.CwndGain"` // Cwnd gain shifted left 8 bits +} + +// The BBRInfo struct contains information measured using BBR. This structure is +// an extension to the ndt7 specification. Variables here have the same +// measurement unit that is used by the Linux kernel. +type BBRInfo struct { + InetDiagBBRInfo + ElapsedTime int64 +} + +// The TCPInfo struct contains information measured using TCP_INFO. This +// structure is described in the ndt7 specification. +type TCPInfo struct { + LinuxTCPInfo + ElapsedTime int64 +} + +// The Measurement struct contains measurement results. This message is +// an extension of the one inside of v0.9.0 of the ndt7 spec. +type Measurement struct { + // AppInfo contains application level measurements. + AppInfo *AppInfo `json:",omitempty"` + + // BBRInfo is the data measured using TCP BBR instrumentation. + BBRInfo *BBRInfo `json:",omitempty"` + + // ConnectionInfo contains info on the connection. + ConnectionInfo *ConnectionInfo `json:",omitempty"` + + // Origin indicates who performed this measurement. + Origin OriginKind `json:",omitempty"` + + // Test contains the test name. + Test TestKind `json:",omitempty"` + + // TCPInfo contains metrics measured using TCP_INFO instrumentation. + TCPInfo *TCPInfo `json:",omitempty"` +} diff --git a/internal/engine/experiment/ndt7/upload.go b/internal/engine/experiment/ndt7/upload.go new file mode 100644 index 0000000..26637c9 --- /dev/null +++ b/internal/engine/experiment/ndt7/upload.go @@ -0,0 +1,75 @@ +package ndt7 + +import ( + "context" + "time" + + "github.com/gorilla/websocket" +) + +func newMessage(n int) (*websocket.PreparedMessage, error) { + return websocket.NewPreparedMessage(websocket.BinaryMessage, make([]byte, n)) +} + +type uploadManager struct { + conn mockableConn + fractionForScaling int64 + maxRuntime time.Duration + maxMessageSize int + maxScaledMessageSize int + measureInterval time.Duration + minMessageSize int + newMessage func(int) (*websocket.PreparedMessage, error) + onPerformance callbackPerformance +} + +func newUploadManager( + conn mockableConn, onPerformance callbackPerformance, +) uploadManager { + return uploadManager{ + conn: conn, + fractionForScaling: paramFractionForScaling, + maxRuntime: paramMaxRuntime, + maxMessageSize: paramMaxMessageSize, + maxScaledMessageSize: paramMaxScaledMessageSize, + measureInterval: paramMeasureInterval, + minMessageSize: paramMinMessageSize, + newMessage: newMessage, + onPerformance: onPerformance, + } +} + +func (mgr uploadManager) run(ctx context.Context) error { + var total int64 + start := time.Now() + if err := mgr.conn.SetWriteDeadline(time.Now().Add(mgr.maxRuntime)); err != nil { + return err + } + size := mgr.minMessageSize + message, err := mgr.newMessage(size) + if err != nil { + return err + } + ticker := time.NewTicker(mgr.measureInterval) + defer ticker.Stop() + for ctx.Err() == nil { + if err := mgr.conn.WritePreparedMessage(message); err != nil { + return err + } + total += int64(size) + select { + case now := <-ticker.C: + mgr.onPerformance(now.Sub(start), total) + default: + // NOTHING + } + if size >= mgr.maxScaledMessageSize || int64(size) >= (total/mgr.fractionForScaling) { + continue + } + size <<= 1 + if message, err = mgr.newMessage(size); err != nil { + return err + } + } + return nil +} diff --git a/internal/engine/experiment/ndt7/upload_test.go b/internal/engine/experiment/ndt7/upload_test.go new file mode 100644 index 0000000..0e5c033 --- /dev/null +++ b/internal/engine/experiment/ndt7/upload_test.go @@ -0,0 +1,89 @@ +package ndt7 + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestUploadSetWriteDeadlineFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newUploadManager( + &mockableConnMock{ + WriteDeadlineErr: expected, + }, + defaultCallbackPerformance, + ) + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestUploadNewMessageFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newUploadManager( + &mockableConnMock{}, + defaultCallbackPerformance, + ) + mgr.newMessage = func(int) (*websocket.PreparedMessage, error) { + return nil, expected + } + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestUploadWritePreparedMessageFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newUploadManager( + &mockableConnMock{ + WritePreparedMessageErr: expected, + }, + defaultCallbackPerformance, + ) + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestUploadWritePreparedMessageSubsequentFailure(t *testing.T) { + expected := errors.New("mocked error") + mgr := newUploadManager( + &mockableConnMock{}, + defaultCallbackPerformance, + ) + var already bool + mgr.newMessage = func(int) (*websocket.PreparedMessage, error) { + if !already { + already = true + return new(websocket.PreparedMessage), nil + } + return nil, expected + } + err := mgr.run(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestUploadLoop(t *testing.T) { + mgr := newUploadManager( + &mockableConnMock{}, + defaultCallbackPerformance, + ) + mgr.newMessage = func(int) (*websocket.PreparedMessage, error) { + return new(websocket.PreparedMessage), nil + } + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := mgr.run(ctx) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/engine/experiment/psiphon/psiphon.go b/internal/engine/experiment/psiphon/psiphon.go new file mode 100644 index 0000000..3017310 --- /dev/null +++ b/internal/engine/experiment/psiphon/psiphon.go @@ -0,0 +1,133 @@ +// Package psiphon implements the psiphon network experiment. This +// implements, in particular, v0.2.0 of the spec. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-015-psiphon.md +package psiphon + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const ( + testName = "psiphon" + testVersion = "0.5.0" +) + +// Config contains the experiment's configuration. +type Config struct{} + +// TestKeys contains the experiment's result. +type TestKeys struct { + urlgetter.TestKeys + MaxRuntime float64 `json:"max_runtime"` +} + +// Measurer is the psiphon measurer. +type Measurer struct { + BeforeGetHook func(g urlgetter.Getter) + Config Config +} + +// ExperimentName returns the experiment name +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion returns the experiment version +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +func (m *Measurer) printprogress( + ctx context.Context, wg *sync.WaitGroup, + maxruntime int, callbacks model.ExperimentCallbacks, +) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + step := 1 / float64(maxruntime) + var progress float64 + defer callbacks.OnProgress(1.0, "psiphon experiment complete") + defer wg.Done() + for { + select { + case <-ticker.C: + progress += step + callbacks.OnProgress(progress, "psiphon experiment running") + case <-ctx.Done(): + return + } + } +} + +// Run runs the measurement +func (m *Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + const maxruntime = 60 + ctx, cancel := context.WithTimeout(ctx, maxruntime*time.Second) + var ( + wg sync.WaitGroup + config urlgetter.Config + ) + wg.Add(1) + go m.printprogress(ctx, &wg, maxruntime, callbacks) + config.Tunnel = "psiphon" // force to use psiphon tunnel + urlgetter.RegisterExtensions(measurement) + target := "https://www.google.com/humans.txt" + if measurement.Input != "" { + target = string(measurement.Input) + } + g := urlgetter.Getter{ + Config: config, + Session: sess, + Target: target, + } + if m.BeforeGetHook != nil { + m.BeforeGetHook(g) + } + tk, err := g.Get(ctx) + cancel() + wg.Wait() + measurement.TestKeys = &TestKeys{ + TestKeys: tk, + MaxRuntime: maxruntime, + } + return err +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + BootstrapTime float64 `json:"bootstrap_time"` + Failure string `json:"failure"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + if tk.Failure != nil { + sk.Failure = *tk.Failure + sk.IsAnomaly = true + } + sk.BootstrapTime = tk.BootstrapTime + return sk, nil +} diff --git a/internal/engine/experiment/psiphon/psiphon_test.go b/internal/engine/experiment/psiphon/psiphon_test.go new file mode 100644 index 0000000..d136db6 --- /dev/null +++ b/internal/engine/experiment/psiphon/psiphon_test.go @@ -0,0 +1,163 @@ +package psiphon_test + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/psiphon" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// Implementation note: integration test performed by +// the $topdir/experiment_test.go file + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := psiphon.NewExperimentMeasurer(psiphon.Config{}) + if measurer.ExperimentName() != "psiphon" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.5.0" { + t.Fatal("unexpected version") + } +} + +func TestRunWithCancelledContext(t *testing.T) { + measurer := psiphon.NewExperimentMeasurer(psiphon.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + measurement := new(model.Measurement) + err := measurer.Run(ctx, newfakesession(), measurement, + model.NewPrinterCallbacks(log.Log)) + if !errors.Is(err, context.Canceled) { + t.Fatal("expected another error here") + } + tk := measurement.TestKeys.(*psiphon.TestKeys) + if tk.MaxRuntime <= 0 { + t.Fatal("you did not set the max runtime") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(psiphon.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestRunWithCustomInputAndCancelledContext(t *testing.T) { + expected := "http://x.org" + measurement := &model.Measurement{ + Input: model.MeasurementTarget(expected), + } + measurer := psiphon.NewExperimentMeasurer(psiphon.Config{}) + measurer.(*psiphon.Measurer).BeforeGetHook = func(g urlgetter.Getter) { + if g.Target != expected { + t.Fatal("target was not correctly set") + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + err := measurer.Run(ctx, newfakesession(), measurement, + model.NewPrinterCallbacks(log.Log)) + if !errors.Is(err, context.Canceled) { + t.Fatal("expected another error here") + } + tk := measurement.TestKeys.(*psiphon.TestKeys) + if tk.MaxRuntime <= 0 { + t.Fatal("you did not set the max runtime") + } +} + +func TestRunWillPrintSomethingWithCancelledContext(t *testing.T) { + measurement := new(model.Measurement) + measurer := psiphon.NewExperimentMeasurer(psiphon.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + measurer.(*psiphon.Measurer).BeforeGetHook = func(g urlgetter.Getter) { + time.Sleep(2 * time.Second) + cancel() // fail after we've given the printer a chance to run + } + observer := observerCallbacks{progress: atomicx.NewInt64()} + err := measurer.Run(ctx, newfakesession(), measurement, observer) + if !errors.Is(err, context.Canceled) { + t.Fatal("expected another error here") + } + tk := measurement.TestKeys.(*psiphon.TestKeys) + if tk.MaxRuntime <= 0 { + t.Fatal("you did not set the max runtime") + } + if observer.progress.Load() < 2 { + t.Fatal("not enough progress emitted?!") + } +} + +type observerCallbacks struct { + progress *atomicx.Int64 +} + +func (d observerCallbacks) OnProgress(percentage float64, message string) { + d.progress.Add(1) +} + +func newfakesession() model.ExperimentSession { + return &mockable.Session{MockableLogger: log.Log} +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &psiphon.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysGood(t *testing.T) { + measurement := &model.Measurement{TestKeys: &psiphon.TestKeys{TestKeys: urlgetter.TestKeys{ + BootstrapTime: 123, + }}} + m := &psiphon.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(psiphon.SummaryKeys) + if sk.BootstrapTime != 123 { + t.Fatal("invalid latency") + } + if sk.Failure != "" { + t.Fatal("invalid failure") + } + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} + +func TestSummaryKeysFailure(t *testing.T) { + expected := io.EOF.Error() + measurement := &model.Measurement{TestKeys: &psiphon.TestKeys{TestKeys: urlgetter.TestKeys{ + BootstrapTime: 123, + Failure: &expected, + }}} + m := &psiphon.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(psiphon.SummaryKeys) + if sk.BootstrapTime != 123 { + t.Fatal("invalid latency") + } + if sk.Failure != expected { + t.Fatal("invalid failure") + } + if sk.IsAnomaly == false { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/riseupvpn/riseupvpn.go b/internal/engine/experiment/riseupvpn/riseupvpn.go new file mode 100644 index 0000000..3fc4e31 --- /dev/null +++ b/internal/engine/experiment/riseupvpn/riseupvpn.go @@ -0,0 +1,309 @@ +// Package riseupvpn contains the RiseupVPN network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-026-riseupvpn.md +package riseupvpn + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" +) + +const ( + testName = "riseupvpn" + testVersion = "0.1.0" + eipServiceURL = "https://api.black.riseup.net:443/3/config/eip-service.json" + providerURL = "https://riseup.net/provider.json" + geoServiceURL = "https://api.black.riseup.net:9001/json" + tcpConnect = "tcpconnect://" +) + +// EipService main json object of eip-service.json +type EipService struct { + Gateways []GatewayV3 +} + +// GatewayV3 json obj Version 3 +type GatewayV3 struct { + Capabilities struct { + Transport []TransportV3 + } + Host string + IPAddress string `json:"ip_address"` +} + +// TransportV3 json obj Version 3 +type TransportV3 struct { + Type string + Protocols []string + Ports []string + Options map[string]string +} + +// GatewayConnection describes the connection to a riseupvpn gateway +type GatewayConnection struct { + IP string `json:"ip"` + Port int `json:"port"` + TransportType string `json:"transport_type"` +} + +// Config contains the riseupvpn experiment config. +type Config struct { + urlgetter.Config +} + +// TestKeys contains riseupvpn test keys. +type TestKeys struct { + urlgetter.TestKeys + APIFailure *string `json:"api_failure"` + APIStatus string `json:"api_status"` + CACertStatus bool `json:"ca_cert_status"` + FailingGateways []GatewayConnection `json:"failing_gateways"` +} + +// NewTestKeys creates new riseupvpn TestKeys. +func NewTestKeys() *TestKeys { + return &TestKeys{ + APIFailure: nil, + APIStatus: "ok", + CACertStatus: true, + FailingGateways: nil, + } +} + +// UpdateProviderAPITestKeys updates the TestKeys using the given MultiOutput result. +func (tk *TestKeys) UpdateProviderAPITestKeys(v urlgetter.MultiOutput) { + tk.NetworkEvents = append(tk.NetworkEvents, v.TestKeys.NetworkEvents...) + tk.Queries = append(tk.Queries, v.TestKeys.Queries...) + tk.Requests = append(tk.Requests, v.TestKeys.Requests...) + tk.TCPConnect = append(tk.TCPConnect, v.TestKeys.TCPConnect...) + tk.TLSHandshakes = append(tk.TLSHandshakes, v.TestKeys.TLSHandshakes...) + if tk.APIStatus != "ok" { + return // we already flipped the state + } + if v.TestKeys.Failure != nil { + tk.APIStatus = "blocked" + tk.APIFailure = v.TestKeys.Failure + return + } +} + +// AddGatewayConnectTestKeys updates the TestKeys using the given MultiOutput result of gateway connectivity testing. +func (tk *TestKeys) AddGatewayConnectTestKeys(v urlgetter.MultiOutput, transportType string) { + tk.NetworkEvents = append(tk.NetworkEvents, v.TestKeys.NetworkEvents...) + tk.TCPConnect = append(tk.TCPConnect, v.TestKeys.TCPConnect...) + for _, tcpConnect := range v.TestKeys.TCPConnect { + if !tcpConnect.Status.Success { + gatewayConnection := newGatewayConnection(tcpConnect, transportType) + tk.FailingGateways = append(tk.FailingGateways, *gatewayConnection) + } + } + return +} + +func newGatewayConnection(tcpConnect archival.TCPConnectEntry, transportType string) *GatewayConnection { + return &GatewayConnection{ + IP: tcpConnect.IP, + Port: tcpConnect.Port, + TransportType: transportType, + } +} + +// AddCACertFetchTestKeys Adding generic urlgetter.Get() testKeys to riseupvpn specific test keys +func (tk *TestKeys) AddCACertFetchTestKeys(testKeys urlgetter.TestKeys) { + tk.NetworkEvents = append(tk.NetworkEvents, testKeys.NetworkEvents...) + tk.Queries = append(tk.Queries, testKeys.Queries...) + tk.Requests = append(tk.Requests, testKeys.Requests...) + tk.TCPConnect = append(tk.TCPConnect, testKeys.TCPConnect...) + tk.TLSHandshakes = append(tk.TLSHandshakes, testKeys.TLSHandshakes...) + if testKeys.Failure != nil { + tk.APIStatus = "blocked" + tk.APIFailure = tk.Failure + tk.CACertStatus = false + } +} + +// Measurer performs the measurement +type Measurer struct { + // Config contains the experiment settings. If empty we + // will be using default settings. + Config Config + + // Getter is an optional getter to be used for testing. + Getter urlgetter.MultiGetter +} + +// ExperimentName implements ExperimentMeasurer.ExperimentName +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements ExperimentMeasurer.Run +func (m Measurer) Run(ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks) error { + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + testkeys := NewTestKeys() + measurement.TestKeys = testkeys + urlgetter.RegisterExtensions(measurement) + + caTarget := "https://black.riseup.net/ca.crt" + caGetter := urlgetter.Getter{ + Config: m.Config.Config, + Session: sess, + Target: caTarget, + } + log.Info("Getting CA certificate; please be patient...") + tk, err := caGetter.Get(ctx) + testkeys.AddCACertFetchTestKeys(tk) + + if err != nil { + log.Error("Getting CA certificate failed. Aborting test.") + return nil + } + + certPool := netx.NewDefaultCertPool() + if ok := certPool.AppendCertsFromPEM([]byte(tk.HTTPResponseBody)); !ok { + testkeys.CACertStatus = false + testkeys.APIStatus = "blocked" + errorValue := "invalid_ca" + testkeys.APIFailure = &errorValue + return nil + } + + inputs := []urlgetter.MultiInput{ + + // Here we need to provide the method explicitly. See + // https://github.com/ooni/probe-cli/v3/internal/engine/issues/827. + {Target: providerURL, Config: urlgetter.Config{ + CertPool: certPool, + Method: "GET", + FailOnHTTPError: true, + }}, + {Target: eipServiceURL, Config: urlgetter.Config{ + CertPool: certPool, + Method: "GET", + FailOnHTTPError: true, + }}, + {Target: geoServiceURL, Config: urlgetter.Config{ + CertPool: certPool, + Method: "GET", + FailOnHTTPError: true, + }}, + } + multi := urlgetter.Multi{Begin: measurement.MeasurementStartTimeSaved, Getter: m.Getter, Session: sess} + + for entry := range multi.CollectOverall(ctx, inputs, 0, 50, "riseupvpn", callbacks) { + testkeys.UpdateProviderAPITestKeys(entry) + } + + // test gateways now + gateways := parseGateways(testkeys) + openvpnEndpoints := generateMultiInputs(gateways, "openvpn") + obfs4Endpoints := generateMultiInputs(gateways, "obfs4") + overallCount := len(inputs) + len(openvpnEndpoints) + len(obfs4Endpoints) + + // measure openvpn in parallel + multi = urlgetter.Multi{Begin: measurement.MeasurementStartTimeSaved, Getter: m.Getter, Session: sess} + for entry := range multi.CollectOverall(ctx, openvpnEndpoints, len(inputs), overallCount, "riseupvpn", callbacks) { + testkeys.AddGatewayConnectTestKeys(entry, "openvpn") + } + + // measure obfs4 in parallel + multi = urlgetter.Multi{Begin: measurement.MeasurementStartTimeSaved, Getter: m.Getter, Session: sess} + for entry := range multi.CollectOverall(ctx, obfs4Endpoints, len(inputs)+len(openvpnEndpoints), overallCount, "riseupvpn", callbacks) { + testkeys.AddGatewayConnectTestKeys(entry, "obfs4") + } + + return nil +} + +func generateMultiInputs(gateways []GatewayV3, transportType string) []urlgetter.MultiInput { + var gatewayInputs []urlgetter.MultiInput + for _, gateway := range gateways { + for _, transport := range gateway.Capabilities.Transport { + if transport.Type != transportType { + continue + } + supportsTCP := false + for _, protocol := range transport.Protocols { + if protocol == "tcp" { + supportsTCP = true + } + } + if !supportsTCP { + continue + } + for _, port := range transport.Ports { + tcpConnection := tcpConnect + gateway.IPAddress + ":" + port + gatewayInputs = append(gatewayInputs, urlgetter.MultiInput{Target: tcpConnection}) + } + } + } + return gatewayInputs +} + +func parseGateways(testKeys *TestKeys) []GatewayV3 { + for _, requestEntry := range testKeys.Requests { + if requestEntry.Request.URL == eipServiceURL && requestEntry.Failure == nil { + eipService, err := DecodeEIP3(requestEntry.Response.Body.Value) + if err == nil { + return eipService.Gateways + } + } + } + return nil +} + +// DecodeEIP3 decodes eip-service.json version 3 +func DecodeEIP3(body string) (*EipService, error) { + var eip EipService + err := json.Unmarshal([]byte(body), &eip) + if err != nil { + return nil, err + } + return &eip, nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + APIBlocked bool `json:"api_blocked"` + ValidCACert bool `json:"valid_ca_cert"` + FailingGateways int `json:"failing_gateways"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + sk.APIBlocked = tk.APIStatus != "ok" + sk.ValidCACert = tk.CACertStatus + sk.FailingGateways = len(tk.FailingGateways) + sk.IsAnomaly = (sk.APIBlocked == true || tk.CACertStatus == false || + sk.FailingGateways != 0) + return sk, nil +} diff --git a/internal/engine/experiment/riseupvpn/riseupvpn_test.go b/internal/engine/experiment/riseupvpn/riseupvpn_test.go new file mode 100644 index 0000000..cd9a269 --- /dev/null +++ b/internal/engine/experiment/riseupvpn/riseupvpn_test.go @@ -0,0 +1,497 @@ +package riseupvpn_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/ioutil" + "math/rand" + "net/http" + "testing" + "time" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/riseupvpn" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := riseupvpn.NewExperimentMeasurer(riseupvpn.Config{}) + if measurer.ExperimentName() != "riseupvpn" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.1.0" { + t.Fatal("unexpected version") + } +} + +func TestGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := riseupvpn.NewExperimentMeasurer(riseupvpn.Config{}) + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableLogger: log.Log, + }, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*riseupvpn.TestKeys) + if tk.Agent != "" { + t.Fatal("unexpected Agent: " + tk.Agent) + } + if tk.FailedOperation != nil { + t.Fatal("unexpected FailedOperation") + } + if tk.Failure != nil { + t.Fatal("unexpected Failure") + } + if len(tk.NetworkEvents) <= 0 { + t.Fatal("no NetworkEvents?!") + } + if len(tk.Queries) <= 0 { + t.Fatal("no Queries?!") + } + if len(tk.Requests) <= 0 { + t.Fatal("no Requests?!") + } + if len(tk.TCPConnect) <= 0 { + t.Fatal("no TCPConnect?!") + } + if len(tk.TLSHandshakes) <= 0 { + t.Fatal("no TLSHandshakes?!") + } + if tk.APIFailure != nil { + t.Fatal("unexpected ApiFailure") + } + if tk.APIStatus != "ok" { + t.Fatal("unexpected ApiStatus") + } + if tk.CACertStatus != true { + t.Fatal("unexpected CaCertStatus") + } + if tk.FailingGateways != nil { + t.Fatal("unexpected FailingGateways value") + } +} + +// TestUpdateWithMixedResults tests if one operation failed +// ApiStatus is considered as blocked +func TestUpdateWithMixedResults(t *testing.T) { + tk := riseupvpn.NewTestKeys() + tk.UpdateProviderAPITestKeys(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "https://api.black.riseup.net:443/3/config/eip-service.json", + }, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 200, + }, + }) + tk.UpdateProviderAPITestKeys(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "https://riseup.net/provider.json", + }, + TestKeys: urlgetter.TestKeys{ + FailedOperation: (func() *string { + s := errorx.HTTPRoundTripOperation + return &s + })(), + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + tk.UpdateProviderAPITestKeys(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "https://api.black.riseup.net:9001/json", + }, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 200, + }, + }) + if tk.APIStatus != "blocked" { + t.Fatal("ApiStatus should be blocked") + } + if *tk.APIFailure != errorx.FailureEOFError { + t.Fatal("invalid ApiFailure") + } +} + +func TestFailureCaCertFetch(t *testing.T) { + measurer := riseupvpn.NewExperimentMeasurer(riseupvpn.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + // we're cancelling immediately so that the CA Cert fetch fails + cancel() + + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*riseupvpn.TestKeys) + if tk.CACertStatus != false { + t.Fatal("invalid CACertStatus ") + } + if tk.APIStatus != "blocked" { + t.Fatal("invalid ApiStatus") + } + + if tk.APIFailure != nil { + t.Fatal("ApiFailure should be null") + } + if len(tk.Requests) > 1 { + t.Fatal("Unexpected requests") + } +} + +func TestFailureEipServiceBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := riseupvpn.NewExperimentMeasurer(riseupvpn.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + selfcensor.Enable(`{"PoisonSystemDNS":{"api.black.riseup.net":["NXDOMAIN"]}}`) + + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*riseupvpn.TestKeys) + if tk.CACertStatus != true { + t.Fatal("invalid CACertStatus ") + } + + for _, entry := range tk.Requests { + if entry.Request.URL == "https://api.black.riseup.net:443/3/config/eip-service.json" { + if entry.Failure == nil { + t.Fatal("Failure for " + entry.Request.URL + " should not be null") + } + } + } + + if tk.APIStatus != "blocked" { + t.Fatal("invalid ApiStatus") + } + + if tk.APIFailure == nil { + t.Fatal("ApiFailure should not be null") + } +} + +func TestFailureProviderUrlBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := riseupvpn.NewExperimentMeasurer(riseupvpn.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + selfcensor.Enable(`{"BlockedEndpoints":{"198.252.153.70:443":"REJECT"}}`) + + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*riseupvpn.TestKeys) + + for _, entry := range tk.Requests { + if entry.Request.URL == "https://riseup.net/provider.json" { + if entry.Failure == nil { + t.Fatal("Failure for " + entry.Request.URL + " should not be null") + } + } + } + + if tk.CACertStatus != true { + t.Fatal("invalid CACertStatus ") + } + if tk.APIStatus != "blocked" { + t.Fatal("invalid ApiStatus") + } + + if tk.APIFailure == nil { + t.Fatal("ApiFailure should not be null") + } +} + +func TestFailureGeoIpServiceBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := riseupvpn.NewExperimentMeasurer(riseupvpn.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + selfcensor.Enable(`{"BlockedEndpoints":{"198.252.153.107:9001":"REJECT"}}`) + + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*riseupvpn.TestKeys) + if tk.CACertStatus != true { + t.Fatal("invalid CACertStatus ") + } + + for _, entry := range tk.Requests { + if entry.Request.URL == "https://api.black.riseup.net:9001/json" { + if entry.Failure == nil { + t.Fatal("Failure for " + entry.Request.URL + " should not be null") + } + } + } + + if tk.APIStatus != "blocked" { + t.Fatal("invalid ApiStatus") + } + + if tk.APIFailure == nil { + t.Fatal("ApiFailure should not be null") + } +} + +func TestFailureGateway(t *testing.T) { + var testCases = [...]string{"openvpn", "obfs4"} + eipService, err := fetchEipService() + if err != nil { + t.Log("Preconditions for the test are not met. Skipping due to: " + err.Error()) + t.SkipNow() + } + for _, tc := range testCases { + t.Run(fmt.Sprintf("testing censored transport %s", tc), func(t *testing.T) { + censoredGateway, err := selfCensorRandomGateway(eipService, tc) + if err == nil { + censorString := `{"BlockedEndpoints":{"` + censoredGateway.IP + `:` + censoredGateway.Port + `":"REJECT"}}` + selfcensor.Enable(censorString) + } else { + t.Log("Preconditions for the test are not met. Skipping due to: " + err.Error()) + t.SkipNow() + } + + // - run measurement + runGatewayTest(t, censoredGateway) + }) + } +} + +type SelfCensoredGateway struct { + IP string + Port string +} + +func fetchEipService() (*riseupvpn.EipService, error) { + // - fetch client cert and add to certpool + caFetchClient := &http.Client{ + Timeout: time.Second * 30, + } + + caCertResponse, err := caFetchClient.Get("https://black.riseup.net/ca.crt") + if err != nil { + return nil, err + } + + var bodyString string + + if caCertResponse.StatusCode != http.StatusOK { + return nil, errors.New("unexpected HTTP response code") + } + bodyBytes, err := ioutil.ReadAll(caCertResponse.Body) + defer caCertResponse.Body.Close() + + if err != nil { + return nil, err + } + bodyString = string(bodyBytes) + + certs := x509.NewCertPool() + certs.AppendCertsFromPEM([]byte(bodyString)) + + // - fetch and parse eip-service.json + client := &http.Client{ + Timeout: time.Second * 30, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certs, + }, + }, + } + + eipResponse, err := client.Get("https://api.black.riseup.net/3/config/eip-service.json") + if err != nil { + return nil, err + } + if eipResponse.StatusCode != http.StatusOK { + return nil, errors.New("Unexpected HTTP response code") + } + + bodyBytes, err = ioutil.ReadAll(eipResponse.Body) + defer eipResponse.Body.Close() + if err != nil { + return nil, err + } + bodyString = string(bodyBytes) + + eipService, err := riseupvpn.DecodeEIP3(bodyString) + if err != nil { + return nil, err + } + return eipService, nil +} + +func selfCensorRandomGateway(eipService *riseupvpn.EipService, transportType string) (*SelfCensoredGateway, error) { + + // - self censor random gateway + gateways := eipService.Gateways + if gateways == nil || len(gateways) == 0 { + return nil, errors.New("No gateways found") + } + + var selfcensoredGateways []SelfCensoredGateway + for _, gateway := range gateways { + for _, transport := range gateway.Capabilities.Transport { + if transport.Type == transportType { + selfcensoredGateways = append(selfcensoredGateways, SelfCensoredGateway{IP: gateway.IPAddress, Port: transport.Ports[0]}) + } + } + } + + if len(selfcensoredGateways) == 0 { + return nil, errors.New("transport " + transportType + " doesn't seem to be supported.") + } + + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + min := 0 + max := len(selfcensoredGateways) - 1 + randomIndex := rnd.Intn(max-min+1) + min + return &selfcensoredGateways[randomIndex], nil + +} + +func runGatewayTest(t *testing.T, censoredGateway *SelfCensoredGateway) { + measurer := riseupvpn.NewExperimentMeasurer(riseupvpn.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*riseupvpn.TestKeys) + if tk.CACertStatus != true { + t.Fatal("invalid CACertStatus ") + } + + if tk.FailingGateways == nil || len(tk.FailingGateways) != 1 { + t.Fatal("unexpected amount of failing gateways") + } + + entry := tk.FailingGateways[0] + if entry.IP != censoredGateway.IP || fmt.Sprint(entry.Port) != censoredGateway.Port { + t.Fatal("unexpected failed gateway configuration") + } + + if tk.APIStatus == "blocked" { + t.Fatal("invalid ApiStatus") + } + + if tk.APIFailure != nil { + t.Fatal("ApiFailure should be null") + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &riseupvpn.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysWorksAsIntended(t *testing.T) { + tests := []struct { + tk riseupvpn.TestKeys + sk riseupvpn.SummaryKeys + }{{ + tk: riseupvpn.TestKeys{ + APIStatus: "blocked", + CACertStatus: true, + FailingGateways: nil, + }, + sk: riseupvpn.SummaryKeys{ + APIBlocked: true, + ValidCACert: true, + IsAnomaly: true, + }, + }, { + tk: riseupvpn.TestKeys{ + APIStatus: "ok", + CACertStatus: false, + FailingGateways: nil, + }, + sk: riseupvpn.SummaryKeys{ + ValidCACert: false, + IsAnomaly: true, + }, + }, { + tk: riseupvpn.TestKeys{ + APIStatus: "ok", + CACertStatus: true, + FailingGateways: []riseupvpn.GatewayConnection{{ + IP: "1.1.1.1", + Port: 443, + TransportType: "obfs4", + }}, + }, + sk: riseupvpn.SummaryKeys{ + FailingGateways: 1, + IsAnomaly: true, + ValidCACert: true, + }, + }} + for idx, tt := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + m := &riseupvpn.Measurer{} + measurement := &model.Measurement{TestKeys: &tt.tk} + got, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + return + } + sk := got.(riseupvpn.SummaryKeys) + if diff := cmp.Diff(tt.sk, sk); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/internal/engine/experiment/run/dnscheck.go b/internal/engine/experiment/run/dnscheck.go new file mode 100644 index 0000000..0bdbea3 --- /dev/null +++ b/internal/engine/experiment/run/dnscheck.go @@ -0,0 +1,27 @@ +package run + +import ( + "context" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type dnsCheckMain struct { + Endpoints *dnscheck.Endpoints + mu sync.Mutex +} + +func (m *dnsCheckMain) do(ctx context.Context, input StructuredInput, + sess model.ExperimentSession, measurement *model.Measurement, + callbacks model.ExperimentCallbacks) error { + exp := dnscheck.Measurer{ + Config: input.DNSCheck, + Endpoints: m.Endpoints, + } + measurement.TestName = exp.ExperimentName() + measurement.TestVersion = exp.ExperimentVersion() + measurement.Input = model.MeasurementTarget(input.Input) + return exp.Run(ctx, sess, measurement, callbacks) +} diff --git a/internal/engine/experiment/run/run.go b/internal/engine/experiment/run/run.go new file mode 100644 index 0000000..b7e1cd9 --- /dev/null +++ b/internal/engine/experiment/run/run.go @@ -0,0 +1,79 @@ +// Package run contains code to run other experiments. +// +// This code is currently alpha. +package run + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// Config contains settings. +type Config struct{} + +// Measurer runs the measurement. +type Measurer struct{} + +// ExperimentName implements ExperimentMeasurer.ExperimentName. +func (Measurer) ExperimentName() string { + return "run" +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (Measurer) ExperimentVersion() string { + return "0.2.0" +} + +// StructuredInput contains structured input for this experiment. +type StructuredInput struct { + // Annotations contains extra annotations to add to the + // final measurement. + Annotations map[string]string `json:"annotations"` + + // DNSCheck contains settings for the dnscheck experiment. + DNSCheck dnscheck.Config `json:"dnscheck"` + + // URLGetter contains settings for the urlgetter experiment. + URLGetter urlgetter.Config `json:"urlgetter"` + + // Name is the name of the experiment to run. + Name string `json:"name"` + + // Input is the input for this experiment. + Input string `json:"input"` +} + +// Run implements ExperimentMeasurer.ExperimentVersion. +func (Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + var input StructuredInput + if err := json.Unmarshal([]byte(measurement.Input), &input); err != nil { + return err + } + exprun, found := table[input.Name] + if !found { + return fmt.Errorf("no such experiment: %s", input.Name) + } + measurement.AddAnnotations(input.Annotations) + return exprun.do(ctx, input, sess, measurement, callbacks) +} + +// GetSummaryKeys implements ExperimentMeasurer.GetSummaryKeys +func (Measurer) GetSummaryKeys(*model.Measurement) (interface{}, error) { + // TODO(bassosimone): we could extend this interface to call the + // specific GetSummaryKeys of the experiment we're running. + return dnscheck.SummaryKeys{IsAnomaly: false}, nil +} + +// NewExperimentMeasurer creates a new model.ExperimentMeasurer +// implementing the run experiment. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{} +} diff --git a/internal/engine/experiment/run/run_test.go b/internal/engine/experiment/run/run_test.go new file mode 100644 index 0000000..88c9f66 --- /dev/null +++ b/internal/engine/experiment/run/run_test.go @@ -0,0 +1,107 @@ +package run_test + +import ( + "context" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/run" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestExperimentNameAndVersion(t *testing.T) { + measurer := run.NewExperimentMeasurer(run.Config{}) + if measurer.ExperimentName() != "run" { + t.Error("unexpected experiment name") + } + if measurer.ExperimentVersion() != "0.2.0" { + t.Error("unexpected experiment version") + } +} + +func TestRunDNSCheckWithCancelledContext(t *testing.T) { + measurer := run.NewExperimentMeasurer(run.Config{}) + input := `{"name": "dnscheck", "input": "https://dns.google/dns-query"}` + measurement := new(model.Measurement) + measurement.Input = model.MeasurementTarget(input) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + // TODO(bassosimone): here we could improve the tests by checking + // whether the result makes sense for a cancelled context. + if err != nil { + t.Fatal(err) + } + if _, ok := measurement.TestKeys.(*dnscheck.TestKeys); !ok { + t.Fatal("invalid type for test keys") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + rsk, ok := sk.(dnscheck.SummaryKeys) + if !ok { + t.Fatal("cannot convert summary keys to specific type") + } + if rsk.IsAnomaly != false { + t.Fatal("unexpected IsAnomaly value") + } +} + +func TestRunURLGetterWithCancelledContext(t *testing.T) { + measurer := run.NewExperimentMeasurer(run.Config{}) + input := `{"name": "urlgetter", "input": "https://google.com"}` + measurement := new(model.Measurement) + measurement.Input = model.MeasurementTarget(input) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err == nil { + t.Fatal(err) + } + if len(measurement.Extensions) != 6 { + t.Fatal("not the expected number of extensions") + } + tk, ok := measurement.TestKeys.(*urlgetter.TestKeys) + if !ok { + t.Fatal("invalid type for test keys") + } + if len(tk.DNSCache) != 0 { + t.Fatal("not the DNSCache value we expected") + } +} + +func TestRunWithInvalidJSON(t *testing.T) { + measurer := run.NewExperimentMeasurer(run.Config{}) + input := `{"name": }` + measurement := new(model.Measurement) + measurement.Input = model.MeasurementTarget(input) + ctx := context.Background() + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err == nil || err.Error() != "invalid character '}' looking for beginning of value" { + t.Fatalf("not the error we expected: %+v", err) + } +} + +func TestRunWithUnknownExperiment(t *testing.T) { + measurer := run.NewExperimentMeasurer(run.Config{}) + input := `{"name": "antani"}` + measurement := new(model.Measurement) + measurement.Input = model.MeasurementTarget(input) + ctx := context.Background() + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err == nil || err.Error() != "no such experiment: antani" { + t.Fatalf("not the error we expected: %+v", err) + } +} diff --git a/internal/engine/experiment/run/table.go b/internal/engine/experiment/run/table.go new file mode 100644 index 0000000..d737992 --- /dev/null +++ b/internal/engine/experiment/run/table.go @@ -0,0 +1,26 @@ +package run + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type experimentMain interface { + do(ctx context.Context, input StructuredInput, + sess model.ExperimentSession, measurement *model.Measurement, + callbacks model.ExperimentCallbacks) error +} + +var table = map[string]experimentMain{ + // TODO(bassosimone): before extending run to support more than + // single experiment, we need to handle the case in which we are + // including different experiments into the same report ID. + // Probably, the right way to implement this functionality is to + // use proveservices.Submitter to submit reports. + "dnscheck": &dnsCheckMain{ + Endpoints: &dnscheck.Endpoints{}, + }, + "urlgetter": &urlGetterMain{}, +} diff --git a/internal/engine/experiment/run/urlgetter.go b/internal/engine/experiment/run/urlgetter.go new file mode 100644 index 0000000..e653fa8 --- /dev/null +++ b/internal/engine/experiment/run/urlgetter.go @@ -0,0 +1,22 @@ +package run + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type urlGetterMain struct {} + +func (m *urlGetterMain) do(ctx context.Context, input StructuredInput, + sess model.ExperimentSession, measurement *model.Measurement, + callbacks model.ExperimentCallbacks) error { + exp := urlgetter.Measurer{ + Config: input.URLGetter, + } + measurement.TestName = exp.ExperimentName() + measurement.TestVersion = exp.ExperimentVersion() + measurement.Input = model.MeasurementTarget(input.Input) + return exp.Run(ctx, sess, measurement, callbacks) +} diff --git a/internal/engine/experiment/sniblocking/sniblocking.go b/internal/engine/experiment/sniblocking/sniblocking.go new file mode 100644 index 0000000..5712ca7 --- /dev/null +++ b/internal/engine/experiment/sniblocking/sniblocking.go @@ -0,0 +1,306 @@ +// Package sniblocking contains the SNI blocking network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-024-sni-blocking.md. +package sniblocking + +import ( + "context" + "errors" + "fmt" + "math/rand" + "net" + "net/url" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +const ( + testName = "sni_blocking" + testVersion = "0.3.0" +) + +// Config contains the experiment config. +type Config struct { + // ControlSNI is the SNI to be used for the control. + ControlSNI string + + // TestHelperAddress is the address of the test helper. + TestHelperAddress string +} + +// Subresult contains the keys of a single measurement +// that targets either the target or the control. +type Subresult struct { + urlgetter.TestKeys + Cached bool `json:"-"` + SNI string `json:"sni"` + THAddress string `json:"th_address"` +} + +// TestKeys contains sniblocking test keys. +type TestKeys struct { + Control Subresult `json:"control"` + Result string `json:"result"` + Target Subresult `json:"target"` +} + +const ( + classAnomalyTestHelperUnreachable = "anomaly.test_helper_unreachable" + classAnomalyTimeout = "anomaly.timeout" + classAnomalyUnexpectedFailure = "anomaly.unexpected_failure" + classInterferenceClosed = "interference.closed" + classInterferenceInvalidCertificate = "interference.invalid_certificate" + classInterferenceReset = "interference.reset" + classInterferenceUnknownAuthority = "interference.unknown_authority" + classSuccessGotServerHello = "success.got_server_hello" +) + +func (tk *TestKeys) classify() string { + if tk.Target.Failure == nil { + return classSuccessGotServerHello + } + switch *tk.Target.Failure { + case errorx.FailureConnectionRefused: + return classAnomalyTestHelperUnreachable + case errorx.FailureConnectionReset: + return classInterferenceReset + case errorx.FailureDNSNXDOMAINError: + return classAnomalyTestHelperUnreachable + case errorx.FailureEOFError: + return classInterferenceClosed + case errorx.FailureGenericTimeoutError: + if tk.Control.Failure != nil { + return classAnomalyTestHelperUnreachable + } + return classAnomalyTimeout + case errorx.FailureSSLInvalidCertificate: + return classInterferenceInvalidCertificate + case errorx.FailureSSLInvalidHostname: + return classSuccessGotServerHello + case errorx.FailureSSLUnknownAuthority: + return classInterferenceUnknownAuthority + } + return classAnomalyUnexpectedFailure +} + +// Measurer performs the measurement. +type Measurer struct { + cache map[string]Subresult + config Config + mu sync.Mutex +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +func (m *Measurer) measureone( + ctx context.Context, + sess model.ExperimentSession, + beginning time.Time, + sni string, + thaddr string, +) Subresult { + // slightly delay the measurement + gen := rand.New(rand.NewSource(time.Now().UnixNano())) + sleeptime := time.Duration(gen.Intn(250)) * time.Millisecond + select { + case <-time.After(sleeptime): + case <-ctx.Done(): + s := errorx.FailureInterrupted + failedop := errorx.TopLevelOperation + return Subresult{ + TestKeys: urlgetter.TestKeys{ + FailedOperation: &failedop, + Failure: &s, + }, + THAddress: thaddr, + SNI: sni, + } + } + // perform the measurement + g := urlgetter.Getter{ + Begin: beginning, + Config: urlgetter.Config{TLSServerName: sni}, + Session: sess, + Target: fmt.Sprintf("tlshandshake://%s", thaddr), + } + // Ignoring the error because g.Get() sets the tk.Failure field + // to be the OONI equivalent of the error that occurred. + tk, _ := g.Get(ctx) + // assemble and publish the results + smk := Subresult{ + SNI: sni, + THAddress: thaddr, + TestKeys: tk, + } + return smk +} + +func (m *Measurer) measureonewithcache( + ctx context.Context, + output chan<- Subresult, + sess model.ExperimentSession, + beginning time.Time, + sni string, + thaddr string, +) { + cachekey := sni + thaddr + m.mu.Lock() + smk, okay := m.cache[cachekey] + m.mu.Unlock() + if okay { + output <- smk + return + } + smk = m.measureone(ctx, sess, beginning, sni, thaddr) + output <- smk + smk.Cached = true + m.mu.Lock() + m.cache[cachekey] = smk + m.mu.Unlock() +} + +func (m *Measurer) startall( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, inputs []string, +) <-chan Subresult { + outputs := make(chan Subresult, len(inputs)) + for _, input := range inputs { + go m.measureonewithcache( + ctx, outputs, sess, + measurement.MeasurementStartTimeSaved, + input, m.config.TestHelperAddress, + ) + } + return outputs +} + +func processall( + outputs <-chan Subresult, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, + inputs []string, + sess model.ExperimentSession, + controlSNI string, +) *TestKeys { + var ( + current int + testkeys = new(TestKeys) + ) + for smk := range outputs { + if smk.SNI == controlSNI { + testkeys.Control = smk + } else if smk.SNI == string(measurement.Input) { + testkeys.Target = smk + } else { + panic("unexpected smk.SNI") + } + current++ + sess.Logger().Debugf( + "sni_blocking: %s: %s [cached: %+v]", smk.SNI, + asString(smk.Failure), smk.Cached) + if current >= len(inputs) { + break + } + } + testkeys.Result = testkeys.classify() + sess.Logger().Infof("sni_blocking: result: %s", testkeys.Result) + return testkeys +} + +// maybeURLToSNI handles the case where the input is from the test-lists +// and hence every input is a URL rather than a domain. +func maybeURLToSNI(input model.MeasurementTarget) (model.MeasurementTarget, error) { + parsed, err := url.Parse(string(input)) + if err != nil { + return "", err + } + if parsed.Path == string(input) { + return input, nil + } + return model.MeasurementTarget(parsed.Hostname()), nil +} + +// Run implements ExperimentMeasurer.Run. +func (m *Measurer) Run( + ctx context.Context, + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, +) error { + m.mu.Lock() + if m.cache == nil { + m.cache = make(map[string]Subresult) + } + m.mu.Unlock() + if m.config.ControlSNI == "" { + m.config.ControlSNI = "example.org" + } + if measurement.Input == "" { + return errors.New("Experiment requires measurement.Input") + } + if m.config.TestHelperAddress == "" { + m.config.TestHelperAddress = net.JoinHostPort( + m.config.ControlSNI, "443", + ) + } + urlgetter.RegisterExtensions(measurement) + // TODO(bassosimone): if the user has configured DoT or DoH, here we + // probably want to perform the name resolution before the measurements + // or to make sure that the classify logic is robust to that. + // + // See https://github.com/ooni/probe-cli/v3/internal/engine/issues/392. + maybeParsed, err := maybeURLToSNI(measurement.Input) + if err != nil { + return err + } + measurement.Input = maybeParsed + inputs := []string{m.config.ControlSNI} + if string(measurement.Input) != m.config.ControlSNI { + inputs = append(inputs, string(measurement.Input)) + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second*time.Duration(len(inputs))) + defer cancel() + outputs := m.startall(ctx, sess, measurement, inputs) + measurement.TestKeys = processall( + outputs, measurement, callbacks, inputs, sess, m.config.ControlSNI, + ) + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{config: config} +} + +func asString(failure *string) (result string) { + result = "success" + if failure != nil { + result = *failure + } + return +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/sniblocking/sniblocking_test.go b/internal/engine/experiment/sniblocking/sniblocking_test.go new file mode 100644 index 0000000..c3342e2 --- /dev/null +++ b/internal/engine/experiment/sniblocking/sniblocking_test.go @@ -0,0 +1,434 @@ +package sniblocking + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +const ( + softwareName = "ooniprobe-example" + softwareVersion = "0.0.1" +) + +func TestTestKeysClassify(t *testing.T) { + asStringPtr := func(s string) *string { + return &s + } + t.Run("with tk.Target.Failure == nil", func(t *testing.T) { + tk := new(TestKeys) + if tk.classify() != classSuccessGotServerHello { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == connection_refused", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureConnectionRefused) + if tk.classify() != classAnomalyTestHelperUnreachable { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == dns_nxdomain_error", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureDNSNXDOMAINError) + if tk.classify() != classAnomalyTestHelperUnreachable { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == connection_reset", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureConnectionReset) + if tk.classify() != classInterferenceReset { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == eof_error", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureEOFError) + if tk.classify() != classInterferenceClosed { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == ssl_invalid_hostname", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureSSLInvalidHostname) + if tk.classify() != classSuccessGotServerHello { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == ssl_unknown_authority", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureSSLUnknownAuthority) + if tk.classify() != classInterferenceUnknownAuthority { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == ssl_invalid_certificate", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureSSLInvalidCertificate) + if tk.classify() != classInterferenceInvalidCertificate { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == generic_timeout_error #1", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureGenericTimeoutError) + if tk.classify() != classAnomalyTimeout { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == generic_timeout_error #2", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr(errorx.FailureGenericTimeoutError) + tk.Control.Failure = asStringPtr(errorx.FailureGenericTimeoutError) + if tk.classify() != classAnomalyTestHelperUnreachable { + t.Fatal("unexpected result") + } + }) + t.Run("with tk.Target.Failure == unknown_failure", func(t *testing.T) { + tk := new(TestKeys) + tk.Target.Failure = asStringPtr("unknown_failure") + if tk.classify() != classAnomalyUnexpectedFailure { + t.Fatal("unexpected result") + } + }) +} + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "sni_blocking" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.3.0" { + t.Fatal("unexpected version") + } +} + +func TestMeasurerMeasureNoMeasurementInput(t *testing.T) { + measurer := NewExperimentMeasurer(Config{ + ControlSNI: "example.com", + }) + err := measurer.Run( + context.Background(), + newsession(), + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if err.Error() != "Experiment requires measurement.Input" { + t.Fatal("not the error we expected") + } +} + +func TestMeasurerMeasureWithInvalidInput(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel the context + measurer := NewExperimentMeasurer(Config{ + ControlSNI: "example.com", + }) + measurement := &model.Measurement{ + Input: "\t", + } + err := measurer.Run( + ctx, + newsession(), + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err == nil { + t.Fatal("expected an error here") + } +} + +func TestMeasurerMeasureWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel the context + measurer := NewExperimentMeasurer(Config{ + ControlSNI: "example.com", + }) + measurement := &model.Measurement{ + Input: "kernel.org", + } + err := measurer.Run( + ctx, + newsession(), + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestMeasureoneCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel the context + result := new(Measurer).measureone( + ctx, + &mockable.Session{MockableLogger: log.Log}, + time.Now(), + "kernel.org", + "example.com:443", + ) + if result.Agent != "" { + t.Fatal("not the expected Agent") + } + if result.BootstrapTime != 0.0 { + t.Fatal("not the expected BootstrapTime") + } + if result.DNSCache != nil { + t.Fatal("not the expected DNSCache") + } + if result.FailedOperation == nil || *result.FailedOperation != errorx.TopLevelOperation { + t.Fatal("not the expected FailedOperation") + } + if result.Failure == nil || *result.Failure != errorx.FailureInterrupted { + t.Fatal("not the expected failure") + } + if result.NetworkEvents != nil { + t.Fatal("not the expected NetworkEvents") + } + if result.Queries != nil { + t.Fatal("not the expected Queries") + } + if result.Requests != nil { + t.Fatal("not the expected Requests") + } + if result.SOCKSProxy != "" { + t.Fatal("not the expected SOCKSProxy") + } + if result.TCPConnect != nil { + t.Fatal("not the expected TCPConnect") + } + if result.TLSHandshakes != nil { + t.Fatal("not the expected TLSHandshakes") + } + if result.Tunnel != "" { + t.Fatal("not the expected Tunnel") + } + if result.SNI != "kernel.org" { + t.Fatal("unexpected SNI") + } + if result.THAddress != "example.com:443" { + t.Fatal("unexpected THAddress") + } +} + +func TestMeasureoneWithPreMeasurementFailure(t *testing.T) { + result := new(Measurer).measureone( + context.Background(), + &mockable.Session{MockableLogger: log.Log}, + time.Now(), + "kernel.org", + "example.com:443\t\t\t", // cause URL parse error + ) + if result.Agent != "redirect" { + t.Fatal("not the expected Agent") + } + if result.BootstrapTime != 0.0 { + t.Fatal("not the expected BootstrapTime") + } + if result.DNSCache != nil { + t.Fatal("not the expected DNSCache") + } + if result.FailedOperation == nil || *result.FailedOperation != "top_level" { + t.Fatal("not the expected FailedOperation") + } + if result.Failure == nil || !strings.Contains(*result.Failure, "invalid target URL") { + t.Fatal("not the expected failure") + } + if result.NetworkEvents != nil { + t.Fatal("not the expected NetworkEvents") + } + if result.Queries != nil { + t.Fatal("not the expected Queries") + } + if result.Requests != nil { + t.Fatal("not the expected Requests") + } + if result.SOCKSProxy != "" { + t.Fatal("not the expected SOCKSProxy") + } + if result.TCPConnect != nil { + t.Fatal("not the expected TCPConnect") + } + if result.TLSHandshakes != nil { + t.Fatal("not the expected TLSHandshakes") + } + if result.Tunnel != "" { + t.Fatal("not the expected Tunnel") + } + if result.SNI != "kernel.org" { + t.Fatal("unexpected SNI") + } + if result.THAddress != "example.com:443\t\t\t" { + t.Fatal("unexpected THAddress") + } +} + +func TestMeasureoneSuccess(t *testing.T) { + result := new(Measurer).measureone( + context.Background(), + &mockable.Session{MockableLogger: log.Log}, + time.Now(), + "kernel.org", + "example.com:443", + ) + if result.Agent != "redirect" { + t.Fatal("not the expected Agent") + } + if result.BootstrapTime != 0.0 { + t.Fatal("not the expected BootstrapTime") + } + if result.DNSCache != nil { + t.Fatal("not the expected DNSCache") + } + if result.FailedOperation == nil || *result.FailedOperation != errorx.TLSHandshakeOperation { + t.Fatal("not the expected FailedOperation") + } + if result.Failure == nil || *result.Failure != errorx.FailureSSLInvalidHostname { + t.Fatal("unexpected failure") + } + if len(result.NetworkEvents) < 1 { + t.Fatal("not the expected NetworkEvents") + } + if len(result.Queries) < 1 { + t.Fatal("not the expected Queries") + } + if result.Requests != nil { + t.Fatal("not the expected Requests") + } + if result.SOCKSProxy != "" { + t.Fatal("not the expected SOCKSProxy") + } + if len(result.TCPConnect) < 1 { + t.Fatal("not the expected TCPConnect") + } + if len(result.TLSHandshakes) < 1 { + t.Fatal("not the expected TLSHandshakes") + } + if result.Tunnel != "" { + t.Fatal("not the expected Tunnel") + } + if result.SNI != "kernel.org" { + t.Fatal("unexpected SNI") + } + if result.THAddress != "example.com:443" { + t.Fatal("unexpected THAddress") + } +} + +func TestMeasureonewithcacheWorks(t *testing.T) { + measurer := &Measurer{cache: make(map[string]Subresult)} + output := make(chan Subresult, 2) + for i := 0; i < 2; i++ { + measurer.measureonewithcache( + context.Background(), + output, + &mockable.Session{MockableLogger: log.Log}, + time.Now(), + "kernel.org", + "example.com:443", + ) + } + for _, expected := range []bool{false, true} { + result := <-output + if result.Cached != expected { + t.Fatal("unexpected cached") + } + if *result.Failure != errorx.FailureSSLInvalidHostname { + t.Fatal("unexpected failure") + } + if result.SNI != "kernel.org" { + t.Fatal("unexpected SNI") + } + } +} + +func TestProcessallPanicsIfInvalidSNI(t *testing.T) { + defer func() { + panicdata := recover() + if panicdata == nil { + t.Fatal("expected to see panic here") + } + if panicdata.(string) != "unexpected smk.SNI" { + t.Fatal("not the panic we expected") + } + }() + outputs := make(chan Subresult, 1) + measurement := &model.Measurement{ + Input: "kernel.org", + } + go func() { + outputs <- Subresult{ + SNI: "antani.io", + } + }() + processall( + outputs, + measurement, + model.NewPrinterCallbacks(log.Log), + []string{"kernel.org", "example.com"}, + newsession(), + "example.com", + ) +} + +func TestMaybeURLToSNI(t *testing.T) { + t.Run("for invalid URL", func(t *testing.T) { + parsed, err := maybeURLToSNI("\t") + if err == nil { + t.Fatal("expected an error here") + } + if parsed != "" { + t.Fatal("expected empty parsed here") + } + }) + t.Run("for domain name", func(t *testing.T) { + parsed, err := maybeURLToSNI("kernel.org") + if err != nil { + t.Fatal(err) + } + if parsed != "kernel.org" { + t.Fatal("expected different domain here") + } + }) + t.Run("for valid URL", func(t *testing.T) { + parsed, err := maybeURLToSNI("https://kernel.org/robots.txt") + if err != nil { + t.Fatal(err) + } + if parsed != "kernel.org" { + t.Fatal("expected different domain here") + } + }) +} + +func newsession() model.ExperimentSession { + return &mockable.Session{MockableLogger: log.Log} +} + +func TestSummaryKeysGeneric(t *testing.T) { + measurement := &model.Measurement{TestKeys: &TestKeys{}} + m := &Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/stunreachability/fake_test.go b/internal/engine/experiment/stunreachability/fake_test.go new file mode 100644 index 0000000..d658228 --- /dev/null +++ b/internal/engine/experiment/stunreachability/fake_test.go @@ -0,0 +1,60 @@ +package stunreachability + +import ( + "io" + "net" + "time" +) + +type FakeConn struct { + ReadError error + ReadData []byte + SetDeadlineError error + SetReadDeadlineError error + SetWriteDeadlineError error + WriteError error +} + +func (c *FakeConn) Read(b []byte) (int, error) { + if len(c.ReadData) > 0 { + n := copy(b, c.ReadData) + c.ReadData = c.ReadData[n:] + return n, nil + } + if c.ReadError != nil { + return 0, c.ReadError + } + return 0, io.EOF +} + +func (c *FakeConn) Write(b []byte) (n int, err error) { + if c.WriteError != nil { + return 0, c.WriteError + } + n = len(b) + return +} + +func (*FakeConn) Close() (err error) { + return +} + +func (*FakeConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*FakeConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (c *FakeConn) SetDeadline(t time.Time) (err error) { + return c.SetDeadlineError +} + +func (c *FakeConn) SetReadDeadline(t time.Time) (err error) { + return c.SetReadDeadlineError +} + +func (c *FakeConn) SetWriteDeadline(t time.Time) (err error) { + return c.SetWriteDeadlineError +} diff --git a/internal/engine/experiment/stunreachability/stunreachability.go b/internal/engine/experiment/stunreachability/stunreachability.go new file mode 100644 index 0000000..8bd2845 --- /dev/null +++ b/internal/engine/experiment/stunreachability/stunreachability.go @@ -0,0 +1,169 @@ +// Package stunreachability contains the STUN reachability experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-025-stun-reachability.md. +package stunreachability + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" + "github.com/pion/stun" +) + +const ( + testName = "stun_reachability" + testVersion = "0.1.0" +) + +// Config contains the experiment config. +type Config struct { + dialContext func(ctx context.Context, network, address string) (net.Conn, error) + newClient func(conn stun.Connection, options ...stun.ClientOption) (*stun.Client, error) +} + +// TestKeys contains the experiment's result. +type TestKeys struct { + Endpoint string `json:"endpoint"` + Failure *string `json:"failure"` + NetworkEvents []archival.NetworkEvent `json:"network_events"` + Queries []archival.DNSQueryEntry `json:"queries"` +} + +func registerExtensions(m *model.Measurement) { + archival.ExtDNS.AddTo(m) + archival.ExtNetevents.AddTo(m) +} + +// Measurer performs the measurement. +type Measurer struct { + config Config +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +func wrap(err error) error { + return errorx.SafeErrWrapperBuilder{ + Error: err, + Operation: "stun", + }.MaybeBuild() +} + +// Run implements ExperimentMeasurer.Run. +func (m *Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + tk := new(TestKeys) + measurement.TestKeys = tk + registerExtensions(measurement) + if err := wrap(tk.run(ctx, m.config, sess, measurement, callbacks)); err != nil { + s := err.Error() + tk.Failure = &s + return err + } + return nil +} + +func (tk *TestKeys) run( + ctx context.Context, config Config, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + const defaultAddress = "stun.l.google.com:19302" + endpoint := string(measurement.Input) + if endpoint == "" { + endpoint = defaultAddress + } + callbacks.OnProgress(0, fmt.Sprintf("stunreachability: measuring: %s...", endpoint)) + defer callbacks.OnProgress( + 1, fmt.Sprintf("stunreachability: measuring: %s... done", endpoint)) + tk.Endpoint = endpoint + saver := new(trace.Saver) + begin := time.Now() + err := tk.do(ctx, config, netx.NewDialer(netx.Config{ + ContextByteCounting: true, + DialSaver: saver, + Logger: sess.Logger(), + ReadWriteSaver: saver, + ResolveSaver: saver, + }), endpoint) + events := saver.Read() + tk.NetworkEvents = append( + tk.NetworkEvents, archival.NewNetworkEventsList(begin, events)..., + ) + tk.Queries = append( + tk.Queries, archival.NewDNSQueriesList(begin, events, sess.ASNDatabasePath())..., + ) + return err +} + +func (tk *TestKeys) do( + ctx context.Context, config Config, dialer dialer.Dialer, endpoint string) error { + dialContext := dialer.DialContext + if config.dialContext != nil { + dialContext = config.dialContext + } + conn, err := dialContext(ctx, "udp", endpoint) + if err != nil { + return err + } + defer conn.Close() + newClient := stun.NewClient + if config.newClient != nil { + newClient = config.newClient + } + client, err := newClient(conn, stun.WithNoConnClose) + if err != nil { + return err + } + message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) + ch := make(chan error) + err = client.Start(message, func(ev stun.Event) { + // As mentioned below this code will run after Start has returned. + if ev.Error != nil { + ch <- ev.Error + return + } + var xorAddr stun.XORMappedAddress + ch <- xorAddr.GetFrom(ev.Message) + }) + // Implementation note: if we successfully started, then the callback + // will be called when we receive a response or fail. + if err != nil { + return err + } + return <-ch +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/stunreachability/stunreachability_internal_test.go b/internal/engine/experiment/stunreachability/stunreachability_internal_test.go new file mode 100644 index 0000000..8a5607c --- /dev/null +++ b/internal/engine/experiment/stunreachability/stunreachability_internal_test.go @@ -0,0 +1,18 @@ +package stunreachability + +import ( + "context" + "net" + + "github.com/pion/stun" +) + +func (c *Config) SetNewClient( + f func(conn stun.Connection, options ...stun.ClientOption) (*stun.Client, error)) { + c.newClient = f +} + +func (c *Config) SetDialContext( + f func(ctx context.Context, network, address string) (net.Conn, error)) { + c.dialContext = f +} diff --git a/internal/engine/experiment/stunreachability/stunreachability_test.go b/internal/engine/experiment/stunreachability/stunreachability_test.go new file mode 100644 index 0000000..8cf17e7 --- /dev/null +++ b/internal/engine/experiment/stunreachability/stunreachability_test.go @@ -0,0 +1,242 @@ +package stunreachability_test + +import ( + "context" + "errors" + "net" + "os" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/stunreachability" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/pion/stun" +) + +func TestMeasurerExperimentNameVersion(t *testing.T) { + measurer := stunreachability.NewExperimentMeasurer(stunreachability.Config{}) + if measurer.ExperimentName() != "stun_reachability" { + t.Fatal("unexpected ExperimentName") + } + if measurer.ExperimentVersion() != "0.1.0" { + t.Fatal("unexpected ExperimentVersion") + } +} + +func TestRun(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") == "true" { + // See https://github.com/ooni/probe-cli/v3/internal/engine/issues/874#issuecomment-679850652 + t.Skip("skipping broken test on GitHub Actions") + } + measurer := stunreachability.NewExperimentMeasurer(stunreachability.Config{}) + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*stunreachability.TestKeys) + if tk.Failure != nil { + t.Fatal("expected nil failure here") + } + if tk.Endpoint != "stun.l.google.com:19302" { + t.Fatal("unexpected endpoint") + } + if len(tk.NetworkEvents) <= 0 { + t.Fatal("no network events?!") + } + if len(tk.Queries) <= 0 { + t.Fatal("no DNS queries?!") + } +} + +func TestRunCustomInput(t *testing.T) { + input := "stun.ekiga.net:3478" + measurer := stunreachability.NewExperimentMeasurer(stunreachability.Config{}) + measurement := new(model.Measurement) + measurement.Input = model.MeasurementTarget(input) + err := measurer.Run( + context.Background(), + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*stunreachability.TestKeys) + if tk.Failure != nil { + t.Fatal("expected nil failure here") + } + if tk.Endpoint != input { + t.Fatal("unexpected endpoint") + } + if len(tk.NetworkEvents) <= 0 { + t.Fatal("no network events?!") + } + if len(tk.Queries) <= 0 { + t.Fatal("no DNS queries?!") + } +} + +func TestCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately fail everything + measurer := stunreachability.NewExperimentMeasurer(stunreachability.Config{}) + measurement := new(model.Measurement) + err := measurer.Run( + ctx, + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err.Error() != "interrupted" { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*stunreachability.TestKeys) + if *tk.Failure != "interrupted" { + t.Fatal("expected different failure here") + } + if tk.Endpoint != "stun.l.google.com:19302" { + t.Fatal("unexpected endpoint") + } + if len(tk.NetworkEvents) <= 0 { + t.Fatal("no network events?!") + } + if len(tk.Queries) <= 0 { + t.Fatal("no DNS queries?!") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(stunreachability.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestNewClientFailure(t *testing.T) { + config := &stunreachability.Config{} + expected := errors.New("mocked error") + config.SetNewClient( + func(conn stun.Connection, options ...stun.ClientOption) (*stun.Client, error) { + return nil, expected + }) + measurer := stunreachability.NewExperimentMeasurer(*config) + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*stunreachability.TestKeys) + if !strings.HasPrefix(*tk.Failure, "unknown_failure") { + t.Fatal("expected different failure here") + } + if tk.Endpoint != "stun.l.google.com:19302" { + t.Fatal("unexpected endpoint") + } + if len(tk.NetworkEvents) <= 0 { + t.Fatal("no network events?!") + } + if len(tk.Queries) <= 0 { + t.Fatal("no DNS queries?!") + } +} + +func TestStartFailure(t *testing.T) { + config := &stunreachability.Config{} + expected := errors.New("mocked error") + config.SetDialContext( + func(ctx context.Context, network, address string) (net.Conn, error) { + conn := &stunreachability.FakeConn{WriteError: expected} + return conn, nil + }) + measurer := stunreachability.NewExperimentMeasurer(*config) + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*stunreachability.TestKeys) + if !strings.HasPrefix(*tk.Failure, "unknown_failure") { + t.Fatal("expected different failure here") + } + if tk.Endpoint != "stun.l.google.com:19302" { + t.Fatal("unexpected endpoint") + } + // We're bypassing normal network with custom dial function + if len(tk.NetworkEvents) > 0 { + t.Fatal("network events?!") + } + if len(tk.Queries) > 0 { + t.Fatal("DNS queries?!") + } +} + +func TestReadFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + config := &stunreachability.Config{} + expected := errors.New("mocked error") + config.SetDialContext( + func(ctx context.Context, network, address string) (net.Conn, error) { + conn := &stunreachability.FakeConn{ReadError: expected} + return conn, nil + }) + measurer := stunreachability.NewExperimentMeasurer(*config) + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, stun.ErrTransactionTimeOut) { + t.Fatal("not the error we expected") + } + tk := measurement.TestKeys.(*stunreachability.TestKeys) + if *tk.Failure != errorx.FailureGenericTimeoutError { + t.Fatal("expected different failure here") + } + if tk.Endpoint != "stun.l.google.com:19302" { + t.Fatal("unexpected endpoint") + } + // We're bypassing normal network with custom dial function + if len(tk.NetworkEvents) > 0 { + t.Fatal("network events?!") + } + if len(tk.Queries) > 0 { + t.Fatal("DNS queries?!") + } +} + +func TestSummaryKeysGeneric(t *testing.T) { + measurement := &model.Measurement{TestKeys: &stunreachability.TestKeys{}} + m := &stunreachability.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(stunreachability.SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/telegram/telegram.go b/internal/engine/experiment/telegram/telegram.go new file mode 100644 index 0000000..ecbe36d --- /dev/null +++ b/internal/engine/experiment/telegram/telegram.go @@ -0,0 +1,174 @@ +// Package telegram contains the Telegram network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-020-telegram.md. +package telegram + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +const ( + testName = "telegram" + testVersion = "0.2.0" +) + +// Config contains the telegram experiment config. +type Config struct{} + +// TestKeys contains telegram test keys. +type TestKeys struct { + urlgetter.TestKeys + TelegramHTTPBlocking bool `json:"telegram_http_blocking"` + TelegramTCPBlocking bool `json:"telegram_tcp_blocking"` + TelegramWebFailure *string `json:"telegram_web_failure"` + TelegramWebStatus string `json:"telegram_web_status"` +} + +// NewTestKeys creates new telegram TestKeys. +func NewTestKeys() *TestKeys { + return &TestKeys{ + TelegramHTTPBlocking: true, + TelegramTCPBlocking: true, + TelegramWebFailure: nil, + TelegramWebStatus: "ok", + } +} + +// Update updates the TestKeys using the given MultiOutput result. +func (tk *TestKeys) Update(v urlgetter.MultiOutput) { + // update the easy to update entries first + tk.NetworkEvents = append(tk.NetworkEvents, v.TestKeys.NetworkEvents...) + tk.Queries = append(tk.Queries, v.TestKeys.Queries...) + tk.Requests = append(tk.Requests, v.TestKeys.Requests...) + tk.TCPConnect = append(tk.TCPConnect, v.TestKeys.TCPConnect...) + tk.TLSHandshakes = append(tk.TLSHandshakes, v.TestKeys.TLSHandshakes...) + // then process access points + if v.Input.Config.Method != "GET" { + if v.TestKeys.Failure == nil { + tk.TelegramHTTPBlocking = false + tk.TelegramTCPBlocking = false + return // found successful access point connection + } + if v.TestKeys.FailedOperation == nil || *v.TestKeys.FailedOperation != errorx.ConnectOperation { + tk.TelegramTCPBlocking = false + } + return + } + // now take care of web + if tk.TelegramWebStatus != "ok" { + return // we already flipped the state + } + if v.TestKeys.Failure != nil { + tk.TelegramWebStatus = "blocked" + tk.TelegramWebFailure = v.TestKeys.Failure + return + } + title := `Telegram Web` + if strings.Contains(v.TestKeys.HTTPResponseBody, title) == false { + failureString := "telegram_missing_title_error" + tk.TelegramWebFailure = &failureString + tk.TelegramWebStatus = "blocked" + return + } + return +} + +// Measurer performs the measurement +type Measurer struct { + // Config contains the experiment settings. If empty we + // will be using default settings. + Config Config + + // Getter is an optional getter to be used for testing. + Getter urlgetter.MultiGetter +} + +// ExperimentName implements ExperimentMeasurer.ExperimentName +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements ExperimentMeasurer.Run +func (m Measurer) Run(ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks) error { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + urlgetter.RegisterExtensions(measurement) + inputs := []urlgetter.MultiInput{ + {Target: "http://149.154.175.50/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.167.51/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.175.100/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.167.91/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.171.5/", Config: urlgetter.Config{Method: "POST"}}, + + {Target: "http://149.154.175.50:443/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.167.51:443/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.175.100:443/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.167.91:443/", Config: urlgetter.Config{Method: "POST"}}, + {Target: "http://149.154.171.5:443/", Config: urlgetter.Config{Method: "POST"}}, + + // Here we need to provide the method explicitly. See + // https://github.com/ooni/probe-cli/v3/internal/engine/issues/827. + {Target: "http://web.telegram.org/", Config: urlgetter.Config{ + Method: "GET", + FailOnHTTPError: true, + }}, + {Target: "https://web.telegram.org/", Config: urlgetter.Config{ + Method: "GET", + FailOnHTTPError: true, + }}, + } + multi := urlgetter.Multi{Begin: time.Now(), Getter: m.Getter, Session: sess} + testkeys := NewTestKeys() + testkeys.Agent = "redirect" + measurement.TestKeys = testkeys + for entry := range multi.Collect(ctx, inputs, "telegram", callbacks) { + testkeys.Update(entry) + } + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + HTTPBlocking bool `json:"telegram_http_blocking"` + TCPBlocking bool `json:"telegram_tcp_blocking"` + WebBlocking bool `json:"telegram_web_blocking"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + tcpBlocking := tk.TelegramTCPBlocking + httpBlocking := tk.TelegramHTTPBlocking + webBlocking := tk.TelegramWebFailure != nil + sk.TCPBlocking = tcpBlocking + sk.HTTPBlocking = httpBlocking + sk.WebBlocking = webBlocking + sk.IsAnomaly = webBlocking || httpBlocking || tcpBlocking + return sk, nil +} diff --git a/internal/engine/experiment/telegram/telegram_test.go b/internal/engine/experiment/telegram/telegram_test.go new file mode 100644 index 0000000..f1afcc3 --- /dev/null +++ b/internal/engine/experiment/telegram/telegram_test.go @@ -0,0 +1,416 @@ +package telegram_test + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/telegram" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := telegram.NewExperimentMeasurer(telegram.Config{}) + if measurer.ExperimentName() != "telegram" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.2.0" { + t.Fatal("unexpected version") + } +} + +func TestGood(t *testing.T) { + measurer := telegram.NewExperimentMeasurer(telegram.Config{}) + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableLogger: log.Log, + }, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*telegram.TestKeys) + if tk.Agent != "redirect" { + t.Fatal("unexpected Agent") + } + if tk.FailedOperation != nil { + t.Fatal("unexpected FailedOperation") + } + if tk.Failure != nil { + t.Fatal("unexpected Failure") + } + if len(tk.NetworkEvents) <= 0 { + t.Fatal("no NetworkEvents?!") + } + if len(tk.Queries) <= 0 { + t.Fatal("no Queries?!") + } + if len(tk.Requests) <= 0 { + t.Fatal("no Requests?!") + } + if len(tk.TCPConnect) <= 0 { + t.Fatal("no TCPConnect?!") + } + if len(tk.TLSHandshakes) <= 0 { + t.Fatal("no TLSHandshakes?!") + } + if tk.TelegramHTTPBlocking != false { + t.Fatal("unexpected TelegramHTTPBlocking") + } + if tk.TelegramTCPBlocking != false { + t.Fatal("unexpected TelegramTCPBlocking") + } + if tk.TelegramWebFailure != nil { + t.Fatal("unexpected TelegramWebFailure") + } + if tk.TelegramWebStatus != "ok" { + t.Fatal("unexpected TelegramWebStatus") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(telegram.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestUpdateWithNoAccessPointsBlocking(t *testing.T) { + tk := telegram.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50/", + }, + TestKeys: urlgetter.TestKeys{ + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50:443/", + }, + TestKeys: urlgetter.TestKeys{ + Failure: nil, // this should be enough to declare success + }, + }) + if tk.TelegramHTTPBlocking == true { + t.Fatal("there should be no TelegramHTTPBlocking") + } + if tk.TelegramTCPBlocking == true { + t.Fatal("there should be no TelegramTCPBlocking") + } +} + +func TestUpdateWithNilFailedOperation(t *testing.T) { + tk := telegram.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50/", + }, + TestKeys: urlgetter.TestKeys{ + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50:443/", + }, + TestKeys: urlgetter.TestKeys{ + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + if tk.TelegramHTTPBlocking == false { + t.Fatal("there should be TelegramHTTPBlocking") + } + if tk.TelegramTCPBlocking == true { + t.Fatal("there should be no TelegramTCPBlocking") + } +} + +func TestUpdateWithNonConnectFailedOperation(t *testing.T) { + tk := telegram.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50/", + }, + TestKeys: urlgetter.TestKeys{ + FailedOperation: (func() *string { + s := errorx.ConnectOperation + return &s + })(), + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50:443/", + }, + TestKeys: urlgetter.TestKeys{ + FailedOperation: (func() *string { + s := errorx.HTTPRoundTripOperation + return &s + })(), + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + if tk.TelegramHTTPBlocking == false { + t.Fatal("there should be TelegramHTTPBlocking") + } + if tk.TelegramTCPBlocking == true { + t.Fatal("there should be no TelegramTCPBlocking") + } +} + +func TestUpdateWithAllConnectsFailed(t *testing.T) { + tk := telegram.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50/", + }, + TestKeys: urlgetter.TestKeys{ + FailedOperation: (func() *string { + s := errorx.ConnectOperation + return &s + })(), + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "POST"}, + Target: "http://149.154.175.50:443/", + }, + TestKeys: urlgetter.TestKeys{ + FailedOperation: (func() *string { + s := errorx.ConnectOperation + return &s + })(), + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + if tk.TelegramHTTPBlocking == false { + t.Fatal("there should be TelegramHTTPBlocking") + } + if tk.TelegramTCPBlocking == false { + t.Fatal("there should be TelegramTCPBlocking") + } +} + +func TestUpdateWebWithMixedResults(t *testing.T) { + tk := telegram.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "http://web.telegram.org/", + }, + TestKeys: urlgetter.TestKeys{ + FailedOperation: (func() *string { + s := errorx.HTTPRoundTripOperation + return &s + })(), + Failure: (func() *string { + s := errorx.FailureEOFError + return &s + })(), + }, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "https://web.telegram.org/", + }, + TestKeys: urlgetter.TestKeys{ + HTTPResponseBody: `Telegram Web`, + HTTPResponseStatus: 200, + }, + }) + if tk.TelegramWebStatus != "blocked" { + t.Fatal("TelegramWebStatus should be blocked") + } + if *tk.TelegramWebFailure != errorx.FailureEOFError { + t.Fatal("invalid TelegramWebFailure") + } +} + +func TestWeConfigureWebChecksToFailOnHTTPError(t *testing.T) { + called := atomicx.NewInt64() + failOnErrorHTTPS := atomicx.NewInt64() + failOnErrorHTTP := atomicx.NewInt64() + measurer := telegram.Measurer{ + Config: telegram.Config{}, + Getter: func(ctx context.Context, g urlgetter.Getter) (urlgetter.TestKeys, error) { + called.Add(1) + switch g.Target { + case "https://web.telegram.org/": + if g.Config.FailOnHTTPError { + failOnErrorHTTPS.Add(1) + } + case "http://web.telegram.org/": + if g.Config.FailOnHTTPError { + failOnErrorHTTP.Add(1) + } + } + return urlgetter.DefaultMultiGetter(ctx, g) + }, + } + ctx := context.Background() + sess := &mockable.Session{ + MockableLogger: log.Log, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + if err := measurer.Run(ctx, sess, measurement, callbacks); err != nil { + t.Fatal(err) + } + if called.Load() < 1 { + t.Fatal("not called") + } + if failOnErrorHTTPS.Load() != 1 { + t.Fatal("not configured fail on error for HTTPS") + } + if failOnErrorHTTP.Load() != 1 { + t.Fatal("not configured fail on error for HTTP") + } +} + +func TestUpdateWithMissingTitle(t *testing.T) { + tk := telegram.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "http://web.telegram.org/", + }, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 200, + HTTPResponseBody: "Telegram Web", + }, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "http://web.telegram.org/", + }, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 200, + HTTPResponseBody: "Antani Web", + }, + }) + if tk.TelegramWebStatus != "blocked" { + t.Fatal("TelegramWebStatus should be blocked") + } + if *tk.TelegramWebFailure != "telegram_missing_title_error" { + t.Fatal("invalid TelegramWebFailure") + } +} + +func TestUpdateWithAllGood(t *testing.T) { + tk := telegram.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "http://web.telegram.org/", + }, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 200, + HTTPResponseBody: "Telegram Web", + }, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{ + Config: urlgetter.Config{Method: "GET"}, + Target: "http://web.telegram.org/", + }, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 200, + HTTPResponseBody: "Telegram Web", + }, + }) + if tk.TelegramWebStatus != "ok" { + t.Fatal("TelegramWebStatus should be ok") + } + if tk.TelegramWebFailure != nil { + t.Fatal("invalid TelegramWebFailure") + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &telegram.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysWorksAsIntended(t *testing.T) { + failure := io.EOF.Error() + tests := []struct { + tk telegram.TestKeys + isAnomaly bool + }{{ + tk: telegram.TestKeys{}, + isAnomaly: false, + }, { + tk: telegram.TestKeys{TelegramTCPBlocking: true}, + isAnomaly: true, + }, { + tk: telegram.TestKeys{TelegramHTTPBlocking: true}, + isAnomaly: true, + }, { + tk: telegram.TestKeys{TelegramWebFailure: &failure}, + isAnomaly: true, + }} + for idx, tt := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + m := &telegram.Measurer{} + measurement := &model.Measurement{TestKeys: &tt.tk} + got, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + return + } + sk := got.(telegram.SummaryKeys) + if sk.IsAnomaly != tt.isAnomaly { + t.Fatal("unexpected isAnomaly value") + } + }) + } +} diff --git a/internal/engine/experiment/tlstool/internal/dialer.go b/internal/engine/experiment/tlstool/internal/dialer.go new file mode 100644 index 0000000..322f064 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/dialer.go @@ -0,0 +1,29 @@ +package internal + +import ( + "context" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +// Dialer creates net.Conn instances where (1) we delay writes if +// a delay is configured and (2) we split outgoing buffers if there +// is a configured splitter function. +type Dialer struct { + netx.Dialer + Delay time.Duration + Splitter func([]byte) [][]byte +} + +// DialContext implements netx.Dialer.DialContext. +func (d Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := d.Dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + conn = SleeperWriter{Conn: conn, Delay: d.Delay} + conn = SplitterWriter{Conn: conn, Splitter: d.Splitter} + return conn, nil +} diff --git a/internal/engine/experiment/tlstool/internal/dialer_test.go b/internal/engine/experiment/tlstool/internal/dialer_test.go new file mode 100644 index 0000000..e4317b4 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/dialer_test.go @@ -0,0 +1,56 @@ +package internal_test + +import ( + "context" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool/internal" +) + +func TestDialerFailure(t *testing.T) { + expected := errors.New("mocked error") + dialer := internal.Dialer{Dialer: internal.FakeDialer{ + Err: expected, + }} + conn, err := dialer.DialContext(context.Background(), "tcp", "8.8.8.8:853") + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} + +func TestDialerSuccess(t *testing.T) { + splitter := func([]byte) [][]byte { + return nil // any value is fine we just a need a splitter != nil here + } + innerconn := &internal.FakeConn{} + dialer := internal.Dialer{ + Delay: 12345, + Dialer: internal.FakeDialer{Conn: innerconn}, + Splitter: splitter, + } + conn, err := dialer.DialContext(context.Background(), "tcp", "8.8.8.8:853") + if err != nil { + t.Fatal(err) + } + sconn, ok := conn.(internal.SplitterWriter) + if !ok { + t.Fatal("the outer connection is not a splitter") + } + if sconn.Splitter == nil { + t.Fatal("not the splitter we expected") + } + dconn, ok := sconn.Conn.(internal.SleeperWriter) + if !ok { + t.Fatal("the inner connection is not a sleeper") + } + if dconn.Delay != 12345 { + t.Fatal("invalid delay") + } + if dconn.Conn != innerconn { + t.Fatal("invalid inner connection") + } +} diff --git a/internal/engine/experiment/tlstool/internal/fake_test.go b/internal/engine/experiment/tlstool/internal/fake_test.go new file mode 100644 index 0000000..06ddb16 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/fake_test.go @@ -0,0 +1,73 @@ +package internal + +import ( + "context" + "io" + "net" + "time" +) + +type FakeDialer struct { + Conn net.Conn + Err error +} + +func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return d.Conn, d.Err +} + +type FakeConn struct { + ReadError error + ReadData []byte + SetDeadlineError error + SetReadDeadlineError error + SetWriteDeadlineError error + WriteData [][]byte + WriteError error +} + +func (c *FakeConn) Read(b []byte) (int, error) { + if len(c.ReadData) > 0 { + n := copy(b, c.ReadData) + c.ReadData = c.ReadData[n:] + return n, nil + } + if c.ReadError != nil { + return 0, c.ReadError + } + return 0, io.EOF +} + +func (c *FakeConn) Write(b []byte) (n int, err error) { + if c.WriteError != nil { + return 0, c.WriteError + } + c.WriteData = append(c.WriteData, b) + n = len(b) + return +} + +func (*FakeConn) Close() (err error) { + return +} + +func (*FakeConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*FakeConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (c *FakeConn) SetDeadline(t time.Time) (err error) { + return c.SetDeadlineError +} + +func (c *FakeConn) SetReadDeadline(t time.Time) (err error) { + return c.SetReadDeadlineError +} + +func (c *FakeConn) SetWriteDeadline(t time.Time) (err error) { + return c.SetWriteDeadlineError +} diff --git a/internal/engine/experiment/tlstool/internal/internal.go b/internal/engine/experiment/tlstool/internal/internal.go new file mode 100644 index 0000000..7d17d2d --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/internal.go @@ -0,0 +1,57 @@ +// Package internal contains the implementation of tlstool. +package internal + +import ( + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +// DialerConfig contains the config for creating a dialer +type DialerConfig struct { + Dialer netx.Dialer + Delay time.Duration + SNI string +} + +// NewSNISplitterDialer creates a new dialer that splits +// outgoing messages such that the SNI should end up being +// splitted into different TCP segments. +func NewSNISplitterDialer(config DialerConfig) Dialer { + return Dialer{ + Dialer: config.Dialer, + Delay: config.Delay, + Splitter: func(b []byte) [][]byte { + return SNISplitter(b, []byte(config.SNI)) + }, + } +} + +// NewThriceSplitterDialer creates a new dialer that splits +// outgoing messages in three parts according to the circumvention +// technique described by Kevin Boch in the Internet Measurement +// Village 2020 . +func NewThriceSplitterDialer(config DialerConfig) Dialer { + return Dialer{ + Dialer: config.Dialer, + Delay: config.Delay, + Splitter: Splitter84rest, + } +} + +// NewRandomSplitterDialer creates a new dialer that splits +// the SNI like the fixed splitting schema used by outline. See +// github.com/Jigsaw-Code/outline-go-tun2socks. +func NewRandomSplitterDialer(config DialerConfig) Dialer { + return Dialer{ + Dialer: config.Dialer, + Delay: config.Delay, + Splitter: Splitter3264rand, + } +} + +// NewVanillaDialer creates a new vanilla dialer that does +// nothing and is used to establish a baseline. +func NewVanillaDialer(config DialerConfig) Dialer { + return Dialer{Dialer: config.Dialer} +} diff --git a/internal/engine/experiment/tlstool/internal/internal_test.go b/internal/engine/experiment/tlstool/internal/internal_test.go new file mode 100644 index 0000000..779e307 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/internal_test.go @@ -0,0 +1,40 @@ +package internal_test + +import ( + "context" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool/internal" + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +var config = internal.DialerConfig{ + Dialer: netx.NewDialer(netx.Config{}), + Delay: 10, + SNI: "dns.google", +} + +func dial(t *testing.T, d netx.Dialer) { + td := netx.NewTLSDialer(netx.Config{Dialer: d}) + conn, err := td.DialTLSContext(context.Background(), "tcp", "dns.google:853") + if err != nil { + t.Fatal(err) + } + conn.Close() +} + +func TestNewSNISplitterDialer(t *testing.T) { + dial(t, internal.NewSNISplitterDialer(config)) +} + +func TestNewThriceSplitterDialer(t *testing.T) { + dial(t, internal.NewThriceSplitterDialer(config)) +} + +func TestNewRandomSplitterDialer(t *testing.T) { + dial(t, internal.NewRandomSplitterDialer(config)) +} + +func TestNewVanillaDialer(t *testing.T) { + dial(t, internal.NewVanillaDialer(config)) +} diff --git a/internal/engine/experiment/tlstool/internal/splitter.go b/internal/engine/experiment/tlstool/internal/splitter.go new file mode 100644 index 0000000..795f9a6 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/splitter.go @@ -0,0 +1,67 @@ +package internal + +import ( + "bytes" + "math/rand" + "time" +) + +// SNISplitter splits input such that SNI is splitted across +// a bunch of different output buffers. +func SNISplitter(input []byte, sni []byte) (output [][]byte) { + idx := bytes.Index(input, sni) + if idx < 0 { + output = append(output, input) + return + } + output = append(output, input[:idx]) + // TODO(bassosimone): splitting every three bytes causes + // a bunch of Unicode chatacters (e.g., in Chinese) to be + // sent as part of the same segment. Is that OK? + const segmentsize = 3 + var buf []byte + for _, chr := range input[idx : idx+len(sni)] { + buf = append(buf, chr) + if len(buf) == segmentsize { + output = append(output, buf) + buf = nil + } + } + if len(buf) > 0 { + output = append(output, buf) + buf = nil + } + output = append(output, input[idx+len(sni):]) + return +} + +// Splitter84rest segments the specified buffer into three +// sub-buffers containing respectively 8 bytes, 4 bytes, and +// the rest of the buffer. This segment technique has been +// described by Kevin Bock during the Internet Measurements +// Village 2020: https://youtu.be/ksojSRFLbBM?t=1140. +func Splitter84rest(input []byte) (output [][]byte) { + if len(input) <= 12 { + output = append(output, input) + return + } + output = append(output, input[:8]) + output = append(output, input[8:12]) + output = append(output, input[12:]) + return +} + +// Splitter3264rand splits the specified buffer at a random +// offset between 32 and 64 bytes. This is the methodology used +// by github.com/Jigsaw-Code/outline-go-tun2socks. +func Splitter3264rand(input []byte) (output [][]byte) { + if len(input) <= 64 { + output = append(output, input) + return + } + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + offset := rnd.Intn(32) + 32 + output = append(output, input[:offset]) + output = append(output, input[offset:]) + return +} diff --git a/internal/engine/experiment/tlstool/internal/splitter_test.go b/internal/engine/experiment/tlstool/internal/splitter_test.go new file mode 100644 index 0000000..f564880 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/splitter_test.go @@ -0,0 +1,143 @@ +package internal_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool/internal" + "github.com/ooni/probe-cli/v3/internal/engine/internal/randx" +) + +func TestSplitter84restSmall(t *testing.T) { + input := []byte("1111222") + output := internal.Splitter84rest(input) + if len(output) != 1 { + t.Fatal("invalid output length") + } + if string(output[0]) != "1111222" { + t.Fatal("invalid output[0]") + } +} + +func TestSplitter84restGood(t *testing.T) { + input := []byte("1111222233334") + output := internal.Splitter84rest(input) + if len(output) != 3 { + t.Fatal("invalid output length") + } + if string(output[0]) != "11112222" { + t.Fatal("invalid output[0]") + } + if string(output[1]) != "3333" { + t.Fatal("invalid output[1]") + } + if string(output[2]) != "4" { + t.Fatal("invalid output[2]") + } +} + +func TestSplitter3264randSmall(t *testing.T) { + input := randx.Letters(64) + output := internal.Splitter3264rand([]byte(input)) + if len(output) != 1 { + t.Fatal("invalid output length") + } + if string(output[0]) != input { + t.Fatal("invalid output[0]") + } +} + +func TestSplitter3264Works(t *testing.T) { + input := randx.Letters(65) + output := internal.Splitter3264rand([]byte(input)) + for i := 0; i < 32; i++ { + if len(output) != 2 { + t.Fatal("invalid output length") + } + if len(output[0]) < 32 || len(output[0]) > 64 { + t.Fatal("invalid output[0] length") + } + } +} + +func TestSNISplitterEasyCase(t *testing.T) { + input := []byte("11112222334555foo.barbar.deadbeef.com6777778888") + sni := []byte("barbar.deadbeef.com") + output := internal.SNISplitter(input, sni) + if len(output) != 9 { + t.Fatal("invalid output length") + } + if string(output[0]) != "11112222334555foo." { + t.Fatal("invalid output[0]") + } + if string(output[1]) != "bar" { + t.Fatal("invalid output[1]") + } + if string(output[2]) != "bar" { + t.Fatal("invalid output[2]") + } + if string(output[3]) != ".de" { + t.Fatal("invalid output[3]") + } + if string(output[4]) != "adb" { + t.Fatal("invalid output[4]") + } + if string(output[5]) != "eef" { + t.Fatal("invalid output[5]") + } + if string(output[6]) != ".co" { + t.Fatal("invalid output[6]") + } + if string(output[7]) != "m" { + t.Fatal("invalid output[7]") + } + if string(output[8]) != "6777778888" { + t.Fatal("invalid output[8]") + } +} + +func TestSNISplitterNoMatch(t *testing.T) { + input := []byte("11112222334555foo.barbar.deadbeef.com6777778888") + sni := []byte("www.google.com") + output := internal.SNISplitter(input, sni) + if len(output) != 1 { + t.Fatal("invalid output length") + } + if string(output[0]) != string(input) { + t.Fatal("invalid output[0]") + } +} + +func TestSNISplitterWithUnicode(t *testing.T) { + input := []byte("11112222334555你好世界.com6777778888") + sni := []byte("你好世界.com") + output := internal.SNISplitter(input, sni) + t.Log(string(output[2])) + t.Log(output) + if len(output) != 8 { + t.Fatal("invalid output length") + } + if string(output[0]) != "11112222334555" { + t.Fatal("invalid output[0]") + } + if string(output[1]) != "你" { + t.Fatal("invalid output[1]") + } + if string(output[2]) != "好" { + t.Fatal("invalid output[2]") + } + if string(output[3]) != "世" { + t.Fatal("invalid output[3]") + } + if string(output[4]) != "界" { + t.Fatal("invalid output[4]") + } + if string(output[5]) != ".co" { + t.Fatal("invalid output[5]") + } + if string(output[6]) != "m" { + t.Fatal("invalid output[6]") + } + if string(output[7]) != "6777778888" { + t.Fatal("invalid output[7]") + } +} diff --git a/internal/engine/experiment/tlstool/internal/writer.go b/internal/engine/experiment/tlstool/internal/writer.go new file mode 100644 index 0000000..e778631 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/writer.go @@ -0,0 +1,57 @@ +package internal + +import ( + "net" + "time" +) + +// SleeperWriter is a net.Conn that optionally sleeps for the +// specified delay before posting each write. +type SleeperWriter struct { + net.Conn + Delay time.Duration +} + +func (c SleeperWriter) Write(b []byte) (int, error) { + <-time.After(c.Delay) + return c.Conn.Write(b) +} + +// SplitterWriter is a writer that splits every outgoing buffer +// according to the rules specified by the Splitter. +// +// Caveat +// +// The TLS ClientHello may be retransmitted if the server is +// requesting us to restart the negotiation. Therefore, it is +// not safe to just run the splitting once. Since this code +// is meant to investigate TLS blocking, that's fine. +type SplitterWriter struct { + net.Conn + Splitter func([]byte) [][]byte +} + +// Write implements net.Conn.Write +func (c SplitterWriter) Write(b []byte) (int, error) { + if c.Splitter != nil { + return Writev(c.Conn, c.Splitter(b)) + } + return c.Conn.Write(b) +} + +// Writev writes all the vectors inside datalist using the specified +// conn. Returns either an error or the number of bytes sent. Note +// that this function skips any empty entry in datalist. +func Writev(conn net.Conn, datalist [][]byte) (int, error) { + var total int + for _, data := range datalist { + if len(data) > 0 { + count, err := conn.Write(data) + if err != nil { + return 0, err + } + total += count + } + } + return total, nil +} diff --git a/internal/engine/experiment/tlstool/internal/writer_test.go b/internal/engine/experiment/tlstool/internal/writer_test.go new file mode 100644 index 0000000..4fc7f84 --- /dev/null +++ b/internal/engine/experiment/tlstool/internal/writer_test.go @@ -0,0 +1,164 @@ +package internal_test + +import ( + "errors" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool/internal" +) + +func TestSleeperWriterWorksAsIntended(t *testing.T) { + origconn := &internal.FakeConn{} + const outdata = "deadbeefbadidea" + conn := internal.SleeperWriter{ + Conn: origconn, + Delay: 1 * time.Second, + } + before := time.Now() + count, err := conn.Write([]byte(outdata)) + elapsed := time.Since(before) + if err != nil { + t.Fatal(err) + } + if count != len(outdata) { + t.Fatal("unexpected count") + } + if len(origconn.WriteData) != 1 { + t.Fatal("wrong length of written data queue") + } + if string(origconn.WriteData[0]) != outdata { + t.Fatal("we did not write the right data") + } + if elapsed < 750*time.Millisecond { + t.Fatalf("unexpected elapsed time: %+v", elapsed) + } +} + +func TestSplitterWriterNoSplitSuccess(t *testing.T) { + innerconn := &internal.FakeConn{} + conn := internal.SplitterWriter{Conn: innerconn} + const data = "deadbeef" + count, err := conn.Write([]byte(data)) + if err != nil { + t.Fatal(err) + } + if count != len(data) { + t.Fatal("invalid count") + } + if len(innerconn.WriteData) != 1 { + t.Fatal("invalid data queue") + } + if string(innerconn.WriteData[0]) != data { + t.Fatal("invalid written data") + } +} + +func TestSplitterWriterNoSplitFailure(t *testing.T) { + expected := errors.New("mocked error") + innerconn := &internal.FakeConn{WriteError: expected} + conn := internal.SplitterWriter{Conn: innerconn} + const data = "deadbeef" + count, err := conn.Write([]byte(data)) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if count != 0 { + t.Fatal("invalid count") + } + if len(innerconn.WriteData) != 0 { + t.Fatal("invalid data queue") + } +} + +func TestSplitterWriterSplitSuccess(t *testing.T) { + innerconn := &internal.FakeConn{} + conn := internal.SplitterWriter{ + Conn: innerconn, + Splitter: func(b []byte) [][]byte { + return [][]byte{ + b[:2], b[2:], + } + }, + } + const data = "deadbeef" + count, err := conn.Write([]byte(data)) + if err != nil { + t.Fatal(err) + } + if count != len(data) { + t.Fatal("invalid count") + } + if len(innerconn.WriteData) != 2 { + t.Fatal("invalid data queue") + } + if string(innerconn.WriteData[0]) != "de" { + t.Fatal("invalid written data[0]") + } + if string(innerconn.WriteData[1]) != "adbeef" { + t.Fatal("invalid written data[1]") + } +} + +func TestSplitterWriterSplitFailure(t *testing.T) { + expected := errors.New("mocked error") + innerconn := &internal.FakeConn{WriteError: expected} + conn := internal.SplitterWriter{ + Conn: innerconn, + Splitter: func(b []byte) [][]byte { + return [][]byte{ + b[:2], b[2:], + } + }, + } + const data = "deadbeef" + count, err := conn.Write([]byte(data)) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if count != 0 { + t.Fatal("invalid count") + } + if len(innerconn.WriteData) != 0 { + t.Fatal("invalid data queue") + } +} + +func TestWritevWorksWithAlsoEmptyData(t *testing.T) { + conn := &internal.FakeConn{} + datalist := [][]byte{ + []byte("deadbeef"), + []byte(""), + []byte("dead"), + nil, + []byte("badidea"), + nil, + } + count, err := internal.Writev(conn, datalist) + if err != nil { + t.Fatal(err) + } + if count != 19 { + t.Fatal("invalid number of bytes written") + } +} + +func TestWritevFailsAsIntended(t *testing.T) { + expected := errors.New("mocked error") + conn := &internal.FakeConn{WriteError: expected} + datalist := [][]byte{ + []byte("deadbeef"), + []byte(""), + []byte("dead"), + nil, + []byte("badidea"), + nil, + } + count, err := internal.Writev(conn, datalist) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if count != 0 { + t.Fatal("invalid number of bytes written") + } +} diff --git a/internal/engine/experiment/tlstool/tlstool.go b/internal/engine/experiment/tlstool/tlstool.go new file mode 100644 index 0000000..24d549d --- /dev/null +++ b/internal/engine/experiment/tlstool/tlstool.go @@ -0,0 +1,177 @@ +// Package tlstool contains a TLS tool that we are currently using +// for running quick and dirty experiments. This tool will change +// without notice and may be removed without notice. +// +// Caveats +// +// In particular, this experiment MAY panic when passed incorrect +// input. This is acceptable because this is not production ready code. +package tlstool + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool/internal" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" +) + +const ( + testName = "tlstool" + testVersion = "0.1.0" +) + +// Config contains the experiment configuration. +type Config struct { + Delay int64 `ooni:"Milliseconds to wait between writes"` + SNI string `ooni:"Force using the specified SNI"` +} + +// TestKeys contains the experiment results. +type TestKeys struct { + Experiment map[string]*ExperimentKeys `json:"experiment"` +} + +// ExperimentKeys contains the specific experiment results. +type ExperimentKeys struct { + Failure *string `json:"failure"` +} + +// Measurer performs the measurement. +type Measurer struct { + config Config +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +type method struct { + name string + newDialer func(internal.DialerConfig) internal.Dialer +} + +var allMethods = []method{{ + name: "vanilla", + newDialer: internal.NewVanillaDialer, +}, { + name: "snisplit", + newDialer: internal.NewSNISplitterDialer, +}, { + name: "random", + newDialer: internal.NewRandomSplitterDialer, +}, { + name: "thrice", + newDialer: internal.NewThriceSplitterDialer, +}} + +// Run implements ExperimentMeasurer.Run. +func (m Measurer) Run( + ctx context.Context, + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, +) error { + // TODO(bassosimone): wondering whether this experiment should + // actually be merged with sniblocking instead? + tk := new(TestKeys) + tk.Experiment = make(map[string]*ExperimentKeys) + measurement.TestKeys = tk + address := string(measurement.Input) + for idx, meth := range allMethods { + // TODO(bassosimone): here we actually want to use urlgetter + // if possible and collect standard test keys. + err := m.run(ctx, runConfig{ + address: address, + logger: sess.Logger(), + newDialer: meth.newDialer, + }) + percent := float64(idx) / float64(len(allMethods)) + callbacks.OnProgress(percent, fmt.Sprintf("%s: %+v", meth.name, err)) + tk.Experiment[meth.name] = &ExperimentKeys{ + Failure: archival.NewFailure(err), + } + } + return nil +} + +func (m Measurer) newDialer(logger model.Logger) netx.Dialer { + // TODO(bassosimone): this is a resolver that should hopefully work + // in many places. Maybe allow to configure it? + resolver, err := netx.NewDNSClientWithOverrides(netx.Config{Logger: logger}, + "https://cloudflare.com/dns-query", "dns.cloudflare.com", "", "") + runtimex.PanicOnError(err, "cannot initialize resolver") + return netx.NewDialer(netx.Config{FullResolver: resolver, Logger: logger}) +} + +type runConfig struct { + address string + logger model.Logger + newDialer func(internal.DialerConfig) internal.Dialer +} + +func (m Measurer) run(ctx context.Context, config runConfig) error { + dialer := config.newDialer(internal.DialerConfig{ + Dialer: m.newDialer(config.logger), + Delay: time.Duration(m.config.Delay) * time.Millisecond, + SNI: m.pattern(config.address), + }) + tdialer := netx.NewTLSDialer(netx.Config{ + Dialer: dialer, + Logger: config.logger, + TLSConfig: m.tlsConfig(), + }) + conn, err := tdialer.DialTLSContext(ctx, "tcp", config.address) + if err != nil { + return err + } + conn.Close() + return nil +} + +func (m Measurer) tlsConfig() *tls.Config { + if m.config.SNI != "" { + return &tls.Config{ServerName: m.config.SNI} + } + return nil +} + +func (m Measurer) pattern(address string) string { + if m.config.SNI != "" { + return m.config.SNI + } + addr, _, err := net.SplitHostPort(address) + // TODO(bassosimone): replace this panic with proper error checking. + runtimex.PanicOnError(err, "cannot split address") + return addr +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/tlstool/tlstool_test.go b/internal/engine/experiment/tlstool/tlstool_test.go new file mode 100644 index 0000000..f71d18a --- /dev/null +++ b/internal/engine/experiment/tlstool/tlstool_test.go @@ -0,0 +1,92 @@ +package tlstool_test + +import ( + "context" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestMeasurerExperimentNameVersion(t *testing.T) { + measurer := tlstool.NewExperimentMeasurer(tlstool.Config{}) + if measurer.ExperimentName() != "tlstool" { + t.Fatal("unexpected ExperimentName") + } + if measurer.ExperimentVersion() != "0.1.0" { + t.Fatal("unexpected ExperimentVersion") + } +} + +func TestRunWithExplicitSNI(t *testing.T) { + ctx := context.Background() + measurer := tlstool.NewExperimentMeasurer(tlstool.Config{ + SNI: "dns.google", + }) + measurement := new(model.Measurement) + measurement.Input = "8.8.8.8:853" + err := measurer.Run( + ctx, + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } +} + +func TestRunWithImplicitSNI(t *testing.T) { + ctx := context.Background() + measurer := tlstool.NewExperimentMeasurer(tlstool.Config{}) + measurement := new(model.Measurement) + measurement.Input = "dns.google:853" + err := measurer.Run( + ctx, + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } +} + +func TestRunWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cause failure + measurer := tlstool.NewExperimentMeasurer(tlstool.Config{}) + measurement := new(model.Measurement) + measurement.Input = "dns.google:853" + err := measurer.Run( + ctx, + &mockable.Session{}, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(tlstool.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestSummaryKeysGeneric(t *testing.T) { + measurement := &model.Measurement{TestKeys: &tlstool.TestKeys{}} + m := &tlstool.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(tlstool.SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/tor/tor.go b/internal/engine/experiment/tor/tor.go new file mode 100644 index 0000000..f32e651 --- /dev/null +++ b/internal/engine/experiment/tor/tor.go @@ -0,0 +1,476 @@ +// Package tor contains the tor experiment. +// +// Spec: https://github.com/ooni/spec/blob/master/nettests/ts-023-tor.md +package tor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "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/legacy/netxlogger" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/oonidatamodel" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/oonitemplates" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +const ( + // parallelism is the number of parallel threads we use for this experiment + parallelism = 2 + + // testName is the name of this experiment + testName = "tor" + + // testVersion is the version of this experiment + testVersion = "0.3.0" +) + +// Config contains the experiment config. +type Config struct{} + +// Summary contains a summary of what happened. +type Summary struct { + Failure *string `json:"failure"` +} + +// TargetResults contains the results of measuring a target. +type TargetResults struct { + Agent string `json:"agent"` + Failure *string `json:"failure"` + NetworkEvents oonidatamodel.NetworkEventsList `json:"network_events"` + Queries oonidatamodel.DNSQueriesList `json:"queries"` + Requests oonidatamodel.RequestList `json:"requests"` + Summary map[string]Summary `json:"summary"` + TargetAddress string `json:"target_address"` + TargetName string `json:"target_name,omitempty"` + TargetProtocol string `json:"target_protocol"` + TargetSource string `json:"target_source,omitempty"` + TCPConnect oonidatamodel.TCPConnectList `json:"tcp_connect"` + TLSHandshakes oonidatamodel.TLSHandshakesList `json:"tls_handshakes"` +} + +func registerExtensions(m *model.Measurement) { + oonidatamodel.ExtHTTP.AddTo(m) + oonidatamodel.ExtNetevents.AddTo(m) + oonidatamodel.ExtDNS.AddTo(m) + oonidatamodel.ExtTCPConnect.AddTo(m) + oonidatamodel.ExtTLSHandshake.AddTo(m) +} + +// fillSummary fills the Summary field used by the UI. +func (tr *TargetResults) fillSummary() { + tr.Summary = make(map[string]Summary) + if len(tr.TCPConnect) < 1 { + return + } + tr.Summary[errorx.ConnectOperation] = Summary{ + Failure: tr.TCPConnect[0].Status.Failure, + } + switch tr.TargetProtocol { + case "dir_port": + // The UI currently doesn't care about this protocol + // as long as drawing a table is concerned. + case "obfs4": + // We currently only perform an OBFS4 handshake, hence + // the final Failure is the handshake result + tr.Summary["handshake"] = Summary{ + Failure: tr.Failure, + } + case "or_port_dirauth", "or_port": + if len(tr.TLSHandshakes) < 1 { + return + } + tr.Summary["handshake"] = Summary{ + Failure: tr.TLSHandshakes[0].Failure, + } + } +} + +// TestKeys contains tor test keys. +type TestKeys struct { + DirPortTotal int64 `json:"dir_port_total"` + DirPortAccessible int64 `json:"dir_port_accessible"` + OBFS4Total int64 `json:"obfs4_total"` + OBFS4Accessible int64 `json:"obfs4_accessible"` + ORPortDirauthTotal int64 `json:"or_port_dirauth_total"` + ORPortDirauthAccessible int64 `json:"or_port_dirauth_accessible"` + ORPortTotal int64 `json:"or_port_total"` + ORPortAccessible int64 `json:"or_port_accessible"` + Targets map[string]TargetResults `json:"targets"` +} + +func (tk *TestKeys) fillToplevelKeys() { + for _, value := range tk.Targets { + switch value.TargetProtocol { + case "dir_port": + tk.DirPortTotal++ + if value.Failure == nil { + tk.DirPortAccessible++ + } + case "obfs4": + tk.OBFS4Total++ + if value.Failure == nil { + tk.OBFS4Accessible++ + } + case "or_port_dirauth": + tk.ORPortDirauthTotal++ + if value.Failure == nil { + tk.ORPortDirauthAccessible++ + } + case "or_port": + tk.ORPortTotal++ + if value.Failure == nil { + tk.ORPortAccessible++ + } + } + } +} + +// Measurer performs the measurement. +type Measurer struct { + config Config + fetchTorTargets func(ctx context.Context, clnt model.ExperimentOrchestraClient, cc string) (map[string]model.TorTarget, error) + newOrchestraClient func(ctx context.Context, sess model.ExperimentSession) (model.ExperimentOrchestraClient, error) +} + +// NewMeasurer creates a new Measurer +func NewMeasurer(config Config) *Measurer { + return &Measurer{ + config: config, + fetchTorTargets: func(ctx context.Context, clnt model.ExperimentOrchestraClient, cc string) (map[string]model.TorTarget, error) { + return clnt.FetchTorTargets(ctx, cc) + }, + newOrchestraClient: func(ctx context.Context, sess model.ExperimentSession) (model.ExperimentOrchestraClient, error) { + return sess.NewOrchestraClient(ctx) + }, + } +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements ExperimentMeasurer.Run. +func (m *Measurer) Run( + ctx context.Context, + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, +) error { + targets, err := m.gimmeTargets(ctx, sess) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout( + ctx, 15*time.Second*time.Duration(len(targets)), + ) + defer cancel() + registerExtensions(measurement) + m.measureTargets(ctx, sess, measurement, callbacks, targets) + return nil +} + +func (m *Measurer) gimmeTargets( + ctx context.Context, sess model.ExperimentSession, +) (map[string]model.TorTarget, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + clnt, err := m.newOrchestraClient(ctx, sess) + if err != nil { + return nil, err + } + return m.fetchTorTargets(ctx, clnt, sess.ProbeCC()) +} + +// keytarget contains a key and the related target +type keytarget struct { + key string + target model.TorTarget +} + +// private returns whether a target is private. We consider private +// every target coming from a non-empty data source. +func (kt keytarget) private() bool { + return kt.target.Source != "" +} + +// maybeTargetAddress returns the target address if the target is +// not private, otherwise it returns `"[scrubbed]""`. +func (kt keytarget) maybeTargetAddress() (address string) { + address = "[scrubbed]" + if !kt.private() { + address = kt.target.Address + } + return +} + +func (m *Measurer) measureTargets( + ctx context.Context, + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, + targets map[string]model.TorTarget, +) { + // run measurements in parallel + var waitgroup sync.WaitGroup + rc := newResultsCollector(sess, measurement, callbacks) + waitgroup.Add(len(targets)) + workch := make(chan keytarget) + for i := 0; i < parallelism; i++ { + go func(ch <-chan keytarget, total int) { + for kt := range ch { + rc.measureSingleTarget(ctx, kt, total) + waitgroup.Done() + } + }(workch, len(targets)) + } + for key, target := range targets { + workch <- keytarget{key: key, target: target} + } + close(workch) + waitgroup.Wait() + // fill the measurement entry + testkeys := &TestKeys{Targets: rc.targetresults} + testkeys.fillToplevelKeys() + measurement.TestKeys = testkeys +} + +type resultsCollector struct { + callbacks model.ExperimentCallbacks + completed *atomicx.Int64 + flexibleConnect func(context.Context, keytarget) (oonitemplates.Results, error) + measurement *model.Measurement + mu sync.Mutex + sess model.ExperimentSession + targetresults map[string]TargetResults +} + +func newResultsCollector( + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, +) *resultsCollector { + rc := &resultsCollector{ + callbacks: callbacks, + completed: atomicx.NewInt64(), + measurement: measurement, + sess: sess, + targetresults: make(map[string]TargetResults), + } + rc.flexibleConnect = rc.defaultFlexibleConnect + return rc +} + +func maybeSanitize(input TargetResults, kt keytarget) TargetResults { + if !kt.private() { + return input + } + data, err := json.Marshal(input) + runtimex.PanicOnError(err, "json.Marshal should not fail here") + // Implementation note: here we are using a strict scrubbing policy where + // we remove all IP _endpoints_, mainly for convenience, because we already + // have a well tested implementation that does that. + data = []byte(errorx.Scrub(string(data))) + var out TargetResults + err = json.Unmarshal(data, &out) + runtimex.PanicOnError(err, "json.Unmarshal should not fail here") + return out +} + +func (rc *resultsCollector) measureSingleTarget( + ctx context.Context, kt keytarget, total int, +) { + tk, err := rc.flexibleConnect(ctx, kt) + tr := TargetResults{ + Agent: "redirect", + Failure: setFailure(err), + NetworkEvents: oonidatamodel.NewNetworkEventsList(tk), + Queries: oonidatamodel.NewDNSQueriesList(tk), + Requests: oonidatamodel.NewRequestList(tk), + TCPConnect: oonidatamodel.NewTCPConnectList(tk), + TLSHandshakes: oonidatamodel.NewTLSHandshakesList(tk), + } + tr.fillSummary() + tr = maybeSanitize(tr, kt) + rc.mu.Lock() + tr.TargetAddress = kt.maybeTargetAddress() + tr.TargetName = kt.target.Name + tr.TargetProtocol = kt.target.Protocol + tr.TargetSource = kt.target.Source + rc.targetresults[kt.key] = tr + rc.mu.Unlock() + sofar := rc.completed.Add(1) + percentage := 0.0 + if total > 0 { + percentage = float64(sofar) / float64(total) + } + rc.callbacks.OnProgress(percentage, fmt.Sprintf( + "tor: access %s/%s: %s", kt.maybeTargetAddress(), kt.target.Protocol, + errString(err), + )) +} + +// scrubbingLogger is a logger that scrubs endpoints from its output. We are using +// it only here, currently, since we pay some performance penalty in that we evaluate +// the string to be logged regardless of the logging level. +// +// TODO(bassosimone): find a more efficient way of scrubbing logs. +type scrubbingLogger struct { + model.Logger +} + +func (sl scrubbingLogger) Debug(message string) { + sl.Logger.Debug(errorx.Scrub(message)) +} + +func (sl scrubbingLogger) Debugf(format string, v ...interface{}) { + sl.Debug(fmt.Sprintf(format, v...)) +} + +func (sl scrubbingLogger) Info(message string) { + sl.Logger.Info(errorx.Scrub(message)) +} + +func (sl scrubbingLogger) Infof(format string, v ...interface{}) { + sl.Info(fmt.Sprintf(format, v...)) +} + +func (sl scrubbingLogger) Warn(message string) { + sl.Logger.Warn(errorx.Scrub(message)) +} + +func (sl scrubbingLogger) Warnf(format string, v ...interface{}) { + sl.Warn(fmt.Sprintf(format, v...)) +} + +func maybeScrubbingLogger(input model.Logger, kt keytarget) model.Logger { + if !kt.private() { + return input + } + return scrubbingLogger{Logger: input} +} + +func (rc *resultsCollector) defaultFlexibleConnect( + ctx context.Context, kt keytarget, +) (tk oonitemplates.Results, err error) { + logger := maybeScrubbingLogger(rc.sess.Logger(), kt) + switch kt.target.Protocol { + case "dir_port": + url := url.URL{ + Host: kt.target.Address, + Path: "/tor/status-vote/current/consensus.z", + Scheme: "http", + } + const snapshotsize = 1 << 8 // no need to include all in report + r := oonitemplates.HTTPDo(ctx, oonitemplates.HTTPDoConfig{ + Accept: httpheader.Accept(), + AcceptLanguage: httpheader.AcceptLanguage(), + Beginning: rc.measurement.MeasurementStartTimeSaved, + MaxEventsBodySnapSize: snapshotsize, + MaxResponseBodySnapSize: snapshotsize, + Handler: netxlogger.NewHandler(logger), + Method: "GET", + URL: url.String(), + UserAgent: httpheader.UserAgent(), + }) + tk, err = r.TestKeys, r.Error + case "or_port", "or_port_dirauth": + r := oonitemplates.TLSConnect(ctx, oonitemplates.TLSConnectConfig{ + Address: kt.target.Address, + Beginning: rc.measurement.MeasurementStartTimeSaved, + InsecureSkipVerify: true, + Handler: netxlogger.NewHandler(logger), + }) + tk, err = r.TestKeys, r.Error + case "obfs4": + r := oonitemplates.OBFS4Connect(ctx, oonitemplates.OBFS4ConnectConfig{ + Address: kt.target.Address, + Beginning: rc.measurement.MeasurementStartTimeSaved, + Handler: netxlogger.NewHandler(logger), + Params: kt.target.Params, + StateBaseDir: rc.sess.TempDir(), + }) + tk, err = r.TestKeys, r.Error + default: + r := oonitemplates.TCPConnect(ctx, oonitemplates.TCPConnectConfig{ + Address: kt.target.Address, + Beginning: rc.measurement.MeasurementStartTimeSaved, + Handler: netxlogger.NewHandler(logger), + }) + tk, err = r.TestKeys, r.Error + } + return +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return NewMeasurer(config) +} + +func errString(err error) (s string) { + s = "success" + if err != nil { + s = err.Error() + } + return +} + +func setFailure(err error) (s *string) { + if err != nil { + descr := err.Error() + s = &descr + } + return +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + DirPortTotal int64 `json:"dir_port_total"` + DirPortAccessible int64 `json:"dir_port_accessible"` + OBFS4Total int64 `json:"obfs4_total"` + OBFS4Accessible int64 `json:"obfs4_accessible"` + ORPortDirauthTotal int64 `json:"or_port_dirauth_total"` + ORPortDirauthAccessible int64 `json:"or_port_dirauth_accessible"` + ORPortTotal int64 `json:"or_port_total"` + ORPortAccessible int64 `json:"or_port_accessible"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + sk.DirPortTotal = tk.DirPortTotal + sk.DirPortAccessible = tk.DirPortAccessible + sk.OBFS4Total = tk.OBFS4Total + sk.OBFS4Accessible = tk.OBFS4Accessible + sk.ORPortDirauthTotal = tk.ORPortDirauthTotal + sk.ORPortDirauthAccessible = tk.ORPortDirauthAccessible + sk.ORPortTotal = tk.ORPortTotal + sk.ORPortAccessible = tk.ORPortAccessible + sk.IsAnomaly = ((sk.DirPortAccessible <= 0 && sk.DirPortTotal > 0) || + (sk.OBFS4Accessible <= 0 && sk.OBFS4Total > 0) || + (sk.ORPortDirauthAccessible <= 0 && sk.ORPortDirauthTotal > 0) || + (sk.ORPortAccessible <= 0 && sk.ORPortTotal > 0)) + return sk, nil +} diff --git a/internal/engine/experiment/tor/tor_test.go b/internal/engine/experiment/tor/tor_test.go new file mode 100644 index 0000000..2b6791c --- /dev/null +++ b/internal/engine/experiment/tor/tor_test.go @@ -0,0 +1,931 @@ +package tor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/oonidatamodel" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/oonitemplates" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "tor" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.3.0" { + t.Fatal("unexpected version") + } +} + +func TestMeasurerMeasureNewOrchestraClientError(t *testing.T) { + measurer := NewMeasurer(Config{}) + expected := errors.New("mocked error") + measurer.newOrchestraClient = func(ctx context.Context, sess model.ExperimentSession) (model.ExperimentOrchestraClient, error) { + return nil, expected + } + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestMeasurerMeasureFetchTorTargetsError(t *testing.T) { + measurer := NewMeasurer(Config{}) + expected := errors.New("mocked error") + measurer.newOrchestraClient = func(ctx context.Context, sess model.ExperimentSession) (model.ExperimentOrchestraClient, error) { + return new(probeservices.Client), nil + } + measurer.fetchTorTargets = func(ctx context.Context, clnt model.ExperimentOrchestraClient, cc string) (map[string]model.TorTarget, error) { + return nil, expected + } + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestMeasurerMeasureFetchTorTargetsEmptyList(t *testing.T) { + measurer := NewMeasurer(Config{}) + measurer.newOrchestraClient = func(ctx context.Context, sess model.ExperimentSession) (model.ExperimentOrchestraClient, error) { + return new(probeservices.Client), nil + } + measurer.fetchTorTargets = func(ctx context.Context, clnt model.ExperimentOrchestraClient, cc string) (map[string]model.TorTarget, error) { + return nil, nil + } + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableLogger: log.Log, + }, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*TestKeys) + if len(tk.Targets) != 0 { + t.Fatal("expected no targets here") + } +} + +func TestMeasurerMeasureGoodWithMockedOrchestra(t *testing.T) { + // This test mocks orchestra to return a nil list of targets, so the code runs + // but we don't perform any actualy network actions. + measurer := NewMeasurer(Config{}) + measurer.newOrchestraClient = func(ctx context.Context, sess model.ExperimentSession) (model.ExperimentOrchestraClient, error) { + return new(probeservices.Client), nil + } + measurer.fetchTorTargets = func(ctx context.Context, clnt model.ExperimentOrchestraClient, cc string) (map[string]model.TorTarget, error) { + return nil, nil + } + err := measurer.Run( + context.Background(), + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } +} + +func TestMeasurerMeasureGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := NewMeasurer(Config{}) + sess := newsession() + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + sess, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +var staticPrivateTestingTargetEndpoint = "192.95.36.142:443" + +var staticPrivateTestingTarget = model.TorTarget{ + Address: staticPrivateTestingTargetEndpoint, + Params: map[string][]string{ + "cert": { + "qUVQ0srL1JI/vO6V6m/24anYXiJD3QP2HgzUKQtQ7GRqqUvs7P+tG43RtAqdhLOALP7DJQ", + }, + "iat-mode": {"1"}, + }, + Protocol: "obfs4", + Source: "bridgedb", +} + +func TestMeasurerMeasureSanitiseOutput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := NewMeasurer(Config{}) + sess := newsession() + key := "xyz-xyz-xyz-theCh2ju-ahG4chei-Ai2eka0a" + sess.MockableOrchestraClient = &mockable.ExperimentOrchestraClient{ + MockableFetchTorTargetsResult: map[string]model.TorTarget{ + key: staticPrivateTestingTarget, + }, + } + measurement := new(model.Measurement) + err := measurer.Run( + context.Background(), + sess, + measurement, + model.NewPrinterCallbacks(log.Log), + ) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(measurement) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*TestKeys) + entry := tk.Targets[key] + if entry.Failure != nil { + t.Fatal("measurement failed unexpectedly") + } + if !bytes.Contains(data, []byte(key)) { + t.Fatal("cannot find expected key") + } + if bytes.Contains(data, []byte(staticPrivateTestingTargetEndpoint)) { + t.Fatal("endpoint found in serialized measurement") + } + if !bytes.Contains(data, []byte("[scrubbed]")) { + t.Fatal("[scrubbed] not found in serialized measurement") + } +} + +var staticTestingTargets = []model.TorTarget{ + { + Address: "192.95.36.142:443", + Params: map[string][]string{ + "cert": { + "qUVQ0srL1JI/vO6V6m/24anYXiJD3QP2HgzUKQtQ7GRqqUvs7P+tG43RtAqdhLOALP7DJQ", + }, + "iat-mode": {"1"}, + }, + Protocol: "obfs4", + }, + { + Address: "66.111.2.131:9030", + Protocol: "dir_port", + }, + { + Address: "66.111.2.131:9001", + Protocol: "or_port", + }, + { + Address: "1.1.1.1:80", + Protocol: "tcp", + }, +} + +func TestMeasurerMeasureTargetsNoInput(t *testing.T) { + var measurement model.Measurement + measurer := new(Measurer) + measurer.measureTargets( + context.Background(), + &mockable.Session{ + MockableLogger: log.Log, + }, + &measurement, + model.NewPrinterCallbacks(log.Log), + nil, + ) + if len(measurement.TestKeys.(*TestKeys).Targets) != 0 { + t.Fatal("expected no measurements here") + } +} + +func TestMeasurerMeasureTargetsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // so we don't actually do anything + var measurement model.Measurement + measurer := new(Measurer) + measurer.measureTargets( + ctx, + &mockable.Session{ + MockableLogger: log.Log, + }, + &measurement, + model.NewPrinterCallbacks(log.Log), + map[string]model.TorTarget{ + "xx": staticTestingTargets[0], + }, + ) + targets := measurement.TestKeys.(*TestKeys).Targets + if len(targets) != 1 { + t.Fatal("expected single measurements here") + } + if _, found := targets["xx"]; !found { + t.Fatal("the target we expected is missing") + } + tgt := targets["xx"] + if *tgt.Failure != "interrupted" { + t.Fatal("not the error we expected") + } +} + +func wrapTestingTarget(tt model.TorTarget) keytarget { + return keytarget{ + key: "xx", // using an super simple key; should work anyway + target: tt, + } +} + +func TestResultsCollectorMeasureSingleTargetGood(t *testing.T) { + rc := newResultsCollector( + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + rc.flexibleConnect = func(context.Context, keytarget) (oonitemplates.Results, error) { + return oonitemplates.Results{}, nil + } + rc.measureSingleTarget( + context.Background(), wrapTestingTarget(staticTestingTargets[0]), + len(staticTestingTargets), + ) + if len(rc.targetresults) != 1 { + t.Fatal("wrong number of entries") + } + // Implementation note: here we won't bother with checking that + // oonidatamodel works correctly because we already test that. + if rc.targetresults["xx"].Agent != "redirect" { + t.Fatal("agent is invalid") + } + if rc.targetresults["xx"].Failure != nil { + t.Fatal("failure is invalid") + } + if rc.targetresults["xx"].TargetAddress != staticTestingTargets[0].Address { + t.Fatal("target address is invalid") + } + if rc.targetresults["xx"].TargetProtocol != staticTestingTargets[0].Protocol { + t.Fatal("target protocol is invalid") + } +} + +func TestResultsCollectorMeasureSingleTargetWithFailure(t *testing.T) { + rc := newResultsCollector( + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + rc.flexibleConnect = func(context.Context, keytarget) (oonitemplates.Results, error) { + return oonitemplates.Results{}, errors.New("mocked error") + } + rc.measureSingleTarget( + context.Background(), keytarget{ + key: "xx", // using an super simple key; should work anyway + target: staticTestingTargets[0], + }, + len(staticTestingTargets), + ) + if len(rc.targetresults) != 1 { + t.Fatal("wrong number of entries") + } + // Implementation note: here we won't bother with checking that + // oonidatamodel works correctly because we already test that. + if rc.targetresults["xx"].Agent != "redirect" { + t.Fatal("agent is invalid") + } + if *rc.targetresults["xx"].Failure != "mocked error" { + t.Fatal("failure is invalid") + } + if rc.targetresults["xx"].TargetAddress != staticTestingTargets[0].Address { + t.Fatal("target address is invalid") + } + if rc.targetresults["xx"].TargetProtocol != staticTestingTargets[0].Protocol { + t.Fatal("target protocol is invalid") + } +} + +func TestDefautFlexibleConnectDirPort(t *testing.T) { + rc := newResultsCollector( + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tk, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[1])) + if err == nil { + t.Fatal("expected an error here") + } + if !strings.HasSuffix(err.Error(), "interrupted") { + t.Fatal("not the error we expected") + } + if tk.HTTPRequests == nil { + t.Fatal("expected HTTP data here") + } +} + +func TestDefautFlexibleConnectOrPort(t *testing.T) { + rc := newResultsCollector( + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tk, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[2])) + if err == nil { + t.Fatal("expected an error here") + } + if err.Error() != "interrupted" { + t.Fatal("not the error we expected") + } + if tk.Connects == nil { + t.Fatal("expected connects data here") + } + if tk.NetworkEvents == nil { + t.Fatal("expected network events data here") + } +} + +func TestDefautFlexibleConnectOBFS4(t *testing.T) { + rc := newResultsCollector( + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tk, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[0])) + if err == nil { + t.Fatal("expected an error here") + } + if err.Error() != "interrupted" { + t.Fatal("not the error we expected") + } + if tk.Connects == nil { + t.Fatal("expected connects data here") + } + if tk.NetworkEvents == nil { + t.Fatal("expected network events data here") + } +} + +func TestDefautFlexibleConnectDefault(t *testing.T) { + rc := newResultsCollector( + &mockable.Session{ + MockableLogger: log.Log, + }, + new(model.Measurement), + model.NewPrinterCallbacks(log.Log), + ) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tk, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[3])) + if err == nil { + t.Fatal("expected an error here") + } + if err.Error() != "interrupted" { + t.Fatalf("not the error we expected: %+v", err) + } + if tk.Connects == nil { + t.Fatalf("expected connects data here, found: %+v", tk.Connects) + } +} + +func TestErrString(t *testing.T) { + if errString(nil) != "success" { + t.Fatal("not working with nil") + } + if errString(errors.New("antani")) != "antani" { + t.Fatal("not working with error") + } +} + +func TestSummary(t *testing.T) { + t.Run("without any piece of data", func(t *testing.T) { + tr := new(TargetResults) + tr.fillSummary() + if len(tr.Summary) != 0 { + t.Fatal("summary must be empty") + } + }) + + t.Run("with a TCP connect and nothing else", func(t *testing.T) { + tr := new(TargetResults) + failure := "mocked_error" + tr.TCPConnect = append(tr.TCPConnect, oonidatamodel.TCPConnectEntry{ + Status: oonidatamodel.TCPConnectStatus{ + Success: true, + Failure: &failure, + }, + }) + tr.fillSummary() + if len(tr.Summary) != 1 { + t.Fatal("cannot find expected entry") + } + if *tr.Summary[errorx.ConnectOperation].Failure != failure { + t.Fatal("invalid failure") + } + }) + + t.Run("for OBFS4", func(t *testing.T) { + tr := new(TargetResults) + tr.TCPConnect = append(tr.TCPConnect, oonidatamodel.TCPConnectEntry{ + Status: oonidatamodel.TCPConnectStatus{ + Success: true, + }, + }) + failure := "mocked_error" + tr.TargetProtocol = "obfs4" + tr.Failure = &failure + tr.fillSummary() + if len(tr.Summary) != 2 { + t.Fatal("cannot find expected entry") + } + if tr.Summary[errorx.ConnectOperation].Failure != nil { + t.Fatal("invalid failure") + } + if *tr.Summary["handshake"].Failure != failure { + t.Fatal("invalid failure") + } + }) + + t.Run("for or_port/or_port_dirauth", func(t *testing.T) { + doit := func(targetProtocol string, handshake *oonidatamodel.TLSHandshake) { + tr := new(TargetResults) + tr.TCPConnect = append(tr.TCPConnect, oonidatamodel.TCPConnectEntry{ + Status: oonidatamodel.TCPConnectStatus{ + Success: true, + }, + }) + tr.TargetProtocol = targetProtocol + if handshake != nil { + tr.TLSHandshakes = append(tr.TLSHandshakes, *handshake) + } + tr.fillSummary() + if len(tr.Summary) < 1 { + t.Fatal("cannot find expected entry") + } + if tr.Summary[errorx.ConnectOperation].Failure != nil { + t.Fatal("invalid failure") + } + if handshake == nil { + if len(tr.Summary) != 1 { + t.Fatal("unexpected summary length") + } + return + } + if len(tr.Summary) != 2 { + t.Fatal("unexpected summary length") + } + if tr.Summary["handshake"].Failure != handshake.Failure { + t.Fatal("the failure value is unexpected") + } + } + doit("or_port_dirauth", nil) + doit("or_port", nil) + doit("or_port", &oonidatamodel.TLSHandshake{ + Failure: (func() *string { + s := io.EOF.Error() + return &s + })(), + }) + }) +} + +func TestFillToplevelKeys(t *testing.T) { + var tr TargetResults + tr.TargetProtocol = "or_port" + tk := new(TestKeys) + tk.Targets = make(map[string]TargetResults) + tk.Targets["xxx"] = tr + tk.fillToplevelKeys() + if tk.ORPortTotal != 1 { + t.Fatal("unexpected ORPortTotal value") + } +} + +func newsession() *mockable.Session { + return &mockable.Session{ + MockableLogger: log.Log, + MockableHTTPClient: http.DefaultClient, + } +} + +var referenceTargetResult = []byte(`{ + "agent": "redirect", + "failure": null, + "network_events": [ + { + "address": "85.31.186.98:443", + "conn_id": 19, + "dial_id": 21, + "failure": null, + "operation": "connect", + "proto": "tcp", + "t": 8.639313 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1915, + "operation": "write", + "proto": "tcp", + "t": 8.639686 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1440, + "operation": "read", + "proto": "tcp", + "t": 8.691708 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1440, + "operation": "read", + "proto": "tcp", + "t": 8.691912 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1383, + "operation": "read", + "proto": "tcp", + "t": 8.69234 + } + ], + "queries": null, + "requests": null, + "summary": { + "connect": { + "failure": null + } + }, + "target_address": "85.31.186.98:443", + "target_protocol": "obfs4", + "tcp_connect": [ + { + "conn_id": 19, + "dial_id": 21, + "ip": "85.31.186.98", + "port": 443, + "status": { + "failure": null, + "success": true + }, + "t": 8.639313 + } + ], + "tls_handshakes": null + }`) + +var scrubbedTargetResult = []byte(`{ + "agent": "redirect", + "failure": null, + "network_events": [ + { + "address": "[scrubbed]", + "conn_id": 19, + "dial_id": 21, + "failure": null, + "operation": "connect", + "proto": "tcp", + "t": 8.639313 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1915, + "operation": "write", + "proto": "tcp", + "t": 8.639686 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1440, + "operation": "read", + "proto": "tcp", + "t": 8.691708 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1440, + "operation": "read", + "proto": "tcp", + "t": 8.691912 + }, + { + "conn_id": 19, + "failure": null, + "num_bytes": 1383, + "operation": "read", + "proto": "tcp", + "t": 8.69234 + } + ], + "queries": null, + "requests": null, + "summary": { + "connect": { + "failure": null + } + }, + "target_address": "[scrubbed]", + "target_protocol": "obfs4", + "tcp_connect": [ + { + "conn_id": 19, + "dial_id": 21, + "ip": "[scrubbed]", + "port": 443, + "status": { + "failure": null, + "success": true + }, + "t": 8.639313 + } + ], + "tls_handshakes": null + }`) + +func TestMaybeSanitize(t *testing.T) { + var input TargetResults + if err := json.Unmarshal(referenceTargetResult, &input); err != nil { + t.Fatal(err) + } + t.Run("nothing to do", func(t *testing.T) { + out := maybeSanitize(input, keytarget{target: model.TorTarget{Source: ""}}) + diff := cmp.Diff(input, out) + if diff != "" { + t.Fatal(diff) + } + }) + t.Run("scrubbing to do", func(t *testing.T) { + var expected TargetResults + if err := json.Unmarshal(scrubbedTargetResult, &expected); err != nil { + t.Fatal(err) + } + out := maybeSanitize(input, keytarget{target: model.TorTarget{ + Address: "85.31.186.98:443", + Source: "bridgedb", + }}) + diff := cmp.Diff(expected, out) + if diff != "" { + t.Fatal(diff) + } + }) +} + +type savingLogger struct { + debug []string + info []string + warn []string +} + +func (sl *savingLogger) Debug(message string) { + sl.debug = append(sl.debug, message) +} + +func (sl *savingLogger) Debugf(format string, v ...interface{}) { + sl.Debug(fmt.Sprintf(format, v...)) +} + +func (sl *savingLogger) Info(message string) { + sl.info = append(sl.info, message) +} + +func (sl *savingLogger) Infof(format string, v ...interface{}) { + sl.Info(fmt.Sprintf(format, v...)) +} + +func (sl *savingLogger) Warn(message string) { + sl.warn = append(sl.warn, message) +} + +func (sl *savingLogger) Warnf(format string, v ...interface{}) { + sl.Warn(fmt.Sprintf(format, v...)) +} + +func TestScrubLogger(t *testing.T) { + input := "failure: 130.192.91.211:443: no route the host" + expect := "failure: [scrubbed]: no route the host" + + t.Run("for debug", func(t *testing.T) { + logger := new(savingLogger) + scrubber := scrubbingLogger{Logger: logger} + scrubber.Debug(input) + if len(logger.debug) != 1 && len(logger.info) != 0 && len(logger.warn) != 0 { + t.Fatal("unexpected number of log lines written") + } + if logger.debug[0] != expect { + t.Fatal("unexpected output written") + } + }) + + t.Run("for debugf", func(t *testing.T) { + logger := new(savingLogger) + scrubber := scrubbingLogger{Logger: logger} + scrubber.Debugf("%s", input) + if len(logger.debug) != 1 && len(logger.info) != 0 && len(logger.warn) != 0 { + t.Fatal("unexpected number of log lines written") + } + if logger.debug[0] != expect { + t.Fatal("unexpected output written") + } + }) + + t.Run("for info", func(t *testing.T) { + logger := new(savingLogger) + scrubber := scrubbingLogger{Logger: logger} + scrubber.Info(input) + if len(logger.debug) != 0 && len(logger.info) != 1 && len(logger.warn) != 0 { + t.Fatal("unexpected number of log lines written") + } + if logger.info[0] != expect { + t.Fatal("unexpected output written") + } + }) + + t.Run("for infof", func(t *testing.T) { + logger := new(savingLogger) + scrubber := scrubbingLogger{Logger: logger} + scrubber.Infof("%s", input) + if len(logger.debug) != 0 && len(logger.info) != 1 && len(logger.warn) != 0 { + t.Fatal("unexpected number of log lines written") + } + if logger.info[0] != expect { + t.Fatal("unexpected output written") + } + }) + + t.Run("for warn", func(t *testing.T) { + logger := new(savingLogger) + scrubber := scrubbingLogger{Logger: logger} + scrubber.Warn(input) + if len(logger.debug) != 0 && len(logger.info) != 0 && len(logger.warn) != 1 { + t.Fatal("unexpected number of log lines written") + } + if logger.warn[0] != expect { + t.Fatal("unexpected output written") + } + }) + + t.Run("for warnf", func(t *testing.T) { + logger := new(savingLogger) + scrubber := scrubbingLogger{Logger: logger} + scrubber.Warnf("%s", input) + if len(logger.debug) != 0 && len(logger.info) != 0 && len(logger.warn) != 1 { + t.Fatal("unexpected number of log lines written") + } + if logger.warn[0] != expect { + t.Fatal("unexpected output written") + } + }) +} + +func TestMaybeScrubbingLogger(t *testing.T) { + var input model.Logger = new(savingLogger) + + t.Run("for when we don't need to save", func(t *testing.T) { + kt := keytarget{target: model.TorTarget{ + Source: "", + }} + out := maybeScrubbingLogger(input, kt) + if out != input { + t.Fatal("not the output we expected") + } + if _, ok := out.(*savingLogger); !ok { + t.Fatal("not the output type we expected") + } + }) + + t.Run("for when we need to save", func(t *testing.T) { + kt := keytarget{target: model.TorTarget{ + Source: "bridgedb", + }} + out := maybeScrubbingLogger(input, kt) + if out == input { + t.Fatal("not the output value we expected") + } + if _, ok := out.(scrubbingLogger); !ok { + t.Fatal("not the output type we expected") + } + }) +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysWorksAsIntended(t *testing.T) { + tests := []struct { + tk TestKeys + isAnomaly bool + }{{ + tk: TestKeys{}, + isAnomaly: false, + }, { + tk: TestKeys{DirPortAccessible: 1, DirPortTotal: 3}, + isAnomaly: false, + }, { + tk: TestKeys{DirPortAccessible: 0, DirPortTotal: 3}, + isAnomaly: true, + }, { + tk: TestKeys{OBFS4Accessible: 1, OBFS4Total: 3}, + isAnomaly: false, + }, { + tk: TestKeys{OBFS4Accessible: 0, OBFS4Total: 3}, + isAnomaly: true, + }, { + tk: TestKeys{ORPortDirauthAccessible: 1, ORPortDirauthTotal: 3}, + isAnomaly: false, + }, { + tk: TestKeys{ORPortDirauthAccessible: 0, ORPortDirauthTotal: 3}, + isAnomaly: true, + }, { + tk: TestKeys{ORPortAccessible: 1, ORPortTotal: 3}, + isAnomaly: false, + }, { + tk: TestKeys{ORPortAccessible: 0, ORPortTotal: 3}, + isAnomaly: true, + }} + for idx, tt := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + m := &Measurer{} + measurement := &model.Measurement{TestKeys: &tt.tk} + got, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + return + } + sk := got.(SummaryKeys) + if sk.IsAnomaly != tt.isAnomaly { + t.Fatal("unexpected isAnomaly value") + } + }) + } +} diff --git a/internal/engine/experiment/urlgetter/.gitignore b/internal/engine/experiment/urlgetter/.gitignore new file mode 100644 index 0000000..bc8f44c --- /dev/null +++ b/internal/engine/experiment/urlgetter/.gitignore @@ -0,0 +1 @@ +/urlgetter-tunnel diff --git a/internal/engine/experiment/urlgetter/configurer.go b/internal/engine/experiment/urlgetter/configurer.go new file mode 100644 index 0000000..c92d90b --- /dev/null +++ b/internal/engine/experiment/urlgetter/configurer.go @@ -0,0 +1,102 @@ +package urlgetter + +import ( + "crypto/tls" + "errors" + "net" + "net/url" + "regexp" + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// The Configurer job is to construct a Configuration that can +// later be used by the measurer to perform measurements. +type Configurer struct { + Config Config + Logger model.Logger + ProxyURL *url.URL + Saver *trace.Saver +} + +// The Configuration is the configuration for running a measurement. +type Configuration struct { + HTTPConfig netx.Config + DNSClient netx.DNSClient +} + +// CloseIdleConnections will close idle connections, if needed. +func (c Configuration) CloseIdleConnections() { + c.DNSClient.CloseIdleConnections() +} + +// NewConfiguration builds a new measurement configuration. +func (c Configurer) NewConfiguration() (Configuration, error) { + // set up defaults + configuration := Configuration{ + HTTPConfig: netx.Config{ + BogonIsError: c.Config.RejectDNSBogons, + CacheResolutions: true, + CertPool: c.Config.CertPool, + ContextByteCounting: true, + DialSaver: c.Saver, + HTTP3Enabled: c.Config.HTTP3Enabled, + HTTPSaver: c.Saver, + Logger: c.Logger, + ReadWriteSaver: c.Saver, + ResolveSaver: c.Saver, + TLSSaver: c.Saver, + }, + } + // fill DNS cache + if c.Config.DNSCache != "" { + entry := strings.Split(c.Config.DNSCache, " ") + if len(entry) < 2 { + return configuration, errors.New("invalid DNSCache string") + } + domainregex := regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) + if !domainregex.MatchString(entry[0]) { + return configuration, errors.New("invalid domain in DNSCache") + } + var addresses []string + for i := 1; i < len(entry); i++ { + if net.ParseIP(entry[i]) == nil { + return configuration, errors.New("invalid IP in DNSCache") + } + addresses = append(addresses, entry[i]) + } + configuration.HTTPConfig.DNSCache = map[string][]string{ + entry[0]: addresses, + } + } + dnsclient, err := netx.NewDNSClientWithOverrides( + configuration.HTTPConfig, c.Config.ResolverURL, + c.Config.DNSHTTPHost, c.Config.DNSTLSServerName, + c.Config.DNSTLSVersion, + ) + if err != nil { + return configuration, err + } + configuration.DNSClient = dnsclient + configuration.HTTPConfig.BaseResolver = dnsclient.Resolver + // configure TLS + configuration.HTTPConfig.TLSConfig = &tls.Config{ + NextProtos: []string{"h2", "http/1.1"}, + } + if c.Config.TLSServerName != "" { + configuration.HTTPConfig.TLSConfig.ServerName = c.Config.TLSServerName + } + err = netx.ConfigureTLSVersion( + configuration.HTTPConfig.TLSConfig, c.Config.TLSVersion, + ) + if err != nil { + return configuration, err + } + configuration.HTTPConfig.NoTLSVerify = c.Config.NoTLSVerify + // configure proxy + configuration.HTTPConfig.ProxyURL = c.ProxyURL + return configuration, nil +} diff --git a/internal/engine/experiment/urlgetter/configurer_test.go b/internal/engine/experiment/urlgetter/configurer_test.go new file mode 100644 index 0000000..28490f8 --- /dev/null +++ b/internal/engine/experiment/urlgetter/configurer_test.go @@ -0,0 +1,734 @@ +package urlgetter_test + +import ( + "crypto/tls" + "errors" + "net/url" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestConfigurerNewConfigurationVanilla(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + defer configuration.CloseIdleConnections() + if configuration.HTTPConfig.BogonIsError != false { + t.Fatal("not the BogonIsError we expected") + } + if configuration.HTTPConfig.CacheResolutions != true { + t.Fatal("not the CacheResolutions we expected") + } + if configuration.HTTPConfig.ContextByteCounting != true { + t.Fatal("not the ContextByteCounting we expected") + } + if configuration.HTTPConfig.DialSaver != saver { + t.Fatal("not the DialSaver we expected") + } + if configuration.HTTPConfig.HTTPSaver != saver { + t.Fatal("not the HTTPSaver we expected") + } + if configuration.HTTPConfig.Logger != log.Log { + t.Fatal("not the Logger we expected") + } + if configuration.HTTPConfig.ReadWriteSaver != saver { + t.Fatal("not the ReadWriteSaver we expected") + } + if configuration.HTTPConfig.ResolveSaver != saver { + t.Fatal("not the ResolveSaver we expected") + } + if configuration.HTTPConfig.TLSSaver != saver { + t.Fatal("not the TLSSaver we expected") + } + if configuration.HTTPConfig.BaseResolver == nil { + t.Fatal("not the BaseResolver we expected") + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.NoTLSVerify == true { + t.Fatal("not the NoTLSVerify we expected") + } + if configuration.HTTPConfig.ProxyURL != nil { + t.Fatal("not the ProxyURL we expected") + } +} + +func TestConfigurerNewConfigurationResolverDNSOverHTTPSPowerdns(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + ResolverURL: "doh://google", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + defer configuration.CloseIdleConnections() + if configuration.HTTPConfig.BogonIsError != false { + t.Fatal("not the BogonIsError we expected") + } + if configuration.HTTPConfig.CacheResolutions != true { + t.Fatal("not the CacheResolutions we expected") + } + if configuration.HTTPConfig.ContextByteCounting != true { + t.Fatal("not the ContextByteCounting we expected") + } + if configuration.HTTPConfig.DialSaver != saver { + t.Fatal("not the DialSaver we expected") + } + if configuration.HTTPConfig.HTTPSaver != saver { + t.Fatal("not the HTTPSaver we expected") + } + if configuration.HTTPConfig.Logger != log.Log { + t.Fatal("not the Logger we expected") + } + if configuration.HTTPConfig.ReadWriteSaver != saver { + t.Fatal("not the ReadWriteSaver we expected") + } + if configuration.HTTPConfig.ResolveSaver != saver { + t.Fatal("not the ResolveSaver we expected") + } + if configuration.HTTPConfig.TLSSaver != saver { + t.Fatal("not the TLSSaver we expected") + } + if configuration.HTTPConfig.BaseResolver == nil { + t.Fatal("not the BaseResolver we expected") + } + sr, ok := configuration.HTTPConfig.BaseResolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + stxp, ok := sr.Txp.(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the DNS transport we expected") + } + dohtxp, ok := stxp.RoundTripper.(resolver.DNSOverHTTPS) + if !ok { + t.Fatal("not the DNS transport we expected") + } + if dohtxp.URL != "https://dns.google/dns-query" { + t.Fatal("not the DoH URL we expected") + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.NoTLSVerify == true { + t.Fatal("not the NoTLSVerify we expected") + } + if configuration.HTTPConfig.ProxyURL != nil { + t.Fatal("not the ProxyURL we expected") + } +} + +func TestConfigurerNewConfigurationResolverDNSOverHTTPSGoogle(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + ResolverURL: "doh://google", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + defer configuration.CloseIdleConnections() + if configuration.HTTPConfig.BogonIsError != false { + t.Fatal("not the BogonIsError we expected") + } + if configuration.HTTPConfig.CacheResolutions != true { + t.Fatal("not the CacheResolutions we expected") + } + if configuration.HTTPConfig.ContextByteCounting != true { + t.Fatal("not the ContextByteCounting we expected") + } + if configuration.HTTPConfig.DialSaver != saver { + t.Fatal("not the DialSaver we expected") + } + if configuration.HTTPConfig.HTTPSaver != saver { + t.Fatal("not the HTTPSaver we expected") + } + if configuration.HTTPConfig.Logger != log.Log { + t.Fatal("not the Logger we expected") + } + if configuration.HTTPConfig.ReadWriteSaver != saver { + t.Fatal("not the ReadWriteSaver we expected") + } + if configuration.HTTPConfig.ResolveSaver != saver { + t.Fatal("not the ResolveSaver we expected") + } + if configuration.HTTPConfig.TLSSaver != saver { + t.Fatal("not the TLSSaver we expected") + } + if configuration.HTTPConfig.BaseResolver == nil { + t.Fatal("not the BaseResolver we expected") + } + sr, ok := configuration.HTTPConfig.BaseResolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + stxp, ok := sr.Txp.(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the DNS transport we expected") + } + dohtxp, ok := stxp.RoundTripper.(resolver.DNSOverHTTPS) + if !ok { + t.Fatal("not the DNS transport we expected") + } + if dohtxp.URL != "https://dns.google/dns-query" { + t.Fatal("not the DoH URL we expected") + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.NoTLSVerify == true { + t.Fatal("not the NoTLSVerify we expected") + } + if configuration.HTTPConfig.ProxyURL != nil { + t.Fatal("not the ProxyURL we expected") + } +} + +func TestConfigurerNewConfigurationResolverDNSOverHTTPSCloudflare(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + ResolverURL: "doh://cloudflare", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + defer configuration.CloseIdleConnections() + if configuration.HTTPConfig.BogonIsError != false { + t.Fatal("not the BogonIsError we expected") + } + if configuration.HTTPConfig.CacheResolutions != true { + t.Fatal("not the CacheResolutions we expected") + } + if configuration.HTTPConfig.ContextByteCounting != true { + t.Fatal("not the ContextByteCounting we expected") + } + if configuration.HTTPConfig.DialSaver != saver { + t.Fatal("not the DialSaver we expected") + } + if configuration.HTTPConfig.HTTPSaver != saver { + t.Fatal("not the HTTPSaver we expected") + } + if configuration.HTTPConfig.Logger != log.Log { + t.Fatal("not the Logger we expected") + } + if configuration.HTTPConfig.ReadWriteSaver != saver { + t.Fatal("not the ReadWriteSaver we expected") + } + if configuration.HTTPConfig.ResolveSaver != saver { + t.Fatal("not the ResolveSaver we expected") + } + if configuration.HTTPConfig.TLSSaver != saver { + t.Fatal("not the TLSSaver we expected") + } + if configuration.HTTPConfig.BaseResolver == nil { + t.Fatal("not the BaseResolver we expected") + } + sr, ok := configuration.HTTPConfig.BaseResolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + stxp, ok := sr.Txp.(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the DNS transport we expected") + } + dohtxp, ok := stxp.RoundTripper.(resolver.DNSOverHTTPS) + if !ok { + t.Fatal("not the DNS transport we expected") + } + if dohtxp.URL != "https://cloudflare-dns.com/dns-query" { + t.Fatal("not the DoH URL we expected") + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.NoTLSVerify == true { + t.Fatal("not the NoTLSVerify we expected") + } + if configuration.HTTPConfig.ProxyURL != nil { + t.Fatal("not the ProxyURL we expected") + } +} + +func TestConfigurerNewConfigurationResolverUDP(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + ResolverURL: "udp://8.8.8.8:53", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + defer configuration.CloseIdleConnections() + if configuration.HTTPConfig.BogonIsError != false { + t.Fatal("not the BogonIsError we expected") + } + if configuration.HTTPConfig.CacheResolutions != true { + t.Fatal("not the CacheResolutions we expected") + } + if configuration.HTTPConfig.ContextByteCounting != true { + t.Fatal("not the ContextByteCounting we expected") + } + if configuration.HTTPConfig.DialSaver != saver { + t.Fatal("not the DialSaver we expected") + } + if configuration.HTTPConfig.HTTPSaver != saver { + t.Fatal("not the HTTPSaver we expected") + } + if configuration.HTTPConfig.Logger != log.Log { + t.Fatal("not the Logger we expected") + } + if configuration.HTTPConfig.ReadWriteSaver != saver { + t.Fatal("not the ReadWriteSaver we expected") + } + if configuration.HTTPConfig.ResolveSaver != saver { + t.Fatal("not the ResolveSaver we expected") + } + if configuration.HTTPConfig.TLSSaver != saver { + t.Fatal("not the TLSSaver we expected") + } + if configuration.HTTPConfig.BaseResolver == nil { + t.Fatal("not the BaseResolver we expected") + } + sr, ok := configuration.HTTPConfig.BaseResolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + stxp, ok := sr.Txp.(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the DNS transport we expected") + } + udptxp, ok := stxp.RoundTripper.(resolver.DNSOverUDP) + if !ok { + t.Fatal("not the DNS transport we expected") + } + if udptxp.Address() != "8.8.8.8:53" { + t.Fatal("not the DoH URL we expected") + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("not the TLSConfig we expected") + } + if configuration.HTTPConfig.NoTLSVerify == true { + t.Fatal("not the NoTLSVerify we expected") + } + if configuration.HTTPConfig.ProxyURL != nil { + t.Fatal("not the ProxyURL we expected") + } +} + +func TestConfigurerNewConfigurationDNSCacheInvalidString(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + DNSCache: "a", + }, + Logger: log.Log, + Saver: saver, + } + _, err := configurer.NewConfiguration() + if err == nil || !strings.HasSuffix(err.Error(), "invalid DNSCache string") { + t.Fatal("not the error we expected") + } +} + +func TestConfigurerNewConfigurationDNSCacheNotDomain(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + DNSCache: "b b", + }, + Logger: log.Log, + Saver: saver, + } + _, err := configurer.NewConfiguration() + if err == nil || !strings.HasSuffix(err.Error(), "invalid domain in DNSCache") { + t.Fatal("not the error we expected") + } +} + +func TestConfigurerNewConfigurationDNSCacheNotIP(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + DNSCache: "x.org b", + }, + Logger: log.Log, + Saver: saver, + } + _, err := configurer.NewConfiguration() + if err == nil || !strings.HasSuffix(err.Error(), "invalid IP in DNSCache") { + t.Fatal("not the error we expected") + } +} + +func TestConfigurerNewConfigurationDNSCacheGood(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + DNSCache: "dns.google.com 8.8.8.8 8.8.4.4", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if len(configuration.HTTPConfig.DNSCache) != 1 { + t.Fatal("invalid number of entries in DNSCache") + } + if len(configuration.HTTPConfig.DNSCache["dns.google.com"]) != 2 { + t.Fatal("invalid number of IPs saved in DNSCache") + } + if configuration.HTTPConfig.DNSCache["dns.google.com"][0] != "8.8.8.8" { + t.Fatal("invalid IPs saved in DNSCache") + } + if configuration.HTTPConfig.DNSCache["dns.google.com"][1] != "8.8.4.4" { + t.Fatal("invalid IPs saved in DNSCache") + } +} + +func TestConfigurerNewConfigurationResolverInvalidURL(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + ResolverURL: "\t", + }, + Logger: log.Log, + Saver: saver, + } + _, err := configurer.NewConfiguration() + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } +} + +func TestConfigurerNewConfigurationResolverInvalidURLScheme(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + ResolverURL: "antani://8.8.8.8:53", + }, + Logger: log.Log, + Saver: saver, + } + _, err := configurer.NewConfiguration() + if err == nil || !strings.HasSuffix(err.Error(), "unsupported resolver scheme") { + t.Fatal("not the error we expected") + } +} + +func TestConfigurerNewConfigurationTLSServerName(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + TLSServerName: "www.x.org", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if configuration.HTTPConfig.TLSConfig.ServerName != "www.x.org" { + t.Fatal("invalid ServerName") + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("invalid len(NextProtos)") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("invalid NextProtos[0]") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("invalid NextProtos[1]") + } +} + +func TestConfigurerNewConfigurationNoTLSVerify(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + NoTLSVerify: true, + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if configuration.HTTPConfig.NoTLSVerify != true { + t.Fatal("not the NoTLSVerify we expected") + } +} + +func TestConfigurerNewConfigurationTLSv1(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + TLSVersion: "TLSv1", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("invalid len(NextProtos)") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("invalid NextProtos[0]") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("invalid NextProtos[1]") + } + if configuration.HTTPConfig.TLSConfig.MinVersion != tls.VersionTLS10 { + t.Fatal("invalid MinVersion") + } + if configuration.HTTPConfig.TLSConfig.MaxVersion != tls.VersionTLS10 { + t.Fatal("invalid MaxVersion") + } +} + +func TestConfigurerNewConfigurationTLSv1dot0(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + TLSVersion: "TLSv1.0", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("invalid len(NextProtos)") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("invalid NextProtos[0]") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("invalid NextProtos[1]") + } + if configuration.HTTPConfig.TLSConfig.MinVersion != tls.VersionTLS10 { + t.Fatal("invalid MinVersion") + } + if configuration.HTTPConfig.TLSConfig.MaxVersion != tls.VersionTLS10 { + t.Fatal("invalid MaxVersion") + } +} + +func TestConfigurerNewConfigurationTLSv1dot1(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + TLSVersion: "TLSv1.1", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("invalid len(NextProtos)") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("invalid NextProtos[0]") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("invalid NextProtos[1]") + } + if configuration.HTTPConfig.TLSConfig.MinVersion != tls.VersionTLS11 { + t.Fatal("invalid MinVersion") + } + if configuration.HTTPConfig.TLSConfig.MaxVersion != tls.VersionTLS11 { + t.Fatal("invalid MaxVersion") + } +} + +func TestConfigurerNewConfigurationTLSv1dot2(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + TLSVersion: "TLSv1.2", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("invalid len(NextProtos)") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("invalid NextProtos[0]") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("invalid NextProtos[1]") + } + if configuration.HTTPConfig.TLSConfig.MinVersion != tls.VersionTLS12 { + t.Fatal("invalid MinVersion") + } + if configuration.HTTPConfig.TLSConfig.MaxVersion != tls.VersionTLS12 { + t.Fatal("invalid MaxVersion") + } +} + +func TestConfigurerNewConfigurationTLSv1dot3(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + TLSVersion: "TLSv1.3", + }, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("invalid len(NextProtos)") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("invalid NextProtos[0]") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("invalid NextProtos[1]") + } + if configuration.HTTPConfig.TLSConfig.MinVersion != tls.VersionTLS13 { + t.Fatal("invalid MinVersion") + } + if configuration.HTTPConfig.TLSConfig.MaxVersion != tls.VersionTLS13 { + t.Fatal("invalid MaxVersion") + } +} + +func TestConfigurerNewConfigurationTLSvDefault(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{}, + Logger: log.Log, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if len(configuration.HTTPConfig.TLSConfig.NextProtos) != 2 { + t.Fatal("invalid len(NextProtos)") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[0] != "h2" { + t.Fatal("invalid NextProtos[0]") + } + if configuration.HTTPConfig.TLSConfig.NextProtos[1] != "http/1.1" { + t.Fatal("invalid NextProtos[1]") + } + if configuration.HTTPConfig.TLSConfig.MinVersion != 0 { + t.Fatal("invalid MinVersion") + } + if configuration.HTTPConfig.TLSConfig.MaxVersion != 0 { + t.Fatal("invalid MaxVersion") + } +} + +func TestConfigurerNewConfigurationTLSvInvalid(t *testing.T) { + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Config: urlgetter.Config{ + TLSVersion: "SSLv3", + }, + Logger: log.Log, + Saver: saver, + } + _, err := configurer.NewConfiguration() + if !errors.Is(err, netx.ErrInvalidTLSVersion) { + t.Fatalf("not the error we expected: %+v", err) + } +} + +func TestConfigurerNewConfigurationProxyURL(t *testing.T) { + URL, _ := url.Parse("socks5://127.0.0.1:9050") + saver := new(trace.Saver) + configurer := urlgetter.Configurer{ + Logger: log.Log, + Saver: saver, + ProxyURL: URL, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + t.Fatal(err) + } + if configuration.HTTPConfig.ProxyURL != URL { + t.Fatal("invalid ProxyURL") + } +} diff --git a/internal/engine/experiment/urlgetter/getter.go b/internal/engine/experiment/urlgetter/getter.go new file mode 100644 index 0000000..6ec1dd6 --- /dev/null +++ b/internal/engine/experiment/urlgetter/getter.go @@ -0,0 +1,132 @@ +package urlgetter + +import ( + "context" + "net/url" + "path/filepath" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/tunnel" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// The Getter gets the specified target in the context of the +// given session and with the specified config. +// +// Other OONI experiment should use the Getter to factor code when +// the Getter implements the operations they wanna perform. +type Getter struct { + // Begin is the time when the experiment begun. If you do not + // set this field, every target is measured independently. + Begin time.Time + + // Config contains settings for this run. If not set, then + // we will use the default config. + Config Config + + // Session is the session for this run. This field must + // be set otherwise the code will panic. + Session model.ExperimentSession + + // Target is the thing to measure in this run. This field must + // be set otherwise the code won't know what to do. + Target string +} + +// Get performs the action described by g using the given context +// and returning the test keys and eventually an error +func (g Getter) Get(ctx context.Context) (TestKeys, error) { + if g.Config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, g.Config.Timeout) + defer cancel() + } + if g.Begin.IsZero() { + g.Begin = time.Now() + } + saver := new(trace.Saver) + tk, err := g.get(ctx, saver) + // Make sure we have an operation in cases where we fail before + // hitting our httptransport that does error wrapping. + err = errorx.SafeErrWrapperBuilder{ + Error: err, + Operation: errorx.TopLevelOperation, + }.MaybeBuild() + tk.FailedOperation = archival.NewFailedOperation(err) + tk.Failure = archival.NewFailure(err) + events := saver.Read() + tk.Queries = append( + tk.Queries, archival.NewDNSQueriesList( + g.Begin, events, g.Session.ASNDatabasePath())..., + ) + tk.NetworkEvents = append( + tk.NetworkEvents, archival.NewNetworkEventsList(g.Begin, events)..., + ) + tk.Requests = append( + tk.Requests, archival.NewRequestList(g.Begin, events)..., + ) + if len(tk.Requests) > 0 { + // OONI's convention is that the last request appears first + tk.HTTPResponseStatus = tk.Requests[0].Response.Code + tk.HTTPResponseBody = tk.Requests[0].Response.Body.Value + tk.HTTPResponseLocations = tk.Requests[0].Response.Locations + } + tk.TCPConnect = append( + tk.TCPConnect, archival.NewTCPConnectList(g.Begin, events)..., + ) + tk.TLSHandshakes = append( + tk.TLSHandshakes, archival.NewTLSHandshakesList(g.Begin, events)..., + ) + return tk, err +} + +func (g Getter) get(ctx context.Context, saver *trace.Saver) (TestKeys, error) { + tk := TestKeys{ + Agent: "redirect", + Tunnel: g.Config.Tunnel, + } + if g.Config.DNSCache != "" { + tk.DNSCache = []string{g.Config.DNSCache} + } + if g.Config.NoFollowRedirects { + tk.Agent = "agent" + } + // start tunnel + var proxyURL *url.URL + if g.Config.Tunnel != "" { + tun, err := tunnel.Start(ctx, tunnel.Config{ + Name: g.Config.Tunnel, + Session: g.Session, + WorkDir: filepath.Join(g.Session.TempDir(), "urlgetter-tunnel"), + }) + if err != nil { + return tk, err + } + tk.BootstrapTime = tun.BootstrapTime().Seconds() + proxyURL = tun.SOCKS5ProxyURL() + tk.SOCKSProxy = proxyURL.String() + defer tun.Stop() + } + // create configuration + configurer := Configurer{ + Config: g.Config, + Logger: g.Session.Logger(), + ProxyURL: proxyURL, + Saver: saver, + } + configuration, err := configurer.NewConfiguration() + if err != nil { + return tk, err + } + defer configuration.CloseIdleConnections() + // run the measurement + runner := Runner{ + Config: g.Config, + HTTPConfig: configuration.HTTPConfig, + Target: g.Target, + } + return tk, runner.Run(ctx) +} diff --git a/internal/engine/experiment/urlgetter/getter_test.go b/internal/engine/experiment/urlgetter/getter_test.go new file mode 100644 index 0000000..e08e5d8 --- /dev/null +++ b/internal/engine/experiment/urlgetter/getter_test.go @@ -0,0 +1,777 @@ +package urlgetter_test + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestGetterWithVeryShortTimeout(t *testing.T) { + g := urlgetter.Getter{ + Config: urlgetter.Config{ + Timeout: 1, + }, + Session: &mockable.Session{}, + Target: "https://www.google.com", + } + tk, err := g.Get(context.Background()) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("not the error we expected") + } + if tk.Agent != "redirect" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation == nil || *tk.FailedOperation != errorx.TopLevelOperation { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure == nil || *tk.Failure != "generic_timeout_error" { + t.Fatal("not the Failure we expected") + } + if len(tk.NetworkEvents) != 3 { + t.Fatal("not the NetworkEvents we expected") + } + if tk.NetworkEvents[0].Operation != "http_transaction_start" { + t.Fatal("not the NetworkEvents[0].Operation we expected") + } + if tk.NetworkEvents[1].Operation != "http_request_metadata" { + t.Fatal("not the NetworkEvents[1].Operation we expected") + } + if tk.NetworkEvents[2].Operation != "http_transaction_done" { + t.Fatal("not the NetworkEvents[2].Operation we expected") + } + if len(tk.Queries) != 0 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 0 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 1 { + t.Fatal("not the Requests we expected") + } + if tk.Requests[0].Request.Method != "GET" { + t.Fatal("not the Method we expected") + } + if tk.Requests[0].Request.URL != "https://www.google.com" { + t.Fatal("not the URL we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 0 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 0 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if tk.HTTPResponseBody != "" { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterWithCancelledContextVanilla(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // faily immediately + g := urlgetter.Getter{ + Session: &mockable.Session{}, + Target: "https://www.google.com", + } + tk, err := g.Get(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tk.Agent != "redirect" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation == nil || *tk.FailedOperation != errorx.TopLevelOperation { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure == nil || !strings.HasSuffix(*tk.Failure, "interrupted") { + t.Fatal("not the Failure we expected") + } + if len(tk.NetworkEvents) != 3 { + t.Fatal("not the NetworkEvents we expected") + } + if tk.NetworkEvents[0].Operation != "http_transaction_start" { + t.Fatal("not the NetworkEvents[0].Operation we expected") + } + if tk.NetworkEvents[1].Operation != "http_request_metadata" { + t.Fatal("not the NetworkEvents[1].Operation we expected") + } + if tk.NetworkEvents[2].Operation != "http_transaction_done" { + t.Fatal("not the NetworkEvents[2].Operation we expected") + } + if len(tk.Queries) != 0 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 0 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 1 { + t.Fatal("not the Requests we expected") + } + if tk.Requests[0].Request.Method != "GET" { + t.Fatal("not the Method we expected") + } + if tk.Requests[0].Request.URL != "https://www.google.com" { + t.Fatal("not the URL we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 0 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 0 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if tk.HTTPResponseBody != "" { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterWithCancelledContextAndMethod(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // faily immediately + g := urlgetter.Getter{ + Config: urlgetter.Config{Method: "POST"}, + Session: &mockable.Session{}, + Target: "https://www.google.com", + } + tk, err := g.Get(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tk.Agent != "redirect" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation == nil || *tk.FailedOperation != errorx.TopLevelOperation { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure == nil || !strings.HasSuffix(*tk.Failure, "interrupted") { + t.Fatal("not the Failure we expected") + } + if len(tk.NetworkEvents) != 3 { + t.Fatal("not the NetworkEvents we expected") + } + if tk.NetworkEvents[0].Operation != "http_transaction_start" { + t.Fatal("not the NetworkEvents[0].Operation we expected") + } + if tk.NetworkEvents[1].Operation != "http_request_metadata" { + t.Fatal("not the NetworkEvents[1].Operation we expected") + } + if tk.NetworkEvents[2].Operation != "http_transaction_done" { + t.Fatal("not the NetworkEvents[2].Operation we expected") + } + if len(tk.Queries) != 0 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 0 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 1 { + t.Fatal("not the Requests we expected") + } + if tk.Requests[0].Request.Method != "POST" { + t.Fatal("not the Method we expected") + } + if tk.Requests[0].Request.URL != "https://www.google.com" { + t.Fatal("not the URL we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 0 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 0 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if tk.HTTPResponseBody != "" { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterWithCancelledContextNoFollowRedirects(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // faily immediately + g := urlgetter.Getter{ + Config: urlgetter.Config{ + NoFollowRedirects: true, + }, + Session: &mockable.Session{}, + Target: "https://www.google.com", + } + tk, err := g.Get(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tk.Agent != "agent" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation == nil || *tk.FailedOperation != errorx.TopLevelOperation { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure == nil || !strings.HasSuffix(*tk.Failure, "interrupted") { + t.Fatal("not the Failure we expected") + } + if len(tk.NetworkEvents) != 3 { + t.Fatal("not the NetworkEvents we expected") + } + if tk.NetworkEvents[0].Operation != "http_transaction_start" { + t.Fatal("not the NetworkEvents[0].Operation we expected") + } + if tk.NetworkEvents[1].Operation != "http_request_metadata" { + t.Fatal("not the NetworkEvents[1].Operation we expected") + } + if tk.NetworkEvents[2].Operation != "http_transaction_done" { + t.Fatal("not the NetworkEvents[2].Operation we expected") + } + if len(tk.Queries) != 0 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 0 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 1 { + t.Fatal("not the Requests we expected") + } + if tk.Requests[0].Request.Method != "GET" { + t.Fatal("not the Method we expected") + } + if tk.Requests[0].Request.URL != "https://www.google.com" { + t.Fatal("not the URL we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 0 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 0 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if tk.HTTPResponseBody != "" { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterWithCancelledContextCannotStartTunnel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + g := urlgetter.Getter{ + Config: urlgetter.Config{ + Tunnel: "psiphon", + }, + Session: &mockable.Session{MockableLogger: log.Log}, + Target: "https://www.google.com", + } + tk, err := g.Get(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } + if tk.Agent != "redirect" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation == nil || *tk.FailedOperation != errorx.TopLevelOperation { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure == nil || *tk.Failure != "interrupted" { + t.Fatal("not the Failure we expected") + } + if len(tk.NetworkEvents) != 0 { + t.Fatal("not the NetworkEvents we expected") + } + if len(tk.Queries) != 0 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 0 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 0 { + t.Fatal("not the Requests we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 0 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "psiphon" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 0 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if tk.HTTPResponseBody != "" { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterWithCancelledContextUnknownResolverURL(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // faily immediately + g := urlgetter.Getter{ + Config: urlgetter.Config{ + ResolverURL: "antani://8.8.8.8:53", + }, + Session: &mockable.Session{}, + Target: "https://www.google.com", + } + tk, err := g.Get(ctx) + if err == nil || err.Error() != "unknown_failure: unsupported resolver scheme" { + t.Fatal("not the error we expected") + } + if tk.Agent != "redirect" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation == nil || *tk.FailedOperation != errorx.TopLevelOperation { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure == nil || *tk.Failure != "unknown_failure: unsupported resolver scheme" { + t.Fatal("not the Failure we expected") + } + if len(tk.NetworkEvents) != 0 { + t.Fatal("not the NetworkEvents we expected") + } + if len(tk.Queries) != 0 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 0 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 0 { + t.Fatal("not the Requests we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 0 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 0 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if tk.HTTPResponseBody != "" { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterIntegrationHTTPS(t *testing.T) { + ctx := context.Background() + g := urlgetter.Getter{ + Config: urlgetter.Config{ + NoFollowRedirects: true, // reduce number of events + }, + Session: &mockable.Session{}, + Target: "https://www.google.com", + } + tk, err := g.Get(ctx) + if err != nil { + t.Fatal(err) + } + if tk.Agent != "agent" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation != nil { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure != nil { + t.Fatal("not the Failure we expected") + } + var ( + httpTransactionStart bool + httpRequestMetadata bool + resolveStart bool + resolveDone bool + connect bool + tlsHandshakeStart bool + tlsHandshakeDone bool + httpWroteHeaders bool + httpWroteRequest bool + httpFirstResponseByte bool + httpResponseMetadata bool + httpResponseBodySnapshot bool + httpTransactionDone bool + ) + for _, ev := range tk.NetworkEvents { + switch ev.Operation { + case "http_transaction_start": + httpTransactionStart = true + case "http_request_metadata": + httpRequestMetadata = true + case "resolve_start": + resolveStart = true + case "resolve_done": + resolveDone = true + case errorx.ConnectOperation: + connect = true + case "tls_handshake_start": + tlsHandshakeStart = true + case "tls_handshake_done": + tlsHandshakeDone = true + case "http_wrote_headers": + httpWroteHeaders = true + case "http_wrote_request": + httpWroteRequest = true + case "http_first_response_byte": + httpFirstResponseByte = true + case "http_response_metadata": + httpResponseMetadata = true + case "http_response_body_snapshot": + httpResponseBodySnapshot = true + case "http_transaction_done": + httpTransactionDone = true + } + } + ok := true + ok = ok && httpTransactionStart + ok = ok && httpRequestMetadata + ok = ok && resolveStart + ok = ok && resolveDone + ok = ok && connect + ok = ok && tlsHandshakeStart + ok = ok && tlsHandshakeDone + ok = ok && httpWroteHeaders + ok = ok && httpWroteRequest + ok = ok && httpFirstResponseByte + ok = ok && httpResponseMetadata + ok = ok && httpResponseBodySnapshot + ok = ok && httpTransactionDone + if !ok { + t.Fatal("not the NetworkEvents we expected") + } + if len(tk.Queries) != 2 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 1 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 1 { + t.Fatal("not the Requests we expected") + } + if tk.Requests[0].Request.Method != "GET" { + t.Fatal("not the Method we expected") + } + if tk.Requests[0].Request.URL != "https://www.google.com" { + t.Fatal("not the URL we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 1 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 200 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if len(tk.HTTPResponseBody) <= 0 { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterIntegrationRedirect(t *testing.T) { + ctx := context.Background() + g := urlgetter.Getter{ + Config: urlgetter.Config{NoFollowRedirects: true}, + Session: &mockable.Session{}, + Target: "http://web.whatsapp.com", + } + tk, err := g.Get(ctx) + if err != nil { + t.Fatal(err) + } + if tk.HTTPResponseStatus != 302 { + t.Fatal("unexpected status code") + } + if len(tk.HTTPResponseLocations) != 1 { + t.Fatal("missing redirect URL") + } + if tk.HTTPResponseLocations[0] != "https://web.whatsapp.com/" { + t.Fatal("invalid redirect URL") + } +} + +func TestGetterIntegrationTLSHandshake(t *testing.T) { + ctx := context.Background() + g := urlgetter.Getter{ + Config: urlgetter.Config{ + NoFollowRedirects: true, // reduce number of events + }, + Session: &mockable.Session{}, + Target: "tlshandshake://www.google.com:443", + } + tk, err := g.Get(ctx) + if err != nil { + t.Fatal(err) + } + if tk.Agent != "agent" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime != 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation != nil { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure != nil { + t.Fatal("not the Failure we expected") + } + var ( + httpTransactionStart bool + httpRequestMetadata bool + resolveStart bool + resolveDone bool + connect bool + tlsHandshakeStart bool + tlsHandshakeDone bool + httpWroteHeaders bool + httpWroteRequest bool + httpFirstResponseByte bool + httpResponseMetadata bool + httpResponseBodySnapshot bool + httpTransactionDone bool + ) + for _, ev := range tk.NetworkEvents { + switch ev.Operation { + case "http_transaction_start": + httpTransactionStart = true + case "http_request_metadata": + httpRequestMetadata = true + case "resolve_start": + resolveStart = true + case "resolve_done": + resolveDone = true + case errorx.ConnectOperation: + connect = true + case "tls_handshake_start": + tlsHandshakeStart = true + case "tls_handshake_done": + tlsHandshakeDone = true + case "http_wrote_headers": + httpWroteHeaders = true + case "http_wrote_request": + httpWroteRequest = true + case "http_first_response_byte": + httpFirstResponseByte = true + case "http_response_metadata": + httpResponseMetadata = true + case "http_response_body_snapshot": + httpResponseBodySnapshot = true + case "http_transaction_done": + httpTransactionDone = true + } + } + ok := true + ok = ok && !httpTransactionStart + ok = ok && !httpRequestMetadata + ok = ok && resolveStart + ok = ok && resolveDone + ok = ok && connect + ok = ok && tlsHandshakeStart + ok = ok && tlsHandshakeDone + ok = ok && !httpWroteHeaders + ok = ok && !httpWroteRequest + ok = ok && !httpFirstResponseByte + ok = ok && !httpResponseMetadata + ok = ok && !httpResponseBodySnapshot + ok = ok && !httpTransactionDone + if !ok { + t.Fatal("not the NetworkEvents we expected") + } + if len(tk.Queries) != 2 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 1 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 0 { + t.Fatal("not the Requests we expected") + } + if tk.SOCKSProxy != "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 1 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 0 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if tk.HTTPResponseBody != "" { + t.Fatal("not the HTTPResponseBody we expected") + } +} + +func TestGetterIntegrationHTTPSWithTunnel(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + ctx := context.Background() + g := urlgetter.Getter{ + Config: urlgetter.Config{ + NoFollowRedirects: true, // reduce number of events + Tunnel: "psiphon", + }, + Session: &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + Target: "https://www.google.com", + } + tk, err := g.Get(ctx) + if err != nil { + t.Fatal(err) + } + if tk.Agent != "agent" { + t.Fatal("not the Agent we expected") + } + if tk.BootstrapTime <= 0 { + t.Fatal("not the BootstrapTime we expected") + } + if tk.FailedOperation != nil { + t.Fatal("not the FailedOperation we expected") + } + if tk.Failure != nil { + t.Fatal("not the Failure we expected") + } + var ( + httpTransactionStart bool + httpRequestMetadata bool + resolveStart bool + resolveDone bool + connect bool + tlsHandshakeStart bool + tlsHandshakeDone bool + httpWroteHeaders bool + httpWroteRequest bool + httpFirstResponseByte bool + httpResponseMetadata bool + httpResponseBodySnapshot bool + httpTransactionDone bool + ) + for _, ev := range tk.NetworkEvents { + switch ev.Operation { + case "http_transaction_start": + httpTransactionStart = true + case "http_request_metadata": + httpRequestMetadata = true + case "resolve_start": + resolveStart = true + case "resolve_done": + resolveDone = true + case errorx.ConnectOperation: + connect = true + case "tls_handshake_start": + tlsHandshakeStart = true + case "tls_handshake_done": + tlsHandshakeDone = true + case "http_wrote_headers": + httpWroteHeaders = true + case "http_wrote_request": + httpWroteRequest = true + case "http_first_response_byte": + httpFirstResponseByte = true + case "http_response_metadata": + httpResponseMetadata = true + case "http_response_body_snapshot": + httpResponseBodySnapshot = true + case "http_transaction_done": + httpTransactionDone = true + } + } + ok := true + ok = ok && httpTransactionStart + ok = ok && httpRequestMetadata + ok = ok && resolveStart == false + ok = ok && resolveDone == false + ok = ok && connect + ok = ok && tlsHandshakeStart + ok = ok && tlsHandshakeDone + ok = ok && httpWroteHeaders + ok = ok && httpWroteRequest + ok = ok && httpFirstResponseByte + ok = ok && httpResponseMetadata + ok = ok && httpResponseBodySnapshot + ok = ok && httpTransactionDone + if !ok { + t.Fatalf("not the NetworkEvents we expected: %+v", tk.NetworkEvents) + } + if len(tk.Queries) != 0 { + t.Fatal("not the Queries we expected") + } + if len(tk.TCPConnect) != 1 { + t.Fatal("not the TCPConnect we expected") + } + if len(tk.Requests) != 1 { + t.Fatal("not the Requests we expected") + } + if tk.Requests[0].Request.Method != "GET" { + t.Fatal("not the Method we expected") + } + if tk.Requests[0].Request.URL != "https://www.google.com" { + t.Fatal("not the URL we expected") + } + if tk.SOCKSProxy == "" { + t.Fatal("not the SOCKSProxy we expected") + } + if len(tk.TLSHandshakes) != 1 { + t.Fatal("not the TLSHandshakes we expected") + } + if tk.Tunnel != "psiphon" { + t.Fatal("not the Tunnel we expected") + } + if tk.HTTPResponseStatus != 200 { + t.Fatal("not the HTTPResponseStatus we expected") + } + if len(tk.HTTPResponseBody) <= 0 { + t.Fatal("not the HTTPResponseBody we expected") + } +} diff --git a/internal/engine/experiment/urlgetter/multi.go b/internal/engine/experiment/urlgetter/multi.go new file mode 100644 index 0000000..424cc43 --- /dev/null +++ b/internal/engine/experiment/urlgetter/multi.go @@ -0,0 +1,143 @@ +package urlgetter + +import ( + "context" + "fmt" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// MultiInput is the input for Multi.Run(). +type MultiInput struct { + // Config contains the configuration for this target. + Config Config + + // Target contains the target URL to measure. + Target string +} + +// MultiOutput is the output returned by Multi.Run() +type MultiOutput struct { + // Input is the input for which we measured. + Input MultiInput + + // Err contains the measurement error. + Err error + + // TestKeys contains the measured test keys. + TestKeys TestKeys +} + +// MultiGetter allows to override the behaviour of Multi for testing purposes. +type MultiGetter func(ctx context.Context, g Getter) (TestKeys, error) + +// DefaultMultiGetter is the default MultiGetter +func DefaultMultiGetter(ctx context.Context, g Getter) (TestKeys, error) { + return g.Get(ctx) +} + +// Multi allows to run several urlgetters in paraller. +type Multi struct { + // Begin is the time when the experiment begun. If you do not + // set this field, every target is measured independently. + Begin time.Time + + // Getter is the Getter func to be used. If this is nil we use + // the default getter, which is what you typically want. + Getter MultiGetter + + // Parallelism is the optional parallelism to be used. If this is + // zero, or negative, we use a reasonable default. + Parallelism int + + // Session is the session to be used. If this is nil, the Run + // method will panic with a nil pointer error. + Session model.ExperimentSession +} + +// Run performs several urlgetters in parallel. This function returns a channel +// where each result is posted. This function will always perform all the requested +// measurements: if the ctx is canceled or its deadline expires, then you will see +// a bunch of failed measurements. Since all measurements are always performed, +// you know you're done when you've read len(inputs) results in output. +func (m Multi) Run(ctx context.Context, inputs []MultiInput) <-chan MultiOutput { + parallelism := m.Parallelism + if parallelism <= 0 { + const defaultParallelism = 3 + parallelism = defaultParallelism + } + inputch := make(chan MultiInput) + outputch := make(chan MultiOutput) + go m.source(inputs, inputch) + for i := 0; i < parallelism; i++ { + go m.do(ctx, inputch, outputch) + } + return outputch +} + +// Collect prints on the output channel the result of running urlgetter +// on every provided input. It closes the output channel when done. +func (m Multi) Collect(ctx context.Context, inputs []MultiInput, + prefix string, callbacks model.ExperimentCallbacks) <-chan MultiOutput { + return m.CollectOverall(ctx, inputs, 0, len(inputs), prefix, callbacks) +} + +// CollectOverall prints on the output channel the result of running urlgetter +// on every provided input. You can use this method if you perform multiple collection +// tasks within one experiment as it allows to calculate the overall progress correctly +func (m Multi) CollectOverall(ctx context.Context, inputChunk []MultiInput, overallStartIndex int, overallCount int, + prefix string, callbacks model.ExperimentCallbacks) <-chan MultiOutput { + outputch := make(chan MultiOutput) + go m.collect(len(inputChunk), overallStartIndex, overallCount, prefix, callbacks, m.Run(ctx, inputChunk), outputch) + return outputch +} + +// collect drains inputch, prints progress, and emits to outputch. When done, this +// function will close outputch to notify the calller. +func (m Multi) collect(expect int, overallStartIndex int, overallCount int, prefix string, callbacks model.ExperimentCallbacks, + inputch <-chan MultiOutput, outputch chan<- MultiOutput) { + count := overallStartIndex + var index int + defer close(outputch) + for index < expect { + entry := <-inputch + index++ + count++ + percentage := float64(count) / float64(overallCount) + callbacks.OnProgress(percentage, fmt.Sprintf( + "%s: measure %s: %+v", prefix, entry.Input.Target, entry.Err, + )) + outputch <- entry + } +} + +// source posts all the inputs in the inputch. When done, this +// method will close the input channel to notify the reader. +func (m Multi) source(inputs []MultiInput, inputch chan<- MultiInput) { + defer close(inputch) + for _, input := range inputs { + inputch <- input + } +} + +// do performs urlgetter on all the inputs read from the in channel and +// writes the results on the out channel. If the context is canceled, or +// its deadline expires, this function will continue performing all the +// required measurements, which will all fail. +func (m Multi) do(ctx context.Context, in <-chan MultiInput, out chan<- MultiOutput) { + for input := range in { + g := Getter{ + Begin: m.Begin, + Config: input.Config, + Session: m.Session, + Target: input.Target, + } + fn := m.Getter + if fn == nil { + fn = DefaultMultiGetter + } + tk, err := fn(ctx, g) + out <- MultiOutput{Input: input, Err: err, TestKeys: tk} + } +} diff --git a/internal/engine/experiment/urlgetter/multi_test.go b/internal/engine/experiment/urlgetter/multi_test.go new file mode 100644 index 0000000..9a45b14 --- /dev/null +++ b/internal/engine/experiment/urlgetter/multi_test.go @@ -0,0 +1,256 @@ +package urlgetter_test + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestMultiIntegration(t *testing.T) { + multi := urlgetter.Multi{Session: &mockable.Session{}} + inputs := []urlgetter.MultiInput{{ + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.google.com", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.facebook.com", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.kernel.org", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.instagram.com", + }} + outputs := multi.Collect(context.Background(), inputs, "integration-test", + model.NewPrinterCallbacks(log.Log)) + var count int + for result := range outputs { + count++ + switch result.Input.Target { + case "https://www.google.com": + case "https://www.facebook.com": + case "https://www.kernel.org": + case "https://www.instagram.com": + default: + t.Fatal("unexpected Input.Target") + } + if result.Input.Config.Method != "HEAD" { + t.Fatal("unexpected Input.Config.Method") + } + if result.Err != nil { + t.Fatal(result.Err) + } + if result.TestKeys.Agent != "agent" { + t.Fatal("invalid TestKeys.Agent") + } + if len(result.TestKeys.Queries) != 2 { + t.Fatal("invalid number of Queries") + } + if len(result.TestKeys.Requests) != 1 { + t.Fatal("invalid number of Requests") + } + if len(result.TestKeys.TCPConnect) != 1 { + t.Fatal("invalid number of TCPConnects") + } + if len(result.TestKeys.TLSHandshakes) != 1 { + t.Fatal("invalid number of TLSHandshakes") + } + } + if count != 4 { + t.Fatal("invalid number of outputs") + } +} + +func TestMultiIntegrationWithBaseTime(t *testing.T) { + // We set a beginning of time that's significantly in the past and then + // fail the test if we see any T smaller than 3600 seconds. + begin := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + multi := urlgetter.Multi{ + Begin: begin, + Session: &mockable.Session{}, + } + inputs := []urlgetter.MultiInput{{ + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.google.com", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.instagram.com", + }} + outputs := multi.Collect(context.Background(), inputs, "integration-test", + model.NewPrinterCallbacks(log.Log)) + var count int + for result := range outputs { + for _, entry := range result.TestKeys.NetworkEvents { + if entry.T < 3600 { + t.Fatal("base time not correctly set") + } + count++ + } + for _, entry := range result.TestKeys.Queries { + if entry.T < 3600 { + t.Fatal("base time not correctly set") + } + count++ + } + for _, entry := range result.TestKeys.TCPConnect { + if entry.T < 3600 { + t.Fatal("base time not correctly set") + } + count++ + } + for _, entry := range result.TestKeys.TLSHandshakes { + if entry.T < 3600 { + t.Fatal("base time not correctly set") + } + count++ + } + } + if count <= 0 { + t.Fatal("unexpected number of entries processed") + } +} + +func TestMultiIntegrationWithoutBaseTime(t *testing.T) { + // We use the default beginning of time and then fail the test + // if we see any T smaller than 60 seconds. + multi := urlgetter.Multi{Session: &mockable.Session{}} + inputs := []urlgetter.MultiInput{{ + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.google.com", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.instagram.com", + }} + outputs := multi.Collect(context.Background(), inputs, "integration-test", + model.NewPrinterCallbacks(log.Log)) + var count int + for result := range outputs { + for _, entry := range result.TestKeys.NetworkEvents { + if entry.T > 60 { + t.Fatal("base time not correctly set") + } + count++ + } + for _, entry := range result.TestKeys.Queries { + if entry.T > 60 { + t.Fatal("base time not correctly set") + } + count++ + } + for _, entry := range result.TestKeys.TCPConnect { + if entry.T > 60 { + t.Fatal("base time not correctly set") + } + count++ + } + for _, entry := range result.TestKeys.TLSHandshakes { + if entry.T > 60 { + t.Fatal("base time not correctly set") + } + count++ + } + } + if count <= 0 { + t.Fatal("unexpected number of entries processed") + } +} + +func TestMultiContextCanceled(t *testing.T) { + multi := urlgetter.Multi{Session: &mockable.Session{}} + inputs := []urlgetter.MultiInput{{ + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.google.com", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.facebook.com", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.kernel.org", + }, { + Config: urlgetter.Config{Method: "HEAD", NoFollowRedirects: true}, + Target: "https://www.instagram.com", + }} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + outputs := multi.Collect(ctx, inputs, "integration-test", + model.NewPrinterCallbacks(log.Log)) + var count int + for result := range outputs { + count++ + switch result.Input.Target { + case "https://www.google.com": + case "https://www.facebook.com": + case "https://www.kernel.org": + case "https://www.instagram.com": + default: + t.Fatal("unexpected Input.Target") + } + if result.Input.Config.Method != "HEAD" { + t.Fatal("unexpected Input.Config.Method") + } + if !errors.Is(result.Err, context.Canceled) { + t.Fatal("unexpected error") + } + if result.TestKeys.Agent != "agent" { + t.Fatal("invalid TestKeys.Agent") + } + if len(result.TestKeys.Queries) != 0 { + t.Fatal("invalid number of Queries") + } + if len(result.TestKeys.Requests) != 1 { + t.Fatal("invalid number of Requests") + } + if len(result.TestKeys.TCPConnect) != 0 { + t.Fatal("invalid number of TCPConnects") + } + if len(result.TestKeys.TLSHandshakes) != 0 { + t.Fatal("invalid number of TLSHandshakes") + } + } + if count != 4 { + t.Fatal("invalid number of outputs") + } +} + +func TestMultiWithSpecificCertPool(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Hello, client") + })) + defer server.Close() + cert := server.Certificate() + certpool := x509.NewCertPool() + certpool.AddCert(cert) + multi := urlgetter.Multi{Session: &mockable.Session{}} + inputs := []urlgetter.MultiInput{{ + Config: urlgetter.Config{ + CertPool: certpool, + Method: "GET", + NoFollowRedirects: true, + }, + Target: server.URL, + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + outputs := multi.Collect(ctx, inputs, "integration-test", + model.NewPrinterCallbacks(log.Log)) + var count int + for result := range outputs { + count++ + if result.Err != nil { + t.Fatal(result.Err) + } + } + if count != 1 { + t.Fatal("unexpected count value") + } +} diff --git a/internal/engine/experiment/urlgetter/runner.go b/internal/engine/experiment/urlgetter/runner.go new file mode 100644 index 0000000..07f3573 --- /dev/null +++ b/internal/engine/experiment/urlgetter/runner.go @@ -0,0 +1,129 @@ +package urlgetter + +import ( + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/cookiejar" + "net/url" + + "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/netx/errorx" +) + +const httpRequestFailed = "http_request_failed" + +// ErrHTTPRequestFailed indicates that the HTTP request failed. +var ErrHTTPRequestFailed = &errorx.ErrWrapper{ + Failure: httpRequestFailed, + Operation: errorx.TopLevelOperation, + WrappedErr: errors.New(httpRequestFailed), +} + +// The Runner job is to run a single measurement +type Runner struct { + Config Config + HTTPConfig netx.Config + Target string +} + +// Run runs a measurement and returns the measurement result +func (r Runner) Run(ctx context.Context) error { + targetURL, err := url.Parse(r.Target) + if err != nil { + return fmt.Errorf("urlgetter: invalid target URL: %w", err) + } + switch targetURL.Scheme { + case "http", "https": + return r.httpGet(ctx, r.Target) + case "dnslookup": + return r.dnsLookup(ctx, targetURL.Hostname()) + case "tlshandshake": + return r.tlsHandshake(ctx, targetURL.Host) + case "tcpconnect": + return r.tcpConnect(ctx, targetURL.Host) + default: + return errors.New("unknown targetURL scheme") + } +} + +// MaybeUserAgent returns ua if ua is not empty. Otherwise it +// returns httpheader.RandomUserAgent(). +func MaybeUserAgent(ua string) string { + if ua == "" { + ua = httpheader.UserAgent() + } + return ua +} + +func (r Runner) httpGet(ctx context.Context, url string) error { + // Implementation note: empty Method implies using the GET method + req, err := http.NewRequest(r.Config.Method, url, nil) + runtimex.PanicOnError(err, "http.NewRequest failed") + req = req.WithContext(ctx) + req.Header.Set("Accept", httpheader.Accept()) + req.Header.Set("Accept-Language", httpheader.AcceptLanguage()) + req.Header.Set("User-Agent", MaybeUserAgent(r.Config.UserAgent)) + if r.Config.HTTPHost != "" { + req.Host = r.Config.HTTPHost + } + // Implementation note: the following cookiejar accepts all cookies + // from all domains. As such, would not be safe for usage where cookies + // matter, but it's totally fine for performing measurements. + jar, err := cookiejar.New(nil) + runtimex.PanicOnError(err, "cookiejar.New failed") + httpClient := &http.Client{ + Jar: jar, + Transport: netx.NewHTTPTransport(r.HTTPConfig), + } + if r.Config.NoFollowRedirects { + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + } + defer httpClient.CloseIdleConnections() + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if _, err = io.Copy(ioutil.Discard, resp.Body); err != nil { + return err + } + // Implementation note: we shall check for this error once we have read the + // whole body. Even though we discard the body, we want to know whether we + // see any error when reading the body before inspecting the HTTP status code. + if resp.StatusCode >= 400 && r.Config.FailOnHTTPError { + return ErrHTTPRequestFailed + } + return nil +} + +func (r Runner) dnsLookup(ctx context.Context, hostname string) error { + resolver := netx.NewResolver(r.HTTPConfig) + _, err := resolver.LookupHost(ctx, hostname) + return err +} + +func (r Runner) tlsHandshake(ctx context.Context, address string) error { + tlsDialer := netx.NewTLSDialer(r.HTTPConfig) + conn, err := tlsDialer.DialTLSContext(ctx, "tcp", address) + if conn != nil { + conn.Close() + } + return err +} + +func (r Runner) tcpConnect(ctx context.Context, address string) error { + dialer := netx.NewDialer(r.HTTPConfig) + conn, err := dialer.DialContext(ctx, "tcp", address) + if conn != nil { + conn.Close() + } + return err +} diff --git a/internal/engine/experiment/urlgetter/runner_test.go b/internal/engine/experiment/urlgetter/runner_test.go new file mode 100644 index 0000000..a9852d0 --- /dev/null +++ b/internal/engine/experiment/urlgetter/runner_test.go @@ -0,0 +1,287 @@ +package urlgetter_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" +) + +func TestRunnerWithInvalidURLScheme(t *testing.T) { + r := urlgetter.Runner{Target: "antani://www.google.com"} + err := r.Run(context.Background()) + if err == nil || err.Error() != "unknown targetURL scheme" { + t.Fatal("not the error we expected") + } +} + +func TestRunnerHTTPWithContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := urlgetter.Runner{Target: "https://www.google.com"} + err := r.Run(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerDNSLookupWithContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := urlgetter.Runner{Target: "dnslookup://www.google.com"} + err := r.Run(ctx) + if err == nil || err.Error() != "interrupted" { + t.Fatal("not the error we expected") + } +} + +func TestRunnerTLSHandshakeWithContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := urlgetter.Runner{Target: "tlshandshake://www.google.com:443"} + err := r.Run(ctx) + if err == nil || err.Error() != "interrupted" { + t.Fatal("not the error we expected") + } +} + +func TestRunnerTCPConnectWithContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := urlgetter.Runner{Target: "tcpconnect://www.google.com:443"} + err := r.Run(ctx) + if err == nil || err.Error() != "interrupted" { + t.Fatal("not the error we expected") + } +} + +func TestRunnerWithInvalidURL(t *testing.T) { + r := urlgetter.Runner{Target: "\t"} + err := r.Run(context.Background()) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } +} + +func TestRunnerWithEmptyHostname(t *testing.T) { + r := urlgetter.Runner{Target: "http:///foo.txt"} + err := r.Run(context.Background()) + if err == nil || !strings.HasSuffix(err.Error(), "no Host in request URL") { + t.Fatal("not the error we expected") + } +} + +func TestRunnerTLSHandshakeSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + r := urlgetter.Runner{Target: "tlshandshake://www.google.com:443"} + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } +} + +func TestRunnerTCPConnectSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + r := urlgetter.Runner{Target: "tcpconnect://www.google.com:443"} + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } +} + +func TestRunnerDNSLookupSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + r := urlgetter.Runner{Target: "dnslookup://www.google.com"} + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } +} + +func TestRunnerHTTPSSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + r := urlgetter.Runner{Target: "https://www.google.com"} + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } +} + +func TestRunnerHTTPSetHostHeader(t *testing.T) { + var host string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host = r.Host + w.WriteHeader(200) + })) + defer server.Close() + r := urlgetter.Runner{ + Config: urlgetter.Config{ + HTTPHost: "x.org", + }, + Target: server.URL, + } + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if host != "x.org" { + t.Fatal("not the host we expected") + } +} + +func TestRunnerHTTPNoRedirect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Location", "http:///") // cause failure if we redirect + w.WriteHeader(302) + })) + defer server.Close() + r := urlgetter.Runner{ + Config: urlgetter.Config{ + NoFollowRedirects: true, + }, + Target: server.URL, + } + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } +} + +func TestRunnerHTTPCannotReadBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hijacker, ok := w.(http.Hijacker) + if !ok { + panic("hijacking not supported by this server") + } + conn, _, _ := hijacker.Hijack() + conn.Write([]byte("HTTP/1.1 200 Ok\r\n")) + conn.Write([]byte("Content-Length: 1024\r\n")) + conn.Write([]byte("\r\n")) + conn.Write([]byte("123456789")) + conn.Close() + })) + defer server.Close() + r := urlgetter.Runner{ + Config: urlgetter.Config{ + NoFollowRedirects: true, + }, + Target: server.URL, + } + err := r.Run(context.Background()) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerHTTPWeHandle400Correctly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + })) + defer server.Close() + r := urlgetter.Runner{ + Config: urlgetter.Config{ + FailOnHTTPError: true, + NoFollowRedirects: true, + }, + Target: server.URL, + } + err := r.Run(context.Background()) + if !errors.Is(err, urlgetter.ErrHTTPRequestFailed) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerHTTPCannotReadBodyWinsOver400(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hijacker, ok := w.(http.Hijacker) + if !ok { + panic("hijacking not supported by this server") + } + conn, _, _ := hijacker.Hijack() + conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n")) + conn.Write([]byte("Content-Length: 1024\r\n")) + conn.Write([]byte("\r\n")) + conn.Write([]byte("123456789")) + conn.Close() + })) + defer server.Close() + r := urlgetter.Runner{ + Config: urlgetter.Config{ + FailOnHTTPError: true, + NoFollowRedirects: true, + }, + Target: server.URL, + } + err := r.Run(context.Background()) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } +} + +func TestRunnerWeCanForceUserAgent(t *testing.T) { + expected := "antani/1.23.4-dev" + found := atomicx.NewInt64() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") == expected { + found.Add(1) + } + w.WriteHeader(200) + })) + defer server.Close() + r := urlgetter.Runner{ + Config: urlgetter.Config{ + FailOnHTTPError: true, + NoFollowRedirects: true, + UserAgent: expected, + }, + Target: server.URL, + } + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if found.Load() != 1 { + t.Fatal("we didn't override the user agent") + } +} + +func TestRunnerDefaultUserAgent(t *testing.T) { + expected := httpheader.UserAgent() + found := atomicx.NewInt64() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") == expected { + found.Add(1) + } + w.WriteHeader(200) + })) + defer server.Close() + r := urlgetter.Runner{ + Config: urlgetter.Config{ + FailOnHTTPError: true, + NoFollowRedirects: true, + }, + Target: server.URL, + } + err := r.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if found.Load() != 1 { + t.Fatal("we didn't override the user agent") + } +} diff --git a/internal/engine/experiment/urlgetter/urlgetter.go b/internal/engine/experiment/urlgetter/urlgetter.go new file mode 100644 index 0000000..735d67e --- /dev/null +++ b/internal/engine/experiment/urlgetter/urlgetter.go @@ -0,0 +1,133 @@ +// Package urlgetter implements a nettest that fetches a URL. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-027-urlgetter.md. +package urlgetter + +import ( + "context" + "crypto/x509" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" +) + +const ( + testName = "urlgetter" + testVersion = "0.1.0" +) + +// Config contains the experiment's configuration. +type Config struct { + // not settable from command line + CertPool *x509.CertPool + Timeout time.Duration + + // settable from command line + DNSCache string `ooni:"Add 'DOMAIN IP...' to cache"` + DNSHTTPHost string `ooni:"Force using specific HTTP Host header for DNS requests"` + DNSTLSServerName string `ooni:"Force TLS to using a specific SNI for encrypted DNS requests"` + DNSTLSVersion string `ooni:"Force specific TLS version used for DoT/DoH (e.g. 'TLSv1.3')"` + FailOnHTTPError bool `ooni:"Fail HTTP request if status code is 400 or above"` + HTTP3Enabled bool `ooni:"use http3 instead of http/1.1 or http2"` + HTTPHost string `ooni:"Force using specific HTTP Host header"` + Method string `ooni:"Force HTTP method different than GET"` + NoFollowRedirects bool `ooni:"Disable following redirects"` + NoTLSVerify bool `ooni:"Disable TLS verification"` + RejectDNSBogons bool `ooni:"Fail DNS lookup if response contains bogons"` + ResolverURL string `ooni:"URL describing the resolver to use"` + TLSServerName string `ooni:"Force TLS to using a specific SNI in Client Hello"` + TLSVersion string `ooni:"Force specific TLS version (e.g. 'TLSv1.3')"` + Tunnel string `ooni:"Run experiment over a tunnel, e.g. psiphon"` + UserAgent string `ooni:"Use the specified User-Agent"` +} + +// TestKeys contains the experiment's result. +type TestKeys struct { + // The following fields are part of the typical JSON emitted by OONI. + Agent string `json:"agent"` + BootstrapTime float64 `json:"bootstrap_time,omitempty"` + DNSCache []string `json:"dns_cache,omitempty"` + FailedOperation *string `json:"failed_operation"` + Failure *string `json:"failure"` + NetworkEvents []archival.NetworkEvent `json:"network_events"` + Queries []archival.DNSQueryEntry `json:"queries"` + Requests []archival.RequestEntry `json:"requests"` + SOCKSProxy string `json:"socksproxy,omitempty"` + TCPConnect []archival.TCPConnectEntry `json:"tcp_connect"` + TLSHandshakes []archival.TLSHandshake `json:"tls_handshakes"` + Tunnel string `json:"tunnel,omitempty"` + + // The following fields are not serialised but are useful to simplify + // analysing the measurements in telegram, whatsapp, etc. + HTTPResponseStatus int64 `json:"-"` + HTTPResponseBody string `json:"-"` + HTTPResponseLocations []string `json:"-"` +} + +// RegisterExtensions registers the extensions used by the urlgetter +// experiment into the provided measurement. +func RegisterExtensions(m *model.Measurement) { + archival.ExtHTTP.AddTo(m) + archival.ExtDNS.AddTo(m) + archival.ExtNetevents.AddTo(m) + archival.ExtTCPConnect.AddTo(m) + archival.ExtTLSHandshake.AddTo(m) + archival.ExtTunnel.AddTo(m) +} + +// Measurer performs the measurement. +type Measurer struct { + Config +} + +// ExperimentName implements model.ExperimentSession.ExperimentName +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements model.ExperimentSession.ExperimentVersion +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements model.ExperimentSession.Run +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + // When using the urlgetter experiment directly, there is a nonconfigurable + // default timeout that applies. When urlgetter is used as a library, it's + // instead the responsibility of the user of urlgetter to set timeouts. Note + // that this code is indeed only called when using urlgetter directly. + if m.Config.Timeout <= 0 { + m.Config.Timeout = 45 * time.Second + } + RegisterExtensions(measurement) + g := Getter{ + Config: m.Config, + Session: sess, + Target: string(measurement.Input), + } + tk, err := g.Get(ctx) + measurement.TestKeys = &tk + return err +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/urlgetter/urlgetter_test.go b/internal/engine/experiment/urlgetter/urlgetter_test.go new file mode 100644 index 0000000..60bb2f0 --- /dev/null +++ b/internal/engine/experiment/urlgetter/urlgetter_test.go @@ -0,0 +1,90 @@ +package urlgetter_test + +import ( + "context" + "errors" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestMeasurer(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + m := urlgetter.NewExperimentMeasurer(urlgetter.Config{}) + if m.ExperimentName() != "urlgetter" { + t.Fatal("invalid experiment name") + } + if m.ExperimentVersion() != "0.1.0" { + t.Fatal("invalid experiment version") + } + measurement := new(model.Measurement) + measurement.Input = "https://www.google.com" + err := m.Run( + ctx, &mockable.Session{}, + measurement, model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if len(measurement.Extensions) != 6 { + t.Fatal("not the expected number of extensions") + } + tk := measurement.TestKeys.(*urlgetter.TestKeys) + if len(tk.DNSCache) != 0 { + t.Fatal("not the DNSCache value we expected") + } + sk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(urlgetter.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestMeasurerDNSCache(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + m := urlgetter.NewExperimentMeasurer(urlgetter.Config{ + DNSCache: "dns.google 8.8.8.8 8.8.4.4", + }) + if m.ExperimentName() != "urlgetter" { + t.Fatal("invalid experiment name") + } + if m.ExperimentVersion() != "0.1.0" { + t.Fatal("invalid experiment version") + } + measurement := new(model.Measurement) + measurement.Input = "https://www.google.com" + err := m.Run( + ctx, &mockable.Session{}, + measurement, model.NewPrinterCallbacks(log.Log), + ) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if len(measurement.Extensions) != 6 { + t.Fatal("not the expected number of extensions") + } + tk := measurement.TestKeys.(*urlgetter.TestKeys) + if len(tk.DNSCache) != 1 || tk.DNSCache[0] != "dns.google 8.8.8.8 8.8.4.4" { + t.Fatal("invalid tk.DNSCache") + } +} + +func TestSummaryKeysGeneric(t *testing.T) { + measurement := &model.Measurement{TestKeys: &urlgetter.TestKeys{}} + m := &urlgetter.Measurer{} + osk, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + sk := osk.(urlgetter.SummaryKeys) + if sk.IsAnomaly { + t.Fatal("invalid isAnomaly") + } +} diff --git a/internal/engine/experiment/webconnectivity/connects.go b/internal/engine/experiment/webconnectivity/connects.go new file mode 100644 index 0000000..4f047d2 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/connects.go @@ -0,0 +1,58 @@ +package webconnectivity + +import ( + "context" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// ConnectsConfig contains the config for Connects +type ConnectsConfig struct { + Session model.ExperimentSession + TargetURL *url.URL + URLGetterURLs []string +} + +// TODO(bassosimone): we should normalize the timings + +// ConnectsResult contains the results of Connects +type ConnectsResult struct { + AllKeys []urlgetter.TestKeys + Successes int + Total int +} + +// Connects performs 0..N connects (either using TCP or TLS) to +// check whether the resolved endpoints are reachable. +func Connects(ctx context.Context, config ConnectsConfig) (out ConnectsResult) { + out.AllKeys = []urlgetter.TestKeys{} + multi := urlgetter.Multi{Session: config.Session} + inputs := []urlgetter.MultiInput{} + for _, url := range config.URLGetterURLs { + inputs = append(inputs, urlgetter.MultiInput{ + Config: urlgetter.Config{ + TLSServerName: config.TargetURL.Hostname(), + }, + Target: url, + }) + } + outputs := multi.Collect(ctx, inputs, "check", ConnectsNoCallbacks{}) + for multiout := range outputs { + out.AllKeys = append(out.AllKeys, multiout.TestKeys) + for _, entry := range multiout.TestKeys.TCPConnect { + if entry.Status.Success { + out.Successes++ + } + out.Total++ + } + } + return +} + +// ConnectsNoCallbacks suppresses the callbacks +type ConnectsNoCallbacks struct{} + +// OnProgress implements ExperimentCallbacks.OnProgress +func (ConnectsNoCallbacks) OnProgress(percentage float64, message string) {} diff --git a/internal/engine/experiment/webconnectivity/connects_test.go b/internal/engine/experiment/webconnectivity/connects_test.go new file mode 100644 index 0000000..62060da --- /dev/null +++ b/internal/engine/experiment/webconnectivity/connects_test.go @@ -0,0 +1,55 @@ +package webconnectivity_test + +import ( + "context" + "net/url" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" +) + +func TestConnectsSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + ctx := context.Background() + r := webconnectivity.Connects(ctx, webconnectivity.ConnectsConfig{ + Session: newsession(t, false), + TargetURL: &url.URL{Scheme: "https", Host: "cloudflare-dns.com", Path: "/"}, + URLGetterURLs: []string{ + "tlshandshake://104.16.249.249:443", "tlshandshake://104.16.248.249:443", + "tlshandshake://[2606:4700::6810:f9f9]:443", + "tlshandshake://[2606:4700::6810:f8f9]:443", + }, + }) + if len(r.AllKeys) != 4 { + t.Fatal("unexpected number of TestKeys lists") + } + if r.Successes < 1 { + t.Fatal("no successes?!") + } + if r.Total != 4 { + t.Fatal("unexpected number of attempts") + } +} + +func TestConnectsNoInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + ctx := context.Background() + r := webconnectivity.Connects(ctx, webconnectivity.ConnectsConfig{ + Session: newsession(t, false), + TargetURL: &url.URL{Scheme: "https", Host: "cloudflare-dns.com", Path: "/"}, + URLGetterURLs: []string{}, + }) + if len(r.AllKeys) != 0 { + t.Fatal("unexpected number of TestKeys lists") + } + if r.Successes != 0 { + t.Fatal("successes?!") + } + if r.Total != 0 { + t.Fatal("unexpected number of attempts") + } +} diff --git a/internal/engine/experiment/webconnectivity/control.go b/internal/engine/experiment/webconnectivity/control.go new file mode 100644 index 0000000..fecf6e9 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/control.go @@ -0,0 +1,84 @@ +package webconnectivity + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/geolocate" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// ControlRequest is the request that we send to the control +type ControlRequest struct { + HTTPRequest string `json:"http_request"` + HTTPRequestHeaders map[string][]string `json:"http_request_headers"` + TCPConnect []string `json:"tcp_connect"` +} + +// ControlTCPConnectResult is the result of the TCP connect +// attempt performed by the control vantage point. +type ControlTCPConnectResult struct { + Status bool `json:"status"` + Failure *string `json:"failure"` +} + +// ControlHTTPRequestResult is the result of the HTTP request +// performed by the control vantage point. +type ControlHTTPRequestResult struct { + BodyLength int64 `json:"body_length"` + Failure *string `json:"failure"` + Title string `json:"title"` + Headers map[string]string `json:"headers"` + StatusCode int64 `json:"status_code"` +} + +// ControlDNSResult is the result of the DNS lookup +// performed by the control vantage point. +type ControlDNSResult struct { + Failure *string `json:"failure"` + Addrs []string `json:"addrs"` + ASNs []int64 `json:"-"` // not visible from the JSON +} + +// ControlResponse is the response from the control service. +type ControlResponse struct { + TCPConnect map[string]ControlTCPConnectResult `json:"tcp_connect"` + HTTPRequest ControlHTTPRequestResult `json:"http_request"` + DNS ControlDNSResult `json:"dns"` +} + +// Control performs the control request and returns the response. +func Control( + ctx context.Context, sess model.ExperimentSession, + thAddr string, creq ControlRequest) (out ControlResponse, err error) { + clnt := httpx.Client{ + BaseURL: thAddr, + HTTPClient: sess.DefaultHTTPClient(), + Logger: sess.Logger(), + } + sess.Logger().Infof("control %s...", creq.HTTPRequest) + // make sure error is wrapped + err = errorx.SafeErrWrapperBuilder{ + Error: clnt.PostJSON(ctx, "/", creq, &out), + Operation: errorx.TopLevelOperation, + }.MaybeBuild() + sess.Logger().Infof("control %s... %+v", creq.HTTPRequest, err) + (&out.DNS).FillASNs(sess) + return +} + +// FillASNs fills the ASNs array of ControlDNSResult. For each Addr inside +// of the ControlDNSResult structure, we obtain the corresponding ASN. +// +// This is very useful to know what ASNs were the IP addresses returned by +// the control according to the probe's ASN database. +func (dns *ControlDNSResult) FillASNs(sess model.ExperimentSession) { + dns.ASNs = []int64{} + for _, ip := range dns.Addrs { + // TODO(bassosimone): this would be more efficient if we'd open just + // once the database and then reuse it for every address. + asn, _, _ := geolocate.LookupASN(sess.ASNDatabasePath(), ip) + dns.ASNs = append(dns.ASNs, int64(asn)) + } +} diff --git a/internal/engine/experiment/webconnectivity/control_test.go b/internal/engine/experiment/webconnectivity/control_test.go new file mode 100644 index 0000000..dc704ad --- /dev/null +++ b/internal/engine/experiment/webconnectivity/control_test.go @@ -0,0 +1,36 @@ +package webconnectivity_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" +) + +func TestFillASNsEmpty(t *testing.T) { + dns := new(webconnectivity.ControlDNSResult) + dns.FillASNs(new(mockable.Session)) + if diff := cmp.Diff(dns.ASNs, []int64{}); diff != "" { + t.Fatal(diff) + } +} + +func TestFillASNsNoDatabase(t *testing.T) { + dns := new(webconnectivity.ControlDNSResult) + dns.Addrs = []string{"8.8.8.8", "1.1.1.1"} + dns.FillASNs(new(mockable.Session)) + if diff := cmp.Diff(dns.ASNs, []int64{0, 0}); diff != "" { + t.Fatal(diff) + } +} + +func TestFillASNsSuccess(t *testing.T) { + sess := newsession(t, false) + dns := new(webconnectivity.ControlDNSResult) + dns.Addrs = []string{"8.8.8.8", "1.1.1.1"} + dns.FillASNs(sess) + if diff := cmp.Diff(dns.ASNs, []int64{15169, 13335}); diff != "" { + t.Fatal(diff) + } +} diff --git a/internal/engine/experiment/webconnectivity/dnsanalysis.go b/internal/engine/experiment/webconnectivity/dnsanalysis.go new file mode 100644 index 0000000..5c0bb39 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/dnsanalysis.go @@ -0,0 +1,99 @@ +package webconnectivity + +import ( + "net" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// DNSAnalysisResult contains the results of analysing comparing +// the measurement and the control DNS results. +type DNSAnalysisResult struct { + DNSConsistency *string `json:"dns_consistency"` +} + +// DNSNameError is the error returned by the control on NXDOMAIN +const DNSNameError = "dns_name_error" + +var ( + // DNSConsistent indicates that the measurement and the + // control have consistent DNS results. + DNSConsistent = "consistent" + + // DNSInconsistent indicates that the measurement and the + // control have inconsistent DNS results. + DNSInconsistent = "inconsistent" +) + +// DNSAnalysis compares the measurement and the control DNS results. This +// implementation is a simplified version of the implementation of the same +// check implemented in Measurement Kit v0.10.11. +func DNSAnalysis(URL *url.URL, measurement DNSLookupResult, + control ControlResponse) (out DNSAnalysisResult) { + // 0. start assuming it's not consistent + out.DNSConsistency = &DNSInconsistent + // 1. flip to consistent if we're targeting an IP address because the + // control will actually return dns_name_error in this case. + if net.ParseIP(URL.Hostname()) != nil { + out.DNSConsistency = &DNSConsistent + return + } + // 2. flip to consistent if the failures are compatible + if measurement.Failure != nil && control.DNS.Failure != nil { + switch *control.DNS.Failure { + case DNSNameError: // the control returns this on NXDOMAIN error + switch *measurement.Failure { + case errorx.FailureDNSNXDOMAINError: + out.DNSConsistency = &DNSConsistent + } + } + return + } + // 3. flip to consistent if measurement and control returned IP addresses + // that belong to the same Autonomous System(s). + // + // This specific check is present in MK's implementation. + // + // Note that this covers also the cases where the measurement contains only + // bogons while the control does not contain bogons. + // + // Note that this also covers the cases where results are equal. + const ( + inMeasurement = 1 << 0 + inControl = 1 << 1 + inBoth = inMeasurement | inControl + ) + asnmap := make(map[int64]int) + for _, asn := range measurement.Addrs { + asnmap[asn] |= inMeasurement + } + for _, asn := range control.DNS.ASNs { + asnmap[asn] |= inControl + } + for key, value := range asnmap { + // zero means that ASN lookup failed + if key != 0 && (value&inBoth) == inBoth { + out.DNSConsistency = &DNSConsistent + return + } + } + // 4. when ASN lookup failed (unlikely), check whether + // there is overlap in the returned IP addresses + ipmap := make(map[string]int) + for ip := range measurement.Addrs { + ipmap[ip] |= inMeasurement + } + for _, ip := range control.DNS.Addrs { + ipmap[ip] |= inControl + } + for key, value := range ipmap { + // just in case an empty string slipped through + if key != "" && (value&inBoth) == inBoth { + out.DNSConsistency = &DNSConsistent + return + } + } + // 5. conclude that measurement and control are inconsistent + return +} diff --git a/internal/engine/experiment/webconnectivity/dnsanalysis_test.go b/internal/engine/experiment/webconnectivity/dnsanalysis_test.go new file mode 100644 index 0000000..d4369e9 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/dnsanalysis_test.go @@ -0,0 +1,193 @@ +package webconnectivity_test + +import ( + "io" + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestDNSAnalysis(t *testing.T) { + measurementFailure := errorx.FailureDNSNXDOMAINError + controlFailure := webconnectivity.DNSNameError + eofFailure := io.EOF.Error() + type args struct { + URL *url.URL + measurement webconnectivity.DNSLookupResult + control webconnectivity.ControlResponse + } + tests := []struct { + name string + args args + wantOut webconnectivity.DNSAnalysisResult + }{{ + name: "when the URL contains an IP address", + args: args{ + URL: &url.URL{ + Host: "10.0.0.1", + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + Failure: &controlFailure, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + }, { + name: "when the failures are not compatible", + args: args{ + URL: &url.URL{ + Host: "www.kerneltrap.org", + }, + measurement: webconnectivity.DNSLookupResult{ + Failure: &eofFailure, + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + Failure: &controlFailure, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + }, { + name: "when the failures are compatible", + args: args{ + URL: &url.URL{ + Host: "www.kerneltrap.org", + }, + measurement: webconnectivity.DNSLookupResult{ + Failure: &measurementFailure, + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + Failure: &controlFailure, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + }, { + name: "when the ASNs are equal", + args: args{ + URL: &url.URL{ + Host: "fancy.dns", + }, + measurement: webconnectivity.DNSLookupResult{ + Addrs: map[string]int64{ + "1.1.1.1": 15169, + "8.8.8.8": 13335, + }, + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + ASNs: []int64{13335, 15169}, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + }, { + name: "when the ASNs overlap", + args: args{ + URL: &url.URL{ + Host: "fancy.dns", + }, + measurement: webconnectivity.DNSLookupResult{ + Addrs: map[string]int64{ + "1.1.1.1": 15169, + "8.8.8.8": 13335, + }, + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + ASNs: []int64{13335, 13335}, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + }, { + name: "when the ASNs do not overlap", + args: args{ + URL: &url.URL{ + Host: "fancy.dns", + }, + measurement: webconnectivity.DNSLookupResult{ + Addrs: map[string]int64{ + "1.1.1.1": 15169, + "8.8.8.8": 15169, + }, + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + ASNs: []int64{13335, 13335}, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + }, { + name: "when ASNs lookup fails but IPs overlap", + args: args{ + URL: &url.URL{ + Host: "fancy.dns", + }, + measurement: webconnectivity.DNSLookupResult{ + Addrs: map[string]int64{ + "2001:4860:4860::8844": 0, + "8.8.4.4": 0, + }, + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + Addrs: []string{"8.8.8.8", "2001:4860:4860::8844"}, + ASNs: []int64{0, 0}, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + }, { + name: "when ASNs lookup fails and IPs do not overlap", + args: args{ + URL: &url.URL{ + Host: "fancy.dns", + }, + measurement: webconnectivity.DNSLookupResult{ + Addrs: map[string]int64{ + "2001:4860:4860::8888": 0, + "8.8.8.8": 0, + }, + }, + control: webconnectivity.ControlResponse{ + DNS: webconnectivity.ControlDNSResult{ + Addrs: []string{"8.8.4.4", "2001:4860:4860::8844"}, + ASNs: []int64{0, 0}, + }, + }, + }, + wantOut: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := webconnectivity.DNSAnalysis(tt.args.URL, tt.args.measurement, tt.args.control) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/internal/engine/experiment/webconnectivity/dnslookup.go b/internal/engine/experiment/webconnectivity/dnslookup.go new file mode 100644 index 0000000..92133b7 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/dnslookup.go @@ -0,0 +1,59 @@ +package webconnectivity + +import ( + "context" + "fmt" + "net/url" + "sort" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// DNSLookupConfig contains settings for the DNS lookup. +type DNSLookupConfig struct { + Session model.ExperimentSession + URL *url.URL +} + +// DNSLookupResult contains the result of the DNS lookup. +type DNSLookupResult struct { + Addrs map[string]int64 + Failure *string + TestKeys urlgetter.TestKeys +} + +// DNSLookup performs the DNS lookup part of Web Connectivity. +func DNSLookup(ctx context.Context, config DNSLookupConfig) (out DNSLookupResult) { + target := fmt.Sprintf("dnslookup://%s", config.URL.Hostname()) + config.Session.Logger().Infof("%s...", target) + result, err := urlgetter.Getter{Session: config.Session, Target: target}.Get(ctx) + out.Addrs = make(map[string]int64) + for _, query := range result.Queries { + for _, answer := range query.Answers { + if answer.IPv4 != "" { + out.Addrs[answer.IPv4] = answer.ASN + continue + } + if answer.IPv6 != "" { + out.Addrs[answer.IPv6] = answer.ASN + } + } + } + config.Session.Logger().Infof("%s... %+v", target, err) + out.Failure = result.Failure + out.TestKeys = result + return +} + +// Addresses returns the IP addresses in the DNSLookupResult +func (r DNSLookupResult) Addresses() (out []string) { + out = []string{} + for addr := range r.Addrs { + out = append(out, addr) + } + sort.Slice(out, func(i, j int) bool { + return out[i] < out[j] + }) + return +} diff --git a/internal/engine/experiment/webconnectivity/dnslookup_test.go b/internal/engine/experiment/webconnectivity/dnslookup_test.go new file mode 100644 index 0000000..0b75708 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/dnslookup_test.go @@ -0,0 +1,79 @@ +package webconnectivity_test + +import ( + "context" + "net" + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" +) + +func TestDNSLookup(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + config := webconnectivity.DNSLookupConfig{ + Session: newsession(t, true), + URL: &url.URL{Host: "dns.google"}, + } + out := webconnectivity.DNSLookup(context.Background(), config) + if out.Failure != nil { + t.Fatal(*out.Failure) + } + if len(out.Addrs) < 1 { + t.Fatal("no addresses?!") + } + for addr, asn := range out.Addrs { + if net.ParseIP(addr) == nil { + t.Fatal("invalid addr") + } + if asn != 15169 { + t.Fatal("invalid asn") + } + } + if len(out.TestKeys.NetworkEvents) < 1 { + t.Fatal("no network events?!") + } + if len(out.TestKeys.Queries) < 1 { + t.Fatal("no queries?!") + } +} + +func TestDNSLookupResult_Addresses(t *testing.T) { + type fields struct { + Addrs map[string]int64 + Failure *string + TestKeys urlgetter.TestKeys + } + tests := []struct { + name string + fields fields + wantOut []string + }{{ + name: "with no entries", + fields: fields{}, + wantOut: []string{}, + }, { + name: "with some entries", + fields: fields{ + Addrs: map[string]int64{"1.1.1.1": 1, "2001:4860:4860::8844": 2}, + }, + wantOut: []string{"1.1.1.1", "2001:4860:4860::8844"}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := webconnectivity.DNSLookupResult{ + Addrs: tt.fields.Addrs, + Failure: tt.fields.Failure, + TestKeys: tt.fields.TestKeys, + } + gotOut := r.Addresses() + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/internal/engine/experiment/webconnectivity/endpoints.go b/internal/engine/experiment/webconnectivity/endpoints.go new file mode 100644 index 0000000..ce39326 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/endpoints.go @@ -0,0 +1,77 @@ +package webconnectivity + +import ( + "net" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" +) + +// EndpointInfo describes a TCP/TLS endpoint. +type EndpointInfo struct { + String string // String representation + URLGetterURL string // URL for urlgetter +} + +// EndpointsList is a list of EndpointInfo +type EndpointsList []EndpointInfo + +// Endpoints returns a list of endpoints for TCP connect +func (el EndpointsList) Endpoints() (out []string) { + out = []string{} + for _, ei := range el { + out = append(out, ei.String) + } + return +} + +// URLs returns a list of URLs for TCP urlgetter +func (el EndpointsList) URLs() (out []string) { + out = []string{} + for _, ei := range el { + out = append(out, ei.URLGetterURL) + } + return +} + +// NewEndpoints creates a list of TCP/TLS endpoints to test from the +// target URL and the list of resolved IP addresses. +func NewEndpoints(URL *url.URL, addrs []string) (out EndpointsList) { + out = EndpointsList{} + port := NewEndpointPort(URL) + for _, addr := range addrs { + endpoint := net.JoinHostPort(addr, port.Port) + out = append(out, EndpointInfo{ + String: endpoint, + URLGetterURL: (&url.URL{Scheme: port.URLGetterScheme, Host: endpoint}).String(), + }) + } + return +} + +// EndpointPort is the port to be used by a TCP/TLS endpoint. +type EndpointPort struct { + URLGetterScheme string + Port string +} + +// NewEndpointPort creates an EndpointPort from the given URL. This function +// panic if the scheme is not `http` or `https` as well as if the host is not +// valid. The latter should not happen if you used url.Parse. +func NewEndpointPort(URL *url.URL) (out EndpointPort) { + if URL.Scheme != "http" && URL.Scheme != "https" { + panic("passed an unexpected scheme") + } + switch URL.Scheme { + case "http": + out.URLGetterScheme, out.Port = "tcpconnect", "80" + case "https": + out.URLGetterScheme, out.Port = "tlshandshake", "443" + } + if URL.Host != URL.Hostname() { + _, port, err := net.SplitHostPort(URL.Host) + runtimex.PanicOnError(err, "SplitHostPort should not fail here") + out.Port = port + } + return +} diff --git a/internal/engine/experiment/webconnectivity/endpoints_test.go b/internal/engine/experiment/webconnectivity/endpoints_test.go new file mode 100644 index 0000000..9baffbf --- /dev/null +++ b/internal/engine/experiment/webconnectivity/endpoints_test.go @@ -0,0 +1,224 @@ +package webconnectivity_test + +import ( + "net/url" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" +) + +func TestNewEndpointPortPanicsWithInvalidScheme(t *testing.T) { + counter := atomicx.NewInt64() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer func() { + if recover() != nil { + counter.Add(1) + } + wg.Done() + }() + webconnectivity.NewEndpointPort(&url.URL{Scheme: "antani"}) + }() + wg.Wait() + if counter.Load() != 1 { + t.Fatal("did not panic") + } +} + +func TestNewEndpointPortPanicsWithInvalidHost(t *testing.T) { + counter := atomicx.NewInt64() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer func() { + if recover() != nil { + counter.Add(1) + } + wg.Done() + }() + webconnectivity.NewEndpointPort(&url.URL{Scheme: "http", Host: "[::1"}) + }() + wg.Wait() + if counter.Load() != 1 { + t.Fatal("did not panic") + } +} + +func TestNewEndpointPortCommonCase(t *testing.T) { + type args struct { + URL *url.URL + } + tests := []struct { + name string + args args + wantOut webconnectivity.EndpointPort + }{{ + name: "with http and no default port", + args: args{URL: &url.URL{ + Scheme: "http", + Host: "www.example.com", + Path: "/", + }}, + wantOut: webconnectivity.EndpointPort{ + URLGetterScheme: "tcpconnect", + Port: "80", + }, + }, { + name: "with https and no default port", + args: args{URL: &url.URL{ + Scheme: "https", + Host: "www.example.com", + Path: "/", + }}, + wantOut: webconnectivity.EndpointPort{ + URLGetterScheme: "tlshandshake", + Port: "443", + }, + }, { + name: "with http and custom port", + args: args{URL: &url.URL{ + Scheme: "http", + Host: "www.example.com:11", + Path: "/", + }}, + wantOut: webconnectivity.EndpointPort{ + URLGetterScheme: "tcpconnect", + Port: "11", + }, + }, { + name: "with https and custom port", + args: args{URL: &url.URL{ + Scheme: "https", + Host: "www.example.com:11", + Path: "/", + }}, + wantOut: webconnectivity.EndpointPort{ + URLGetterScheme: "tlshandshake", + Port: "11", + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := webconnectivity.NewEndpointPort(tt.args.URL) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestNewEndpoints(t *testing.T) { + type args struct { + URL *url.URL + addrs []string + } + tests := []struct { + name string + args args + wantOut webconnectivity.EndpointsList + }{{ + name: "with all empty", + args: args{ + URL: &url.URL{ + Scheme: "http", + }, + }, + wantOut: webconnectivity.EndpointsList{}, + }, { + name: "with some https endpoints", + args: args{ + URL: &url.URL{ + Scheme: "https", + }, + addrs: []string{"1.1.1.1", "8.8.8.8"}, + }, + wantOut: webconnectivity.EndpointsList{{ + URLGetterURL: "tlshandshake://1.1.1.1:443", + String: "1.1.1.1:443", + }, { + URLGetterURL: "tlshandshake://8.8.8.8:443", + String: "8.8.8.8:443", + }}, + }, { + name: "with some http endpoints", + args: args{ + URL: &url.URL{ + Scheme: "http", + }, + addrs: []string{"2001:4860:4860::8888", "2001:4860:4860::8844"}, + }, + wantOut: webconnectivity.EndpointsList{{ + URLGetterURL: "tcpconnect://[2001:4860:4860::8888]:80", + String: "[2001:4860:4860::8888]:80", + }, { + URLGetterURL: "tcpconnect://[2001:4860:4860::8844]:80", + String: "[2001:4860:4860::8844]:80", + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := webconnectivity.NewEndpoints(tt.args.URL, tt.args.addrs) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestEndpointsList_Endpoints(t *testing.T) { + tests := []struct { + name string + el webconnectivity.EndpointsList + wantOut []string + }{{ + name: "when empty", + wantOut: []string{}, + }, { + name: "common case", + el: webconnectivity.EndpointsList{{ + String: "1.1.1.1:443", + }, { + String: "8.8.8.8:80", + }}, + wantOut: []string{"1.1.1.1:443", "8.8.8.8:80"}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := tt.el.Endpoints() + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestEndpointsList_URLs(t *testing.T) { + tests := []struct { + name string + el webconnectivity.EndpointsList + wantOut []string + }{{ + name: "when empty", + wantOut: []string{}, + }, { + name: "common case", + el: webconnectivity.EndpointsList{{ + URLGetterURL: "tlshandshake://1.1.1.1:443", + }, { + URLGetterURL: "tcpconnect://8.8.8.8:80", + }}, + wantOut: []string{"tlshandshake://1.1.1.1:443", "tcpconnect://8.8.8.8:80"}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := tt.el.URLs() + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/internal/engine/experiment/webconnectivity/httpanalysis.go b/internal/engine/experiment/webconnectivity/httpanalysis.go new file mode 100644 index 0000000..f81baf1 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/httpanalysis.go @@ -0,0 +1,250 @@ +package webconnectivity + +import ( + "reflect" + "regexp" + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity/internal" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// HTTPAnalysisResult contains the results of the analysis performed on the +// client. We obtain it by comparing the measurement and the control. +type HTTPAnalysisResult struct { + BodyLengthMatch *bool `json:"body_length_match"` + BodyProportion float64 `json:"body_proportion"` + StatusCodeMatch *bool `json:"status_code_match"` + HeadersMatch *bool `json:"headers_match"` + TitleMatch *bool `json:"title_match"` +} + +// Log logs the results of the analysis +func (har HTTPAnalysisResult) Log(logger model.Logger) { + logger.Infof("BodyLengthMatch: %+v", internal.BoolPointerToString(har.BodyLengthMatch)) + logger.Infof("BodyProportion: %+v", har.BodyProportion) + logger.Infof("StatusCodeMatch: %+v", internal.BoolPointerToString(har.StatusCodeMatch)) + logger.Infof("HeadersMatch: %+v", internal.BoolPointerToString(har.HeadersMatch)) + logger.Infof("TitleMatch: %+v", internal.BoolPointerToString(har.TitleMatch)) +} + +// HTTPAnalysis performs follow-up analysis on the webconnectivity measurement by +// comparing the measurement test keys and the control. +func HTTPAnalysis(tk urlgetter.TestKeys, ctrl ControlResponse) (out HTTPAnalysisResult) { + out.BodyLengthMatch, out.BodyProportion = HTTPBodyLengthChecks(tk, ctrl) + out.StatusCodeMatch = HTTPStatusCodeMatch(tk, ctrl) + out.HeadersMatch = HTTPHeadersMatch(tk, ctrl) + out.TitleMatch = HTTPTitleMatch(tk, ctrl) + return +} + +// HTTPBodyLengthChecks returns whether the measured body is reasonably +// long as much as the control body as well as the proportion between +// the two bodies. This check may return nil, nil when such a +// comparison would actually not be applicable. +func HTTPBodyLengthChecks( + tk urlgetter.TestKeys, ctrl ControlResponse) (match *bool, proportion float64) { + control := ctrl.HTTPRequest.BodyLength + if control <= 0 { + return + } + if len(tk.Requests) <= 0 { + return + } + response := tk.Requests[0].Response + if response.BodyIsTruncated { + return + } + measurement := int64(len(response.Body.Value)) + if measurement <= 0 { + return + } + const bodyProportionFactor = 0.7 + if measurement >= control { + proportion = float64(control) / float64(measurement) + } else { + proportion = float64(measurement) / float64(control) + } + v := proportion > bodyProportionFactor + match = &v + return +} + +// HTTPStatusCodeMatch returns whether the status code of the measurement +// matches the status code of the control, or nil if such comparison +// is actually not applicable. +func HTTPStatusCodeMatch(tk urlgetter.TestKeys, ctrl ControlResponse) (out *bool) { + control := ctrl.HTTPRequest.StatusCode + if len(tk.Requests) < 1 { + return // no real status code + } + measurement := tk.Requests[0].Response.Code + if control == 0 { + return // no real status code + } + if measurement == 0 { + return // no real status code + } + value := control == measurement + if value == true { + // if the status codes are equal, they clearly match + out = &value + return + } + // This fix is part of Web Connectivity in MK and in Python since + // basically forever; my recollection is that we want to work around + // cases where the test helper is failing(?!). Unlike previous + // implementations, this implementation avoids a false positive + // when both measurement and control statuses are 500. + if control/100 == 5 { + return + } + out = &value + return +} + +// HTTPHeadersMatch returns whether uncommon headers match between control and +// measurement, or nil if check is not applicable. +func HTTPHeadersMatch(tk urlgetter.TestKeys, ctrl ControlResponse) *bool { + if len(tk.Requests) <= 0 { + return nil + } + if tk.Requests[0].Response.Code == 0 { + return nil + } + if ctrl.HTTPRequest.StatusCode == 0 { + return nil + } + control := ctrl.HTTPRequest.Headers + // Implementation note: using map because we only care about the + // keys being different and we ignore the values. + measurement := tk.Requests[0].Response.Headers + const ( + inMeasurement = 1 << 0 + inControl = 1 << 1 + inBoth = inMeasurement | inControl + ) + commonHeaders := map[string]bool{ + "date": true, + "content-type": true, + "server": true, + "cache-control": true, + "vary": true, + "set-cookie": true, + "location": true, + "expires": true, + "x-powered-by": true, + "content-encoding": true, + "last-modified": true, + "accept-ranges": true, + "pragma": true, + "x-frame-options": true, + "etag": true, + "x-content-type-options": true, + "age": true, + "via": true, + "p3p": true, + "x-xss-protection": true, + "content-language": true, + "cf-ray": true, + "strict-transport-security": true, + "link": true, + "x-varnish": true, + } + matching := make(map[string]int) + ours := make(map[string]bool) + for key := range measurement { + key = strings.ToLower(key) + if _, ok := commonHeaders[key]; !ok { + matching[key] |= inMeasurement + } + ours[key] = true + } + theirs := make(map[string]bool) + for key := range control { + key = strings.ToLower(key) + if _, ok := commonHeaders[key]; !ok { + matching[key] |= inControl + } + theirs[key] = true + } + // if they are equal we're done + if good := reflect.DeepEqual(ours, theirs); good { + return &good + } + // compute the intersection of uncommon headers + var intersection int + for _, value := range matching { + if (value & inBoth) == inBoth { + intersection++ + } + } + good := intersection > 0 + return &good +} + +// GetTitle returns the title or an empty string. +func GetTitle(measurementBody string) string { + re := regexp.MustCompile(`(?i)([^<]{1,128})`) // like MK + v := re.FindStringSubmatch(measurementBody) + if len(v) < 2 { + return "" + } + return v[1] +} + +// HTTPTitleMatch returns whether the measurement and the control titles +// reasonably match, or nil if not applicable. +func HTTPTitleMatch(tk urlgetter.TestKeys, ctrl ControlResponse) (out *bool) { + if len(tk.Requests) <= 0 { + return + } + response := tk.Requests[0].Response + if response.Code == 0 { + return + } + if response.BodyIsTruncated { + return + } + if ctrl.HTTPRequest.StatusCode == 0 { + return + } + control := ctrl.HTTPRequest.Title + measurementBody := response.Body.Value + measurement := GetTitle(measurementBody) + if measurement == "" { + return + } + const ( + inMeasurement = 1 << 0 + inControl = 1 << 1 + inBoth = inMeasurement | inControl + ) + words := make(map[string]int) + // We don't consider to match words that are shorter than 5 + // characters (5 is the average word length for english) + // + // The original implementation considered the word order but + // considering different languages it seems we could have less + // false positives by ignoring the word order. + const minWordLength = 5 + for _, word := range strings.Split(measurement, " ") { + if len(word) >= minWordLength { + words[strings.ToLower(word)] |= inMeasurement + } + } + for _, word := range strings.Split(control, " ") { + if len(word) >= minWordLength { + words[strings.ToLower(word)] |= inControl + } + } + good := true + for _, score := range words { + if (score & inBoth) != inBoth { + good = false + break + } + } + return &good +} diff --git a/internal/engine/experiment/webconnectivity/httpanalysis_test.go b/internal/engine/experiment/webconnectivity/httpanalysis_test.go new file mode 100644 index 0000000..fc69b1b --- /dev/null +++ b/internal/engine/experiment/webconnectivity/httpanalysis_test.go @@ -0,0 +1,760 @@ +package webconnectivity_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "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/internal/randx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" +) + +func TestHTTPBodyLengthChecks(t *testing.T) { + var ( + trueValue = true + falseValue = false + ) + type args struct { + tk urlgetter.TestKeys + ctrl webconnectivity.ControlResponse + } + tests := []struct { + name string + args args + lengthMatch *bool + proportion float64 + }{{ + name: "nothing", + args: args{}, + lengthMatch: nil, + }, { + name: "control length is nonzero", + args: args{ + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + BodyLength: 1024, + }, + }, + }, + lengthMatch: nil, + }, { + name: "response body is truncated", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + BodyIsTruncated: true, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + BodyLength: 1024, + }, + }, + }, + lengthMatch: nil, + }, { + name: "response body length is zero", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{}, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + BodyLength: 1024, + }, + }, + }, + lengthMatch: nil, + }, { + name: "match with bigger control", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Body: archival.MaybeBinaryValue{ + Value: randx.Letters(768), + }, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + BodyLength: 1024, + }, + }, + }, + lengthMatch: &trueValue, + proportion: 0.75, + }, { + name: "match with bigger measurement", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Body: archival.MaybeBinaryValue{ + Value: randx.Letters(1024), + }, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + BodyLength: 768, + }, + }, + }, + lengthMatch: &trueValue, + proportion: 0.75, + }, { + name: "not match with bigger control", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Body: archival.MaybeBinaryValue{ + Value: randx.Letters(8), + }, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + BodyLength: 16, + }, + }, + }, + lengthMatch: &falseValue, + proportion: 0.5, + }, { + name: "match with bigger measurement", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Body: archival.MaybeBinaryValue{ + Value: randx.Letters(16), + }, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + BodyLength: 8, + }, + }, + }, + lengthMatch: &falseValue, + proportion: 0.5, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, proportion := webconnectivity.HTTPBodyLengthChecks(tt.args.tk, tt.args.ctrl) + if diff := cmp.Diff(tt.lengthMatch, match); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff(tt.proportion, proportion); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestStatusCodeMatch(t *testing.T) { + var ( + trueValue = true + falseValue = false + ) + type args struct { + tk urlgetter.TestKeys + ctrl webconnectivity.ControlResponse + } + tests := []struct { + name string + args args + wantOut *bool + }{{ + name: "with all zero", + args: args{}, + }, { + name: "with a request but zero status codes", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{}}, + }, + }, + }, { + name: "with equal status codes including 5xx", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 501, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 501, + }, + }, + }, + wantOut: &trueValue, + }, { + name: "with different status codes and the control being 5xx", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 407, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 501, + }, + }, + }, + wantOut: nil, + }, { + name: "with different status codes and the control being not 5xx", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 407, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 200, + }, + }, + }, + wantOut: &falseValue, + }, { + name: "with only response status code and no control status code", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + }, + }}, + }, + }, + }, { + name: "with only control status code and no response status code", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 0, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 200, + }, + }, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := webconnectivity.HTTPStatusCodeMatch(tt.args.tk, tt.args.ctrl) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestHeadersMatch(t *testing.T) { + var ( + trueValue = true + falseValue = false + ) + type args struct { + tk urlgetter.TestKeys + ctrl webconnectivity.ControlResponse + } + tests := []struct { + name string + args args + want *bool + }{{ + name: "with no requests", + args: args{ + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + "Date": "Mon Jul 13 21:05:43 CEST 2020", + "Antani": "Mascetti", + }, + StatusCode: 200, + }, + }, + }, + want: nil, + }, { + name: "with basically nothing", + args: args{}, + want: nil, + }, { + name: "with request and no response status code", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{}}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + "Date": "Mon Jul 13 21:05:43 CEST 2020", + "Antani": "Mascetti", + }, + StatusCode: 200, + }, + }, + }, + want: nil, + }, { + name: "with no control status code", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "Date": {Value: "Mon Jul 13 21:10:08 CEST 2020"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{}, + }, + want: nil, + }, { + name: "with no uncommon headers", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "Date": {Value: "Mon Jul 13 21:10:08 CEST 2020"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + "Date": "Mon Jul 13 21:05:43 CEST 2020", + }, + StatusCode: 200, + }, + }, + }, + want: &trueValue, + }, { + name: "with equal uncommon headers", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "Date": {Value: "Mon Jul 13 21:10:08 CEST 2020"}, + "Antani": {Value: "MASCETTI"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + "Date": "Mon Jul 13 21:05:43 CEST 2020", + "Antani": "MELANDRI", + }, + StatusCode: 200, + }, + }, + }, + want: &trueValue, + }, { + name: "with different uncommon headers", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "Date": {Value: "Mon Jul 13 21:10:08 CEST 2020"}, + "Antani": {Value: "MASCETTI"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + "Date": "Mon Jul 13 21:05:43 CEST 2020", + "Melandri": "MASCETTI", + }, + StatusCode: 200, + }, + }, + }, + want: &falseValue, + }, { + name: "with small uncommon intersection (X-Cache)", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "Accept-Ranges": {Value: "bytes"}, + "Age": {Value: "404727"}, + "Cache-Control": {Value: "max-age=604800"}, + "Content-Length": {Value: "1256"}, + "Content-Type": {Value: "text/html; charset=UTF-8"}, + "Date": {Value: "Tue, 14 Jul 2020 22:26:09 GMT"}, + "Etag": {Value: "\"3147526947\""}, + "Expires": {Value: "Tue, 21 Jul 2020 22:26:09 GMT"}, + "Last-Modified": {Value: "Thu, 17 Oct 2019 07:18:26 GMT"}, + "Server": {Value: "ECS (dcb/7F3C)"}, + "Vary": {Value: "Accept-Encoding"}, + "X-Cache": {Value: "HIT"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + // Note: the test helper was probably requesting the + // resource in a different way. There is content-length + // in this response, maybe it's using HTTP/1.0? + "Accept-Ranges": "bytes", + "Age": "469182", + "Cache-Control": "max-age=604800", + "Content-Type": "text/html; charset=UTF-8", + "Date": "Tue, 14 Jul 2020 22:26:08 GMT", + "Etag": "\"3147526947\"", + "Expires": "Tue, 21 Jul 2020 22:26:08 GMT", + "Last-Modified": "Thu, 17 Oct 2019 07:18:26 GMT", + "Server": "ECS (nyb/1D07)", + "Vary": "Accept-Encoding", + "X-Cache": "HIT", + }, + StatusCode: 200, + }, + }, + }, + want: &trueValue, + }, { + name: "with no uncommon intersection", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "Accept-Ranges": {Value: "bytes"}, + "Age": {Value: "404727"}, + "Cache-Control": {Value: "max-age=604800"}, + "Content-Length": {Value: "1256"}, + "Content-Type": {Value: "text/html; charset=UTF-8"}, + "Date": {Value: "Tue, 14 Jul 2020 22:26:09 GMT"}, + "Etag": {Value: "\"3147526947\""}, + "Expires": {Value: "Tue, 21 Jul 2020 22:26:09 GMT"}, + "Last-Modified": {Value: "Thu, 17 Oct 2019 07:18:26 GMT"}, + "Server": {Value: "ECS (dcb/7F3C)"}, + "Vary": {Value: "Accept-Encoding"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + // Note: the test helper was probably requesting the + // resource in a different way. There is content-length + // in this response, maybe it's using HTTP/1.0? + "Accept-Ranges": "bytes", + "Age": "469182", + "Cache-Control": "max-age=604800", + "Content-Type": "text/html; charset=UTF-8", + "Date": "Tue, 14 Jul 2020 22:26:08 GMT", + "Etag": "\"3147526947\"", + "Expires": "Tue, 21 Jul 2020 22:26:08 GMT", + "Last-Modified": "Thu, 17 Oct 2019 07:18:26 GMT", + "Server": "ECS (nyb/1D07)", + "Vary": "Accept-Encoding", + }, + StatusCode: 200, + }, + }, + }, + want: &falseValue, + }, { + name: "with exactly equal headers", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "Accept-Ranges": {Value: "bytes"}, + "Age": {Value: "404727"}, + "Cache-Control": {Value: "max-age=604800"}, + "Content-Type": {Value: "text/html; charset=UTF-8"}, + "Date": {Value: "Tue, 14 Jul 2020 22:26:09 GMT"}, + "Etag": {Value: "\"3147526947\""}, + "Expires": {Value: "Tue, 21 Jul 2020 22:26:09 GMT"}, + "Last-Modified": {Value: "Thu, 17 Oct 2019 07:18:26 GMT"}, + "Server": {Value: "ECS (dcb/7F3C)"}, + "Vary": {Value: "Accept-Encoding"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + "Accept-Ranges": "bytes", + "Age": "469182", + "Cache-Control": "max-age=604800", + "Content-Type": "text/html; charset=UTF-8", + "Date": "Tue, 14 Jul 2020 22:26:08 GMT", + "Etag": "\"3147526947\"", + "Expires": "Tue, 21 Jul 2020 22:26:08 GMT", + "Last-Modified": "Thu, 17 Oct 2019 07:18:26 GMT", + "Server": "ECS (nyb/1D07)", + "Vary": "Accept-Encoding", + }, + StatusCode: 200, + }, + }, + }, + want: &trueValue, + }, { + name: "with equal headers except for the case", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Headers: map[string]archival.MaybeBinaryValue{ + "accept-ranges": {Value: "bytes"}, + "AGE": {Value: "404727"}, + "cache-Control": {Value: "max-age=604800"}, + "Content-TyPe": {Value: "text/html; charset=UTF-8"}, + "DatE": {Value: "Tue, 14 Jul 2020 22:26:09 GMT"}, + "etag": {Value: "\"3147526947\""}, + "expires": {Value: "Tue, 21 Jul 2020 22:26:09 GMT"}, + "Last-Modified": {Value: "Thu, 17 Oct 2019 07:18:26 GMT"}, + "SerVer": {Value: "ECS (dcb/7F3C)"}, + "Vary": {Value: "Accept-Encoding"}, + }, + Code: 200, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Headers: map[string]string{ + "Accept-Ranges": "bytes", + "Age": "469182", + "Cache-Control": "max-age=604800", + "Content-Type": "text/html; charset=UTF-8", + "Date": "Tue, 14 Jul 2020 22:26:08 GMT", + "Etag": "\"3147526947\"", + "Expires": "Tue, 21 Jul 2020 22:26:08 GMT", + "Last-Modified": "Thu, 17 Oct 2019 07:18:26 GMT", + "Server": "ECS (nyb/1D07)", + "Vary": "Accept-Encoding", + }, + StatusCode: 200, + }, + }, + }, + want: &trueValue, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webconnectivity.HTTPHeadersMatch(tt.args.tk, tt.args.ctrl) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestTitleMatch(t *testing.T) { + var ( + trueValue = true + falseValue = false + ) + type args struct { + tk urlgetter.TestKeys + ctrl webconnectivity.ControlResponse + } + tests := []struct { + name string + args args + wantOut *bool + }{{ + name: "with all empty", + args: args{}, + wantOut: nil, + }, { + name: "with a request and no response", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{}}, + }, + }, + wantOut: nil, + }, { + name: "with a response with truncated body", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + BodyIsTruncated: true, + }, + }}, + }, + }, + wantOut: nil, + }, { + name: "with a response with good body", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + Body: archival.MaybeBinaryValue{Value: ""}, + }, + }}, + }, + }, + wantOut: nil, + }, { + name: "with all good but no titles", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + Body: archival.MaybeBinaryValue{Value: ""}, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 200, + Title: "", + }, + }, + }, + wantOut: nil, + }, { + name: "reasonably common case where it succeeds", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + Body: archival.MaybeBinaryValue{ + Value: "La community di MSN"}, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 200, + Title: "MSN Community", + }, + }, + }, + wantOut: &trueValue, + }, { + name: "reasonably common case where it fails", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + Body: archival.MaybeBinaryValue{ + Value: "La communità di MSN"}, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 200, + Title: "MSN Community", + }, + }, + }, + wantOut: &falseValue, + }, { + name: "when the title is too long", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + Body: archival.MaybeBinaryValue{ + Value: "" + randx.Letters(1024) + ""}, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 200, + Title: "MSN Community", + }, + }, + }, + wantOut: nil, + }, { + name: "reasonably common case where it succeeds with case variations", + args: args{ + tk: urlgetter.TestKeys{ + Requests: []archival.RequestEntry{{ + Response: archival.HTTPResponse{ + Code: 200, + Body: archival.MaybeBinaryValue{ + Value: "La commUNity di MSN"}, + }, + }}, + }, + ctrl: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + StatusCode: 200, + Title: "MSN COmmunity", + }, + }, + }, + wantOut: &trueValue, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := webconnectivity.HTTPTitleMatch(tt.args.tk, tt.args.ctrl) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/internal/engine/experiment/webconnectivity/httpget.go b/internal/engine/experiment/webconnectivity/httpget.go new file mode 100644 index 0000000..ad177f5 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/httpget.go @@ -0,0 +1,50 @@ +package webconnectivity + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// HTTPGetConfig contains the config for HTTPGet +type HTTPGetConfig struct { + Addresses []string + Session model.ExperimentSession + TargetURL *url.URL +} + +// TODO(bassosimone): we should normalize the timings + +// HTTPGetResult contains the results of HTTPGet +type HTTPGetResult struct { + TestKeys urlgetter.TestKeys + Failure *string +} + +// HTTPGet performs the HTTP/HTTPS part of Web Connectivity. +func HTTPGet(ctx context.Context, config HTTPGetConfig) (out HTTPGetResult) { + addresses := strings.Join(config.Addresses, " ") + if addresses == "" { + // TODO(bassosimone): what to do in this case? We clearly + // cannot fill the DNS cache... + return + } + target := config.TargetURL.String() + config.Session.Logger().Infof("GET %s...", target) + domain := config.TargetURL.Hostname() + result, err := urlgetter.Getter{ + Config: urlgetter.Config{ + DNSCache: fmt.Sprintf("%s %s", domain, addresses), + }, + Session: config.Session, + Target: target, + }.Get(ctx) + config.Session.Logger().Infof("GET %s... %+v", target, err) + out.Failure = result.Failure + out.TestKeys = result + return +} diff --git a/internal/engine/experiment/webconnectivity/httpget_test.go b/internal/engine/experiment/webconnectivity/httpget_test.go new file mode 100644 index 0000000..d19cdbf --- /dev/null +++ b/internal/engine/experiment/webconnectivity/httpget_test.go @@ -0,0 +1,27 @@ +package webconnectivity_test + +import ( + "context" + "net/url" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" +) + +func TestHTTPGet(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + ctx := context.Background() + r := webconnectivity.HTTPGet(ctx, webconnectivity.HTTPGetConfig{ + Addresses: []string{"104.16.249.249", "104.16.248.249"}, + Session: newsession(t, false), + TargetURL: &url.URL{Scheme: "https", Host: "cloudflare-dns.com", Path: "/"}, + }) + if r.TestKeys.Failure != nil { + t.Fatal(*r.TestKeys.Failure) + } + if r.Failure != nil { + t.Fatal(*r.Failure) + } +} diff --git a/internal/engine/experiment/webconnectivity/internal/internal.go b/internal/engine/experiment/webconnectivity/internal/internal.go new file mode 100644 index 0000000..7268793 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/internal/internal.go @@ -0,0 +1,23 @@ +// Package internal contains internal code. +package internal + +import "fmt" + +// StringPointerToString converts a string pointer to a string. When the +// pointer is null, we return the "nil" string. +func StringPointerToString(v *string) (out string) { + out = "nil" + if v != nil { + out = fmt.Sprintf("%+v", *v) + } + return +} + +// BoolPointerToString is like StringPointerToString but for bool. +func BoolPointerToString(v *bool) (out string) { + out = "nil" + if v != nil { + out = fmt.Sprintf("%+v", *v) + } + return +} diff --git a/internal/engine/experiment/webconnectivity/internal/internal_test.go b/internal/engine/experiment/webconnectivity/internal/internal_test.go new file mode 100644 index 0000000..f59f74f --- /dev/null +++ b/internal/engine/experiment/webconnectivity/internal/internal_test.go @@ -0,0 +1,31 @@ +package internal_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity/internal" +) + +func TestStringPointerToString(t *testing.T) { + s := "ANTANI" + if internal.StringPointerToString(&s) != s { + t.Fatal("unexpected result") + } + if internal.StringPointerToString(nil) != "nil" { + t.Fatal("unexpected result") + } +} + +func TestBoolPointerToString(t *testing.T) { + v := true + if internal.BoolPointerToString(&v) != "true" { + t.Fatal("unexpected result") + } + v = false + if internal.BoolPointerToString(&v) != "false" { + t.Fatal("unexpected result") + } + if internal.BoolPointerToString(nil) != "nil" { + t.Fatal("unexpected result") + } +} diff --git a/internal/engine/experiment/webconnectivity/summary.go b/internal/engine/experiment/webconnectivity/summary.go new file mode 100644 index 0000000..9589be3 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/summary.go @@ -0,0 +1,280 @@ +package webconnectivity + +import ( + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity/internal" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// The following set of status flags identifies in a more nuanced way the +// reason why we say something is blocked, accessible, etc. +// +// This is an experimental implementation. The objective is to start using +// it and learning from it, to eventually understand in which direction +// the Web Connectivity experiment should evolve. For example, there are +// a bunch of flags where our understandind is fuzzy or unclear. +// +// It also helps to write more precise unit and integration tests. +const ( + StatusSuccessSecure = 1 << iota // success when using HTTPS + StatusSuccessCleartext // success when using HTTP + StatusSuccessNXDOMAIN // probe and control agree on NXDOMAIN + + StatusAnomalyControlUnreachable // cannot access the control + StatusAnomalyControlFailure // control failed for HTTP + StatusAnomalyDNS // probe seems blocked by their DNS + StatusAnomalyHTTPDiff // probe and control do not agree on HTTP features + StatusAnomalyConnect // we saw an error when connecting + StatusAnomalyReadWrite // we saw an error when doing I/O + StatusAnomalyUnknown // we don't know when the error happened + StatusAnomalyTLSHandshake // we think error was during TLS handshake + + StatusExperimentDNS // we noticed something in the DNS experiment + StatusExperimentConnect // ... in the connect experiment + StatusExperimentHTTP // ... in the HTTP experiment + + StatusBugNoRequests // this should never happen +) + +// Summary contains the Web Connectivity summary. +type Summary struct { + // Accessible is nil when the measurement failed, true if we do + // not think there was blocking, false in case of blocking. + Accessible *bool `json:"accessible"` + + // BlockingReason indicates the cause of blocking when the Accessible + // variable is false. BlockingReason is meaningless otherwise. + // + // This is an intermediate variable used to compute Blocking, which + // is what OONI data consumers expect to see. + BlockingReason *string `json:"-"` + + // Blocking implements the blocking variable as expected by OONI + // data consumers. See DetermineBlocking's docs. + Blocking interface{} `json:"blocking"` + + // Status contains zero or more status flags. This is currently + // an experimental interface subject to change at any time. + Status int64 `json:"x_status"` +} + +// DetermineBlocking returns the value of Summary.Blocking according to +// the expectations of OONI data consumers (nil|false|string). +// +// Measurement Kit sets blocking to false when accessible is true. The spec +// doesn't mention this possibility, as of 2019-08-20-001. Yet we implemented +// it back in 2016, with little explanation . +// +// We eventually managed to link such a change with the 0.3.4 release of +// Measurement Kit . This led us to find out the +// related issue #867 . From this issue it become +// clear that the change on Measurement Kit was applied to mirror a change +// implemented in OONI Probe Legacy . In such a +// change, determine_blocking() was modified to return False in case no +// blocking was detected, to distinguish this case from the case where +// there was an early failure in the experiment. +// +// Indeed, the OONI Android app uses the case where `blocking` is `null` +// to flag failed tests. Instead, success is identified by `blocking` being +// false and all other cases indicate anomaly . +// +// Because of that, we must preserve the original behaviour. +func DetermineBlocking(s Summary) interface{} { + if s.Accessible != nil && *s.Accessible == true { + return false + } + return s.BlockingReason +} + +// Log logs the summary using the provided logger. +func (s Summary) Log(logger model.Logger) { + logger.Infof("Blocking: %+v", internal.StringPointerToString(s.BlockingReason)) + logger.Infof("Accessible: %+v", internal.BoolPointerToString(s.Accessible)) +} + +// Summarize computes the summary from the TestKeys. +func Summarize(tk *TestKeys) (out Summary) { + // Make sure we correctly set out.Blocking's value. + defer func() { + out.Blocking = DetermineBlocking(out) + }() + var ( + accessible = true + inaccessible = false + dns = "dns" + httpDiff = "http-diff" + httpFailure = "http-failure" + tcpIP = "tcp_ip" + ) + // If the measurement was for an HTTPS website and the HTTP experiment + // succeded, then either there is a compromised CA in our pool (which is + // certifi-go), or there is transparent proxying, or we are actually + // speaking with the legit server. We assume the latter. This applies + // also to cases in which we are redirected to HTTPS. + if len(tk.Requests) > 0 && tk.Requests[0].Failure == nil && + strings.HasPrefix(tk.Requests[0].Request.URL, "https://") { + out.Accessible = &accessible + out.Status |= StatusSuccessSecure + return + } + // If we couldn't contact the control, we cannot do much more here. + if tk.ControlFailure != nil { + out.Status |= StatusAnomalyControlUnreachable + return + } + // If DNS failed with NXDOMAIN and the control DNS is consistent, then it + // means this website does not exist anymore. + if tk.DNSExperimentFailure != nil && + *tk.DNSExperimentFailure == errorx.FailureDNSNXDOMAINError && + tk.DNSConsistency != nil && *tk.DNSConsistency == DNSConsistent { + // TODO(bassosimone): MK flags this as accessible. This result is debateable. We + // are doing what MK does. But we most likely want to make it better later. + // + // See . + out.Accessible = &accessible + out.Status |= StatusSuccessNXDOMAIN | StatusExperimentDNS + return + } + // Otherwise, if DNS failed with NXDOMAIN, it's DNS based blocking. + // TODO(bassosimone): do we wanna include other errors here? Like timeout? + if tk.DNSExperimentFailure != nil && + *tk.DNSExperimentFailure == errorx.FailureDNSNXDOMAINError { + out.Accessible = &inaccessible + out.BlockingReason = &dns + out.Status |= StatusAnomalyDNS | StatusExperimentDNS + return + } + // If we tried to connect more than once and never succeded and we were + // able to measure DNS consistency, then we can conclude something. + if tk.TCPConnectAttempts > 0 && tk.TCPConnectSuccesses <= 0 && tk.DNSConsistency != nil { + out.Status |= StatusAnomalyConnect | StatusExperimentConnect + switch *tk.DNSConsistency { + case DNSConsistent: + // If the DNS is consistent, then it's TCP/IP blocking. + out.BlockingReason = &tcpIP + out.Accessible = &inaccessible + case DNSInconsistent: + // Otherwise, the culprit is the DNS. + out.BlockingReason = &dns + out.Accessible = &inaccessible + out.Status |= StatusAnomalyDNS + default: + // this case should not happen with this implementation + // so it's fine to leave this as unknown + out.Status |= StatusAnomalyUnknown + } + return + } + // If the control failed for HTTP it's not immediate for us to + // say anything specific on this measurement. + if tk.Control.HTTPRequest.Failure != nil { + out.Status |= StatusAnomalyControlFailure + return + } + // Likewise, if we don't have requests to examine, leave it. + if len(tk.Requests) < 1 { + out.Status |= StatusBugNoRequests + return + } + // If the HTTP measurement failed there could be a bunch of reasons + // why this occurred, because of HTTP redirects. Try to guess what + // could have been wrong by inspecting the error code. + if tk.Requests[0].Failure != nil { + out.Status |= StatusExperimentHTTP + switch *tk.Requests[0].Failure { + case errorx.FailureConnectionRefused: + // This is possibly because a subsequent connection to some + // other endpoint has been blocked. We call this http-failure + // because this is what MK would actually do. + out.BlockingReason = &httpFailure + out.Accessible = &inaccessible + out.Status |= StatusAnomalyConnect + case errorx.FailureConnectionReset: + // We don't currently support TLS failures and we don't have a + // way to know if it was during TLS or later. So, for now we are + // going to call this error condition an http-failure. + out.BlockingReason = &httpFailure + out.Accessible = &inaccessible + out.Status |= StatusAnomalyReadWrite + case errorx.FailureDNSNXDOMAINError: + // This is possibly because a subsequent resolution to + // some other domain name has been blocked. + out.BlockingReason = &dns + out.Accessible = &inaccessible + out.Status |= StatusAnomalyDNS + case errorx.FailureEOFError: + // We have seen this happening with TLS handshakes as well as + // sometimes with HTTP blocking. So http-failure. + out.BlockingReason = &httpFailure + out.Accessible = &inaccessible + out.Status |= StatusAnomalyReadWrite + case errorx.FailureGenericTimeoutError: + // Alas, here we don't know whether it's connect or whether it's + // perhaps the TLS handshake. So use the same classification used by MK. + out.BlockingReason = &httpFailure + out.Accessible = &inaccessible + out.Status |= StatusAnomalyUnknown + case errorx.FailureSSLInvalidHostname, + errorx.FailureSSLInvalidCertificate, + errorx.FailureSSLUnknownAuthority: + // We treat these three cases equally. Misconfiguration is a bit + // less likely since we also checked with the control. Since there + // is no TLS, for now we're going to call this http-failure. + out.BlockingReason = &httpFailure + out.Accessible = &inaccessible + out.Status |= StatusAnomalyTLSHandshake + default: + // We have not been able to classify the error. Could this perhaps be + // caused by a programmer's error? Let us be conservative. + } + // So, good that we have classified the error. Yet, how long is the + // redirect chain? If it's exactly one and we have determined that we + // should not trust the resolver, then let's bet on the DNS. If the + // chain is longer, for now better to be conservative. (I would argue + // that with a lying DNS that's likely the culprit, honestly.) + if out.BlockingReason != nil && len(tk.Requests) == 1 && + tk.DNSConsistency != nil && *tk.DNSConsistency == DNSInconsistent { + out.BlockingReason = &dns + out.Status |= StatusAnomalyDNS + } + return + } + // So the HTTP request did not fail in the measurement and did not + // fail in the control as well, didn't it? Then, let us try to guess + // whether we've got the expected webpage after all. This set of + // conditions is adapted from MK v0.10.11. + if tk.StatusCodeMatch != nil && *tk.StatusCodeMatch { + if tk.BodyLengthMatch != nil && *tk.BodyLengthMatch { + out.Accessible = &accessible + out.Status |= StatusSuccessCleartext + return + } + if tk.HeadersMatch != nil && *tk.HeadersMatch { + out.Accessible = &accessible + out.Status |= StatusSuccessCleartext + return + } + if tk.TitleMatch != nil && *tk.TitleMatch { + out.Accessible = &accessible + out.Status |= StatusSuccessCleartext + return + } + } + // Set the status flag first + out.Status |= StatusAnomalyHTTPDiff + // It seems we didn't get the expected web page. What now? Well, if + // the DNS does not seem trustworthy, let us blame it. + if tk.DNSConsistency != nil && *tk.DNSConsistency == DNSInconsistent { + out.BlockingReason = &dns + out.Accessible = &inaccessible + out.Status |= StatusAnomalyDNS + return + } + // The only remaining conclusion seems that the web page we have got + // doesn't match what we were expecting. + out.BlockingReason = &httpDiff + out.Accessible = &inaccessible + return +} diff --git a/internal/engine/experiment/webconnectivity/summary_test.go b/internal/engine/experiment/webconnectivity/summary_test.go new file mode 100644 index 0000000..e728169 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/summary_test.go @@ -0,0 +1,459 @@ +package webconnectivity_test + +import ( + "io" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestSummarize(t *testing.T) { + var ( + genericFailure = io.EOF.Error() + dns = "dns" + falseValue = false + httpDiff = "http-diff" + httpFailure = "http-failure" + nilstring *string + probeConnectionRefused = errorx.FailureConnectionRefused + probeConnectionReset = errorx.FailureConnectionReset + probeEOFError = errorx.FailureEOFError + probeNXDOMAIN = errorx.FailureDNSNXDOMAINError + probeTimeout = errorx.FailureGenericTimeoutError + probeSSLInvalidHost = errorx.FailureSSLInvalidHostname + probeSSLInvalidCert = errorx.FailureSSLInvalidCertificate + probeSSLUnknownAuth = errorx.FailureSSLUnknownAuthority + tcpIP = "tcp_ip" + trueValue = true + ) + type args struct { + tk *webconnectivity.TestKeys + } + tests := []struct { + name string + args args + wantOut webconnectivity.Summary + }{{ + name: "with an HTTPS request with no failure", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Request: archival.HTTPRequest{ + URL: "https://www.kernel.org/", + }, + Failure: nil, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: false, + Accessible: &trueValue, + Status: webconnectivity.StatusSuccessSecure, + }, + }, { + name: "with failure in contacting the control", + args: args{ + tk: &webconnectivity.TestKeys{ + ControlFailure: &genericFailure, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: nilstring, + Accessible: nil, + Status: webconnectivity.StatusAnomalyControlUnreachable, + }, + }, { + name: "with non-existing website", + args: args{ + tk: &webconnectivity.TestKeys{ + DNSExperimentFailure: &probeNXDOMAIN, + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: false, + Accessible: &trueValue, + Status: webconnectivity.StatusSuccessNXDOMAIN | + webconnectivity.StatusExperimentDNS, + }, + }, { + name: "with NXDOMAIN measured only by the probe", + args: args{ + tk: &webconnectivity.TestKeys{ + DNSExperimentFailure: &probeNXDOMAIN, + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &dns, + Blocking: &dns, + Accessible: &falseValue, + Status: webconnectivity.StatusAnomalyDNS | + webconnectivity.StatusExperimentDNS, + }, + }, { + name: "with TCP total failure and consistent DNS", + args: args{ + tk: &webconnectivity.TestKeys{ + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + TCPConnectAttempts: 7, + TCPConnectSuccesses: 0, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &tcpIP, + Blocking: &tcpIP, + Accessible: &falseValue, + Status: webconnectivity.StatusAnomalyConnect | + webconnectivity.StatusExperimentConnect, + }, + }, { + name: "with TCP total failure and inconsistent DNS", + args: args{ + tk: &webconnectivity.TestKeys{ + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + TCPConnectAttempts: 7, + TCPConnectSuccesses: 0, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &dns, + Blocking: &dns, + Accessible: &falseValue, + Status: webconnectivity.StatusAnomalyConnect | + webconnectivity.StatusExperimentConnect | + webconnectivity.StatusAnomalyDNS, + }, + }, { + name: "with TCP total failure and unexpected DNS consistency", + args: args{ + tk: &webconnectivity.TestKeys{ + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: func() *string { + s := "ANTANI" + return &s + }(), + }, + TCPConnectAttempts: 7, + TCPConnectSuccesses: 0, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: nilstring, + Accessible: nil, + Status: webconnectivity.StatusAnomalyConnect | + webconnectivity.StatusExperimentConnect | + webconnectivity.StatusAnomalyUnknown, + }, + }, { + name: "with failed control HTTP request", + args: args{ + tk: &webconnectivity.TestKeys{ + Control: webconnectivity.ControlResponse{ + HTTPRequest: webconnectivity.ControlHTTPRequestResult{ + Failure: &genericFailure, + }, + }, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: nilstring, + Accessible: nil, + Status: webconnectivity.StatusAnomalyControlFailure, + }, + }, { + name: "with less that one request entry", + args: args{ + tk: &webconnectivity.TestKeys{}, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: nilstring, + Accessible: nil, + Status: webconnectivity.StatusBugNoRequests, + }, + }, { + name: "with connection refused", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeConnectionRefused, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyConnect, + }, + }, { + name: "with connection reset", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeConnectionReset, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyReadWrite, + }, + }, { + name: "with NXDOMAIN", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeNXDOMAIN, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &dns, + Blocking: &dns, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyDNS, + }, + }, { + name: "with EOF", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeEOFError, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyReadWrite, + }, + }, { + name: "with timeout", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeTimeout, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyUnknown, + }, + }, { + name: "with SSL invalid hostname", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeSSLInvalidHost, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyTLSHandshake, + }, + }, { + name: "with SSL invalid cert", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeSSLInvalidCert, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyTLSHandshake, + }, + }, { + name: "with SSL unknown auth", + args: args{ + tk: &webconnectivity.TestKeys{ + Requests: []archival.RequestEntry{{ + Failure: &probeSSLUnknownAuth, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyTLSHandshake, + }, + }, { + name: "with SSL unknown auth _and_ untrustworthy DNS", + args: args{ + tk: &webconnectivity.TestKeys{ + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + Requests: []archival.RequestEntry{{ + Failure: &probeSSLUnknownAuth, + }}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &dns, + Blocking: &dns, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyTLSHandshake | + webconnectivity.StatusAnomalyDNS, + }, + }, { + name: "with SSL unknown auth _and_ untrustworthy DNS _and_ a longer chain", + args: args{ + tk: &webconnectivity.TestKeys{ + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + Requests: []archival.RequestEntry{{ + Failure: &probeSSLUnknownAuth, + }, {}}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpFailure, + Blocking: &httpFailure, + Accessible: &falseValue, + Status: webconnectivity.StatusExperimentHTTP | + webconnectivity.StatusAnomalyTLSHandshake, + }, + }, { + name: "with status code and body length matching", + args: args{ + tk: &webconnectivity.TestKeys{ + HTTPAnalysisResult: webconnectivity.HTTPAnalysisResult{ + StatusCodeMatch: &trueValue, + BodyLengthMatch: &trueValue, + }, + Requests: []archival.RequestEntry{{}}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: falseValue, + Accessible: &trueValue, + Status: webconnectivity.StatusSuccessCleartext, + }, + }, { + name: "with status code and headers matching", + args: args{ + tk: &webconnectivity.TestKeys{ + HTTPAnalysisResult: webconnectivity.HTTPAnalysisResult{ + StatusCodeMatch: &trueValue, + HeadersMatch: &trueValue, + }, + Requests: []archival.RequestEntry{{}}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: falseValue, + Accessible: &trueValue, + Status: webconnectivity.StatusSuccessCleartext, + }, + }, { + name: "with status code and title matching", + args: args{ + tk: &webconnectivity.TestKeys{ + HTTPAnalysisResult: webconnectivity.HTTPAnalysisResult{ + StatusCodeMatch: &trueValue, + TitleMatch: &trueValue, + }, + Requests: []archival.RequestEntry{{}}, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: nil, + Blocking: falseValue, + Accessible: &trueValue, + Status: webconnectivity.StatusSuccessCleartext, + }, + }, { + name: "with suspect http-diff and inconsistent DNS", + args: args{ + tk: &webconnectivity.TestKeys{ + HTTPAnalysisResult: webconnectivity.HTTPAnalysisResult{ + StatusCodeMatch: &falseValue, + TitleMatch: &trueValue, + }, + Requests: []archival.RequestEntry{{}}, + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSInconsistent, + }, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &dns, + Blocking: &dns, + Accessible: &falseValue, + Status: webconnectivity.StatusAnomalyHTTPDiff | + webconnectivity.StatusAnomalyDNS, + }, + }, { + name: "with suspect http-diff and consistent DNS", + args: args{ + tk: &webconnectivity.TestKeys{ + HTTPAnalysisResult: webconnectivity.HTTPAnalysisResult{ + StatusCodeMatch: &falseValue, + TitleMatch: &trueValue, + }, + Requests: []archival.RequestEntry{{}}, + DNSAnalysisResult: webconnectivity.DNSAnalysisResult{ + DNSConsistency: &webconnectivity.DNSConsistent, + }, + }, + }, + wantOut: webconnectivity.Summary{ + BlockingReason: &httpDiff, + Blocking: &httpDiff, + Accessible: &falseValue, + Status: webconnectivity.StatusAnomalyHTTPDiff, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut := webconnectivity.Summarize(tt.args.tk) + if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/internal/engine/experiment/webconnectivity/webconnectivity.go b/internal/engine/experiment/webconnectivity/webconnectivity.go new file mode 100644 index 0000000..50419ac --- /dev/null +++ b/internal/engine/experiment/webconnectivity/webconnectivity.go @@ -0,0 +1,225 @@ +// Package webconnectivity implements OONI's Web Connectivity experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-017-web-connectivity.md +package webconnectivity + +import ( + "context" + "errors" + "net" + "net/url" + "strconv" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity/internal" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" +) + +const ( + testName = "web_connectivity" + testVersion = "0.2.0" +) + +// Config contains the experiment config. +type Config struct{} + +// TestKeys contains webconnectivity test keys. +type TestKeys struct { + Agent string `json:"agent"` + ClientResolver string `json:"client_resolver"` + Retries *int64 `json:"retries"` // unused + SOCKSProxy *string `json:"socksproxy"` // unused + + // DNS experiment + Queries []archival.DNSQueryEntry `json:"queries"` + DNSExperimentFailure *string `json:"dns_experiment_failure"` + DNSAnalysisResult + + // Control experiment + ControlFailure *string `json:"control_failure"` + ControlRequest ControlRequest `json:"-"` + Control ControlResponse `json:"control"` + + // TCP connect experiment + TCPConnect []archival.TCPConnectEntry `json:"tcp_connect"` + TCPConnectSuccesses int `json:"-"` + TCPConnectAttempts int `json:"-"` + + // HTTP experiment + Requests []archival.RequestEntry `json:"requests"` + HTTPExperimentFailure *string `json:"http_experiment_failure"` + HTTPAnalysisResult + + // Top-level analysis + Summary +} + +// Measurer performs the measurement. +type Measurer struct { + Config Config +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// ExperimentName implements ExperimentMeasurer.ExperExperimentName. +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperExperimentVersion. +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +var ( + // ErrNoAvailableTestHelpers is emitted when there are no available test helpers. + ErrNoAvailableTestHelpers = errors.New("no available helpers") + + // ErrNoInput indicates that no input was provided + ErrNoInput = errors.New("no input provided") + + // ErrInputIsNotAnURL indicates that the input is not an URL. + ErrInputIsNotAnURL = errors.New("input is not an URL") + + // ErrUnsupportedInput indicates that the input URL scheme is unsupported. + ErrUnsupportedInput = errors.New("unsupported input scheme") +) + +// Run implements ExperimentMeasurer.Run. +func (m Measurer) Run( + ctx context.Context, + sess model.ExperimentSession, + measurement *model.Measurement, + callbacks model.ExperimentCallbacks, +) error { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + tk := new(TestKeys) + measurement.TestKeys = tk + tk.Agent = "redirect" + tk.ClientResolver = sess.ResolverIP() + if measurement.Input == "" { + return ErrNoInput + } + URL, err := url.Parse(string(measurement.Input)) + if err != nil { + return ErrInputIsNotAnURL + } + if URL.Scheme != "http" && URL.Scheme != "https" { + return ErrUnsupportedInput + } + // 1. find test helper + testhelpers, _ := sess.GetTestHelpersByName("web-connectivity") + var testhelper *model.Service + for _, th := range testhelpers { + if th.Type == "https" { + testhelper = &th + break + } + } + if testhelper == nil { + return ErrNoAvailableTestHelpers + } + measurement.TestHelpers = map[string]interface{}{ + "backend": testhelper, + } + // 2. perform the DNS lookup step + dnsResult := DNSLookup(ctx, DNSLookupConfig{Session: sess, URL: URL}) + tk.Queries = append(tk.Queries, dnsResult.TestKeys.Queries...) + tk.DNSExperimentFailure = dnsResult.Failure + epnts := NewEndpoints(URL, dnsResult.Addresses()) + sess.Logger().Infof("using control: %s", testhelper.Address) + // 3. perform the control measurement + tk.Control, err = Control(ctx, sess, testhelper.Address, ControlRequest{ + HTTPRequest: URL.String(), + HTTPRequestHeaders: map[string][]string{ + "Accept": {httpheader.Accept()}, + "Accept-Language": {httpheader.AcceptLanguage()}, + "User-Agent": {httpheader.UserAgent()}, + }, + TCPConnect: epnts.Endpoints(), + }) + tk.ControlFailure = archival.NewFailure(err) + // 4. analyze DNS results + if tk.ControlFailure == nil { + tk.DNSAnalysisResult = DNSAnalysis(URL, dnsResult, tk.Control) + } + sess.Logger().Infof("DNS analysis result: %+v", internal.StringPointerToString( + tk.DNSAnalysisResult.DNSConsistency)) + // 5. perform TCP/TLS connects + connectsResult := Connects(ctx, ConnectsConfig{ + Session: sess, + TargetURL: URL, + URLGetterURLs: epnts.URLs(), + }) + sess.Logger().Infof( + "TCP/TLS endpoints: %d/%d reachable", connectsResult.Successes, connectsResult.Total) + for _, tcpkeys := range connectsResult.AllKeys { + // rewrite TCPConnect to include blocking information - it is very + // sad that we're storing analysis result inside the measurement + tk.TCPConnect = append(tk.TCPConnect, ComputeTCPBlocking( + tcpkeys.TCPConnect, tk.Control.TCPConnect)...) + } + tk.TCPConnectAttempts = connectsResult.Total + tk.TCPConnectSuccesses = connectsResult.Successes + // 6. perform HTTP/HTTPS measurement + httpResult := HTTPGet(ctx, HTTPGetConfig{ + Addresses: dnsResult.Addresses(), + Session: sess, + TargetURL: URL, + }) + tk.HTTPExperimentFailure = httpResult.Failure + tk.Requests = append(tk.Requests, httpResult.TestKeys.Requests...) + // 7. compare HTTP measurement to control + tk.HTTPAnalysisResult = HTTPAnalysis(httpResult.TestKeys, tk.Control) + tk.HTTPAnalysisResult.Log(sess.Logger()) + tk.Summary = Summarize(tk) + tk.Summary.Log(sess.Logger()) + return nil +} + +// ComputeTCPBlocking will return a copy of the input TCPConnect structure +// where we set the Blocking value depending on the control results. +func ComputeTCPBlocking(measurement []archival.TCPConnectEntry, + control map[string]ControlTCPConnectResult) (out []archival.TCPConnectEntry) { + out = []archival.TCPConnectEntry{} + for _, me := range measurement { + epnt := net.JoinHostPort(me.IP, strconv.Itoa(me.Port)) + if ce, ok := control[epnt]; ok { + v := ce.Failure == nil && me.Status.Failure != nil + me.Status.Blocked = &v + } + out = append(out, me) + } + return +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + Accessible bool `json:"accessible"` + Blocking string `json:"blocking"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + sk.IsAnomaly = tk.BlockingReason != nil + if tk.BlockingReason != nil { + sk.Blocking = *tk.BlockingReason + } + sk.Accessible = tk.Accessible != nil && *tk.Accessible + return sk, nil +} diff --git a/internal/engine/experiment/webconnectivity/webconnectivity_test.go b/internal/engine/experiment/webconnectivity/webconnectivity_test.go new file mode 100644 index 0000000..e9e3335 --- /dev/null +++ b/internal/engine/experiment/webconnectivity/webconnectivity_test.go @@ -0,0 +1,417 @@ +package webconnectivity_test + +import ( + "context" + "errors" + "fmt" + "io" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := webconnectivity.NewExperimentMeasurer(webconnectivity.Config{}) + if measurer.ExperimentName() != "web_connectivity" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.2.0" { + t.Fatal("unexpected version") + } +} + +func TestSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := webconnectivity.NewExperimentMeasurer(webconnectivity.Config{}) + ctx := context.Background() + // we need a real session because we need the web-connectivity helper + // as well as the ASN database + sess := newsession(t, true) + measurement := &model.Measurement{Input: "http://www.example.com"} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*webconnectivity.TestKeys) + if tk.ControlFailure != nil { + t.Fatal("unexpected control_failure") + } + if tk.DNSExperimentFailure != nil { + t.Fatal("unexpected dns_experiment_failure") + } + if tk.HTTPExperimentFailure != nil { + t.Fatal("unexpected http_experiment_failure") + } + // TODO(bassosimone): write further checks here? +} + +func TestMeasureWithCancelledContext(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := webconnectivity.NewExperimentMeasurer(webconnectivity.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately fail + // we need a real session because we need the web-connectivity helper + sess := newsession(t, true) + measurement := &model.Measurement{Input: "http://www.example.com"} + callbacks := model.NewPrinterCallbacks(log.Log) + if err := measurer.Run(ctx, sess, measurement, callbacks); err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*webconnectivity.TestKeys) + if *tk.ControlFailure != errorx.FailureInterrupted { + t.Fatal("unexpected control_failure") + } + if *tk.DNSExperimentFailure != errorx.FailureInterrupted { + t.Fatal("unexpected dns_experiment_failure") + } + if tk.HTTPExperimentFailure != nil { + t.Fatal("unexpected http_experiment_failure") + } + // TODO(bassosimone): write further checks here? + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(webconnectivity.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestMeasureWithNoInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := webconnectivity.NewExperimentMeasurer(webconnectivity.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // we need a real session because we need the web-connectivity helper + sess := newsession(t, true) + measurement := &model.Measurement{Input: ""} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, webconnectivity.ErrNoInput) { + t.Fatal(err) + } + tk := measurement.TestKeys.(*webconnectivity.TestKeys) + if tk.ControlFailure != nil { + t.Fatal("unexpected control_failure") + } + if tk.DNSExperimentFailure != nil { + t.Fatal("unexpected dns_experiment_failure") + } + if tk.HTTPExperimentFailure != nil { + t.Fatal("unexpected http_experiment_failure") + } + // TODO(bassosimone): write further checks here? +} + +func TestMeasureWithInputNotBeingAnURL(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := webconnectivity.NewExperimentMeasurer(webconnectivity.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // we need a real session because we need the web-connectivity helper + sess := newsession(t, true) + measurement := &model.Measurement{Input: "\t\t\t\t\t\t"} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, webconnectivity.ErrInputIsNotAnURL) { + t.Fatal(err) + } + tk := measurement.TestKeys.(*webconnectivity.TestKeys) + if tk.ControlFailure != nil { + t.Fatal("unexpected control_failure") + } + if tk.DNSExperimentFailure != nil { + t.Fatal("unexpected dns_experiment_failure") + } + if tk.HTTPExperimentFailure != nil { + t.Fatal("unexpected http_experiment_failure") + } + // TODO(bassosimone): write further checks here? +} + +func TestMeasureWithUnsupportedInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := webconnectivity.NewExperimentMeasurer(webconnectivity.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // we need a real session because we need the web-connectivity helper + sess := newsession(t, true) + measurement := &model.Measurement{Input: "dnslookup://example.com"} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, webconnectivity.ErrUnsupportedInput) { + t.Fatal(err) + } + tk := measurement.TestKeys.(*webconnectivity.TestKeys) + if tk.ControlFailure != nil { + t.Fatal("unexpected control_failure") + } + if tk.DNSExperimentFailure != nil { + t.Fatal("unexpected dns_experiment_failure") + } + if tk.HTTPExperimentFailure != nil { + t.Fatal("unexpected http_experiment_failure") + } + // TODO(bassosimone): write further checks here? +} + +func TestMeasureWithNoAvailableTestHelpers(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + measurer := webconnectivity.NewExperimentMeasurer(webconnectivity.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // we need a real session because we need the web-connectivity helper + sess := newsession(t, false) + measurement := &model.Measurement{Input: "https://www.example.com"} + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if !errors.Is(err, webconnectivity.ErrNoAvailableTestHelpers) { + t.Fatal(err) + } + tk := measurement.TestKeys.(*webconnectivity.TestKeys) + if tk.ControlFailure != nil { + t.Fatal("unexpected control_failure") + } + if tk.DNSExperimentFailure != nil { + t.Fatal("unexpected dns_experiment_failure") + } + if tk.HTTPExperimentFailure != nil { + t.Fatal("unexpected http_experiment_failure") + } + // TODO(bassosimone): write further checks here? +} + +func newsession(t *testing.T, lookupBackends bool) model.ExperimentSession { + sess, err := engine.NewSession(engine.SessionConfig{ + AssetsDir: "../../testdata", + AvailableProbeServices: []model.Service{{ + Address: "https://ams-pg-test.ooni.org", + Type: "https", + }}, + Logger: log.Log, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + if lookupBackends { + if err := sess.MaybeLookupBackends(); err != nil { + t.Fatal(err) + } + } + if err := sess.MaybeLookupLocation(); err != nil { + t.Fatal(err) + } + return sess +} + +func TestComputeTCPBlocking(t *testing.T) { + var ( + falseValue = false + trueValue = true + ) + failure := io.EOF.Error() + anotherFailure := "unknown_error" + type args struct { + measurement []archival.TCPConnectEntry + control map[string]webconnectivity.ControlTCPConnectResult + } + tests := []struct { + name string + args args + want []archival.TCPConnectEntry + }{{ + name: "with all empty", + args: args{}, + want: []archival.TCPConnectEntry{}, + }, { + name: "with control failure", + args: args{ + measurement: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Failure: &failure, + Success: false, + }, + }}, + }, + want: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Failure: &failure, + Success: false, + }, + }}, + }, { + name: "with failures on both ends", + args: args{ + measurement: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Failure: &failure, + Success: false, + }, + }}, + control: map[string]webconnectivity.ControlTCPConnectResult{ + "1.1.1.1:853": { + Failure: &anotherFailure, + Status: false, + }, + }, + }, + want: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Blocked: &falseValue, + Failure: &failure, + Success: false, + }, + }}, + }, { + name: "with failure on the probe side", + args: args{ + measurement: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Failure: &failure, + Success: false, + }, + }}, + control: map[string]webconnectivity.ControlTCPConnectResult{ + "1.1.1.1:853": { + Failure: nil, + Status: true, + }, + }, + }, + want: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Blocked: &trueValue, + Failure: &failure, + Success: false, + }, + }}, + }, { + name: "with failure on the control side", + args: args{ + measurement: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Failure: nil, + Success: true, + }, + }}, + control: map[string]webconnectivity.ControlTCPConnectResult{ + "1.1.1.1:853": { + Failure: &failure, + Status: false, + }, + }, + }, + want: []archival.TCPConnectEntry{{ + IP: "1.1.1.1", + Port: 853, + Status: archival.TCPConnectStatus{ + Blocked: &falseValue, + Failure: nil, + Success: true, + }, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webconnectivity.ComputeTCPBlocking(tt.args.measurement, tt.args.control) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &webconnectivity.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysWorksAsIntended(t *testing.T) { + failure := io.EOF.Error() + truy := true + tests := []struct { + tk webconnectivity.TestKeys + Accessible bool + Blocking string + isAnomaly bool + }{{ + tk: webconnectivity.TestKeys{}, + Accessible: false, + Blocking: "", + isAnomaly: false, + }, { + tk: webconnectivity.TestKeys{Summary: webconnectivity.Summary{ + BlockingReason: &failure, + }}, + Accessible: false, + Blocking: failure, + isAnomaly: true, + }, { + tk: webconnectivity.TestKeys{Summary: webconnectivity.Summary{ + Accessible: &truy, + }}, + Accessible: true, + Blocking: "", + isAnomaly: false, + }} + for idx, tt := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + m := &webconnectivity.Measurer{} + measurement := &model.Measurement{TestKeys: &tt.tk} + got, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + return + } + sk := got.(webconnectivity.SummaryKeys) + if sk.IsAnomaly != tt.isAnomaly { + t.Fatal("unexpected isAnomaly value") + } + if sk.Accessible != tt.Accessible { + t.Fatal("unexpected Accessible value") + } + if sk.Blocking != tt.Blocking { + t.Fatal("unexpected Accessible value") + } + }) + } +} diff --git a/internal/engine/experiment/whatsapp/whatsapp.go b/internal/engine/experiment/whatsapp/whatsapp.go new file mode 100644 index 0000000..035f2fc --- /dev/null +++ b/internal/engine/experiment/whatsapp/whatsapp.go @@ -0,0 +1,237 @@ +// Package whatsapp contains the WhatsApp network experiment. +// +// See https://github.com/ooni/spec/blob/master/nettests/ts-018-whatsapp.md. +package whatsapp + +import ( + "context" + "errors" + "fmt" + "math/rand" + "net/url" + "regexp" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpfailure" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const ( + // RegistrationServiceURL is the URL used by WhatsApp registration service + RegistrationServiceURL = "https://v.whatsapp.net/v2/register" + + // WebHTTPURL is WhatsApp web's HTTP URL + WebHTTPURL = "http://web.whatsapp.com/" + + // WebHTTPSURL is WhatsApp web's HTTPS URL + WebHTTPSURL = "https://web.whatsapp.com/" + + testName = "whatsapp" + testVersion = "0.9.0" +) + +var endpointPattern = regexp.MustCompile(`^tcpconnect://e[0-9]{1,2}\.whatsapp\.net:[0-9]{3,5}$`) + +// Config contains the experiment config. +type Config struct{} + +// TestKeys contains the experiment results +type TestKeys struct { + urlgetter.TestKeys + RegistrationServerFailure *string `json:"registration_server_failure"` + RegistrationServerStatus string `json:"registration_server_status"` + WhatsappEndpointsBlocked []string `json:"whatsapp_endpoints_blocked"` + WhatsappEndpointsDNSInconsistent []string `json:"whatsapp_endpoints_dns_inconsistent"` + WhatsappEndpointsStatus string `json:"whatsapp_endpoints_status"` + WhatsappWebFailure *string `json:"whatsapp_web_failure"` + WhatsappWebStatus string `json:"whatsapp_web_status"` + WhatsappEndpointsCount map[string]int `json:"-"` + WhatsappHTTPFailure *string `json:"-"` + WhatsappHTTPSFailure *string `json:"-"` +} + +// NewTestKeys returns a new instance of the test keys. +func NewTestKeys() *TestKeys { + failure := "unknown_failure" + return &TestKeys{ + RegistrationServerFailure: &failure, + RegistrationServerStatus: "blocked", + WhatsappEndpointsBlocked: []string{}, + WhatsappEndpointsDNSInconsistent: []string{}, + WhatsappEndpointsStatus: "blocked", + WhatsappWebFailure: &failure, + WhatsappWebStatus: "blocked", + WhatsappEndpointsCount: make(map[string]int), + WhatsappHTTPFailure: &failure, + WhatsappHTTPSFailure: &failure, + } +} + +// Update updates the TestKeys using the given MultiOutput result. +func (tk *TestKeys) Update(v urlgetter.MultiOutput) { + // Update the easy to update entries first + tk.NetworkEvents = append(tk.NetworkEvents, v.TestKeys.NetworkEvents...) + tk.Queries = append(tk.Queries, v.TestKeys.Queries...) + tk.Requests = append(tk.Requests, v.TestKeys.Requests...) + tk.TCPConnect = append(tk.TCPConnect, v.TestKeys.TCPConnect...) + tk.TLSHandshakes = append(tk.TLSHandshakes, v.TestKeys.TLSHandshakes...) + // Set the status of WhatsApp endpoints + if endpointPattern.MatchString(v.Input.Target) { + if v.TestKeys.Failure != nil { + parsed, err := url.Parse(v.Input.Target) + runtimex.PanicOnError(err, "url.Parse should not fail here") + hostname := parsed.Hostname() + tk.WhatsappEndpointsCount[hostname]++ + if tk.WhatsappEndpointsCount[hostname] >= 2 { + tk.WhatsappEndpointsBlocked = append(tk.WhatsappEndpointsBlocked, hostname) + } + return + } + tk.WhatsappEndpointsStatus = "ok" + return + } + // Set the status of the registration service + if v.Input.Target == RegistrationServiceURL { + tk.RegistrationServerFailure = v.TestKeys.Failure + if v.TestKeys.Failure == nil { + tk.RegistrationServerStatus = "ok" + } + return + } + // Track result of accessing the web interface. + switch v.Input.Target { + case WebHTTPSURL: + tk.WhatsappHTTPSFailure = v.TestKeys.Failure + case WebHTTPURL: + failure := v.TestKeys.Failure + if failure != nil { + // nothing to do here + } else if v.TestKeys.HTTPResponseStatus != 302 { + failure = &httpfailure.UnexpectedStatusCode + } else if len(v.TestKeys.HTTPResponseLocations) != 1 { + failure = &httpfailure.UnexpectedRedirectURL + } else if v.TestKeys.HTTPResponseLocations[0] != WebHTTPSURL { + failure = &httpfailure.UnexpectedRedirectURL + } + tk.WhatsappHTTPFailure = failure + } +} + +// ComputeWebStatus sets the web status fields. +func (tk *TestKeys) ComputeWebStatus() { + if tk.WhatsappHTTPFailure == nil && tk.WhatsappHTTPSFailure == nil { + tk.WhatsappWebFailure = nil + tk.WhatsappWebStatus = "ok" + return + } + tk.WhatsappWebStatus = "blocked" // must be here because of unit tests + if tk.WhatsappHTTPSFailure != nil { + tk.WhatsappWebFailure = tk.WhatsappHTTPSFailure + return + } + tk.WhatsappWebFailure = tk.WhatsappHTTPFailure +} + +// Measurer performs the measurement +type Measurer struct { + // Config contains the experiment settings. If empty we + // will be using default settings. + Config Config + + // Getter is an optional getter to be used for testing. + Getter urlgetter.MultiGetter +} + +// ExperimentName implements ExperimentMeasurer.ExperimentName +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements ExperimentMeasurer.Run +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + urlgetter.RegisterExtensions(measurement) + // generate all the inputs + var inputs []urlgetter.MultiInput + for idx := 1; idx <= 16; idx++ { + for _, port := range []string{"443", "5222"} { + inputs = append(inputs, urlgetter.MultiInput{ + Target: fmt.Sprintf("tcpconnect://e%d.whatsapp.net:%s", idx, port), + }) + } + } + inputs = append(inputs, urlgetter.MultiInput{ + Config: urlgetter.Config{FailOnHTTPError: true}, + Target: RegistrationServiceURL, + }) + inputs = append(inputs, urlgetter.MultiInput{ + // We consider this check successful if we can establish a TLS + // connection and we don't see any socket/TLS errors. Hence, we + // don't care about the HTTP response code. + Target: WebHTTPSURL, + }) + inputs = append(inputs, urlgetter.MultiInput{ + // We consider this check successful if we get a valid redirect + // for the HTTPS web interface. No need to follow redirects. + Config: urlgetter.Config{NoFollowRedirects: true}, + Target: WebHTTPURL, + }) + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + rnd.Shuffle(len(inputs), func(i, j int) { + inputs[i], inputs[j] = inputs[j], inputs[i] + }) + // measure in parallel + multi := urlgetter.Multi{Begin: time.Now(), Getter: m.Getter, Session: sess} + testkeys := NewTestKeys() + testkeys.Agent = "redirect" + measurement.TestKeys = testkeys + for entry := range multi.Collect(ctx, inputs, "whatsapp", callbacks) { + testkeys.Update(entry) + } + testkeys.ComputeWebStatus() + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with probe-cli +// therefore we should be careful when changing it. +type SummaryKeys struct { + RegistrationServerBlocking bool `json:"registration_server_blocking"` + WebBlocking bool `json:"whatsapp_web_blocking"` + EndpointsBlocking bool `json:"whatsapp_endpoints_blocking"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + tk, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + blocking := func(value string) bool { + return value == "blocked" + } + sk.RegistrationServerBlocking = blocking(tk.RegistrationServerStatus) + sk.WebBlocking = blocking(tk.WhatsappWebStatus) + sk.EndpointsBlocking = blocking(tk.WhatsappEndpointsStatus) + sk.IsAnomaly = (sk.RegistrationServerBlocking || sk.WebBlocking || sk.EndpointsBlocking) + return sk, nil +} diff --git a/internal/engine/experiment/whatsapp/whatsapp_test.go b/internal/engine/experiment/whatsapp/whatsapp_test.go new file mode 100644 index 0000000..89f13eb --- /dev/null +++ b/internal/engine/experiment/whatsapp/whatsapp_test.go @@ -0,0 +1,675 @@ +package whatsapp_test + +import ( + "context" + "fmt" + "io" + "regexp" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/engine/experiment/whatsapp" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpfailure" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := whatsapp.NewExperimentMeasurer(whatsapp.Config{}) + if measurer.ExperimentName() != "whatsapp" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.9.0" { + t.Fatal("unexpected version") + } +} + +func TestSuccess(t *testing.T) { + measurer := whatsapp.NewExperimentMeasurer(whatsapp.Config{}) + ctx := context.Background() + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*whatsapp.TestKeys) + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if tk.WhatsappWebFailure != nil { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "ok" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestFailureAllEndpoints(t *testing.T) { + measurer := whatsapp.NewExperimentMeasurer(whatsapp.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + sess := &mockable.Session{MockableLogger: log.Log} + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + err := measurer.Run(ctx, sess, measurement, callbacks) + if err != nil { + t.Fatal(err) + } + tk := measurement.TestKeys.(*whatsapp.TestKeys) + if *tk.RegistrationServerFailure != "interrupted" { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "blocked" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 16 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + pattern := regexp.MustCompile("^e[0-9]{1,2}.whatsapp.net$") + for i := 0; i < len(tk.WhatsappEndpointsBlocked); i++ { + if !pattern.MatchString(tk.WhatsappEndpointsBlocked[i]) { + t.Fatalf("invalid WhatsappEndpointsBlocked[%d]", i) + } + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "blocked" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if *tk.WhatsappWebFailure != "interrupted" { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "blocked" { + t.Fatal("invalid WhatsappWebStatus") + } + sk, err := measurer.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + } + if _, ok := sk.(whatsapp.SummaryKeys); !ok { + t.Fatal("invalid type for summary keys") + } +} + +func TestTestKeysComputeWebStatus(t *testing.T) { + errorString := io.EOF.Error() + secondErrorString := context.Canceled.Error() + type fields struct { + TestKeys urlgetter.TestKeys + RegistrationServerFailure *string + RegistrationServerStatus string + WhatsappEndpointsBlocked []string + WhatsappEndpointsDNSInconsistent []string + WhatsappEndpointsStatus string + WhatsappWebStatus string + WhatsappWebFailure *string + WhatsappHTTPFailure *string + WhatsappHTTPSFailure *string + } + tests := []struct { + name string + fields fields + failure *string + status string + }{{ + name: "with success", + failure: nil, + status: "ok", + }, { + name: "with HTTP failure", + fields: fields{ + WhatsappHTTPFailure: &errorString, + }, + failure: &errorString, + status: "blocked", + }, { + name: "with HTTPS failure", + fields: fields{ + WhatsappHTTPSFailure: &errorString, + }, + failure: &errorString, + status: "blocked", + }, { + name: "with both HTTP and HTTPS failure", + fields: fields{ + WhatsappHTTPFailure: &errorString, + WhatsappHTTPSFailure: &secondErrorString, + }, + failure: &secondErrorString, + status: "blocked", + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tk := &whatsapp.TestKeys{ + TestKeys: tt.fields.TestKeys, + RegistrationServerFailure: tt.fields.RegistrationServerFailure, + RegistrationServerStatus: tt.fields.RegistrationServerStatus, + WhatsappEndpointsBlocked: tt.fields.WhatsappEndpointsBlocked, + WhatsappEndpointsDNSInconsistent: tt.fields.WhatsappEndpointsDNSInconsistent, + WhatsappEndpointsStatus: tt.fields.WhatsappEndpointsStatus, + WhatsappWebStatus: tt.fields.WhatsappWebStatus, + WhatsappWebFailure: tt.fields.WhatsappWebFailure, + WhatsappHTTPFailure: tt.fields.WhatsappHTTPFailure, + WhatsappHTTPSFailure: tt.fields.WhatsappHTTPSFailure, + } + tk.ComputeWebStatus() + diff := cmp.Diff(tk.WhatsappWebFailure, tt.failure) + if diff != "" { + t.Fatal(diff) + } + diff = cmp.Diff(tk.WhatsappWebStatus, tt.status) + if diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestTestKeysMixedEndpointsFailure(t *testing.T) { + failure := io.EOF.Error() + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{Failure: &failure}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:5222"}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 302, + HTTPResponseLocations: []string{whatsapp.WebHTTPSURL}, + }, + }) + tk.ComputeWebStatus() + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if tk.WhatsappWebFailure != nil { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "ok" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestTestKeysOnlyEndpointsFailure(t *testing.T) { + failure := io.EOF.Error() + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{Failure: &failure}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:5222"}, + TestKeys: urlgetter.TestKeys{Failure: &failure}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 302, + HTTPResponseLocations: []string{whatsapp.WebHTTPSURL}, + }, + }) + tk.ComputeWebStatus() + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 1 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "blocked" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if tk.WhatsappWebFailure != nil { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "ok" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestTestKeysOnlyRegistrationServerFailure(t *testing.T) { + failure := io.EOF.Error() + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{Failure: &failure}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 302, + HTTPResponseLocations: []string{whatsapp.WebHTTPSURL}, + }, + }) + tk.ComputeWebStatus() + if *tk.RegistrationServerFailure != failure { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "blocked" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if tk.WhatsappWebFailure != nil { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "ok" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestTestKeysOnlyWebHTTPSFailure(t *testing.T) { + failure := io.EOF.Error() + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{Failure: &failure}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 302, + HTTPResponseLocations: []string{whatsapp.WebHTTPSURL}, + }, + }) + tk.ComputeWebStatus() + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if *tk.WhatsappWebFailure != failure { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "blocked" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestTestKeysOnlyWebHTTPFailureNo302(t *testing.T) { + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 400, + }, + }) + tk.ComputeWebStatus() + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if *tk.WhatsappWebFailure != httpfailure.UnexpectedStatusCode { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "blocked" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestTestKeysOnlyWebHTTPFailureNoLocations(t *testing.T) { + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 302, + HTTPResponseLocations: nil, + }, + }) + tk.ComputeWebStatus() + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if *tk.WhatsappWebFailure != httpfailure.UnexpectedRedirectURL { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "blocked" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestTestKeysOnlyWebHTTPFailureNotExpectedURL(t *testing.T) { + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 302, + HTTPResponseLocations: []string{"https://x.org/"}, + }, + }) + tk.ComputeWebStatus() + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if *tk.WhatsappWebFailure != httpfailure.UnexpectedRedirectURL { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "blocked" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestTestKeysOnlyWebHTTPFailureTooManyURLs(t *testing.T) { + tk := whatsapp.NewTestKeys() + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: "tcpconnect://e7.whatsapp.net:443"}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.RegistrationServiceURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPSURL}, + TestKeys: urlgetter.TestKeys{}, + }) + tk.Update(urlgetter.MultiOutput{ + Input: urlgetter.MultiInput{Target: whatsapp.WebHTTPURL}, + TestKeys: urlgetter.TestKeys{ + HTTPResponseStatus: 302, + HTTPResponseLocations: []string{whatsapp.WebHTTPSURL, "https://x.org/"}, + }, + }) + tk.ComputeWebStatus() + if tk.RegistrationServerFailure != nil { + t.Fatal("invalid RegistrationServerFailure") + } + if tk.RegistrationServerStatus != "ok" { + t.Fatal("invalid RegistrationServerStatus") + } + if len(tk.WhatsappEndpointsBlocked) != 0 { + t.Fatal("invalid WhatsappEndpointsBlocked") + } + if len(tk.WhatsappEndpointsDNSInconsistent) != 0 { + t.Fatal("invalid WhatsappEndpointsDNSInconsistent") + } + if tk.WhatsappEndpointsStatus != "ok" { + t.Fatal("invalid WhatsappEndpointsStatus") + } + if *tk.WhatsappWebFailure != httpfailure.UnexpectedRedirectURL { + t.Fatal("invalid WhatsappWebFailure") + } + if tk.WhatsappWebStatus != "blocked" { + t.Fatal("invalid WhatsappWebStatus") + } +} + +func TestWeConfigureWebChecksCorrectly(t *testing.T) { + called := atomicx.NewInt64() + emptyConfig := urlgetter.Config{} + configWithFailOnHTTPError := urlgetter.Config{FailOnHTTPError: true} + configWithNoFollowRedirects := urlgetter.Config{NoFollowRedirects: true} + measurer := whatsapp.Measurer{ + Config: whatsapp.Config{}, + Getter: func(ctx context.Context, g urlgetter.Getter) (urlgetter.TestKeys, error) { + switch g.Target { + case whatsapp.WebHTTPSURL: + called.Add(1) + if diff := cmp.Diff(g.Config, emptyConfig); diff != "" { + panic(diff) + } + case whatsapp.WebHTTPURL: + called.Add(2) + if diff := cmp.Diff(g.Config, configWithNoFollowRedirects); diff != "" { + panic(diff) + } + case whatsapp.RegistrationServiceURL: + called.Add(4) + if diff := cmp.Diff(g.Config, configWithFailOnHTTPError); diff != "" { + panic(diff) + } + default: + called.Add(8) + if diff := cmp.Diff(g.Config, emptyConfig); diff != "" { + panic(diff) + } + } + return urlgetter.DefaultMultiGetter(ctx, g) + }, + } + ctx := context.Background() + sess := &mockable.Session{ + MockableLogger: log.Log, + } + measurement := new(model.Measurement) + callbacks := model.NewPrinterCallbacks(log.Log) + if err := measurer.Run(ctx, sess, measurement, callbacks); err != nil { + t.Fatal(err) + } + if called.Load() != 263 { + t.Fatal("not called the expected number of times") + } +} + +func TestSummaryKeysInvalidType(t *testing.T) { + measurement := new(model.Measurement) + m := &whatsapp.Measurer{} + _, err := m.GetSummaryKeys(measurement) + if err.Error() != "invalid test keys type" { + t.Fatal("not the error we expected") + } +} + +func TestSummaryKeysWorksAsIntended(t *testing.T) { + tests := []struct { + tk whatsapp.TestKeys + RegistrationServerBlocking bool + WebBlocking bool + EndpointsBlocking bool + isAnomaly bool + }{{ + tk: whatsapp.TestKeys{}, + RegistrationServerBlocking: false, + WebBlocking: false, + EndpointsBlocking: false, + isAnomaly: false, + }, { + tk: whatsapp.TestKeys{ + RegistrationServerStatus: "blocked", + }, + RegistrationServerBlocking: true, + WebBlocking: false, + EndpointsBlocking: false, + isAnomaly: true, + }, { + tk: whatsapp.TestKeys{ + WhatsappWebStatus: "blocked", + }, + RegistrationServerBlocking: false, + WebBlocking: true, + EndpointsBlocking: false, + isAnomaly: true, + }, { + tk: whatsapp.TestKeys{ + WhatsappEndpointsStatus: "blocked", + }, + RegistrationServerBlocking: false, + WebBlocking: false, + EndpointsBlocking: true, + isAnomaly: true, + }} + for idx, tt := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + m := &whatsapp.Measurer{} + measurement := &model.Measurement{TestKeys: &tt.tk} + got, err := m.GetSummaryKeys(measurement) + if err != nil { + t.Fatal(err) + return + } + sk := got.(whatsapp.SummaryKeys) + if sk.IsAnomaly != tt.isAnomaly { + t.Fatal("unexpected isAnomaly value") + } + if sk.RegistrationServerBlocking != tt.RegistrationServerBlocking { + t.Fatal("unexpected registrationServerBlocking value") + } + if sk.WebBlocking != tt.WebBlocking { + t.Fatal("unexpected webBlocking value") + } + if sk.EndpointsBlocking != tt.EndpointsBlocking { + t.Fatal("unexpected endpointsBlocking value") + } + }) + } +} diff --git a/internal/engine/experiment_integration_test.go b/internal/engine/experiment_integration_test.go new file mode 100644 index 0000000..e689b00 --- /dev/null +++ b/internal/engine/experiment_integration_test.go @@ -0,0 +1,559 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/example" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestCreateAll(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + for _, name := range AllExperiments() { + builder, err := sess.NewExperimentBuilder(name) + if err != nil { + t.Fatal(err) + } + exp := builder.NewExperiment() + good := (exp.Name() == name) + if !good { + t.Fatal("unexpected experiment name") + } + } +} + +func TestRunDASH(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("dash") + if err != nil { + t.Fatal(err) + } + if !builder.Interruptible() { + t.Fatal("dash not marked as interruptible") + } + runexperimentflow(t, builder.NewExperiment(), "") +} + +func TestRunExample(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + runexperimentflow(t, builder.NewExperiment(), "") +} + +func TestRunNdt7(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("ndt7") + if err != nil { + t.Fatal(err) + } + if !builder.Interruptible() { + t.Fatal("ndt7 not marked as interruptible") + } + runexperimentflow(t, builder.NewExperiment(), "") +} + +func TestRunPsiphon(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("psiphon") + if err != nil { + t.Fatal(err) + } + runexperimentflow(t, builder.NewExperiment(), "") +} + +func TestRunSNIBlocking(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("sni_blocking") + if err != nil { + t.Fatal(err) + } + runexperimentflow(t, builder.NewExperiment(), "kernel.org") +} + +func TestRunTelegram(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("telegram") + if err != nil { + t.Fatal(err) + } + runexperimentflow(t, builder.NewExperiment(), "") +} + +func TestRunTor(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("tor") + if err != nil { + t.Fatal(err) + } + runexperimentflow(t, builder.NewExperiment(), "") +} + +func TestNeedsInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("web_connectivity") + if err != nil { + t.Fatal(err) + } + if builder.InputPolicy() != InputOrQueryTestLists { + t.Fatal("web_connectivity certainly needs input") + } +} + +func TestSetCallbacks(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + if err := builder.SetOptionInt("SleepTime", 0); err != nil { + t.Fatal(err) + } + register := ®isterCallbacksCalled{} + builder.SetCallbacks(register) + if _, err := builder.NewExperiment().Measure(""); err != nil { + t.Fatal(err) + } + if register.onProgressCalled == false { + t.Fatal("OnProgress not called") + } +} + +type registerCallbacksCalled struct { + onProgressCalled bool +} + +func (c *registerCallbacksCalled) OnProgress(percentage float64, message string) { + c.onProgressCalled = true +} + +func TestCreateInvalidExperiment(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("antani") + if err == nil { + t.Fatal("expected an error here") + } + if builder != nil { + t.Fatal("expected a nil builder here") + } +} + +func TestMeasurementFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + if err := builder.SetOptionBool("ReturnError", true); err != nil { + t.Fatal(err) + } + measurement, err := builder.NewExperiment().Measure("") + if err == nil { + t.Fatal("expected an error here") + } + if err.Error() != "mocked error" { + t.Fatal("unexpected error type") + } + if measurement == nil { + t.Fatal("expected non nil measurement here") + } +} + +func TestUseOptions(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + options, err := builder.Options() + if err != nil { + t.Fatal(err) + } + var ( + returnError bool + message bool + sleepTime bool + other int64 + ) + for name, option := range options { + if name == "ReturnError" { + returnError = true + if option.Type != "bool" { + t.Fatal("ReturnError is not a bool") + } + if option.Doc != "Toogle to return a mocked error" { + t.Fatal("ReturnError doc is wrong") + } + } else if name == "Message" { + message = true + if option.Type != "string" { + t.Fatal("Message is not a string") + } + if option.Doc != "Message to emit at test completion" { + t.Fatal("Message doc is wrong") + } + } else if name == "SleepTime" { + sleepTime = true + if option.Type != "int64" { + t.Fatal("SleepTime is not an int64") + } + if option.Doc != "Amount of time to sleep for" { + t.Fatal("SleepTime doc is wrong") + } + } else { + other++ + } + } + if other != 0 { + t.Fatal("found unexpected option") + } + if !returnError { + t.Fatal("did not find ReturnError option") + } + if !message { + t.Fatal("did not find Message option") + } + if !sleepTime { + t.Fatal("did not find SleepTime option") + } + if err := builder.SetOptionBool("ReturnError", true); err != nil { + t.Fatal("cannot set ReturnError field") + } + if err := builder.SetOptionInt("SleepTime", 10); err != nil { + t.Fatal("cannot set SleepTime field") + } + if err := builder.SetOptionString("Message", "antani"); err != nil { + t.Fatal("cannot set Message field") + } + config := builder.config.(*example.Config) + if config.ReturnError != true { + t.Fatal("config.ReturnError was not changed") + } + if config.SleepTime != 10 { + t.Fatal("config.SleepTime was not changed") + } + if config.Message != "antani" { + t.Fatal("config.Message was not changed") + } +} + +func TestRunHHFM(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("http_header_field_manipulation") + if err != nil { + t.Fatal(err) + } + runexperimentflow(t, builder.NewExperiment(), "") +} + +func runexperimentflow(t *testing.T, experiment *Experiment, input string) { + err := experiment.OpenReport() + if err != nil { + t.Fatal(err) + } + if experiment.ReportID() == "" { + t.Fatal("reportID should not be empty here") + } + measurement, err := experiment.Measure(input) + if err != nil { + t.Fatal(err) + } + measurement.AddAnnotations(map[string]string{ + "probe-engine-ci": "yes", + }) + data, err := json.Marshal(measurement) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("data is nil") + } + err = experiment.SubmitAndUpdateMeasurement(measurement) + if err != nil { + t.Fatal(err) + } + err = experiment.SaveMeasurement(measurement, "/tmp/experiment.jsonl") + if err != nil { + t.Fatal(err) + } + if experiment.KibiBytesSent() <= 0 { + t.Fatal("no data sent?!") + } + if experiment.KibiBytesReceived() <= 0 { + t.Fatal("no data received?!") + } + if _, err := experiment.GetSummaryKeys(measurement); err != nil { + t.Fatal(err) + } +} + +func TestSaveMeasurementErrors(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + exp := builder.NewExperiment() + dirname, err := ioutil.TempDir("", "ooniprobe-engine-save-measurement") + if err != nil { + t.Fatal(err) + } + filename := filepath.Join(dirname, "report.jsonl") + m := new(model.Measurement) + err = exp.SaveMeasurementEx( + m, filename, func(v interface{}) ([]byte, error) { + return nil, errors.New("mocked error") + }, os.OpenFile, func(fp *os.File, b []byte) (int, error) { + return fp.Write(b) + }, + ) + if err == nil { + t.Fatal("expected an error here") + } + err = exp.SaveMeasurementEx( + m, filename, json.Marshal, + func(name string, flag int, perm os.FileMode) (*os.File, error) { + return nil, errors.New("mocked error") + }, func(fp *os.File, b []byte) (int, error) { + return fp.Write(b) + }, + ) + if err == nil { + t.Fatal("expected an error here") + } + err = exp.SaveMeasurementEx( + m, filename, json.Marshal, os.OpenFile, + func(fp *os.File, b []byte) (int, error) { + return 0, errors.New("mocked error") + }, + ) + if err == nil { + t.Fatal("expected an error here") + } +} + +func TestOpenReportIdempotent(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + exp := builder.NewExperiment() + if exp.ReportID() != "" { + t.Fatal("unexpected initial report ID") + } + if err := exp.SubmitAndUpdateMeasurement(&model.Measurement{}); err == nil { + t.Fatal("we should not be able to submit before OpenReport") + } + err = exp.OpenReport() + if err != nil { + t.Fatal(err) + } + rid := exp.ReportID() + if rid == "" { + t.Fatal("invalid report ID") + } + err = exp.OpenReport() + if err != nil { + t.Fatal(err) + } + if rid != exp.ReportID() { + t.Fatal("OpenReport is not idempotent") + } +} + +func TestOpenReportFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + }, + )) + defer server.Close() + sess := newSessionForTestingNoBackendsLookup(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + exp := builder.NewExperiment() + exp.session.selectedProbeService = &model.Service{ + Address: server.URL, + Type: "https", + } + err = exp.OpenReport() + if !strings.HasPrefix(err.Error(), "httpx: request failed") { + t.Fatal("not the error we expected") + } +} + +func TestOpenReportNewClientFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoBackendsLookup(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + exp := builder.NewExperiment() + exp.session.selectedProbeService = &model.Service{ + Address: "antani:///", + Type: "antani", + } + err = exp.OpenReport() + if err.Error() != "probe services: unsupported endpoint type" { + t.Fatal(err) + } +} + +func TestSubmitAndUpdateMeasurementWithClosedReport(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTesting(t) + defer sess.Close() + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + exp := builder.NewExperiment() + m := new(model.Measurement) + err = exp.SubmitAndUpdateMeasurement(m) + if err == nil { + t.Fatal("expected an error here") + } +} + +func TestMeasureLookupLocationFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + exp := NewExperiment(sess, new(antaniMeasurer)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // so we fail immediately + if _, err := exp.MeasureWithContext(ctx, "xx"); err == nil { + t.Fatal("expected an error here") + } +} + +func TestOpenReportNonHTTPS(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + sess.availableProbeServices = []model.Service{ + { + Address: "antani", + Type: "mascetti", + }, + } + exp := NewExperiment(sess, new(antaniMeasurer)) + if err := exp.OpenReport(); err == nil { + t.Fatal("expected an error here") + } +} + +type antaniMeasurer struct{} + +func (am *antaniMeasurer) ExperimentName() string { + return "antani" +} + +func (am *antaniMeasurer) ExperimentVersion() string { + return "0.1.1" +} + +func (am *antaniMeasurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + return nil +} + +func (am *antaniMeasurer) GetSummaryKeys(m *model.Measurement) (interface{}, error) { + return struct { + Failure *string `json:"failure"` + }{}, nil +} diff --git a/internal/engine/experiment_internal_test.go b/internal/engine/experiment_internal_test.go new file mode 100644 index 0000000..fa9d7e6 --- /dev/null +++ b/internal/engine/experiment_internal_test.go @@ -0,0 +1,16 @@ +package engine + +import ( + "os" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func (e *Experiment) SaveMeasurementEx( + measurement *model.Measurement, filePath string, + marshal func(v interface{}) ([]byte, error), + openFile func(name string, flag int, perm os.FileMode) (*os.File, error), + write func(fp *os.File, b []byte) (n int, err error), +) error { + return e.saveMeasurement(measurement, filePath, marshal, openFile, write) +} diff --git a/internal/engine/experiment_test.go b/internal/engine/experiment_test.go new file mode 100644 index 0000000..639ddbb --- /dev/null +++ b/internal/engine/experiment_test.go @@ -0,0 +1,75 @@ +package engine + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/geolocate" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestExperimentHonoursSharingDefaults(t *testing.T) { + measure := func(info *geolocate.Results) *model.Measurement { + sess := &Session{location: info} + builder, err := sess.NewExperimentBuilder("example") + if err != nil { + t.Fatal(err) + } + exp := builder.NewExperiment() + return exp.newMeasurement("") + } + type spec struct { + name string + locationInfo *geolocate.Results + expect func(*model.Measurement) bool + } + allspecs := []spec{{ + name: "probeIP", + locationInfo: &geolocate.Results{ProbeIP: "8.8.8.8"}, + expect: func(m *model.Measurement) bool { + return m.ProbeIP == geolocate.DefaultProbeIP + }, + }, { + name: "probeASN", + locationInfo: &geolocate.Results{ASN: 30722}, + expect: func(m *model.Measurement) bool { + return m.ProbeASN == "AS30722" + }, + }, { + name: "probeCC", + locationInfo: &geolocate.Results{CountryCode: "IT"}, + expect: func(m *model.Measurement) bool { + return m.ProbeCC == "IT" + }, + }, { + name: "probeNetworkName", + locationInfo: &geolocate.Results{NetworkName: "Vodafone Italia"}, + expect: func(m *model.Measurement) bool { + return m.ProbeNetworkName == "Vodafone Italia" + }, + }, { + name: "resolverIP", + locationInfo: &geolocate.Results{ResolverIP: "9.9.9.9"}, + expect: func(m *model.Measurement) bool { + return m.ResolverIP == "9.9.9.9" + }, + }, { + name: "resolverASN", + locationInfo: &geolocate.Results{ResolverASN: 44}, + expect: func(m *model.Measurement) bool { + return m.ResolverASN == "AS44" + }, + }, { + name: "resolverNetworkName", + locationInfo: &geolocate.Results{ResolverNetworkName: "Google LLC"}, + expect: func(m *model.Measurement) bool { + return m.ResolverNetworkName == "Google LLC" + }, + }} + for _, spec := range allspecs { + t.Run(spec.name, func(t *testing.T) { + if !spec.expect(measure(spec.locationInfo)) { + t.Fatal("expectation failed") + } + }) + } +} diff --git a/internal/engine/experimentbuilder.go b/internal/engine/experimentbuilder.go new file mode 100644 index 0000000..994efc8 --- /dev/null +++ b/internal/engine/experimentbuilder.go @@ -0,0 +1,203 @@ +package engine + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "strconv" + + "github.com/iancoleman/strcase" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// InputPolicy describes the experiment policy with respect to input. That is +// whether it requires input, optionally accepts input, does not want input. +type InputPolicy string + +const ( + // InputOrQueryTestLists indicates that the experiment requires + // external input to run and that this kind of input is URLs + // from the citizenlab/test-lists repository. If this input + // not provided to the experiment, then the code that runs the + // experiment is supposed to fetch from URLs from OONI's backends. + InputOrQueryTestLists = InputPolicy("or_query_test_lists") + + // InputStrictlyRequired indicates that the experiment + // requires input and we currently don't have an API for + // fetching such input. Therefore, either the user specifies + // input or the experiment will fail for the lack of input. + InputStrictlyRequired = InputPolicy("strictly_required") + + // InputOptional indicates that the experiment handles input, + // if any; otherwise it fetchs input/uses a default. + InputOptional = InputPolicy("optional") + + // InputNone indicates that the experiment does not want any + // input and ignores the input if provided with it. + InputNone = InputPolicy("none") +) + +// ExperimentBuilder is an experiment builder. +type ExperimentBuilder struct { + build func(interface{}) *Experiment + callbacks model.ExperimentCallbacks + config interface{} + inputPolicy InputPolicy + interruptible bool +} + +// Interruptible tells you whether this is an interruptible experiment. This kind +// of experiments (e.g. ndt7) may be interrupted mid way. +func (b *ExperimentBuilder) Interruptible() bool { + return b.interruptible +} + +// InputPolicy returns the experiment input policy +func (b *ExperimentBuilder) InputPolicy() InputPolicy { + return b.inputPolicy +} + +// OptionInfo contains info about an option +type OptionInfo struct { + Doc string + Type string +} + +// Options returns info about all options +func (b *ExperimentBuilder) Options() (map[string]OptionInfo, error) { + result := make(map[string]OptionInfo) + ptrinfo := reflect.ValueOf(b.config) + if ptrinfo.Kind() != reflect.Ptr { + return nil, errors.New("config is not a pointer") + } + structinfo := ptrinfo.Elem().Type() + if structinfo.Kind() != reflect.Struct { + return nil, errors.New("config is not a struct") + } + for i := 0; i < structinfo.NumField(); i++ { + field := structinfo.Field(i) + result[field.Name] = OptionInfo{ + Doc: field.Tag.Get("ooni"), + Type: field.Type.String(), + } + } + return result, nil +} + +// SetOptionBool sets a bool option +func (b *ExperimentBuilder) SetOptionBool(key string, value bool) error { + field, err := fieldbyname(b.config, key) + if err != nil { + return err + } + if field.Kind() != reflect.Bool { + return errors.New("field is not a bool") + } + field.SetBool(value) + return nil +} + +// SetOptionInt sets an int option +func (b *ExperimentBuilder) SetOptionInt(key string, value int64) error { + field, err := fieldbyname(b.config, key) + if err != nil { + return err + } + if field.Kind() != reflect.Int64 { + return errors.New("field is not an int64") + } + field.SetInt(value) + return nil +} + +// SetOptionString sets a string option +func (b *ExperimentBuilder) SetOptionString(key, value string) error { + field, err := fieldbyname(b.config, key) + if err != nil { + return err + } + if field.Kind() != reflect.String { + return errors.New("field is not a string") + } + field.SetString(value) + return nil +} + +var intregexp = regexp.MustCompile("^[0-9]+$") + +// SetOptionGuessType sets an option whose type depends on the +// option value. If the value is `"true"` or `"false"` we +// assume the option is boolean. If the value is numeric, then we +// set an integer option. Otherwise we set a string option. +func (b *ExperimentBuilder) SetOptionGuessType(key, value string) error { + if value == "true" || value == "false" { + return b.SetOptionBool(key, value == "true") + } + if !intregexp.MatchString(value) { + return b.SetOptionString(key, value) + } + number, _ := strconv.ParseInt(value, 10, 64) + return b.SetOptionInt(key, number) +} + +// SetOptionsGuessType calls the SetOptionGuessType method for every +// key, value pair contained by the opts input map. +func (b *ExperimentBuilder) SetOptionsGuessType(opts map[string]string) error { + for k, v := range opts { + if err := b.SetOptionGuessType(k, v); err != nil { + return err + } + } + return nil +} + +// SetCallbacks sets the interactive callbacks +func (b *ExperimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) { + b.callbacks = callbacks +} + +func fieldbyname(v interface{}, key string) (reflect.Value, error) { + // See https://stackoverflow.com/a/6396678/4354461 + ptrinfo := reflect.ValueOf(v) + if ptrinfo.Kind() != reflect.Ptr { + return reflect.Value{}, errors.New("value is not a pointer") + } + structinfo := ptrinfo.Elem() + if structinfo.Kind() != reflect.Struct { + return reflect.Value{}, errors.New("value is not a pointer to struct") + } + field := structinfo.FieldByName(key) + if !field.IsValid() || !field.CanSet() { + return reflect.Value{}, errors.New("no such field") + } + return field, nil +} + +// NewExperiment creates the experiment +func (b *ExperimentBuilder) NewExperiment() *Experiment { + experiment := b.build(b.config) + experiment.callbacks = b.callbacks + return experiment +} + +// canonicalizeExperimentName allows code to provide experiment names +// in a more flexible way, where we have aliases. +func canonicalizeExperimentName(name string) string { + switch name = strcase.ToSnake(name); name { + case "ndt_7": + name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default + default: + } + return name +} + +func newExperimentBuilder(session *Session, name string) (*ExperimentBuilder, error) { + factory, _ := experimentsByName[canonicalizeExperimentName(name)] + if factory == nil { + return nil, fmt.Errorf("no such experiment: %s", name) + } + builder := factory(session) + builder.callbacks = model.NewPrinterCallbacks(session.Logger()) + return builder, nil +} diff --git a/internal/engine/experimentbuilder_test.go b/internal/engine/experimentbuilder_test.go new file mode 100644 index 0000000..6762a63 --- /dev/null +++ b/internal/engine/experimentbuilder_test.go @@ -0,0 +1,190 @@ +package engine + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/example" +) + +func TestExperimentBuilderOptions(t *testing.T) { + t.Run("when config is not a pointer", func(t *testing.T) { + b := &ExperimentBuilder{ + config: 17, + } + options, err := b.Options() + if err == nil { + t.Fatal("expected an error here") + } + if options != nil { + t.Fatal("expected nil here") + } + }) + t.Run("when config is not a struct", func(t *testing.T) { + number := 17 + b := &ExperimentBuilder{ + config: &number, + } + options, err := b.Options() + if err == nil { + t.Fatal("expected an error here") + } + if options != nil { + t.Fatal("expected nil here") + } + }) +} + +func TestExperimentBuilderSetOption(t *testing.T) { + t.Run("when config is not a pointer", func(t *testing.T) { + b := &ExperimentBuilder{ + config: 17, + } + if err := b.SetOptionBool("antani", false); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when config is not a struct", func(t *testing.T) { + number := 17 + b := &ExperimentBuilder{ + config: &number, + } + if err := b.SetOptionBool("antani", false); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when field is not valid", func(t *testing.T) { + b := &ExperimentBuilder{ + config: &ExperimentBuilder{}, + } + if err := b.SetOptionBool("antani", false); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when field is not bool", func(t *testing.T) { + b := &ExperimentBuilder{ + config: new(example.Config), + } + if err := b.SetOptionBool("Message", false); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when field is not string", func(t *testing.T) { + b := &ExperimentBuilder{ + config: new(example.Config), + } + if err := b.SetOptionString("ReturnError", "xx"); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when field is not int", func(t *testing.T) { + b := &ExperimentBuilder{ + config: new(example.Config), + } + if err := b.SetOptionInt("ReturnError", 17); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when int field does not exist", func(t *testing.T) { + b := &ExperimentBuilder{ + config: new(example.Config), + } + if err := b.SetOptionInt("antani", 17); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when string field does not exist", func(t *testing.T) { + b := &ExperimentBuilder{ + config: new(example.Config), + } + if err := b.SetOptionString("antani", "xx"); err == nil { + t.Fatal("expected an error here") + } + }) +} + +func TestExperimentBuilderSetOptionGuessType(t *testing.T) { + type fiction struct { + String string + Truth bool + Value int64 + } + b := &ExperimentBuilder{config: &fiction{}} + t.Run("we correctly guess a boolean", func(t *testing.T) { + if err := b.SetOptionGuessType("Truth", "true"); err != nil { + t.Fatal(err) + } + if err := b.SetOptionGuessType("Truth", "false"); err != nil { + t.Fatal(err) + } + if err := b.SetOptionGuessType("Truth", "1234"); err == nil { + t.Fatal("expected an error here") + } + if err := b.SetOptionGuessType("Truth", "yoloyolo"); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("we correctly guess an integer", func(t *testing.T) { + if err := b.SetOptionGuessType("Value", "true"); err == nil { + t.Fatal("expected an error here") + } + if err := b.SetOptionGuessType("Value", "false"); err == nil { + t.Fatal("expected an error here") + } + if err := b.SetOptionGuessType("Value", "1234"); err != nil { + t.Fatal(err) + } + if err := b.SetOptionGuessType("Value", "yoloyolo"); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("we correctly guess a string", func(t *testing.T) { + if err := b.SetOptionGuessType("String", "true"); err == nil { + t.Fatal("expected an error here") + } + if err := b.SetOptionGuessType("String", "false"); err == nil { + t.Fatal("expected an error here") + } + if err := b.SetOptionGuessType("String", "1234"); err == nil { + t.Fatal("expected an error here") + } + if err := b.SetOptionGuessType("String", "yoloyolo"); err != nil { + t.Fatal(err) + } + }) + t.Run("we correctly handle an empty map", func(t *testing.T) { + if err := b.SetOptionsGuessType(nil); err != nil { + t.Fatal(err) + } + }) + t.Run("we correctly handle a map containing options", func(t *testing.T) { + f := &fiction{} + privateb := &ExperimentBuilder{config: f} + opts := map[string]string{ + "String": "yoloyolo", + "Value": "174", + "Truth": "true", + } + if err := privateb.SetOptionsGuessType(opts); err != nil { + t.Fatal(err) + } + if f.String != "yoloyolo" { + t.Fatal("cannot set string value") + } + if f.Value != 174 { + t.Fatal("cannot set integer value") + } + if f.Truth != true { + t.Fatal("cannot set bool value") + } + }) + t.Run("we handle mistakes in a map containing options", func(t *testing.T) { + opts := map[string]string{ + "String": "yoloyolo", + "Value": "antani;", + "Truth": "true", + } + if err := b.SetOptionsGuessType(opts); err == nil { + t.Fatal("expected an error here") + } + }) +} diff --git a/internal/engine/geolocate/README.md b/internal/engine/geolocate/README.md new file mode 100644 index 0000000..96a7755 --- /dev/null +++ b/internal/engine/geolocate/README.md @@ -0,0 +1,3 @@ +# Package github.com/ooni/probe-engine/geolocate + +Package geolocate implements IP lookup, resolver lookup, and geolocation. diff --git a/internal/engine/geolocate/avast.go b/internal/engine/geolocate/avast.go new file mode 100644 index 0000000..26c2445 --- /dev/null +++ b/internal/engine/geolocate/avast.go @@ -0,0 +1,31 @@ +package geolocate + +import ( + "context" + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" +) + +type avastResponse struct { + IP string `json:"ip"` +} + +func avastIPLookup( + ctx context.Context, + httpClient *http.Client, + logger Logger, + userAgent string, +) (string, error) { + var v avastResponse + err := (httpx.Client{ + BaseURL: "https://ip-info.ff.avast.com", + HTTPClient: httpClient, + Logger: logger, + UserAgent: userAgent, + }).GetJSON(ctx, "/v1/info", &v) + if err != nil { + return DefaultProbeIP, err + } + return v.IP, nil +} diff --git a/internal/engine/geolocate/avast_test.go b/internal/engine/geolocate/avast_test.go new file mode 100644 index 0000000..c16f138 --- /dev/null +++ b/internal/engine/geolocate/avast_test.go @@ -0,0 +1,26 @@ +package geolocate + +import ( + "context" + "net" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" +) + +func TestIPLookupWorksUsingAvast(t *testing.T) { + ip, err := avastIPLookup( + context.Background(), + http.DefaultClient, + log.Log, + httpheader.UserAgent(), + ) + if err != nil { + t.Fatal(err) + } + if net.ParseIP(ip) == nil { + t.Fatalf("not an IP address: '%s'", ip) + } +} diff --git a/internal/engine/geolocate/fake_test.go b/internal/engine/geolocate/fake_test.go new file mode 100644 index 0000000..93978c5 --- /dev/null +++ b/internal/engine/geolocate/fake_test.go @@ -0,0 +1,27 @@ +package geolocate + +import ( + "io/ioutil" + "net/http" + "time" +) + +type FakeTransport struct { + Err error + Resp *http.Response +} + +func (txp FakeTransport) RoundTrip(req *http.Request) (*http.Response, error) { + time.Sleep(10 * time.Microsecond) + 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() {} diff --git a/internal/engine/geolocate/geolocate.go b/internal/engine/geolocate/geolocate.go new file mode 100644 index 0000000..b1f84db --- /dev/null +++ b/internal/engine/geolocate/geolocate.go @@ -0,0 +1,242 @@ +// Package geolocate implements IP lookup, resolver lookup, and geolocation. +package geolocate + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/version" +) + +const ( + // DefaultProbeASN is the default probe ASN as number. + DefaultProbeASN uint = 0 + + // DefaultProbeCC is the default probe CC. + DefaultProbeCC = "ZZ" + + // DefaultProbeIP is the default probe IP. + DefaultProbeIP = model.DefaultProbeIP + + // DefaultProbeNetworkName is the default probe network name. + DefaultProbeNetworkName = "" + + // DefaultResolverASN is the default resolver ASN. + DefaultResolverASN uint = 0 + + // DefaultResolverIP is the default resolver IP. + DefaultResolverIP = "127.0.0.2" + + // DefaultResolverNetworkName is the default resolver network name. + DefaultResolverNetworkName = "" +) + +var ( + // DefaultProbeASNString is the default probe ASN as a string. + DefaultProbeASNString = fmt.Sprintf("AS%d", DefaultProbeASN) + + // DefaultResolverASNString is the default resolver ASN as a string. + DefaultResolverASNString = fmt.Sprintf("AS%d", DefaultResolverASN) +) + +var ( + // ErrMissingResourcesManager indicates that no resources + // manager has been configured inside of Config. + ErrMissingResourcesManager = errors.New("geolocate: ResourcesManager is nil") +) + +// Logger is the definition of Logger used by this package. +type Logger interface { + Debugf(format string, v ...interface{}) +} + +// Results contains geolocate results +type Results struct { + // ASN is the autonomous system number + ASN uint + + // CountryCode is the country code + CountryCode string + + // DidResolverLookup indicates whether we did a resolver lookup. + DidResolverLookup bool + + // NetworkName is the network name + NetworkName string + + // IP is the probe IP + ProbeIP string + + // ResolverASN is the resolver ASN + ResolverASN uint + + // ResolverIP is the resolver IP + ResolverIP string + + // ResolverNetworkName is the resolver network name + ResolverNetworkName string +} + +// ASNString returns the ASN as a string +func (r *Results) ASNString() string { + return fmt.Sprintf("AS%d", r.ASN) +} + +type probeIPLookupper interface { + LookupProbeIP(ctx context.Context) (addr string, err error) +} + +type asnLookupper interface { + LookupASN(path string, ip string) (asn uint, network string, err error) +} + +type countryLookupper interface { + LookupCC(path string, ip string) (cc string, err error) +} + +type resolverIPLookupper interface { + LookupResolverIP(ctx context.Context) (addr string, err error) +} + +// ResourcesManager manages the required resources. +type ResourcesManager interface { + // ASNDatabasePath returns the path of the ASN database. + ASNDatabasePath() string + + // CountryDatabasePath returns the path of the country database. + CountryDatabasePath() string + + // MaybeUpdateResources ensures that the required resources + // have been downloaded and are current. + MaybeUpdateResources(ctx context.Context) error +} + +// Config contains configuration for a geolocate Task. +type Config struct { + // EnableResolverLookup indicates whether we want to + // perform the optional resolver lookup. + EnableResolverLookup bool + + // HTTPClient is the HTTP client to use. If not set, then + // we will use the http.DefaultClient. + HTTPClient *http.Client + + // Logger is the logger to use. If not set, then we will + // use a logger that discards all messages. + Logger Logger + + // ResourcesManager is the mandatory resources manager. If not + // set, we will not be able to perform any lookup. + ResourcesManager ResourcesManager + + // UserAgent is the user agent to use. If not set, then + // we will use a default user agent. + UserAgent string +} + +// Must ensures that NewTask is successful. +func Must(task *Task, err error) *Task { + runtimex.PanicOnError(err, "NewTask failed") + return task +} + +// NewTask creates a new instance of Task from config. +func NewTask(config Config) (*Task, error) { + if config.HTTPClient == nil { + config.HTTPClient = http.DefaultClient + } + if config.Logger == nil { + config.Logger = model.DiscardLogger + } + if config.ResourcesManager == nil { + return nil, ErrMissingResourcesManager + } + if config.UserAgent == "" { + config.UserAgent = fmt.Sprintf("ooniprobe-engine/%s", version.Version) + } + return &Task{ + countryLookupper: mmdbLookupper{}, + enableResolverLookup: config.EnableResolverLookup, + probeIPLookupper: ipLookupClient{ + HTTPClient: config.HTTPClient, + Logger: config.Logger, + UserAgent: config.UserAgent, + }, + probeASNLookupper: mmdbLookupper{}, + resolverASNLookupper: mmdbLookupper{}, + resolverIPLookupper: resolverLookupClient{}, + resourcesManager: config.ResourcesManager, + }, nil +} + +// Task performs a geolocation. You must create a new +// instance of Task using the NewTask factory. +type Task struct { + countryLookupper countryLookupper + enableResolverLookup bool + probeIPLookupper probeIPLookupper + probeASNLookupper asnLookupper + resolverASNLookupper asnLookupper + resolverIPLookupper resolverIPLookupper + resourcesManager ResourcesManager +} + +// Run runs the task. +func (op Task) Run(ctx context.Context) (*Results, error) { + var err error + out := &Results{ + ASN: DefaultProbeASN, + CountryCode: DefaultProbeCC, + NetworkName: DefaultProbeNetworkName, + ProbeIP: DefaultProbeIP, + ResolverASN: DefaultResolverASN, + ResolverIP: DefaultResolverIP, + ResolverNetworkName: DefaultResolverNetworkName, + } + if err := op.resourcesManager.MaybeUpdateResources(ctx); err != nil { + return out, fmt.Errorf("MaybeUpdateResource failed: %w", err) + } + ip, err := op.probeIPLookupper.LookupProbeIP(ctx) + if err != nil { + return out, fmt.Errorf("lookupProbeIP failed: %w", err) + } + out.ProbeIP = ip + asn, networkName, err := op.probeASNLookupper.LookupASN( + op.resourcesManager.ASNDatabasePath(), out.ProbeIP) + if err != nil { + return out, fmt.Errorf("lookupASN failed: %w", err) + } + out.ASN = asn + out.NetworkName = networkName + cc, err := op.countryLookupper.LookupCC( + op.resourcesManager.CountryDatabasePath(), out.ProbeIP) + if err != nil { + return out, fmt.Errorf("lookupProbeCC failed: %w", err) + } + out.CountryCode = cc + if op.enableResolverLookup { + out.DidResolverLookup = true + // Note: ignoring the result of lookupResolverIP and lookupASN + // here is intentional. We don't want this (~minor) failure + // to influence the result of the overall lookup. Another design + // here could be that of retrying the operation N times? + resolverIP, err := op.resolverIPLookupper.LookupResolverIP(ctx) + if err != nil { + return out, nil + } + out.ResolverIP = resolverIP + resolverASN, resolverNetworkName, err := op.resolverASNLookupper.LookupASN( + op.resourcesManager.ASNDatabasePath(), out.ResolverIP, + ) + if err != nil { + return out, nil + } + out.ResolverASN = resolverASN + out.ResolverNetworkName = resolverNetworkName + } + return out, nil +} diff --git a/internal/engine/geolocate/geolocate_test.go b/internal/engine/geolocate/geolocate_test.go new file mode 100644 index 0000000..99091ee --- /dev/null +++ b/internal/engine/geolocate/geolocate_test.go @@ -0,0 +1,395 @@ +package geolocate + +import ( + "context" + "errors" + "testing" +) + +type taskResourcesManager struct { + asnDatabasePath string + countryDatabasePath string + err error +} + +func (c taskResourcesManager) ASNDatabasePath() string { + return c.asnDatabasePath +} + +func (c taskResourcesManager) CountryDatabasePath() string { + return c.countryDatabasePath +} + +func (c taskResourcesManager) MaybeUpdateResources(ctx context.Context) error { + return c.err +} + +func TestLocationLookupCannotUpdateResources(t *testing.T) { + expected := errors.New("mocked error") + op := Task{ + resourcesManager: taskResourcesManager{err: expected}, + } + ctx := context.Background() + out, err := op.Run(ctx) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != DefaultProbeASN { + t.Fatal("invalid ASN value") + } + if out.CountryCode != DefaultProbeCC { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != DefaultProbeNetworkName { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != DefaultProbeIP { + t.Fatal("invalid ProbeIP value") + } + if out.ResolverASN != DefaultResolverASN { + t.Fatal("invalid ResolverASN value") + } + if out.ResolverIP != DefaultResolverIP { + t.Fatal("invalid ResolverIP value") + } + if out.ResolverNetworkName != DefaultResolverNetworkName { + t.Fatal("invalid ResolverNetworkName value") + } +} + +type taskProbeIPLookupper struct { + ip string + err error +} + +func (c taskProbeIPLookupper) LookupProbeIP(ctx context.Context) (string, error) { + return c.ip, c.err +} + +func TestLocationLookupCannotLookupProbeIP(t *testing.T) { + expected := errors.New("mocked error") + op := Task{ + resourcesManager: taskResourcesManager{}, + probeIPLookupper: taskProbeIPLookupper{err: expected}, + } + ctx := context.Background() + out, err := op.Run(ctx) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != DefaultProbeASN { + t.Fatal("invalid ASN value") + } + if out.CountryCode != DefaultProbeCC { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != DefaultProbeNetworkName { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != DefaultProbeIP { + t.Fatal("invalid ProbeIP value") + } + if out.ResolverASN != DefaultResolverASN { + t.Fatal("invalid ResolverASN value") + } + if out.ResolverIP != DefaultResolverIP { + t.Fatal("invalid ResolverIP value") + } + if out.ResolverNetworkName != DefaultResolverNetworkName { + t.Fatal("invalid ResolverNetworkName value") + } +} + +type taskASNLookupper struct { + err error + asn uint + name string +} + +func (c taskASNLookupper) LookupASN(path string, ip string) (uint, string, error) { + return c.asn, c.name, c.err +} + +func TestLocationLookupCannotLookupProbeASN(t *testing.T) { + expected := errors.New("mocked error") + op := Task{ + resourcesManager: taskResourcesManager{}, + probeIPLookupper: taskProbeIPLookupper{ip: "1.2.3.4"}, + probeASNLookupper: taskASNLookupper{err: expected}, + } + ctx := context.Background() + out, err := op.Run(ctx) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != DefaultProbeASN { + t.Fatal("invalid ASN value") + } + if out.CountryCode != DefaultProbeCC { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != DefaultProbeNetworkName { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != "1.2.3.4" { + t.Fatal("invalid ProbeIP value") + } + if out.ResolverASN != DefaultResolverASN { + t.Fatal("invalid ResolverASN value") + } + if out.ResolverIP != DefaultResolverIP { + t.Fatal("invalid ResolverIP value") + } + if out.ResolverNetworkName != DefaultResolverNetworkName { + t.Fatal("invalid ResolverNetworkName value") + } +} + +type taskCCLookupper struct { + err error + cc string +} + +func (c taskCCLookupper) LookupCC(path string, ip string) (string, error) { + return c.cc, c.err +} + +func TestLocationLookupCannotLookupProbeCC(t *testing.T) { + expected := errors.New("mocked error") + op := Task{ + resourcesManager: taskResourcesManager{}, + probeIPLookupper: taskProbeIPLookupper{ip: "1.2.3.4"}, + probeASNLookupper: taskASNLookupper{asn: 1234, name: "1234.com"}, + countryLookupper: taskCCLookupper{cc: "US", err: expected}, + } + ctx := context.Background() + out, err := op.Run(ctx) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != 1234 { + t.Fatal("invalid ASN value") + } + if out.CountryCode != DefaultProbeCC { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != "1234.com" { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != "1.2.3.4" { + t.Fatal("invalid ProbeIP value") + } + if out.ResolverASN != DefaultResolverASN { + t.Fatal("invalid ResolverASN value") + } + if out.ResolverIP != DefaultResolverIP { + t.Fatal("invalid ResolverIP value") + } + if out.ResolverNetworkName != DefaultResolverNetworkName { + t.Fatal("invalid ResolverNetworkName value") + } +} + +type taskResolverIPLookupper struct { + ip string + err error +} + +func (c taskResolverIPLookupper) LookupResolverIP(ctx context.Context) (string, error) { + return c.ip, c.err +} + +func TestLocationLookupCannotLookupResolverIP(t *testing.T) { + expected := errors.New("mocked error") + op := Task{ + resourcesManager: taskResourcesManager{}, + probeIPLookupper: taskProbeIPLookupper{ip: "1.2.3.4"}, + probeASNLookupper: taskASNLookupper{asn: 1234, name: "1234.com"}, + countryLookupper: taskCCLookupper{cc: "IT"}, + resolverIPLookupper: taskResolverIPLookupper{err: expected}, + enableResolverLookup: true, + } + ctx := context.Background() + out, err := op.Run(ctx) + if err != nil { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != 1234 { + t.Fatal("invalid ASN value") + } + if out.CountryCode != "IT" { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != "1234.com" { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != "1.2.3.4" { + t.Fatal("invalid ProbeIP value") + } + if out.DidResolverLookup != true { + t.Fatal("invalid DidResolverLookup value") + } + if out.ResolverASN != DefaultResolverASN { + t.Fatal("invalid ResolverASN value") + } + if out.ResolverIP != DefaultResolverIP { + t.Fatal("invalid ResolverIP value") + } + if out.ResolverNetworkName != DefaultResolverNetworkName { + t.Fatal("invalid ResolverNetworkName value") + } +} + +func TestLocationLookupCannotLookupResolverNetworkName(t *testing.T) { + expected := errors.New("mocked error") + op := Task{ + resourcesManager: taskResourcesManager{}, + probeIPLookupper: taskProbeIPLookupper{ip: "1.2.3.4"}, + probeASNLookupper: taskASNLookupper{asn: 1234, name: "1234.com"}, + countryLookupper: taskCCLookupper{cc: "IT"}, + resolverIPLookupper: taskResolverIPLookupper{ip: "4.3.2.1"}, + resolverASNLookupper: taskASNLookupper{err: expected}, + enableResolverLookup: true, + } + ctx := context.Background() + out, err := op.Run(ctx) + if err != nil { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != 1234 { + t.Fatal("invalid ASN value") + } + if out.CountryCode != "IT" { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != "1234.com" { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != "1.2.3.4" { + t.Fatal("invalid ProbeIP value") + } + if out.DidResolverLookup != true { + t.Fatal("invalid DidResolverLookup value") + } + if out.ResolverASN != DefaultResolverASN { + t.Fatalf("invalid ResolverASN value: %+v", out.ResolverASN) + } + if out.ResolverIP != "4.3.2.1" { + t.Fatalf("invalid ResolverIP value: %+v", out.ResolverIP) + } + if out.ResolverNetworkName != DefaultResolverNetworkName { + t.Fatal("invalid ResolverNetworkName value") + } +} + +func TestLocationLookupSuccessWithResolverLookup(t *testing.T) { + op := Task{ + resourcesManager: taskResourcesManager{}, + probeIPLookupper: taskProbeIPLookupper{ip: "1.2.3.4"}, + probeASNLookupper: taskASNLookupper{asn: 1234, name: "1234.com"}, + countryLookupper: taskCCLookupper{cc: "IT"}, + resolverIPLookupper: taskResolverIPLookupper{ip: "4.3.2.1"}, + resolverASNLookupper: taskASNLookupper{asn: 4321, name: "4321.com"}, + enableResolverLookup: true, + } + ctx := context.Background() + out, err := op.Run(ctx) + if err != nil { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != 1234 { + t.Fatal("invalid ASN value") + } + if out.CountryCode != "IT" { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != "1234.com" { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != "1.2.3.4" { + t.Fatal("invalid ProbeIP value") + } + if out.DidResolverLookup != true { + t.Fatal("invalid DidResolverLookup value") + } + if out.ResolverASN != 4321 { + t.Fatalf("invalid ResolverASN value: %+v", out.ResolverASN) + } + if out.ResolverIP != "4.3.2.1" { + t.Fatalf("invalid ResolverIP value: %+v", out.ResolverIP) + } + if out.ResolverNetworkName != "4321.com" { + t.Fatal("invalid ResolverNetworkName value") + } +} + +func TestLocationLookupSuccessWithoutResolverLookup(t *testing.T) { + op := Task{ + resourcesManager: taskResourcesManager{}, + probeIPLookupper: taskProbeIPLookupper{ip: "1.2.3.4"}, + probeASNLookupper: taskASNLookupper{asn: 1234, name: "1234.com"}, + countryLookupper: taskCCLookupper{cc: "IT"}, + resolverIPLookupper: taskResolverIPLookupper{ip: "4.3.2.1"}, + resolverASNLookupper: taskASNLookupper{asn: 4321, name: "4321.com"}, + } + ctx := context.Background() + out, err := op.Run(ctx) + if err != nil { + t.Fatalf("not the error we expected: %+v", err) + } + if out.ASN != 1234 { + t.Fatal("invalid ASN value") + } + if out.CountryCode != "IT" { + t.Fatal("invalid CountryCode value") + } + if out.NetworkName != "1234.com" { + t.Fatal("invalid NetworkName value") + } + if out.ProbeIP != "1.2.3.4" { + t.Fatal("invalid ProbeIP value") + } + if out.DidResolverLookup != false { + t.Fatal("invalid DidResolverLookup value") + } + if out.ResolverASN != DefaultResolverASN { + t.Fatalf("invalid ResolverASN value: %+v", out.ResolverASN) + } + if out.ResolverIP != DefaultResolverIP { + t.Fatalf("invalid ResolverIP value: %+v", out.ResolverIP) + } + if out.ResolverNetworkName != DefaultResolverNetworkName { + t.Fatal("invalid ResolverNetworkName value") + } +} + +func TestSmoke(t *testing.T) { + maybeFetchResources(t) + config := Config{ + EnableResolverLookup: true, + ResourcesManager: taskResourcesManager{ + asnDatabasePath: asnDBPath, + countryDatabasePath: countryDBPath, + }, + } + task := Must(NewTask(config)) + result, err := task.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if result == nil { + t.Fatal("expected non nil result") + } + // we already checked above that the returned + // value is okay for all codepaths. +} + +func TestNewTaskWithNoResourcesManager(t *testing.T) { + task, err := NewTask(Config{}) + if !errors.Is(err, ErrMissingResourcesManager) { + t.Fatal("not the error we expected") + } + if task != nil { + t.Fatal("expected nil task here") + } +} diff --git a/internal/engine/geolocate/invalid_test.go b/internal/engine/geolocate/invalid_test.go new file mode 100644 index 0000000..ad4e08a --- /dev/null +++ b/internal/engine/geolocate/invalid_test.go @@ -0,0 +1,15 @@ +package geolocate + +import ( + "context" + "net/http" +) + +func invalidIPLookup( + ctx context.Context, + httpClient *http.Client, + logger Logger, + userAgent string, +) (string, error) { + return "invalid IP", nil +} diff --git a/internal/engine/geolocate/ipconfig.go b/internal/engine/geolocate/ipconfig.go new file mode 100644 index 0000000..37f8cb3 --- /dev/null +++ b/internal/engine/geolocate/ipconfig.go @@ -0,0 +1,30 @@ +package geolocate + +import ( + "context" + "net/http" + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" +) + +func ipConfigIPLookup( + ctx context.Context, + httpClient *http.Client, + logger Logger, + userAgent string, +) (string, error) { + data, err := (httpx.Client{ + BaseURL: "https://ipconfig.io", + HTTPClient: httpClient, + Logger: logger, + UserAgent: httpheader.CLIUserAgent(), + }).FetchResource(ctx, "/") + if err != nil { + return DefaultProbeIP, err + } + ip := strings.Trim(string(data), "\r\n\t ") + logger.Debugf("ipconfig: body: %s", ip) + return ip, nil +} diff --git a/internal/engine/geolocate/ipconfig_test.go b/internal/engine/geolocate/ipconfig_test.go new file mode 100644 index 0000000..0fbde20 --- /dev/null +++ b/internal/engine/geolocate/ipconfig_test.go @@ -0,0 +1,26 @@ +package geolocate + +import ( + "context" + "net" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" +) + +func TestIPLookupWorksUsingIPConfig(t *testing.T) { + ip, err := ipConfigIPLookup( + context.Background(), + http.DefaultClient, + log.Log, + httpheader.UserAgent(), + ) + if err != nil { + t.Fatal(err) + } + if net.ParseIP(ip) == nil { + t.Fatalf("not an IP address: '%s'", ip) + } +} diff --git a/internal/engine/geolocate/ipinfo.go b/internal/engine/geolocate/ipinfo.go new file mode 100644 index 0000000..773322e --- /dev/null +++ b/internal/engine/geolocate/ipinfo.go @@ -0,0 +1,33 @@ +package geolocate + +import ( + "context" + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" +) + +type ipInfoResponse struct { + IP string `json:"ip"` +} + +func ipInfoIPLookup( + ctx context.Context, + httpClient *http.Client, + logger Logger, + userAgent string, +) (string, error) { + var v ipInfoResponse + err := (httpx.Client{ + Accept: "application/json", + BaseURL: "https://ipinfo.io", + HTTPClient: httpClient, + Logger: logger, + UserAgent: httpheader.CLIUserAgent(), // we must be a CLI client + }).GetJSON(ctx, "/", &v) + if err != nil { + return DefaultProbeIP, err + } + return v.IP, nil +} diff --git a/internal/engine/geolocate/ipinfo_test.go b/internal/engine/geolocate/ipinfo_test.go new file mode 100644 index 0000000..1b257c4 --- /dev/null +++ b/internal/engine/geolocate/ipinfo_test.go @@ -0,0 +1,26 @@ +package geolocate + +import ( + "context" + "net" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" +) + +func TestIPLookupWorksUsingIPInfo(t *testing.T) { + ip, err := ipInfoIPLookup( + context.Background(), + http.DefaultClient, + log.Log, + httpheader.UserAgent(), + ) + if err != nil { + t.Fatal(err) + } + if net.ParseIP(ip) == nil { + t.Fatalf("not an IP address: '%s'", ip) + } +} diff --git a/internal/engine/geolocate/iplookup.go b/internal/engine/geolocate/iplookup.go new file mode 100644 index 0000000..7bb6a92 --- /dev/null +++ b/internal/engine/geolocate/iplookup.go @@ -0,0 +1,113 @@ +package geolocate + +import ( + "context" + "errors" + "fmt" + "math/rand" + "net" + "net/http" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/multierror" +) + +var ( + // ErrAllIPLookuppersFailed indicates that we failed with looking + // up the probe IP for with all the lookuppers that we tried. + ErrAllIPLookuppersFailed = errors.New("all IP lookuppers failed") + + // ErrInvalidIPAddress indicates that the code returned to us a + // string that actually isn't a valid IP address. + ErrInvalidIPAddress = errors.New("lookupper did not return a valid IP") +) + +type lookupFunc func( + ctx context.Context, client *http.Client, + logger Logger, userAgent string, +) (string, error) + +type method struct { + name string + fn lookupFunc +} + +var ( + methods = []method{ + { + name: "avast", + fn: avastIPLookup, + }, + { + name: "ipconfig", + fn: ipConfigIPLookup, + }, + { + name: "ipinfo", + fn: ipInfoIPLookup, + }, + { + name: "stun_ekiga", + fn: stunEkigaIPLookup, + }, + { + name: "stun_google", + fn: stunGoogleIPLookup, + }, + { + name: "ubuntu", + fn: ubuntuIPLookup, + }, + } + + once sync.Once +) + +type ipLookupClient struct { + // HTTPClient is the HTTP client to use + HTTPClient *http.Client + + // Logger is the logger to use + Logger Logger + + // UserAgent is the user agent to use + UserAgent string +} + +func makeSlice() []method { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + ret := make([]method, len(methods)) + perm := r.Perm(len(methods)) + for idx, randIdx := range perm { + ret[idx] = methods[randIdx] + } + return ret +} + +func (c ipLookupClient) doWithCustomFunc( + ctx context.Context, fn lookupFunc, +) (string, error) { + ip, err := fn(ctx, c.HTTPClient, c.Logger, c.UserAgent) + if err != nil { + return DefaultProbeIP, err + } + if net.ParseIP(ip) == nil { + return DefaultProbeIP, fmt.Errorf("%w: %s", ErrInvalidIPAddress, ip) + } + c.Logger.Debugf("iplookup: IP: %s", ip) + return ip, nil +} + +func (c ipLookupClient) LookupProbeIP(ctx context.Context) (string, error) { + union := multierror.New(ErrAllIPLookuppersFailed) + for _, method := range makeSlice() { + c.Logger.Debugf("iplookup: using %s", method.name) + ip, err := c.doWithCustomFunc(ctx, method.fn) + if err == nil { + return ip, nil + } + union.Add(err) + } + return DefaultProbeIP, union +} diff --git a/internal/engine/geolocate/iplookup_test.go b/internal/engine/geolocate/iplookup_test.go new file mode 100644 index 0000000..6d53a9c --- /dev/null +++ b/internal/engine/geolocate/iplookup_test.go @@ -0,0 +1,56 @@ +package geolocate + +import ( + "context" + "errors" + "net" + "net/http" + "testing" + + "github.com/apex/log" +) + +func TestIPLookupGood(t *testing.T) { + ip, err := (ipLookupClient{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).LookupProbeIP(context.Background()) + if err != nil { + t.Fatal(err) + } + if net.ParseIP(ip) == nil { + t.Fatal("not an IP address") + } +} + +func TestIPLookupAllFailed(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel to cause Do() to fail + ip, err := (ipLookupClient{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).LookupProbeIP(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatal("expected an error here") + } + if ip != DefaultProbeIP { + t.Fatal("expected the default IP here") + } +} + +func TestIPLookupInvalidIP(t *testing.T) { + ctx := context.Background() + ip, err := (ipLookupClient{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).doWithCustomFunc(ctx, invalidIPLookup) + if !errors.Is(err, ErrInvalidIPAddress) { + t.Fatal("expected an error here") + } + if ip != DefaultProbeIP { + t.Fatal("expected the default IP here") + } +} diff --git a/internal/engine/geolocate/mmdblookup.go b/internal/engine/geolocate/mmdblookup.go new file mode 100644 index 0000000..dc33158 --- /dev/null +++ b/internal/engine/geolocate/mmdblookup.go @@ -0,0 +1,53 @@ +package geolocate + +import ( + "net" + + "github.com/oschwald/geoip2-golang" +) + +type mmdbLookupper struct{} + +func (mmdbLookupper) LookupASN(path, ip string) (asn uint, org string, err error) { + asn, org = DefaultProbeASN, DefaultProbeNetworkName + db, err := geoip2.Open(path) + if err != nil { + return + } + defer db.Close() + record, err := db.ASN(net.ParseIP(ip)) + if err != nil { + return + } + asn = record.AutonomousSystemNumber + if record.AutonomousSystemOrganization != "" { + org = record.AutonomousSystemOrganization + } + return +} + +// LookupASN returns the ASN and the organization associated with the +// given ip using the ASN database at path. +func LookupASN(path, ip string) (asn uint, org string, err error) { + return (mmdbLookupper{}).LookupASN(path, ip) +} + +func (mmdbLookupper) LookupCC(path, ip string) (cc string, err error) { + cc = DefaultProbeCC + db, err := geoip2.Open(path) + if err != nil { + return + } + defer db.Close() + record, err := db.Country(net.ParseIP(ip)) + if err != nil { + return + } + // With MaxMind DB we used record.RegisteredCountry.IsoCode but that does + // not seem to work with the db-ip.com database. The record is empty, at + // least for my own IP address in Italy. --Simone (2020-02-25) + if record.Country.IsoCode != "" { + cc = record.Country.IsoCode + } + return +} diff --git a/internal/engine/geolocate/mmdblookup_test.go b/internal/engine/geolocate/mmdblookup_test.go new file mode 100644 index 0000000..07153b5 --- /dev/null +++ b/internal/engine/geolocate/mmdblookup_test.go @@ -0,0 +1,103 @@ +package geolocate + +import ( + "context" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/resources" +) + +const ( + asnDBPath = "../testdata/asn.mmdb" + countryDBPath = "../testdata/country.mmdb" + ipAddr = "35.204.49.125" +) + +func maybeFetchResources(t *testing.T) { + c := &resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + WorkDir: "../testdata/", + } + if err := c.Ensure(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestLookupASN(t *testing.T) { + maybeFetchResources(t) + asn, org, err := LookupASN(asnDBPath, ipAddr) + if err != nil { + t.Fatal(err) + } + if asn <= 0 { + t.Fatal("unexpected ASN value") + } + if len(org) <= 0 { + t.Fatal("unexpected org value") + } +} + +func TestLookupASNInvalidFile(t *testing.T) { + maybeFetchResources(t) + asn, org, err := LookupASN("/nonexistent", ipAddr) + if err == nil { + t.Fatal("expected an error here") + } + if asn != DefaultProbeASN { + t.Fatal("expected a zero ASN") + } + if org != DefaultProbeNetworkName { + t.Fatal("expected an empty org") + } +} + +func TestLookupASNInvalidIP(t *testing.T) { + maybeFetchResources(t) + asn, org, err := LookupASN(asnDBPath, "xxx") + if err == nil { + t.Fatal("expected an error here") + } + if asn != DefaultProbeASN { + t.Fatal("expected a zero ASN") + } + if org != DefaultProbeNetworkName { + t.Fatal("expected an empty org") + } +} + +func TestLookupCC(t *testing.T) { + maybeFetchResources(t) + cc, err := (mmdbLookupper{}).LookupCC(countryDBPath, ipAddr) + if err != nil { + t.Fatal(err) + } + if len(cc) != 2 { + t.Fatal("does not seem a country code") + } +} + +func TestLookupCCInvalidFile(t *testing.T) { + maybeFetchResources(t) + cc, err := (mmdbLookupper{}).LookupCC("/nonexistent", ipAddr) + if err == nil { + t.Fatal("expected an error here") + } + if cc != DefaultProbeCC { + t.Fatal("expected an empty cc") + } +} + +func TestLookupCCInvalidIP(t *testing.T) { + maybeFetchResources(t) + cc, err := (mmdbLookupper{}).LookupCC(asnDBPath, "xxx") + if err == nil { + t.Fatal("expected an error here") + } + if cc != DefaultProbeCC { + t.Fatal("expected an empty cc") + } +} diff --git a/internal/engine/geolocate/resolverlookup.go b/internal/engine/geolocate/resolverlookup.go new file mode 100644 index 0000000..51b6b9e --- /dev/null +++ b/internal/engine/geolocate/resolverlookup.go @@ -0,0 +1,35 @@ +package geolocate + +import ( + "context" + "errors" + "net" +) + +var ( + // ErrNoIPAddressReturned indicates that no IP address was + // returned by a specific DNS resolver. + ErrNoIPAddressReturned = errors.New("geolocate: no IP address returned") +) + +type dnsResolver interface { + LookupHost(ctx context.Context, host string) (addrs []string, err error) +} + +type resolverLookupClient struct{} + +func (rlc resolverLookupClient) do(ctx context.Context, r dnsResolver) (string, error) { + var ips []string + ips, err := r.LookupHost(ctx, "whoami.akamai.net") + if err != nil { + return "", err + } + if len(ips) < 1 { + return "", ErrNoIPAddressReturned + } + return ips[0], nil +} + +func (rlc resolverLookupClient) LookupResolverIP(ctx context.Context) (ip string, err error) { + return rlc.do(ctx, &net.Resolver{}) +} diff --git a/internal/engine/geolocate/resolverlookup_test.go b/internal/engine/geolocate/resolverlookup_test.go new file mode 100644 index 0000000..0333161 --- /dev/null +++ b/internal/engine/geolocate/resolverlookup_test.go @@ -0,0 +1,50 @@ +package geolocate + +import ( + "context" + "errors" + "testing" +) + +func TestLookupResolverIP(t *testing.T) { + addr, err := (resolverLookupClient{}).LookupResolverIP(context.Background()) + if err != nil { + t.Fatal(err) + } + if addr == "" { + t.Fatal("expected a non-empty string") + } +} + +type brokenHostLookupper struct { + err error +} + +func (bhl brokenHostLookupper) LookupHost(ctx context.Context, host string) ([]string, error) { + return nil, bhl.err +} + +func TestLookupResolverIPFailure(t *testing.T) { + expected := errors.New("mocked error") + rlc := resolverLookupClient{} + addr, err := rlc.do(context.Background(), brokenHostLookupper{ + err: expected, + }) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if len(addr) != 0 { + t.Fatal("expected an empty address") + } +} + +func TestLookupResolverIPNoAddressReturned(t *testing.T) { + rlc := resolverLookupClient{} + addr, err := rlc.do(context.Background(), brokenHostLookupper{}) + if !errors.Is(err, ErrNoIPAddressReturned) { + t.Fatalf("not the error we expected: %+v", err) + } + if len(addr) != 0 { + t.Fatal("expected an empty address") + } +} diff --git a/internal/engine/geolocate/stun.go b/internal/engine/geolocate/stun.go new file mode 100644 index 0000000..34e9abe --- /dev/null +++ b/internal/engine/geolocate/stun.go @@ -0,0 +1,92 @@ +package geolocate + +import ( + "context" + "net/http" + + "github.com/pion/stun" +) + +type stunClient interface { + Close() error + Start(m *stun.Message, h stun.Handler) error +} + +type stunConfig struct { + Dial func(network string, address string) (stunClient, error) + Endpoint string + Logger Logger +} + +func stunDialer(network string, address string) (stunClient, error) { + return stun.Dial(network, address) +} + +func stunIPLookup(ctx context.Context, config stunConfig) (string, error) { + config.Logger.Debugf("STUNIPLookup: start using %s", config.Endpoint) + ip, err := func() (string, error) { + dial := config.Dial + if dial == nil { + dial = stunDialer + } + clnt, err := dial("udp", config.Endpoint) + if err != nil { + return DefaultProbeIP, err + } + defer clnt.Close() + message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) + errch, ipch := make(chan error, 1), make(chan string, 1) + err = clnt.Start(message, func(ev stun.Event) { + if ev.Error != nil { + errch <- ev.Error + return + } + var xorAddr stun.XORMappedAddress + if err := xorAddr.GetFrom(ev.Message); err != nil { + errch <- err + return + } + ipch <- xorAddr.IP.String() + }) + if err != nil { + return DefaultProbeIP, err + } + select { + case err := <-errch: + return DefaultProbeIP, err + case ip := <-ipch: + return ip, nil + case <-ctx.Done(): + return DefaultProbeIP, ctx.Err() + } + }() + if err != nil { + config.Logger.Debugf("STUNIPLookup: failure using %s: %+v", config.Endpoint, err) + return DefaultProbeIP, err + } + return ip, nil +} + +func stunEkigaIPLookup( + ctx context.Context, + httpClient *http.Client, + logger Logger, + userAgent string, +) (string, error) { + return stunIPLookup(ctx, stunConfig{ + Endpoint: "stun.ekiga.net:3478", + Logger: logger, + }) +} + +func stunGoogleIPLookup( + ctx context.Context, + httpClient *http.Client, + logger Logger, + userAgent string, +) (string, error) { + return stunIPLookup(ctx, stunConfig{ + Endpoint: "stun.l.google.com:19302", + Logger: logger, + }) +} diff --git a/internal/engine/geolocate/stun_test.go b/internal/engine/geolocate/stun_test.go new file mode 100644 index 0000000..ba0c62c --- /dev/null +++ b/internal/engine/geolocate/stun_test.go @@ -0,0 +1,154 @@ +package geolocate + +import ( + "context" + "errors" + "net" + "net/http" + "testing" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" + "github.com/pion/stun" +) + +func TestSTUNIPLookupCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // stop immediately + ip, err := stunIPLookup(ctx, stunConfig{ + Endpoint: "stun.ekiga.net:3478", + Logger: log.Log, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } + if ip != DefaultProbeIP { + t.Fatalf("not the IP address we expected: %+v", ip) + } +} + +func TestSTUNIPLookupDialFailure(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + ip, err := stunIPLookup(ctx, stunConfig{ + Dial: func(network, address string) (stunClient, error) { + return nil, expected + }, + Endpoint: "stun.ekiga.net:3478", + Logger: log.Log, + }) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if ip != DefaultProbeIP { + t.Fatalf("not the IP address we expected: %+v", ip) + } +} + +type MockableSTUNClient struct { + StartErr error + Event stun.Event +} + +func (c MockableSTUNClient) Close() error { + return nil +} + +func (c MockableSTUNClient) Start(m *stun.Message, h stun.Handler) error { + if c.StartErr != nil { + return c.StartErr + } + go func() { + <-time.After(100 * time.Millisecond) + h(c.Event) + }() + return nil +} + +func TestSTUNIPLookupStartReturnsError(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + ip, err := stunIPLookup(ctx, stunConfig{ + Dial: func(network, address string) (stunClient, error) { + return MockableSTUNClient{StartErr: expected}, nil + }, + Endpoint: "stun.ekiga.net:3478", + Logger: log.Log, + }) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if ip != DefaultProbeIP { + t.Fatalf("not the IP address we expected: %+v", ip) + } +} + +func TestSTUNIPLookupStunEventContainsError(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + ip, err := stunIPLookup(ctx, stunConfig{ + Dial: func(network, address string) (stunClient, error) { + return MockableSTUNClient{Event: stun.Event{ + Error: expected, + }}, nil + }, + Endpoint: "stun.ekiga.net:3478", + Logger: log.Log, + }) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if ip != DefaultProbeIP { + t.Fatalf("not the IP address we expected: %+v", ip) + } +} + +func TestSTUNIPLookupCannotDecodeMessage(t *testing.T) { + ctx := context.Background() + ip, err := stunIPLookup(ctx, stunConfig{ + Dial: func(network, address string) (stunClient, error) { + return MockableSTUNClient{Event: stun.Event{ + Message: &stun.Message{}, + }}, nil + }, + Endpoint: "stun.ekiga.net:3478", + Logger: log.Log, + }) + if !errors.Is(err, stun.ErrAttributeNotFound) { + t.Fatalf("not the error we expected: %+v", err) + } + if ip != DefaultProbeIP { + t.Fatalf("not the IP address we expected: %+v", ip) + } +} + +func TestIPLookupWorksUsingSTUNEkiga(t *testing.T) { + ip, err := stunEkigaIPLookup( + context.Background(), + http.DefaultClient, + log.Log, + httpheader.UserAgent(), + ) + if err != nil { + t.Fatal(err) + } + if net.ParseIP(ip) == nil { + t.Fatalf("not an IP address: '%s'", ip) + } +} + +func TestIPLookupWorksUsingSTUNGoogle(t *testing.T) { + ip, err := stunGoogleIPLookup( + context.Background(), + http.DefaultClient, + log.Log, + httpheader.UserAgent(), + ) + if err != nil { + t.Fatal(err) + } + if net.ParseIP(ip) == nil { + t.Fatalf("not an IP address: '%s'", ip) + } +} diff --git a/internal/engine/geolocate/ubuntu.go b/internal/engine/geolocate/ubuntu.go new file mode 100644 index 0000000..63260a2 --- /dev/null +++ b/internal/engine/geolocate/ubuntu.go @@ -0,0 +1,38 @@ +package geolocate + +import ( + "context" + "encoding/xml" + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" +) + +type ubuntuResponse struct { + XMLName xml.Name `xml:"Response"` + IP string `xml:"Ip"` +} + +func ubuntuIPLookup( + ctx context.Context, + httpClient *http.Client, + logger Logger, + userAgent string, +) (string, error) { + data, err := (httpx.Client{ + BaseURL: "https://geoip.ubuntu.com/", + HTTPClient: httpClient, + Logger: logger, + UserAgent: userAgent, + }).FetchResource(ctx, "/lookup") + if err != nil { + return DefaultProbeIP, err + } + logger.Debugf("ubuntu: body: %s", string(data)) + var v ubuntuResponse + err = xml.Unmarshal(data, &v) + if err != nil { + return DefaultProbeIP, err + } + return v.IP, nil +} diff --git a/internal/engine/geolocate/ubuntu_test.go b/internal/engine/geolocate/ubuntu_test.go new file mode 100644 index 0000000..4575fd3 --- /dev/null +++ b/internal/engine/geolocate/ubuntu_test.go @@ -0,0 +1,48 @@ +package geolocate + +import ( + "context" + "io/ioutil" + "net" + "net/http" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" +) + +func TestUbuntuParseError(t *testing.T) { + ip, err := ubuntuIPLookup( + context.Background(), + &http.Client{Transport: FakeTransport{ + Resp: &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(strings.NewReader("<")), + }, + }}, + log.Log, + httpheader.UserAgent(), + ) + if err == nil || !strings.HasPrefix(err.Error(), "XML syntax error") { + t.Fatalf("not the error we expected: %+v", err) + } + if ip != DefaultProbeIP { + t.Fatalf("not the expected IP address: %s", ip) + } +} + +func TestIPLookupWorksUsingUbuntu(t *testing.T) { + ip, err := ubuntuIPLookup( + context.Background(), + http.DefaultClient, + log.Log, + httpheader.UserAgent(), + ) + if err != nil { + t.Fatal(err) + } + if net.ParseIP(ip) == nil { + t.Fatalf("not an IP address: '%s'", ip) + } +} diff --git a/internal/engine/inputloader.go b/internal/engine/inputloader.go new file mode 100644 index 0000000..02a27a4 --- /dev/null +++ b/internal/engine/inputloader.go @@ -0,0 +1,209 @@ +package engine + +import ( + "bufio" + "context" + "errors" + "fmt" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/fsx" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// The following errors are returned by the InputLoader. +var ( + ErrNoInputExpected = errors.New("we did not expect any input") + ErrInputRequired = errors.New("no input provided") + ErrDetectedEmptyFile = errors.New("file did not contain any input") +) + +// InputLoaderSession is the session according to an InputLoader. +type InputLoaderSession interface { + MaybeLookupLocationContext(ctx context.Context) error + NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error) + ProbeCC() string +} + +// InputLoader loads input according to the specified policy +// from the specified sources and OONI services. The behaviour +// depends on the input policy as described below. +// +// InputNone +// +// We fail if there is any StaticInput or any SourceFiles. If +// there's no input, we return a single, empty entry that causes +// experiments that don't require input to run once. +// +// InputOptional +// +// We gather input from StaticInput and SourceFiles. If there is +// input, we return it. Otherwise we return a single, empty entry +// that causes experiments that don't require input to run once. +// +// InputOrQueryTestLists +// +// We gather input from StaticInput and SourceFiles. If there is +// input, we return it. Otherwise, we use OONI's probe services +// to gather input using the test lists API. +// +// InputStrictlyRequired +// +// Like InputOrQueryTestLists but, if there is no input, it's an +// user error and we just abort running the experiment. +type InputLoader interface { + // Load attempts to load input using the specified input loader. We will + // return a list of URLs because this is the only input we support. + Load(ctx context.Context) ([]model.URLInfo, error) +} + +// InputLoaderConfig contains config for InputLoader. +type InputLoaderConfig struct { + // StaticInputs contains optional input to be added + // to the resulting input list if possible. + StaticInputs []string + + // SourceFiles contains optional files to read input + // from. Each file should contain a single input string + // per line. We will fail if any file is unreadable. + SourceFiles []string + + // InputPolicy specifies the input policy for the + // current experiment. We will not load any input if + // the policy says we should not. + InputPolicy InputPolicy + + // Session is the current measurement session. + Session InputLoaderSession + + // URLLimit is the optional limit on the number of URLs + // that probe services should return to us. + URLLimit int64 + + // URLCategories limits the categories of URLs that + // probe services should return to us. + URLCategories []string +} + +// NewInputLoader creates a new InputLoader. +func NewInputLoader(config InputLoaderConfig) InputLoader { + // TODO(bassosimone): the current implementation stems from a + // simple refactoring from a previous implementation where + // we weren't using interfaces. Because now we're using interfaces, + // there is the opportunity to select behaviour here depending + // on the specified policy rather than later inside Load. + return inputLoader{InputLoaderConfig: config} +} + +type inputLoader struct { + InputLoaderConfig +} + +var _ InputLoader = inputLoader{} + +// Load attempts to load input using the specified input loader. We will +// return a list of URLs because this is the only input we support. +func (il inputLoader) Load(ctx context.Context) ([]model.URLInfo, error) { + switch il.InputPolicy { + case InputOptional: + return il.loadOptional() + case InputOrQueryTestLists: + return il.loadOrQueryTestList(ctx) + case InputStrictlyRequired: + return il.loadStrictlyRequired(ctx) + default: + return il.loadNone() + } +} + +func (il inputLoader) loadNone() ([]model.URLInfo, error) { + if len(il.StaticInputs) > 0 || len(il.SourceFiles) > 0 { + return nil, ErrNoInputExpected + } + return []model.URLInfo{{}}, nil +} + +func (il inputLoader) loadOptional() ([]model.URLInfo, error) { + inputs, err := il.loadLocal() + if err == nil && len(inputs) <= 0 { + inputs = []model.URLInfo{{}} + } + return inputs, err +} + +func (il inputLoader) loadStrictlyRequired(ctx context.Context) ([]model.URLInfo, error) { + inputs, err := il.loadLocal() + if err != nil || len(inputs) > 0 { + return inputs, err + } + return nil, ErrInputRequired +} + +func (il inputLoader) loadOrQueryTestList(ctx context.Context) ([]model.URLInfo, error) { + inputs, err := il.loadLocal() + if err != nil || len(inputs) > 0 { + return inputs, err + } + return il.loadRemote(loadRemoteConfig{ctx: ctx, session: il.Session}) +} + +func (il inputLoader) loadLocal() ([]model.URLInfo, error) { + inputs := []model.URLInfo{} + for _, input := range il.StaticInputs { + inputs = append(inputs, model.URLInfo{URL: input}) + } + for _, filepath := range il.SourceFiles { + extra, err := il.readfile(filepath, fsx.Open) + if err != nil { + return nil, err + } + // See https://github.com/ooni/probe-cli/v3/internal/engine/issues/1123. + if len(extra) <= 0 { + return nil, fmt.Errorf("%w: %s", ErrDetectedEmptyFile, filepath) + } + inputs = append(inputs, extra...) + } + return inputs, nil +} + +func (il inputLoader) readfile(filepath string, open func(string) (fsx.File, error)) ([]model.URLInfo, error) { + inputs := []model.URLInfo{} + filep, err := open(filepath) + if err != nil { + return nil, err + } + defer filep.Close() + // Implementation note: when you save file with vim, you have newline at + // end of file and you don't want to consider that an input line. While there + // ignore any other empty line that may occur inside the file. + scanner := bufio.NewScanner(filep) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + inputs = append(inputs, model.URLInfo{URL: line}) + } + } + if scanner.Err() != nil { + return nil, scanner.Err() + } + return inputs, nil +} + +type loadRemoteConfig struct { + ctx context.Context + session InputLoaderSession +} + +func (il inputLoader) loadRemote(conf loadRemoteConfig) ([]model.URLInfo, error) { + if err := conf.session.MaybeLookupLocationContext(conf.ctx); err != nil { + return nil, err + } + client, err := conf.session.NewOrchestraClient(conf.ctx) + if err != nil { + return nil, err + } + return client.FetchURLList(conf.ctx, model.URLListConfig{ + CountryCode: conf.session.ProbeCC(), + Limit: il.URLLimit, + Categories: il.URLCategories, + }) +} diff --git a/internal/engine/inputloader_integration_test.go b/internal/engine/inputloader_integration_test.go new file mode 100644 index 0000000..28af8b4 --- /dev/null +++ b/internal/engine/inputloader_integration_test.go @@ -0,0 +1,316 @@ +package engine_test + +import ( + "context" + "errors" + "syscall" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestInputLoaderInputNoneWithStaticInputs(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + StaticInputs: []string{"https://www.google.com/"}, + InputPolicy: engine.InputNone, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if !errors.Is(err, engine.ErrNoInputExpected) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputNoneWithFilesInputs(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + SourceFiles: []string{ + "testdata/inputloader1.txt", + "testdata/inputloader2.txt", + }, + InputPolicy: engine.InputNone, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if !errors.Is(err, engine.ErrNoInputExpected) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputNoneWithBothInputs(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + StaticInputs: []string{"https://www.google.com/"}, + SourceFiles: []string{ + "testdata/inputloader1.txt", + "testdata/inputloader2.txt", + }, + InputPolicy: engine.InputNone, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if !errors.Is(err, engine.ErrNoInputExpected) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputNoneWithNoInput(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + InputPolicy: engine.InputNone, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].URL != "" { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputOptionalWithNoInput(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + InputPolicy: engine.InputOptional, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].URL != "" { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputOptionalWithInput(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + StaticInputs: []string{"https://www.google.com/"}, + SourceFiles: []string{ + "testdata/inputloader1.txt", + "testdata/inputloader2.txt", + }, + InputPolicy: engine.InputOptional, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if err != nil { + t.Fatal(err) + } + if len(out) != 5 { + t.Fatal("not the output length we expected") + } + expect := []model.URLInfo{ + {URL: "https://www.google.com/"}, + {URL: "https://www.x.org/"}, + {URL: "https://www.slashdot.org/"}, + {URL: "https://abc.xyz/"}, + {URL: "https://run.ooni.io/"}, + } + if diff := cmp.Diff(out, expect); diff != "" { + t.Fatal(diff) + } +} + +func TestInputLoaderInputOptionalNonexistentFile(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + StaticInputs: []string{"https://www.google.com/"}, + SourceFiles: []string{ + "testdata/inputloader1.txt", + "/nonexistent", + "testdata/inputloader2.txt", + }, + InputPolicy: engine.InputOptional, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if !errors.Is(err, syscall.ENOENT) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputStrictlyRequiredWithInput(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + StaticInputs: []string{"https://www.google.com/"}, + SourceFiles: []string{ + "testdata/inputloader1.txt", + "testdata/inputloader2.txt", + }, + InputPolicy: engine.InputStrictlyRequired, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if err != nil { + t.Fatal(err) + } + if len(out) != 5 { + t.Fatal("not the output length we expected") + } + expect := []model.URLInfo{ + {URL: "https://www.google.com/"}, + {URL: "https://www.x.org/"}, + {URL: "https://www.slashdot.org/"}, + {URL: "https://abc.xyz/"}, + {URL: "https://run.ooni.io/"}, + } + if diff := cmp.Diff(out, expect); diff != "" { + t.Fatal(diff) + } +} + +func TestInputLoaderInputStrictlyRequiredWithoutInput(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + InputPolicy: engine.InputStrictlyRequired, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if !errors.Is(err, engine.ErrInputRequired) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputStrictlyRequiredWithEmptyFile(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + InputPolicy: engine.InputStrictlyRequired, + SourceFiles: []string{ + "testdata/inputloader1.txt", + "testdata/inputloader3.txt", // we want it before inputloader2.txt + "testdata/inputloader2.txt", + }, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if !errors.Is(err, engine.ErrDetectedEmptyFile) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputOrQueryTestListsWithInput(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + StaticInputs: []string{"https://www.google.com/"}, + SourceFiles: []string{ + "testdata/inputloader1.txt", + "testdata/inputloader2.txt", + }, + InputPolicy: engine.InputOrQueryTestLists, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if err != nil { + t.Fatal(err) + } + if len(out) != 5 { + t.Fatal("not the output length we expected") + } + expect := []model.URLInfo{ + {URL: "https://www.google.com/"}, + {URL: "https://www.x.org/"}, + {URL: "https://www.slashdot.org/"}, + {URL: "https://abc.xyz/"}, + {URL: "https://run.ooni.io/"}, + } + if diff := cmp.Diff(out, expect); diff != "" { + t.Fatal(diff) + } +} + +func TestInputLoaderInputOrQueryTestListsWithNoInputAndCancelledContext(t *testing.T) { + sess, err := engine.NewSession(engine.SessionConfig{ + AssetsDir: "testdata", + KVStore: kvstore.NewMemoryKeyValueStore(), + Logger: log.Log, + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + TempDir: "testdata", + }) + if err != nil { + t.Fatal(err) + } + defer sess.Close() + il := engine.NewInputLoader(engine.InputLoaderConfig{ + InputPolicy: engine.InputOrQueryTestLists, + Session: sess, + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + out, err := il.Load(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +func TestInputLoaderInputOrQueryTestListsWithNoInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := engine.NewSession(engine.SessionConfig{ + AvailableProbeServices: []model.Service{{ + Address: "https://ams-pg-test.ooni.org/", + Type: "https", + }}, + AssetsDir: "testdata", + KVStore: kvstore.NewMemoryKeyValueStore(), + Logger: log.Log, + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + TempDir: "testdata", + }) + if err != nil { + t.Fatal(err) + } + defer sess.Close() + il := engine.NewInputLoader(engine.InputLoaderConfig{ + InputPolicy: engine.InputOrQueryTestLists, + Session: sess, + URLLimit: 30, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if err != nil { + t.Fatal(err) + } + if len(out) < 10 { + t.Fatal("not the output length we expected") + } +} + +func TestInputLoaderInputOrQueryTestListsWithEmptyFile(t *testing.T) { + il := engine.NewInputLoader(engine.InputLoaderConfig{ + InputPolicy: engine.InputOrQueryTestLists, + SourceFiles: []string{ + "testdata/inputloader1.txt", + "testdata/inputloader3.txt", // we want it before inputloader2.txt + "testdata/inputloader2.txt", + }, + }) + ctx := context.Background() + out, err := il.Load(ctx) + if !errors.Is(err, engine.ErrDetectedEmptyFile) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("not the output we expected") + } +} diff --git a/internal/engine/inputloader_test.go b/internal/engine/inputloader_test.go new file mode 100644 index 0000000..38bf483 --- /dev/null +++ b/internal/engine/inputloader_test.go @@ -0,0 +1,110 @@ +package engine + +import ( + "context" + "errors" + "io" + "os" + "syscall" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/fsx" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type InputLoaderBrokenFS struct{} + +func (InputLoaderBrokenFS) Open(filepath string) (fsx.File, error) { + return InputLoaderBrokenFile{}, nil +} + +type InputLoaderBrokenFile struct{} + +func (InputLoaderBrokenFile) Stat() (os.FileInfo, error) { + return nil, nil +} + +func (InputLoaderBrokenFile) Read([]byte) (int, error) { + return 0, syscall.EFAULT +} + +func (InputLoaderBrokenFile) Close() error { + return nil +} + +func TestInputLoaderReadfileScannerFailure(t *testing.T) { + il := inputLoader{} + out, err := il.readfile("", InputLoaderBrokenFS{}.Open) + if !errors.Is(err, syscall.EFAULT) { + t.Fatal("not the error we expected") + } + if out != nil { + t.Fatal("not the output we expected") + } +} + +type InputLoaderBrokenSession struct { + OrchestraClient model.ExperimentOrchestraClient + Error error +} + +func (InputLoaderBrokenSession) MaybeLookupLocationContext(ctx context.Context) error { + return nil +} + +func (ilbs InputLoaderBrokenSession) NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error) { + if ilbs.OrchestraClient != nil { + return ilbs.OrchestraClient, nil + } + return nil, io.EOF +} + +func (InputLoaderBrokenSession) ProbeCC() string { + return "IT" +} + +func TestInputLoaderNewOrchestraClientFailure(t *testing.T) { + il := inputLoader{} + lrc := loadRemoteConfig{ + ctx: context.Background(), + session: InputLoaderBrokenSession{}, + } + out, err := il.loadRemote(lrc) + if !errors.Is(err, io.EOF) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("expected nil output here") + } +} + +type InputLoaderBrokenOrchestraClient struct{} + +func (InputLoaderBrokenOrchestraClient) FetchPsiphonConfig(ctx context.Context) ([]byte, error) { + return nil, io.EOF +} + +func (InputLoaderBrokenOrchestraClient) FetchTorTargets(ctx context.Context, cc string) (map[string]model.TorTarget, error) { + return nil, io.EOF +} + +func (InputLoaderBrokenOrchestraClient) FetchURLList(ctx context.Context, config model.URLListConfig) ([]model.URLInfo, error) { + return nil, io.EOF +} + +func TestInputLoaderFetchURLListFailure(t *testing.T) { + il := inputLoader{} + lrc := loadRemoteConfig{ + ctx: context.Background(), + session: InputLoaderBrokenSession{ + OrchestraClient: InputLoaderBrokenOrchestraClient{}, + }, + } + out, err := il.loadRemote(lrc) + if !errors.Is(err, io.EOF) { + t.Fatalf("not the error we expected: %+v", err) + } + if out != nil { + t.Fatal("expected nil output here") + } +} diff --git a/internal/engine/inputprocessor.go b/internal/engine/inputprocessor.go new file mode 100644 index 0000000..1bf3d3a --- /dev/null +++ b/internal/engine/inputprocessor.go @@ -0,0 +1,139 @@ +package engine + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// InputProcessorExperiment is the Experiment +// according to InputProcessor. +type InputProcessorExperiment interface { + MeasureWithContext( + ctx context.Context, input string) (*model.Measurement, error) +} + +// InputProcessorExperimentWrapper is a wrapper for an +// Experiment that also allow to pass around the input index. +type InputProcessorExperimentWrapper interface { + MeasureWithContext( + ctx context.Context, idx int, input string) (*model.Measurement, error) +} + +// NewInputProcessorExperimentWrapper creates a new +// instance of InputProcessorExperimentWrapper. +func NewInputProcessorExperimentWrapper( + exp InputProcessorExperiment) InputProcessorExperimentWrapper { + return inputProcessorExperimentWrapper{exp: exp} +} + +type inputProcessorExperimentWrapper struct { + exp InputProcessorExperiment +} + +func (ipew inputProcessorExperimentWrapper) MeasureWithContext( + ctx context.Context, idx int, input string) (*model.Measurement, error) { + return ipew.exp.MeasureWithContext(ctx, input) +} + +var _ InputProcessorExperimentWrapper = inputProcessorExperimentWrapper{} + +// InputProcessor processes inputs. We perform a Measurement +// for each input using the given Experiment. +type InputProcessor struct { + // Annotations contains the measurement annotations + Annotations map[string]string + + // Experiment is the code that will run the experiment. + Experiment InputProcessorExperimentWrapper + + // Inputs is the list of inputs to measure. + Inputs []model.URLInfo + + // Options contains command line options for this experiment. + Options []string + + // Saver is the code that will save measurement results + // on persistent storage (e.g. the file system). + Saver InputProcessorSaverWrapper + + // Submitter is the code that will submit measurements + // to the OONI collector. + Submitter InputProcessorSubmitterWrapper +} + +// InputProcessorSaverWrapper is InputProcessor's +// wrapper for a Saver implementation. +type InputProcessorSaverWrapper interface { + SaveMeasurement(idx int, m *model.Measurement) error +} + +type inputProcessorSaverWrapper struct { + saver Saver +} + +// NewInputProcessorSaverWrapper wraps a Saver for InputProcessor. +func NewInputProcessorSaverWrapper(saver Saver) InputProcessorSaverWrapper { + return inputProcessorSaverWrapper{saver: saver} +} + +func (ipsw inputProcessorSaverWrapper) SaveMeasurement( + idx int, m *model.Measurement) error { + return ipsw.saver.SaveMeasurement(m) +} + +// InputProcessorSubmitterWrapper is InputProcessor's +// wrapper for a Submitter implementation. +type InputProcessorSubmitterWrapper interface { + Submit(ctx context.Context, idx int, m *model.Measurement) error +} + +type inputProcessorSubmitterWrapper struct { + submitter Submitter +} + +// NewInputProcessorSubmitterWrapper wraps a Submitter +// for the InputProcessor. +func NewInputProcessorSubmitterWrapper(submitter Submitter) InputProcessorSubmitterWrapper { + return inputProcessorSubmitterWrapper{submitter: submitter} +} + +func (ipsw inputProcessorSubmitterWrapper) Submit( + ctx context.Context, idx int, m *model.Measurement) error { + return ipsw.submitter.Submit(ctx, m) +} + +// Run processes all the input subject to the duration of the +// context. The code will perform measurements using the given +// experiment; submit measurements using the given submitter; +// save measurements using the given saver. +// +// Annotations and Options will be saved in the measurement. +// +// The default behaviour of this code is that an error while +// measuring, while submitting, or while saving a measurement +// is always causing us to break out of the loop. The user +// though is free to choose different policies by configuring +// the Experiment, Submitter, and Saver fields properly. +func (ip InputProcessor) Run(ctx context.Context) error { + for idx, url := range ip.Inputs { + input := url.URL + meas, err := ip.Experiment.MeasureWithContext(ctx, idx, input) + if err != nil { + return err + } + meas.AddAnnotations(ip.Annotations) + meas.Options = ip.Options + err = ip.Submitter.Submit(ctx, idx, meas) + if err != nil { + return err + } + // Note: must be after submission because submission modifies + // the measurement to include the report ID. + err = ip.Saver.SaveMeasurement(idx, meas) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/engine/inputprocessor_test.go b/internal/engine/inputprocessor_test.go new file mode 100644 index 0000000..4d604b1 --- /dev/null +++ b/internal/engine/inputprocessor_test.go @@ -0,0 +1,166 @@ +package engine + +import ( + "context" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type FakeInputProcessorExperiment struct { + Err error + M []*model.Measurement +} + +func (fipe *FakeInputProcessorExperiment) MeasureWithContext( + ctx context.Context, input string) (*model.Measurement, error) { + if fipe.Err != nil { + return nil, fipe.Err + } + m := new(model.Measurement) + // Here we add annotations to ensure that the input processor + // is MERGING annotations as opposed to overwriting them. + m.AddAnnotation("antani", "antani") + m.AddAnnotation("foo", "baz") // would be bar below + m.Input = model.MeasurementTarget(input) + fipe.M = append(fipe.M, m) + return m, nil +} + +func TestInputProcessorMeasurementFailed(t *testing.T) { + expected := errors.New("mocked error") + ip := InputProcessor{ + Experiment: NewInputProcessorExperimentWrapper( + &FakeInputProcessorExperiment{Err: expected}, + ), + Inputs: []model.URLInfo{{ + URL: "https://www.kernel.org/", + }}, + } + ctx := context.Background() + if err := ip.Run(ctx); !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } +} + +type FakeInputProcessorSubmitter struct { + Err error + M []*model.Measurement +} + +func (fips *FakeInputProcessorSubmitter) Submit( + ctx context.Context, m *model.Measurement) error { + fips.M = append(fips.M, m) + return fips.Err +} + +func TestInputProcessorSubmissionFailed(t *testing.T) { + fipe := &FakeInputProcessorExperiment{} + expected := errors.New("mocked error") + ip := InputProcessor{ + Annotations: map[string]string{ + "foo": "bar", + }, + Experiment: NewInputProcessorExperimentWrapper(fipe), + Inputs: []model.URLInfo{{ + URL: "https://www.kernel.org/", + }}, + Options: []string{"fake=true"}, + Submitter: NewInputProcessorSubmitterWrapper( + &FakeInputProcessorSubmitter{Err: expected}, + ), + } + ctx := context.Background() + if err := ip.Run(ctx); !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if len(fipe.M) != 1 { + t.Fatal("no measurements generated") + } + m := fipe.M[0] + if m.Input != "https://www.kernel.org/" { + t.Fatal("invalid input") + } + if len(m.Annotations) != 2 { + t.Fatal("invalid number of annotations") + } + if m.Annotations["foo"] != "bar" { + t.Fatal("invalid annotation: foo") + } + if m.Annotations["antani"] != "antani" { + t.Fatal("invalid annotation: antani") + } + if len(m.Options) != 1 || m.Options[0] != "fake=true" { + t.Fatal("options not set") + } +} + +type FakeInputProcessorSaver struct { + Err error + M []*model.Measurement +} + +func (fips *FakeInputProcessorSaver) SaveMeasurement(m *model.Measurement) error { + fips.M = append(fips.M, m) + return fips.Err +} + +func TestInputProcessorSaveOnDiskFailed(t *testing.T) { + expected := errors.New("mocked error") + ip := InputProcessor{ + Experiment: NewInputProcessorExperimentWrapper( + &FakeInputProcessorExperiment{}, + ), + Inputs: []model.URLInfo{{ + URL: "https://www.kernel.org/", + }}, + Options: []string{"fake=true"}, + Saver: NewInputProcessorSaverWrapper( + &FakeInputProcessorSaver{Err: expected}, + ), + Submitter: NewInputProcessorSubmitterWrapper( + &FakeInputProcessorSubmitter{Err: nil}, + ), + } + ctx := context.Background() + if err := ip.Run(ctx); !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } +} + +func TestInputProcessorGood(t *testing.T) { + fipe := &FakeInputProcessorExperiment{} + saver := &FakeInputProcessorSaver{Err: nil} + submitter := &FakeInputProcessorSubmitter{Err: nil} + ip := InputProcessor{ + Experiment: NewInputProcessorExperimentWrapper(fipe), + Inputs: []model.URLInfo{{ + URL: "https://www.kernel.org/", + }, { + URL: "https://www.slashdot.org/", + }}, + Options: []string{"fake=true"}, + Saver: NewInputProcessorSaverWrapper(saver), + Submitter: NewInputProcessorSubmitterWrapper(submitter), + } + ctx := context.Background() + if err := ip.Run(ctx); err != nil { + t.Fatal(err) + } + if len(fipe.M) != 2 || len(saver.M) != 2 || len(submitter.M) != 2 { + t.Fatal("not all measurements saved") + } + if submitter.M[0].Input != "https://www.kernel.org/" { + t.Fatal("invalid submitter.M[0].Input") + } + if submitter.M[1].Input != "https://www.slashdot.org/" { + t.Fatal("invalid submitter.M[1].Input") + } + if saver.M[0].Input != "https://www.kernel.org/" { + t.Fatal("invalid saver.M[0].Input") + } + if saver.M[1].Input != "https://www.slashdot.org/" { + t.Fatal("invalid saver.M[1].Input") + } +} diff --git a/internal/engine/internal/fsx/fsx.go b/internal/engine/internal/fsx/fsx.go new file mode 100644 index 0000000..6805ed1 --- /dev/null +++ b/internal/engine/internal/fsx/fsx.go @@ -0,0 +1,51 @@ +// Package fsx contains file system extension +package fsx + +import ( + "fmt" + "os" + "syscall" +) + +// File is a generic file. This interface is taken from the draft +// iofs golang design. We'll use fs.File when available. +type File interface { + Stat() (os.FileInfo, error) + Read([]byte) (int, error) + Close() error +} + +// FS is a generic file system. Like File, it's adapted from +// the draft iofs golang design document. +type FS interface { + Open(name string) (File, error) +} + +// Open is a wrapper for os.Open that ensures that we're opening a file. +func Open(pathname string) (File, error) { + return OpenWithFS(filesystem{}, pathname) +} + +// OpenWithFS is like Open but with explicit file system argument. +func OpenWithFS(fs FS, pathname string) (File, error) { + file, err := fs.Open(pathname) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + file.Close() + return nil, err + } + if info.IsDir() { + file.Close() + return nil, fmt.Errorf("input path points to a directory: %w", syscall.EISDIR) + } + return file, nil +} + +type filesystem struct{} + +func (filesystem) Open(pathname string) (File, error) { + return os.Open(pathname) +} diff --git a/internal/engine/internal/fsx/fsx_test.go b/internal/engine/internal/fsx/fsx_test.go new file mode 100644 index 0000000..9555923 --- /dev/null +++ b/internal/engine/internal/fsx/fsx_test.go @@ -0,0 +1,75 @@ +package fsx_test + +import ( + "errors" + "os" + "sync/atomic" + "syscall" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/fsx" +) + +var StateBaseDir = "./testdata/" + +type FailingStatFS struct { + CloseCount *int32 +} + +type FailingStatFile struct { + CloseCount *int32 +} + +var errStatFailed = errors.New("stat failed") + +func (FailingStatFile) Stat() (os.FileInfo, error) { + return nil, errStatFailed +} + +func (fs FailingStatFS) Open(pathname string) (fsx.File, error) { + return FailingStatFile{CloseCount: fs.CloseCount}, nil +} + +func (fs FailingStatFile) Close() error { + if fs.CloseCount != nil { + atomic.AddInt32(fs.CloseCount, 1) + } + return nil +} + +func (FailingStatFile) Read([]byte) (int, error) { + return 0, nil +} + +func TestOpenWithFailingStat(t *testing.T) { + var count int32 + _, err := fsx.OpenWithFS(FailingStatFS{CloseCount: &count}, StateBaseDir+"testfile.txt") + if !errors.Is(err, errStatFailed) { + t.Errorf("expected error with invalid FS: %+v", err) + } + if count != 1 { + t.Error("expected counter to be equal to 1") + } +} + +func TestOpenNonexistentFile(t *testing.T) { + _, err := fsx.Open(StateBaseDir + "invalidtestfile.txt") + if !errors.Is(err, syscall.ENOENT) { + t.Errorf("not the error we expected") + } +} + +func TestOpenDirectoryShouldFail(t *testing.T) { + _, err := fsx.Open(StateBaseDir) + if !errors.Is(err, syscall.EISDIR) { + t.Fatalf("not the error we expected: %+v", err) + } +} + +func TestOpeningExistingFileShouldWork(t *testing.T) { + file, err := fsx.Open(StateBaseDir + "testfile.txt") + if err != nil { + t.Fatal(err) + } + defer file.Close() +} diff --git a/internal/engine/internal/httpfailure/httpfailure.go b/internal/engine/internal/httpfailure/httpfailure.go new file mode 100644 index 0000000..141084a --- /dev/null +++ b/internal/engine/internal/httpfailure/httpfailure.go @@ -0,0 +1,15 @@ +// Package httpfailure groups a bunch of extra HTTP failures. +// +// These failures only matter in the context of processing the results +// of specific experiments, e.g., whatsapp, telegram. +package httpfailure + +var ( + // UnexpectedStatusCode indicates that we re not getting + // the expected (range of) HTTP status code(s). + UnexpectedStatusCode = "http_unexpected_status_code" + + // UnexpectedRedirectURL indicates that the redirect URL + // returned by the server is not the expected one. + UnexpectedRedirectURL = "http_unexpected_redirect_url" +) diff --git a/internal/engine/internal/httpheader/accept.go b/internal/engine/internal/httpheader/accept.go new file mode 100644 index 0000000..7e90660 --- /dev/null +++ b/internal/engine/internal/httpheader/accept.go @@ -0,0 +1,6 @@ +package httpheader + +// Accept returns the Accept header used for measuring. +func Accept() string { + return "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" +} diff --git a/internal/engine/internal/httpheader/acceptlanguage.go b/internal/engine/internal/httpheader/acceptlanguage.go new file mode 100644 index 0000000..149efc1 --- /dev/null +++ b/internal/engine/internal/httpheader/acceptlanguage.go @@ -0,0 +1,6 @@ +package httpheader + +// AcceptLanguage returns the Accept-Language header used for measuring. +func AcceptLanguage() string { + return "en-US;q=0.8,en;q=0.5" +} diff --git a/internal/engine/internal/httpheader/useragent.go b/internal/engine/internal/httpheader/useragent.go new file mode 100644 index 0000000..2abc72f --- /dev/null +++ b/internal/engine/internal/httpheader/useragent.go @@ -0,0 +1,16 @@ +// Package httpheader contains code to set common HTTP headers. +package httpheader + +// UserAgent returns the User-Agent header used for measuring. +func UserAgent() string { + // 12.0% as of Jan 29, 2021 according to https://techblog.willshouse.com/2012/01/03/most-common-user-agents/ + const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36" + return ua +} + +// CLIUserAgent returns the User-Agent used when we want to +// pretent to be a command line HTTP client. +func CLIUserAgent() string { + // here we always put the latest version of cURL. + return "curl/7.73.0" +} diff --git a/internal/engine/internal/httpx/fake_test.go b/internal/engine/internal/httpx/fake_test.go new file mode 100644 index 0000000..2ae6cfd --- /dev/null +++ b/internal/engine/internal/httpx/fake_test.go @@ -0,0 +1,44 @@ +package httpx + +import ( + "io/ioutil" + "net/http" + "time" +) + +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() {} + +type FakeBody struct { + Err error +} + +func (fb FakeBody) Read(p []byte) (int, error) { + time.Sleep(10 * time.Microsecond) + return 0, fb.Err +} + +func (fb FakeBody) Close() error { + return nil +} diff --git a/internal/engine/internal/httpx/fetch.go b/internal/engine/internal/httpx/fetch.go new file mode 100644 index 0000000..8db6502 --- /dev/null +++ b/internal/engine/internal/httpx/fetch.go @@ -0,0 +1,31 @@ +package httpx + +import ( + "context" + "crypto/sha256" + "fmt" +) + +// FetchResource fetches the specified resource and returns it. +func (c Client) FetchResource(ctx context.Context, URLPath string) ([]byte, error) { + request, err := c.NewRequest(ctx, "GET", URLPath, nil, nil) + if err != nil { + return nil, err + } + return c.Do(request) +} + +// FetchResourceAndVerify fetches and verifies a specific resource. +func (c Client) FetchResourceAndVerify(ctx context.Context, URL, SHA256Sum string) ([]byte, error) { + c.Logger.Debugf("httpx: expected SHA256: %s", SHA256Sum) + data, err := c.FetchResource(ctx, URL) + if err != nil { + return nil, err + } + s := fmt.Sprintf("%x", sha256.Sum256(data)) + c.Logger.Debugf("httpx: real SHA256: %s", s) + if SHA256Sum != s { + return nil, fmt.Errorf("httpx: SHA256 mismatch: got %s and expected %s", s, SHA256Sum) + } + return data, nil +} diff --git a/internal/engine/internal/httpx/fetch_test.go b/internal/engine/internal/httpx/fetch_test.go new file mode 100644 index 0000000..c7086aa --- /dev/null +++ b/internal/engine/internal/httpx/fetch_test.go @@ -0,0 +1,154 @@ +package httpx_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" +) + +func TestFetchResourceIntegration(t *testing.T) { + log.SetLevel(log.DebugLevel) + ctx := context.Background() + data, err := (httpx.Client{ + BaseURL: "http://facebook.com/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).FetchResource(ctx, "/robots.txt") + if err != nil { + t.Fatal(err) + } + if len(data) <= 0 { + t.Fatal("Did not expect an empty resource") + } +} + +func TestFetchResourceExpiredContext(t *testing.T) { + log.SetLevel(log.DebugLevel) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + data, err := (httpx.Client{ + BaseURL: "http://facebook.com/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).FetchResource(ctx, "/robots.txt") + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if len(data) != 0 { + t.Fatal("expected an empty resource") + } +} + +func TestFetchResourceAndVerifyIntegration(t *testing.T) { + log.SetLevel(log.DebugLevel) + ctx := context.Background() + data, err := (httpx.Client{ + BaseURL: "https://github.com/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).FetchResourceAndVerify( + ctx, + "/measurement-kit/generic-assets/releases/download/20190426155936/generic-assets-20190426155936.tar.gz", + "34d8a9c8ab30c242469482dc280be832d8a06b4400f8927604dd361bf979b795", + ) + if err != nil { + t.Fatal(err) + } + if len(data) <= 0 { + t.Fatal("Did not expect an empty resource") + } +} + +func TestFetchResourceInvalidURL(t *testing.T) { + log.SetLevel(log.DebugLevel) + ctx := context.Background() + data, err := (httpx.Client{ + BaseURL: "http://\t/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).FetchResource(ctx, "/robots.txt") + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + if len(data) != 0 { + t.Fatal("expected an empty resource") + } +} + +func TestFetchResource400(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + }, + )) + defer server.Close() + log.SetLevel(log.DebugLevel) + ctx := context.Background() + data, err := (httpx.Client{ + Authorization: "foobar", + BaseURL: server.URL, + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).FetchResource(ctx, "") + if err == nil || !strings.HasSuffix(err.Error(), "400 Bad Request") { + t.Fatal("not the error we expected") + } + if len(data) != 0 { + t.Fatal("expected an empty resource") + } +} + +func TestFetchResourceAndVerify400(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + }, + )) + defer server.Close() + log.SetLevel(log.DebugLevel) + ctx := context.Background() + data, err := (httpx.Client{ + BaseURL: server.URL, + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).FetchResourceAndVerify(ctx, "", "abcde") + if err == nil || !strings.HasSuffix(err.Error(), "400 Bad Request") { + t.Fatal("not the error we expected") + } + if len(data) != 0 { + t.Fatal("expected an empty resource") + } +} + +func TestFetchResourceAndVerifyInvalidSHA256(t *testing.T) { + log.SetLevel(log.DebugLevel) + ctx := context.Background() + data, err := (httpx.Client{ + BaseURL: "https://github.com/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + }).FetchResourceAndVerify( + ctx, + "/measurement-kit/generic-assets/releases/download/20190426155936/generic-assets-20190426155936.tar.gz", + "34d8a9ceeb30c242469482dc280be832d8a06b4400f8927604dd361bf979b795", + ) + if err == nil || !strings.HasPrefix(err.Error(), "httpx: SHA256 mismatch:") { + t.Fatal("not the error we expected") + } + if len(data) != 0 { + t.Fatal("expected an empty resource") + } +} diff --git a/internal/engine/internal/httpx/jsonapi.go b/internal/engine/internal/httpx/jsonapi.go new file mode 100644 index 0000000..627f34a --- /dev/null +++ b/internal/engine/internal/httpx/jsonapi.go @@ -0,0 +1,168 @@ +// Package httpx contains http extensions. +package httpx + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +// Logger is the definition of Logger used by this package. +type Logger interface { + Debugf(format string, v ...interface{}) +} + +// Client is an extended client. +type Client struct { + // Accept contains the accept header. + Accept string + + // Authorization contains the authorization header. + Authorization string + + // BaseURL is the base URL of the API. + BaseURL string + + // HTTPClient is the real http client to use. + HTTPClient *http.Client + + // Host allows to set a specific host header. This is useful + // to implement, e.g., cloudfronting. + Host string + + // Logger is the logger to use. + Logger Logger + + // ProxyURL allows to force a proxy URL to fallback to a + // tunnel, e.g., Psiphon. + ProxyURL *url.URL + + // UserAgent is the user agent to use. + UserAgent string +} + +// NewRequestWithJSONBody creates a new request with a JSON body +func (c Client) NewRequestWithJSONBody( + ctx context.Context, method, resourcePath string, + query url.Values, body interface{}) (*http.Request, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + c.Logger.Debugf("httpx: request body: %d bytes", len(data)) + request, err := c.NewRequest( + ctx, method, resourcePath, query, bytes.NewReader(data)) + if err != nil { + return nil, err + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + return request, nil +} + +// NewRequest creates a new request. +func (c Client) NewRequest(ctx context.Context, method, resourcePath string, + query url.Values, body io.Reader) (*http.Request, error) { + URL, err := url.Parse(c.BaseURL) + if err != nil { + return nil, err + } + URL.Path = resourcePath + if query != nil { + URL.RawQuery = query.Encode() + } + c.Logger.Debugf("httpx: method: %s", method) + c.Logger.Debugf("httpx: URL: %s", URL.String()) + request, err := http.NewRequest(method, URL.String(), body) + if err != nil { + return nil, err + } + request.Host = c.Host // allow cloudfronting + if c.Authorization != "" { + request.Header.Set("Authorization", c.Authorization) + } + if c.Accept != "" { + request.Header.Set("Accept", c.Accept) + } + request.Header.Set("User-Agent", c.UserAgent) + // Implementation note: the following allows tunneling if c.ProxyURL + // is not nil. Because the proxy URL is set as part of each request + // generated using this function, every request that eventually needs + // to reconnect will always do so using the proxy. + ctx = dialer.WithProxyURL(ctx, c.ProxyURL) + return request.WithContext(ctx), nil +} + +// Do performs the provided request and returns the response body or an error. +func (c Client) Do(request *http.Request) ([]byte, error) { + response, err := c.HTTPClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + return nil, fmt.Errorf("httpx: request failed: %s", response.Status) + } + return ioutil.ReadAll(response.Body) +} + +// DoJSON performs the provided request and unmarshals the JSON response body +// into the provided output variable. +func (c Client) DoJSON(request *http.Request, output interface{}) error { + data, err := c.Do(request) + if err != nil { + return err + } + c.Logger.Debugf("httpx: response body: %d bytes", len(data)) + return json.Unmarshal(data, output) +} + +// GetJSON reads the JSON resource at resourcePath and unmarshals the +// results into output. The request is bounded by the lifetime of the +// context passed as argument. Returns the error that occurred. +func (c Client) GetJSON(ctx context.Context, resourcePath string, output interface{}) error { + return c.GetJSONWithQuery(ctx, resourcePath, nil, output) +} + +// GetJSONWithQuery is like GetJSON but also has a query. +func (c Client) GetJSONWithQuery( + ctx context.Context, resourcePath string, + query url.Values, output interface{}) error { + request, err := c.NewRequest(ctx, "GET", resourcePath, query, nil) + if err != nil { + return err + } + return c.DoJSON(request, output) +} + +// PostJSON creates a JSON subresource of the resource at resourcePath +// using the JSON document at input and returning the result into the +// JSON document at output. The request is bounded by the context's +// lifetime. Returns the error that occurred. +func (c Client) PostJSON( + ctx context.Context, resourcePath string, input, output interface{}) error { + request, err := c.NewRequestWithJSONBody(ctx, "POST", resourcePath, nil, input) + if err != nil { + return err + } + return c.DoJSON(request, output) +} + +// PutJSON updates a JSON resource at a specific path and returns +// the error that occurred and possibly an output document +func (c Client) PutJSON( + ctx context.Context, resourcePath string, input, output interface{}) error { + request, err := c.NewRequestWithJSONBody(ctx, "PUT", resourcePath, nil, input) + if err != nil { + return err + } + return c.DoJSON(request, output) +} diff --git a/internal/engine/internal/httpx/jsonapi_test.go b/internal/engine/internal/httpx/jsonapi_test.go new file mode 100644 index 0000000..d7a8fec --- /dev/null +++ b/internal/engine/internal/httpx/jsonapi_test.go @@ -0,0 +1,316 @@ +package httpx_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +const userAgent = "miniooni/0.1.0-dev" + +func newClient() httpx.Client { + return httpx.Client{ + BaseURL: "https://httpbin.org", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: userAgent, + } +} + +func TestNewRequestWithJSONBodyJSONMarshalFailure(t *testing.T) { + client := newClient() + req, err := client.NewRequestWithJSONBody( + context.Background(), "GET", "/", nil, make(chan interface{}), + ) + if err == nil || !strings.HasPrefix(err.Error(), "json: unsupported type") { + t.Fatal("not the error we expected") + } + if req != nil { + t.Fatal("expected nil request here") + } +} + +func TestNewRequestWithJSONBodyNewRequestFailure(t *testing.T) { + client := newClient() + client.BaseURL = "\t\t\t" // cause URL parse error + req, err := client.NewRequestWithJSONBody( + context.Background(), "GET", "/", nil, nil, + ) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + if req != nil { + t.Fatal("expected nil request here") + } +} + +func TestNewRequestWithQuery(t *testing.T) { + client := newClient() + q := url.Values{} + q.Add("antani", "mascetti") + q.Add("melandri", "conte") + req, err := client.NewRequest( + context.Background(), "GET", "/", q, nil, + ) + if err != nil { + t.Fatal(err) + } + if req.URL.Query().Get("antani") != "mascetti" { + t.Fatal("expected different query string here") + } + if req.URL.Query().Get("melandri") != "conte" { + t.Fatal("expected different query string here") + } +} + +func TestNewRequestNewRequestFailure(t *testing.T) { + client := newClient() + req, err := client.NewRequest( + context.Background(), "\t\t\t", "/", nil, nil, + ) + if err == nil || !strings.HasPrefix(err.Error(), "net/http: invalid method") { + t.Fatal("not the error we expected") + } + if req != nil { + t.Fatal("expected nil request here") + } +} + +func TestNewRequestCloudfronting(t *testing.T) { + client := newClient() + client.Host = "www.x.org" + req, err := client.NewRequest( + context.Background(), "GET", "/", nil, nil, + ) + if err != nil { + t.Fatal(err) + } + if req.Host != client.Host { + t.Fatal("expected different req.Host here") + } +} + +func TestNewRequestAcceptIsSet(t *testing.T) { + client := newClient() + client.Accept = "application/xml" + req, err := client.NewRequestWithJSONBody( + context.Background(), "GET", "/", nil, []string{}, + ) + if err != nil { + t.Fatal(err) + } + if req.Header.Get("Accept") != "application/xml" { + t.Fatal("expected different Accept here") + } +} + +func TestNewRequestContentTypeIsSet(t *testing.T) { + client := newClient() + req, err := client.NewRequestWithJSONBody( + context.Background(), "GET", "/", nil, []string{}, + ) + if err != nil { + t.Fatal(err) + } + if req.Header.Get("Content-Type") != "application/json" { + t.Fatal("expected different Content-Type here") + } +} + +func TestNewRequestAuthorizationHeader(t *testing.T) { + client := newClient() + client.Authorization = "deadbeef" + req, err := client.NewRequest( + context.Background(), "GET", "/", nil, nil, + ) + if err != nil { + t.Fatal(err) + } + if req.Header.Get("Authorization") != client.Authorization { + t.Fatal("expected different Authorization here") + } +} + +func TestNewRequestUserAgentIsSet(t *testing.T) { + client := newClient() + req, err := client.NewRequest( + context.Background(), "GET", "/", nil, nil, + ) + if err != nil { + t.Fatal(err) + } + if req.Header.Get("User-Agent") != userAgent { + t.Fatal("expected different User-Agent here") + } +} + +func TestNewRequestTunnelingIsPossible(t *testing.T) { + client := newClient() + client.ProxyURL = &url.URL{Scheme: "socks5", Host: "[::1]:54321"} + req, err := client.NewRequest( + context.Background(), "GET", "/", nil, nil, + ) + if err != nil { + t.Fatal(err) + } + cmp := cmp.Diff(dialer.ContextProxyURL(req.Context()), client.ProxyURL) + if cmp != "" { + t.Fatal(cmp) + } +} + +func TestClientDoJSONClientDoFailure(t *testing.T) { + expected := errors.New("mocked error") + client := newClient() + client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{ + Err: expected, + }} + err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestClientDoJSONResponseNotSuccessful(t *testing.T) { + client := newClient() + client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{ + Resp: &http.Response{ + StatusCode: 401, + Body: httpx.FakeBody{}, + }, + }} + err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil) + if err == nil || !strings.HasPrefix(err.Error(), "httpx: request failed") { + t.Fatal("not the error we expected") + } +} + +func TestClientDoJSONResponseReadingBodyError(t *testing.T) { + expected := errors.New("mocked error") + client := newClient() + client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{ + Resp: &http.Response{ + StatusCode: 200, + Body: httpx.FakeBody{ + Err: expected, + }, + }, + }} + err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestClientDoJSONResponseIsNotJSON(t *testing.T) { + client := newClient() + client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{ + Resp: &http.Response{ + StatusCode: 200, + Body: httpx.FakeBody{ + Err: io.EOF, + }, + }, + }} + err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil) + if err == nil || err.Error() != "unexpected end of JSON input" { + t.Fatal("not the error we expected") + } +} + +type httpbinheaders struct { + Headers map[string]string `json:"headers"` +} + +func TestReadJSONSuccess(t *testing.T) { + var headers httpbinheaders + err := newClient().GetJSON(context.Background(), "/headers", &headers) + if err != nil { + t.Fatal(err) + } + if headers.Headers["Host"] != "httpbin.org" { + t.Fatal("unexpected Host header") + } + if headers.Headers["User-Agent"] != "miniooni/0.1.0-dev" { + t.Fatal("unexpected Host header") + } +} + +type httpbinpost struct { + Data string `json:"data"` +} + +func TestCreateJSONSuccess(t *testing.T) { + headers := httpbinheaders{ + Headers: map[string]string{ + "Foo": "bar", + }, + } + var response httpbinpost + err := newClient().PostJSON(context.Background(), "/post", &headers, &response) + if err != nil { + t.Fatal(err) + } + if response.Data != `{"headers":{"Foo":"bar"}}` { + t.Fatal(response.Data) + } +} + +type httpbinput struct { + Data string `json:"data"` +} + +func TestUpdateJSONSuccess(t *testing.T) { + headers := httpbinheaders{ + Headers: map[string]string{ + "Foo": "bar", + }, + } + var response httpbinpost + err := newClient().PutJSON(context.Background(), "/put", &headers, &response) + if err != nil { + t.Fatal(err) + } + if response.Data != `{"headers":{"Foo":"bar"}}` { + t.Fatal(response.Data) + } +} + +func TestReadJSONFailure(t *testing.T) { + var headers httpbinheaders + client := newClient() + client.BaseURL = "\t\t\t\t" + err := client.GetJSON(context.Background(), "/headers", &headers) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } +} + +func TestCreateJSONFailure(t *testing.T) { + var headers httpbinheaders + client := newClient() + client.BaseURL = "\t\t\t\t" + err := client.PostJSON(context.Background(), "/headers", &headers, &headers) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } +} + +func TestUpdateJSONFailure(t *testing.T) { + var headers httpbinheaders + client := newClient() + client.BaseURL = "\t\t\t\t" + err := client.PutJSON(context.Background(), "/headers", &headers, &headers) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } +} diff --git a/internal/engine/internal/humanizex/humanizex.go b/internal/engine/internal/humanizex/humanizex.go new file mode 100644 index 0000000..a41aa77 --- /dev/null +++ b/internal/engine/internal/humanizex/humanizex.go @@ -0,0 +1,26 @@ +// Package humanizex is like dustin/go-humanize +package humanizex + +import "fmt" + +// SI is like dustin/go-humanize.SI +func SI(value float64, unit string) string { + value, prefix := reduce(value) + return fmt.Sprintf("%3.0f %s%s", value, prefix, unit) +} + +func reduce(value float64) (float64, string) { + if value < 1e03 { + return value, " " + } + value /= 1e03 + if value < 1e03 { + return value, "k" + } + value /= 1e03 + if value < 1e03 { + return value, "M" + } + value /= 1e03 + return value, "G" +} diff --git a/internal/engine/internal/humanizex/humanizex_test.go b/internal/engine/internal/humanizex/humanizex_test.go new file mode 100644 index 0000000..0d5f10c --- /dev/null +++ b/internal/engine/internal/humanizex/humanizex_test.go @@ -0,0 +1,34 @@ +package humanizex_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/humanizex" +) + +func TestGood(t *testing.T) { + if humanizex.SI(128, "bit/s") != "128 bit/s" { + t.Fatal("unexpected result") + } + if humanizex.SI(1280, "bit/s") != " 1 kbit/s" { + t.Fatal("unexpected result") + } + if humanizex.SI(12800, "bit/s") != " 13 kbit/s" { + t.Fatal("unexpected result") + } + if humanizex.SI(128000, "bit/s") != "128 kbit/s" { + t.Fatal("unexpected result") + } + if humanizex.SI(1280000, "bit/s") != " 1 Mbit/s" { + t.Fatal("unexpected result") + } + if humanizex.SI(12800000, "bit/s") != " 13 Mbit/s" { + t.Fatal("unexpected result") + } + if humanizex.SI(128000000, "bit/s") != "128 Mbit/s" { + t.Fatal("unexpected result") + } + if humanizex.SI(1280000000, "bit/s") != " 1 Gbit/s" { + t.Fatal("unexpected result") + } +} diff --git a/internal/engine/internal/kvstore/kvstore.go b/internal/engine/internal/kvstore/kvstore.go new file mode 100644 index 0000000..c20334c --- /dev/null +++ b/internal/engine/internal/kvstore/kvstore.go @@ -0,0 +1,44 @@ +// Package kvstore contains key-value stores +package kvstore + +import ( + "errors" + "sync" +) + +// MemoryKeyValueStore is an in-memory key-value store +type MemoryKeyValueStore struct { + m map[string][]byte + mu sync.Mutex +} + +// NewMemoryKeyValueStore creates a new in-memory key-value store +func NewMemoryKeyValueStore() *MemoryKeyValueStore { + return &MemoryKeyValueStore{ + m: make(map[string][]byte), + } +} + +// Get returns a key from the key value store +func (kvs *MemoryKeyValueStore) Get(key string) ([]byte, error) { + var ( + err error + ok bool + value []byte + ) + kvs.mu.Lock() + defer kvs.mu.Unlock() + value, ok = kvs.m[key] + if !ok { + err = errors.New("no such key") + } + return value, err +} + +// Set sets a key into the key value store +func (kvs *MemoryKeyValueStore) Set(key string, value []byte) error { + kvs.mu.Lock() + defer kvs.mu.Unlock() + kvs.m[key] = value + return nil +} diff --git a/internal/engine/internal/kvstore/kvstore_test.go b/internal/engine/internal/kvstore/kvstore_test.go new file mode 100644 index 0000000..b8acfe7 --- /dev/null +++ b/internal/engine/internal/kvstore/kvstore_test.go @@ -0,0 +1,28 @@ +package kvstore + +import "testing" + +func TestNoSuchKey(t *testing.T) { + kvs := NewMemoryKeyValueStore() + value, err := kvs.Get("nonexistent") + if err == nil { + t.Fatal("expected an error here") + } + if value != nil { + t.Fatal("expected empty string here") + } +} + +func TestExistingKey(t *testing.T) { + kvs := NewMemoryKeyValueStore() + if err := kvs.Set("antani", []byte("mascetti")); err != nil { + t.Fatal(err) + } + value, err := kvs.Get("antani") + if err != nil { + t.Fatal(err) + } + if string(value) != "mascetti" { + t.Fatal("not the result we expected") + } +} diff --git a/internal/engine/internal/mlablocate/mlablocate.go b/internal/engine/internal/mlablocate/mlablocate.go new file mode 100644 index 0000000..c8591c6 --- /dev/null +++ b/internal/engine/internal/mlablocate/mlablocate.go @@ -0,0 +1,79 @@ +// Package mlablocate contains a locate.measurementlab.net client. +package mlablocate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// Client is a locate.measurementlab.net client. +type Client struct { + HTTPClient *http.Client + Hostname string + Logger model.Logger + Scheme string + UserAgent string +} + +// NewClient creates a new locate.measurementlab.net client. +func NewClient(httpClient *http.Client, logger model.Logger, userAgent string) *Client { + return &Client{ + HTTPClient: httpClient, + Hostname: "locate.measurementlab.net", + Logger: logger, + Scheme: "https", + UserAgent: userAgent, + } +} + +// Result is a result of a query to locate.measurementlab.net. +type Result struct { + City string `json:"city"` + Country string `json:"country"` + IP []string `json:"ip"` + FQDN string `json:"fqdn"` + Site string `json:"site"` +} + +// Query performs a locate.measurementlab.net query. +func (c *Client) Query(ctx context.Context, tool string) (Result, error) { + URL := &url.URL{ + Scheme: c.Scheme, + Host: c.Hostname, + Path: tool, + } + req, err := http.NewRequestWithContext(ctx, "GET", URL.String(), nil) + if err != nil { + return Result{}, err + } + req.Header.Add("User-Agent", c.UserAgent) + c.Logger.Debugf("mlablocate: GET %s", URL.String()) + resp, err := c.HTTPClient.Do(req) + if err != nil { + return Result{}, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return Result{}, fmt.Errorf("mlablocate: non-200 status code: %d", resp.StatusCode) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return Result{}, err + } + c.Logger.Debugf("mlablocate: %s", string(data)) + var result Result + if err := json.Unmarshal(data, &result); err != nil { + return Result{}, err + } + if result.FQDN == "" { + return Result{}, errors.New("mlablocate: returned empty FQDN") + } + return result, nil +} diff --git a/internal/engine/internal/mlablocate/mlablocate_test.go b/internal/engine/internal/mlablocate/mlablocate_test.go new file mode 100644 index 0000000..e87a635 --- /dev/null +++ b/internal/engine/internal/mlablocate/mlablocate_test.go @@ -0,0 +1,206 @@ +package mlablocate_test + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mlablocate" +) + +func TestWithoutProxy(t *testing.T) { + client := mlablocate.NewClient( + http.DefaultClient, + log.Log, + "miniooni/0.1.0-dev", + ) + result, err := client.Query(context.Background(), "ndt7") + if err != nil { + t.Fatal(err) + } + if result.FQDN == "" { + t.Fatal("unexpected empty fqdn") + } +} + +func Test404Response(t *testing.T) { + client := mlablocate.NewClient( + http.DefaultClient, + log.Log, + "miniooni/0.1.0-dev", + ) + result, err := client.Query(context.Background(), "nonexistent") + if err == nil || !strings.Contains(err.Error(), "mlablocate: non-200 status code") { + t.Fatal("not the error we expected") + } + if result.FQDN != "" { + t.Fatal("expected empty fqdn") + } +} + +func TestNewRequestFailure(t *testing.T) { + client := mlablocate.NewClient( + http.DefaultClient, + log.Log, + "miniooni/0.1.0-dev", + ) + client.Hostname = "\t" + result, err := client.Query(context.Background(), "nonexistent") + if err == nil || !strings.Contains(err.Error(), "invalid URL escape") { + t.Fatal("not the error we expected") + } + if result.FQDN != "" { + t.Fatal("expected empty fqdn") + } +} + +func TestHTTPClientDoFailure(t *testing.T) { + client := mlablocate.NewClient( + http.DefaultClient, + log.Log, + "miniooni/0.1.0-dev", + ) + expected := errors.New("mocked error") + client.HTTPClient = &http.Client{ + Transport: &roundTripFails{Error: expected}, + } + result, err := client.Query(context.Background(), "nonexistent") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.FQDN != "" { + t.Fatal("expected empty fqdn") + } +} + +type roundTripFails struct { + Error error +} + +func (txp *roundTripFails) RoundTrip(*http.Request) (*http.Response, error) { + return nil, txp.Error +} + +func TestCannotReadBody(t *testing.T) { + client := mlablocate.NewClient( + http.DefaultClient, + log.Log, + "miniooni/0.1.0-dev", + ) + expected := errors.New("mocked error") + client.HTTPClient = &http.Client{ + Transport: &readingBodyFails{Error: expected}, + } + result, err := client.Query(context.Background(), "nonexistent") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.FQDN != "" { + t.Fatal("expected empty fqdn") + } +} + +type readingBodyFails struct { + Error error +} + +func (txp *readingBodyFails) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: &readingBodyFailsBody{Error: txp.Error}, + }, nil +} + +type readingBodyFailsBody struct { + Error error +} + +func (b *readingBodyFailsBody) Read(p []byte) (int, error) { + return 0, b.Error +} + +func (b *readingBodyFailsBody) Close() error { + return nil +} + +func TestInvalidJSON(t *testing.T) { + client := mlablocate.NewClient( + http.DefaultClient, + log.Log, + "miniooni/0.1.0-dev", + ) + client.HTTPClient = &http.Client{ + Transport: &invalidJSON{}, + } + result, err := client.Query(context.Background(), "nonexistent") + if err == nil || !strings.Contains(err.Error(), "unexpected end of JSON input") { + t.Fatal("not the error we expected") + } + if result.FQDN != "" { + t.Fatal("expected empty fqdn") + } +} + +type invalidJSON struct{} + +func (txp *invalidJSON) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: &invalidJSONBody{}, + }, nil +} + +type invalidJSONBody struct{} + +func (b *invalidJSONBody) Read(p []byte) (int, error) { + if len(p) < 1 { + return 0, errors.New("slice too short") + } + p[0] = '{' + return 1, io.EOF +} + +func (b *invalidJSONBody) Close() error { + return nil +} + +func TestEmptyFQDN(t *testing.T) { + client := mlablocate.NewClient( + http.DefaultClient, + log.Log, + "miniooni/0.1.0-dev", + ) + client.HTTPClient = &http.Client{ + Transport: &emptyFQDN{}, + } + result, err := client.Query(context.Background(), "nonexistent") + if err == nil || !strings.HasSuffix(err.Error(), "returned empty FQDN") { + t.Fatal("not the error we expected") + } + if result.FQDN != "" { + t.Fatal("expected empty fqdn") + } +} + +type emptyFQDN struct{} + +func (txp *emptyFQDN) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: &emptyFQDNBody{}, + }, nil +} + +type emptyFQDNBody struct{} + +func (b *emptyFQDNBody) Read(p []byte) (int, error) { + return copy(p, []byte(`{"fqdn":""}`)), io.EOF +} + +func (b *emptyFQDNBody) Close() error { + return nil +} diff --git a/internal/engine/internal/mlablocatev2/fake_test.go b/internal/engine/internal/mlablocatev2/fake_test.go new file mode 100644 index 0000000..0fc63ac --- /dev/null +++ b/internal/engine/internal/mlablocatev2/fake_test.go @@ -0,0 +1,45 @@ +package mlablocatev2 + +import ( + "io/ioutil" + "net/http" + "time" +) + +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() {} + +type FakeBody struct { + Data []byte + Err error +} + +func (fb FakeBody) Read(p []byte) (int, error) { + time.Sleep(10 * time.Microsecond) + return copy(p, fb.Data), fb.Err // simplifed but OK +} + +func (fb FakeBody) Close() error { + return nil +} diff --git a/internal/engine/internal/mlablocatev2/mlablocatev2.go b/internal/engine/internal/mlablocatev2/mlablocatev2.go new file mode 100644 index 0000000..cda8cf2 --- /dev/null +++ b/internal/engine/internal/mlablocatev2/mlablocatev2.go @@ -0,0 +1,152 @@ +// Package mlablocatev2 use m-lab locate services API v2. +package mlablocatev2 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "regexp" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const ( + // ndt7URLPath is the URL path to be used for ndt + ndt7URLPath = "v2/nearest/ndt/ndt7" +) + +var ( + // ErrRequestFailed indicates that the response is not "200 Ok" + ErrRequestFailed = errors.New("mlablocatev2: request failed") + + // ErrEmptyResponse indicates that no hosts were returned + ErrEmptyResponse = errors.New("mlablocatev2: empty response") +) + +// Client is a client for v2 of the locate services. +type Client struct { + HTTPClient *http.Client + Hostname string + Logger model.Logger + Scheme string + UserAgent string +} + +// NewClient creates a client for v2 of the locate services. +func NewClient(httpClient *http.Client, logger model.Logger, userAgent string) Client { + return Client{ + HTTPClient: httpClient, + Hostname: "locate.measurementlab.net", + Logger: logger, + Scheme: "https", + UserAgent: userAgent, + } +} + +// entryRecord describes one of the boxes returned by v2 of +// the locate service. It gives you the FQDN of the specific +// box along with URLs for each experiment phase. Use the +// URLs directly because they contain access tokens. +type entryRecord struct { + Machine string `json:"machine"` + URLs map[string]string `json:"urls"` +} + +var ( + // siteRegexp is the regexp to extract the site from the + // machine name when the domain is a v2 domain. + // + // Example: mlab3-mil04.mlab-oti.measurement-lab.org. + siteRegexp = regexp.MustCompile( + `^(mlab[1-4]d?)-([a-z]{3}[0-9tc]{2})\.([a-z0-9-]{1,16})\.(measurement-lab\.org)$`) +) + +// Site returns the site name. If it is not possible to determine +// the site name, we return the empty string. +func (er entryRecord) Site() string { + m := siteRegexp.FindAllStringSubmatch(er.Machine, -1) + if len(m) != 1 || len(m[0]) != 5 { + return "" + } + return m[0][2] +} + +// resultRecord is a result of a query to locate.measurementlab.net. +type resultRecord struct { + Results []entryRecord `json:"results"` +} + +// query performs a locate.measurementlab.net query +// using v2 of the locate protocol. +func (c Client) query(ctx context.Context, path string) (resultRecord, error) { + URL := &url.URL{ + Scheme: c.Scheme, + Host: c.Hostname, + Path: path, + } + req, err := http.NewRequestWithContext(ctx, "GET", URL.String(), nil) + if err != nil { + return resultRecord{}, err + } + req.Header.Add("User-Agent", c.UserAgent) + c.Logger.Debugf("mlablocatev2: GET %s", URL.String()) + resp, err := c.HTTPClient.Do(req) + if err != nil { + return resultRecord{}, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return resultRecord{}, fmt.Errorf("%w: %d", ErrRequestFailed, resp.StatusCode) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return resultRecord{}, err + } + c.Logger.Debugf("mlablocatev2: %s", string(data)) + var result resultRecord + if err := json.Unmarshal(data, &result); err != nil { + return resultRecord{}, err + } + return result, nil +} + +// NDT7Result is the result of a v2 locate services query for ndt7. +type NDT7Result struct { + Hostname string + Site string + WSSDownloadURL string + WSSUploadURL string +} + +// QueryNDT7 performs a v2 locate services query for ndt7. +func (c Client) QueryNDT7(ctx context.Context) ([]NDT7Result, error) { + out, err := c.query(ctx, ndt7URLPath) + if err != nil { + return nil, err + } + var result []NDT7Result + for _, entry := range out.Results { + r := NDT7Result{ + WSSDownloadURL: entry.URLs["wss:///ndt/v7/download"], + WSSUploadURL: entry.URLs["wss:///ndt/v7/upload"], + } + if r.WSSDownloadURL == "" || r.WSSUploadURL == "" { + continue + } + url, err := url.Parse(r.WSSDownloadURL) + if err != nil { + continue + } + r.Site = entry.Site() + r.Hostname = url.Hostname() + result = append(result, r) + } + if len(result) <= 0 { + return nil, ErrEmptyResponse + } + return result, nil +} diff --git a/internal/engine/internal/mlablocatev2/mlablocatev2_internal_test.go b/internal/engine/internal/mlablocatev2/mlablocatev2_internal_test.go new file mode 100644 index 0000000..0fceaf3 --- /dev/null +++ b/internal/engine/internal/mlablocatev2/mlablocatev2_internal_test.go @@ -0,0 +1,16 @@ +package mlablocatev2 + +import "context" + +type ResultRecord resultRecord + +func (c Client) Query(ctx context.Context, path string) (ResultRecord, error) { + out, err := c.query(ctx, path) + if err != nil { + return ResultRecord{}, err + + } + return ResultRecord(out), nil +} + +type EntryRecord = entryRecord diff --git a/internal/engine/internal/mlablocatev2/mlablocatev2_test.go b/internal/engine/internal/mlablocatev2/mlablocatev2_test.go new file mode 100644 index 0000000..19d179b --- /dev/null +++ b/internal/engine/internal/mlablocatev2/mlablocatev2_test.go @@ -0,0 +1,232 @@ +package mlablocatev2_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mlablocatev2" +) + +func TestSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + result, err := client.QueryNDT7(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result) <= 0 { + t.Fatal("unexpected empty result") + } + for _, entry := range result { + if entry.Hostname == "" { + t.Fatal("expected non empty Machine here") + } + if entry.Site == "" { + t.Fatal("expected non=-empty Site here") + } + if entry.WSSDownloadURL == "" { + t.Fatal("expected non-empty WSSDownloadURL here") + } + if _, err := url.Parse(entry.WSSDownloadURL); err != nil { + t.Fatal(err) + } + if entry.WSSUploadURL == "" { + t.Fatal("expected non-empty WSSUploadURL here") + } + if _, err := url.Parse(entry.WSSUploadURL); err != nil { + t.Fatal(err) + } + } +} + +func Test404Response(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + result, err := client.Query(context.Background(), "nonexistent") + if !errors.Is(err, mlablocatev2.ErrRequestFailed) { + t.Fatal("not the error we expected") + } + if result.Results != nil { + t.Fatal("expected empty results") + } +} + +func TestNewRequestFailure(t *testing.T) { + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + client.Hostname = "\t" + result, err := client.Query(context.Background(), "nonexistent") + if err == nil || !strings.Contains(err.Error(), "invalid URL escape") { + t.Fatal("not the error we expected") + } + if result.Results != nil { + t.Fatal("expected empty fqdn") + } +} + +func TestHTTPClientDoFailure(t *testing.T) { + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + expected := errors.New("mocked error") + client.HTTPClient = &http.Client{ + Transport: mlablocatev2.FakeTransport{Err: expected}, + } + result, err := client.Query(context.Background(), "nonexistent") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.Results != nil { + t.Fatal("expected empty fqdn") + } +} + +func TestCannotReadBody(t *testing.T) { + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + expected := errors.New("mocked error") + client.HTTPClient = &http.Client{ + Transport: mlablocatev2.FakeTransport{ + Resp: &http.Response{ + StatusCode: 200, + Body: mlablocatev2.FakeBody{ + Err: expected, + }, + }, + }, + } + result, err := client.Query(context.Background(), "nonexistent") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if result.Results != nil { + t.Fatal("expected empty fqdn") + } +} + +func TestInvalidJSON(t *testing.T) { + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + client.HTTPClient = &http.Client{ + Transport: mlablocatev2.FakeTransport{ + Resp: &http.Response{ + StatusCode: 200, + Body: mlablocatev2.FakeBody{ + Err: io.EOF, + Data: []byte(`{`), + }, + }, + }, + } + result, err := client.Query(context.Background(), "nonexistent") + if err == nil || !strings.Contains(err.Error(), "unexpected end of JSON input") { + t.Fatal("not the error we expected") + } + if result.Results != nil { + t.Fatal("expected empty fqdn") + } +} + +func TestEmptyResponse(t *testing.T) { + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + client.HTTPClient = &http.Client{ + Transport: mlablocatev2.FakeTransport{ + Resp: &http.Response{ + StatusCode: 200, + Body: mlablocatev2.FakeBody{ + Err: io.EOF, + Data: []byte(`{}`), + }, + }, + }, + } + result, err := client.QueryNDT7(context.Background()) + if !errors.Is(err, mlablocatev2.ErrEmptyResponse) { + t.Fatal("not the error we expected") + } + if result != nil { + t.Fatal("expected empty fqdn") + } +} + +func TestNDT7QueryFails(t *testing.T) { + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + client.HTTPClient = &http.Client{ + Transport: mlablocatev2.FakeTransport{ + Resp: &http.Response{ + StatusCode: 404, + Body: mlablocatev2.FakeBody{Err: io.EOF}, + }, + }, + } + result, err := client.QueryNDT7(context.Background()) + if !errors.Is(err, mlablocatev2.ErrRequestFailed) { + t.Fatal("not the error we expected") + } + if result != nil { + t.Fatal("expected empty fqdn") + } +} + +func TestNDT7InvalidURLs(t *testing.T) { + client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev") + client.HTTPClient = &http.Client{ + Transport: mlablocatev2.FakeTransport{ + Resp: &http.Response{ + StatusCode: 200, + Body: mlablocatev2.FakeBody{ + Data: []byte( + `{"results":[{"machine":"mlab3-mil04.mlab-oti.measurement-lab.org","urls":{"wss:///ndt/v7/download":":","wss:///ndt/v7/upload":":"}}]}`), + Err: io.EOF, + }, + }, + }, + } + result, err := client.QueryNDT7(context.Background()) + if !errors.Is(err, mlablocatev2.ErrEmptyResponse) { + t.Fatal("not the error we expected") + } + if result != nil { + t.Fatal("expected empty fqdn") + } +} + +func TestEntryRecordSite(t *testing.T) { + type fields struct { + Machine string + URLs map[string]string + } + tests := []struct { + name string + fields fields + want string + }{{ + name: "with invalid machine name", + fields: fields{ + Machine: "ndt-iupui-mlab3-mil02.mlab-oti.measurement-lab.org", + }, + want: "", + }, { + name: "with valid machine name", + fields: fields{ + Machine: "mlab3-mil04.mlab-oti.measurement-lab.org", + }, + want: "mil04", + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + er := mlablocatev2.EntryRecord{ + Machine: tt.fields.Machine, + URLs: tt.fields.URLs, + } + if got := er.Site(); got != tt.want { + t.Errorf("entryRecord.Site() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/engine/internal/mockable/mockable.go b/internal/engine/internal/mockable/mockable.go new file mode 100644 index 0000000..5d023e4 --- /dev/null +++ b/internal/engine/internal/mockable/mockable.go @@ -0,0 +1,191 @@ +// Package mockable contains mockable objects +package mockable + +import ( + "context" + "net/http" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore" + "github.com/ooni/probe-cli/v3/internal/engine/internal/psiphonx" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/internal/torx" + "github.com/ooni/probe-cli/v3/internal/engine/internal/tunnel" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra" +) + +// Session allows to mock sessions. +type Session struct { + MockableASNDatabasePath string + MockableTestHelpers map[string][]model.Service + MockableHTTPClient *http.Client + MockableLogger model.Logger + MockableMaybeResolverIP string + MockableOrchestraClient model.ExperimentOrchestraClient + MockableOrchestraClientError error + MockableProbeASNString string + MockableProbeCC string + MockableProbeIP string + MockableProbeNetworkName string + MockableProxyURL *url.URL + MockableResolverIP string + MockableSoftwareName string + MockableSoftwareVersion string + MockableTempDir string + MockableTorArgs []string + MockableTorBinary string + MockableUserAgent string +} + +// ASNDatabasePath implements ExperimentSession.ASNDatabasePath +func (sess *Session) ASNDatabasePath() string { + return sess.MockableASNDatabasePath +} + +// GetTestHelpersByName implements ExperimentSession.GetTestHelpersByName +func (sess *Session) GetTestHelpersByName(name string) ([]model.Service, bool) { + services, okay := sess.MockableTestHelpers[name] + return services, okay +} + +// DefaultHTTPClient implements ExperimentSession.DefaultHTTPClient +func (sess *Session) DefaultHTTPClient() *http.Client { + return sess.MockableHTTPClient +} + +// KeyValueStore returns the configured key-value store. +func (sess *Session) KeyValueStore() model.KeyValueStore { + return kvstore.NewMemoryKeyValueStore() +} + +// Logger implements ExperimentSession.Logger +func (sess *Session) Logger() model.Logger { + return sess.MockableLogger +} + +// MaybeResolverIP implements ExperimentSession.MaybeResolverIP. +func (sess *Session) MaybeResolverIP() string { + return sess.MockableMaybeResolverIP +} + +// NewOrchestraClient implements ExperimentSession.NewOrchestraClient +func (sess *Session) NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error) { + if sess.MockableOrchestraClient != nil { + return sess.MockableOrchestraClient, nil + } + if sess.MockableOrchestraClientError != nil { + return nil, sess.MockableOrchestraClientError + } + clnt, err := probeservices.NewClient(sess, model.Service{ + Address: "https://ams-pg-test.ooni.org/", + Type: "https", + }) + runtimex.PanicOnError(err, "orchestra.NewClient should not fail here") + meta := testorchestra.MetadataFixture() + if err := clnt.MaybeRegister(ctx, meta); err != nil { + return nil, err + } + if err := clnt.MaybeLogin(ctx); err != nil { + return nil, err + } + return clnt, nil +} + +// ProbeASNString implements ExperimentSession.ProbeASNString +func (sess *Session) ProbeASNString() string { + return sess.MockableProbeASNString +} + +// ProbeCC implements ExperimentSession.ProbeCC +func (sess *Session) ProbeCC() string { + return sess.MockableProbeCC +} + +// ProbeIP implements ExperimentSession.ProbeIP +func (sess *Session) ProbeIP() string { + return sess.MockableProbeIP +} + +// ProbeNetworkName implements ExperimentSession.ProbeNetworkName +func (sess *Session) ProbeNetworkName() string { + return sess.MockableProbeNetworkName +} + +// ProxyURL implements ExperimentSession.ProxyURL +func (sess *Session) ProxyURL() *url.URL { + return sess.MockableProxyURL +} + +// ResolverIP implements ExperimentSession.ResolverIP +func (sess *Session) ResolverIP() string { + return sess.MockableResolverIP +} + +// SoftwareName implements ExperimentSession.SoftwareName +func (sess *Session) SoftwareName() string { + return sess.MockableSoftwareName +} + +// SoftwareVersion implements ExperimentSession.SoftwareVersion +func (sess *Session) SoftwareVersion() string { + return sess.MockableSoftwareVersion +} + +// TempDir implements ExperimentSession.TempDir +func (sess *Session) TempDir() string { + return sess.MockableTempDir +} + +// TorArgs implements ExperimentSession.TorArgs. +func (sess *Session) TorArgs() []string { + return sess.MockableTorArgs +} + +// TorBinary implements ExperimentSession.TorBinary. +func (sess *Session) TorBinary() string { + return sess.MockableTorBinary +} + +// UserAgent implements ExperimentSession.UserAgent +func (sess *Session) UserAgent() string { + return sess.MockableUserAgent +} + +var _ model.ExperimentSession = &Session{} +var _ probeservices.Session = &Session{} +var _ psiphonx.Session = &Session{} +var _ tunnel.Session = &Session{} +var _ torx.Session = &Session{} + +// ExperimentOrchestraClient is the experiment's view of +// a client for querying the OONI orchestra. +type ExperimentOrchestraClient struct { + MockableFetchPsiphonConfigResult []byte + MockableFetchPsiphonConfigErr error + MockableFetchTorTargetsResult map[string]model.TorTarget + MockableFetchTorTargetsErr error + MockableFetchURLListResult []model.URLInfo + MockableFetchURLListErr error +} + +// FetchPsiphonConfig implements ExperimentOrchestraClient.FetchPsiphonConfig +func (c ExperimentOrchestraClient) FetchPsiphonConfig( + ctx context.Context) ([]byte, error) { + return c.MockableFetchPsiphonConfigResult, c.MockableFetchPsiphonConfigErr +} + +// FetchTorTargets implements ExperimentOrchestraClient.TorTargets +func (c ExperimentOrchestraClient) FetchTorTargets( + ctx context.Context, cc string) (map[string]model.TorTarget, error) { + return c.MockableFetchTorTargetsResult, c.MockableFetchTorTargetsErr +} + +// FetchURLList implements ExperimentOrchestraClient.FetchURLList. +func (c ExperimentOrchestraClient) FetchURLList( + ctx context.Context, config model.URLListConfig) ([]model.URLInfo, error) { + return c.MockableFetchURLListResult, c.MockableFetchURLListErr +} + +var _ model.ExperimentOrchestraClient = ExperimentOrchestraClient{} diff --git a/internal/engine/internal/multierror/multierror.go b/internal/engine/internal/multierror/multierror.go new file mode 100644 index 0000000..2440633 --- /dev/null +++ b/internal/engine/internal/multierror/multierror.go @@ -0,0 +1,66 @@ +// Package multierror contains code to manage multiple errors. +package multierror + +import ( + "errors" + "fmt" + "strings" +) + +// Union is the logical union of several errors. The Union will +// appear to be the Root error, except that it will actually +// be possible to look deeper and see specific sub errors that +// occurred using errors.As and errors.Is. +type Union struct { + Children []error + Root error +} + +// New creates a new Union error instance. +func New(root error) *Union { + return &Union{Root: root} +} + +// Unwrap returns the Root error of the Union error. +func (err Union) Unwrap() error { + return err.Root +} + +// Add adds the specified child error to the Union error. +func (err *Union) Add(child error) { + err.Children = append(err.Children, child) +} + +// AddWithPrefix adds the specified child error to the Union error +// with the specified prefix before the child error. +func (err *Union) AddWithPrefix(prefix string, child error) { + err.Add(fmt.Errorf("%s: %w", prefix, child)) +} + +// Is returns whether the Union error contains at least one child +// error that is exactly the specified target error. +func (err Union) Is(target error) bool { + if errors.Is(err.Root, target) { + return true + } + for _, c := range err.Children { + if errors.Is(c, target) { + return true + } + } + return false +} + +// Error returns a string representation of the Union error. +func (err Union) Error() string { + var sb strings.Builder + sb.WriteString(err.Root.Error()) + sb.WriteString(": [") + for _, c := range err.Children { + sb.WriteString(" ") + sb.WriteString(c.Error()) + sb.WriteString(";") + } + sb.WriteString("]") + return sb.String() +} diff --git a/internal/engine/internal/multierror/multierror_test.go b/internal/engine/internal/multierror/multierror_test.go new file mode 100644 index 0000000..bb973db --- /dev/null +++ b/internal/engine/internal/multierror/multierror_test.go @@ -0,0 +1,85 @@ +package multierror_test + +import ( + "context" + "errors" + "fmt" + "io" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/internal/multierror" +) + +func TestEmpty(t *testing.T) { + root := errors.New("antani") + var err error = multierror.New(root) + if err.Error() != "antani: []" { + t.Fatal("unexpected Error value") + } + if !errors.Is(err, root) { + t.Fatal("error should be root") + } + if !errors.Is(errors.Unwrap(err), root) { + t.Fatal("unwrapping did not return root") + } + if errors.Is(err, io.EOF) { + t.Fatal("error should not be EOF") + } +} + +func TestNonEmpty(t *testing.T) { + root := errors.New("antani") + container := multierror.New(root) + container.AddWithPrefix("first operation failed", io.EOF) + container.AddWithPrefix("second operation failed", context.Canceled) + var err error = container + expect := "antani: [ first operation failed: EOF; second operation failed: context canceled;]" + if diff := cmp.Diff(err.Error(), expect); diff != "" { + t.Fatal(diff) + } + if !errors.Is(err, root) { + t.Fatal("error should be root") + } + if !errors.Is(errors.Unwrap(err), root) { + t.Fatal("unwrapping did not return root") + } + if !errors.Is(err, io.EOF) { + t.Fatal("error should be EOF") + } + if !errors.Is(err, context.Canceled) { + t.Fatal("error should be context.Canceled") + } + var as *multierror.Union + if !errors.As(err, &as) { + t.Fatal("cannot cast error to multierror.Union") + } + if !errors.Is(as.Root, root) { + t.Fatal("unexpected root") + } + if len(as.Children) != 2 { + t.Fatal("unexpected number of children") + } +} + +type SpecificRootError struct { + Value int +} + +func (sre SpecificRootError) Error() string { + return fmt.Sprintf("%d", sre.Value) +} + +func TestAsWorksForRoot(t *testing.T) { + const expected = 144 + var ( + err error = multierror.New(&SpecificRootError{Value: expected}) + sre *SpecificRootError + ) + if !errors.As(err, &sre) { + t.Fatal("cannot cast error to original type") + } + if sre.Value != expected { + t.Fatal("unexpected sre.Value") + } +} diff --git a/internal/engine/internal/platform/platform.go b/internal/engine/internal/platform/platform.go new file mode 100644 index 0000000..5641331 --- /dev/null +++ b/internal/engine/internal/platform/platform.go @@ -0,0 +1,46 @@ +// Package platform returns the platform name. The name returned here +// is compatible with the names returned by Measurement Kit. +package platform + +import "runtime" + +// Name returns the platform name. The returned value is one of: +// +// 1. "android" +// 2. "ios" +// 3. "linux" +// 5. "macos" +// 4. "windows" +// 5. "unknown" +// +// The android, ios, linux, macos, windows, and unknown strings are +// also returned by Measurement Kit. As a known bug, the detection of +// darwin-based systems relies on the architecture, when CGO support +// has been disabled. In such case, the code will return "ios" when +// using arm{,64} and "macos" when using x86{,_64}. +func Name() string { + if name := cgoname(); name != "unknown" { + return name + } + return puregoname(runtime.GOOS, runtime.GOARCH) +} + +func puregoname(goos, goarch string) string { + switch goos { + case "android", "linux", "windows": + return goos + case "darwin": + return detectDarwin(goarch) + } + return "unknown" +} + +func detectDarwin(goarch string) string { + switch goarch { + case "386", "amd64": + return "macos" + case "arm", "arm64": + return "ios" + } + return "unknown" +} diff --git a/internal/engine/internal/platform/platform_cgo.go b/internal/engine/internal/platform/platform_cgo.go new file mode 100644 index 0000000..192293a --- /dev/null +++ b/internal/engine/internal/platform/platform_cgo.go @@ -0,0 +1,31 @@ +// +build cgo + +package platform + +// +// /* Guess the platform in which we are. +// +// See: +// */ +// +//#if defined __ANDROID__ +//# define OONI_PLATFORM "android" +//#elif defined __linux__ +//# define OONI_PLATFORM "linux" +//#elif defined _WIN32 +//# define OONI_PLATFORM "windows" +//#elif defined __APPLE__ +//# include +//# if TARGET_OS_IPHONE +//# define OONI_PLATFORM "ios" +//# else +//# define OONI_PLATFORM "macos" +//# endif +//#else +//# define OONI_PLATFORM "unknown" +//#endif +import "C" + +func cgoname() string { + return C.OONI_PLATFORM +} diff --git a/internal/engine/internal/platform/platform_otherwise.go b/internal/engine/internal/platform/platform_otherwise.go new file mode 100644 index 0000000..b7a7b25 --- /dev/null +++ b/internal/engine/internal/platform/platform_otherwise.go @@ -0,0 +1,7 @@ +// +build !cgo + +package platform + +func cgoname() string { + return "unknown" +} diff --git a/internal/engine/internal/platform/platform_test.go b/internal/engine/internal/platform/platform_test.go new file mode 100644 index 0000000..e759070 --- /dev/null +++ b/internal/engine/internal/platform/platform_test.go @@ -0,0 +1,68 @@ +package platform + +import ( + "fmt" + "testing" +) + +func TestGood(t *testing.T) { + var expected bool + switch Name() { + case "android", "ios", "linux", "macos", "windows": + expected = true + } + if !expected { + t.Fatal("unexpected platform name") + } +} + +func TestPuregoname(t *testing.T) { + var runtimevariables = []struct { + expected string + goarch string + goos string + }{{ + expected: "android", + goarch: "*", + goos: "android", + }, { + expected: "ios", + goarch: "arm64", + goos: "darwin", + }, { + expected: "ios", + goarch: "arm", + goos: "darwin", + }, { + expected: "linux", + goarch: "*", + goos: "linux", + }, { + expected: "macos", + goarch: "amd64", + goos: "darwin", + }, { + expected: "macos", + goarch: "386", + goos: "darwin", + }, { + expected: "unknown", + goarch: "*", + goos: "solaris", + }, { + expected: "unknown", + goarch: "mips", + goos: "darwin", + }, { + expected: "windows", + goarch: "*", + goos: "windows", + }} + for _, v := range runtimevariables { + t.Run(fmt.Sprintf("with %s/%s", v.goos, v.goarch), func(t *testing.T) { + if puregoname(v.goos, v.goarch) != v.expected { + t.Fatal("unexpected results") + } + }) + } +} diff --git a/internal/engine/internal/psiphonx/psiphonx.go b/internal/engine/internal/psiphonx/psiphonx.go new file mode 100644 index 0000000..c7ba9c1 --- /dev/null +++ b/internal/engine/internal/psiphonx/psiphonx.go @@ -0,0 +1,136 @@ +// Package psiphonx is a wrapper around the psiphon-tunnel-core. +package psiphonx + +import ( + "context" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/psiphon/oopsi/github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib" +) + +// Session is the way in which this package sees a Session. +type Session interface { + NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error) + TempDir() string +} + +// Dependencies contains dependencies for Start +type Dependencies interface { + MkdirAll(path string, perm os.FileMode) error + RemoveAll(path string) error + Start(ctx context.Context, config []byte, + workdir string) (*clientlib.PsiphonTunnel, error) +} + +type defaultDependencies struct{} + +func (defaultDependencies) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (defaultDependencies) RemoveAll(path string) error { + return os.RemoveAll(path) +} + +func (defaultDependencies) Start( + ctx context.Context, config []byte, workdir string) (*clientlib.PsiphonTunnel, error) { + return clientlib.StartTunnel(ctx, config, "", clientlib.Parameters{ + DataRootDirectory: &workdir}, nil, nil) +} + +// Config contains the settings for Start. The empty config object implies +// that we will be using default settings for starting the tunnel. +type Config struct { + // Dependencies contains dependencies for Start. + Dependencies Dependencies + + // WorkDir is the directory where Psiphon should store + // its configuration database. + WorkDir string +} + +// Tunnel is a psiphon tunnel +type Tunnel struct { + tunnel *clientlib.PsiphonTunnel + duration time.Duration +} + +func makeworkingdir(config Config) (string, error) { + const testdirname = "oonipsiphon" + workdir := filepath.Join(config.WorkDir, testdirname) + if err := config.Dependencies.RemoveAll(workdir); err != nil { + return "", err + } + if err := config.Dependencies.MkdirAll(workdir, 0700); err != nil { + return "", err + } + return workdir, nil +} + +// Start starts the psiphon tunnel. +func Start( + ctx context.Context, sess Session, config Config) (*Tunnel, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() // simplifies unit testing this code + default: + } + if config.Dependencies == nil { + config.Dependencies = defaultDependencies{} + } + if config.WorkDir == "" { + config.WorkDir = sess.TempDir() + } + clnt, err := sess.NewOrchestraClient(ctx) + if err != nil { + return nil, err + } + configJSON, err := clnt.FetchPsiphonConfig(ctx) + if err != nil { + return nil, err + } + workdir, err := makeworkingdir(config) + if err != nil { + return nil, err + } + start := time.Now() + tunnel, err := config.Dependencies.Start(ctx, configJSON, workdir) + if err != nil { + return nil, err + } + stop := time.Now() + return &Tunnel{tunnel: tunnel, duration: stop.Sub(start)}, nil +} + +// Stop is an idempotent method that shuts down the tunnel +func (t *Tunnel) Stop() { + if t != nil { + t.tunnel.Stop() + } +} + +// SOCKS5ProxyURL returns the SOCKS5 proxy URL. +func (t *Tunnel) SOCKS5ProxyURL() (proxyURL *url.URL) { + if t != nil { + proxyURL = &url.URL{ + Scheme: "socks5", + Host: net.JoinHostPort( + "127.0.0.1", fmt.Sprintf("%d", t.tunnel.SOCKSProxyPort)), + } + } + return +} + +// BootstrapTime returns the bootstrap time +func (t *Tunnel) BootstrapTime() (duration time.Duration) { + if t != nil { + duration = t.duration + } + return +} diff --git a/internal/engine/internal/psiphonx/psiphonx_test.go b/internal/engine/internal/psiphonx/psiphonx_test.go new file mode 100644 index 0000000..7f7da19 --- /dev/null +++ b/internal/engine/internal/psiphonx/psiphonx_test.go @@ -0,0 +1,188 @@ +package psiphonx_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/ooni/psiphon/oopsi/github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib" + "github.com/apex/log" + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/internal/psiphonx" +) + +func TestStartWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + sess, err := engine.NewSession(engine.SessionConfig{ + AssetsDir: "../../testdata", + Logger: log.Log, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + tunnel, err := psiphonx.Start(ctx, sess, psiphonx.Config{}) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestStartStop(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := engine.NewSession(engine.SessionConfig{ + AssetsDir: "../../testdata", + Logger: log.Log, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{}) + if err != nil { + t.Fatal(err) + } + if tunnel.SOCKS5ProxyURL() == nil { + t.Fatal("expected non nil URL here") + } + if tunnel.BootstrapTime() <= 0 { + t.Fatal("expected positive bootstrap time here") + } + tunnel.Stop() +} + +func TestNewOrchestraClientFailure(t *testing.T) { + expected := errors.New("mocked error") + sess := &mockable.Session{ + MockableOrchestraClientError: expected, + } + tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{}) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestFetchPsiphonConfigFailure(t *testing.T) { + expected := errors.New("mocked error") + clnt := mockable.ExperimentOrchestraClient{ + MockableFetchPsiphonConfigErr: expected, + } + sess := &mockable.Session{ + MockableOrchestraClient: clnt, + } + tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{}) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestMakeMkdirAllFailure(t *testing.T) { + expected := errors.New("mocked error") + dependencies := FakeDependencies{ + MkdirAllErr: expected, + } + clnt := mockable.ExperimentOrchestraClient{ + MockableFetchPsiphonConfigResult: []byte(`{}`), + } + sess := &mockable.Session{ + MockableOrchestraClient: clnt, + } + tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{ + Dependencies: dependencies, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestMakeRemoveAllFailure(t *testing.T) { + expected := errors.New("mocked error") + dependencies := FakeDependencies{ + RemoveAllErr: expected, + } + clnt := mockable.ExperimentOrchestraClient{ + MockableFetchPsiphonConfigResult: []byte(`{}`), + } + sess := &mockable.Session{ + MockableOrchestraClient: clnt, + } + tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{ + Dependencies: dependencies, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestMakeStartFailure(t *testing.T) { + expected := errors.New("mocked error") + dependencies := FakeDependencies{ + StartErr: expected, + } + clnt := mockable.ExperimentOrchestraClient{ + MockableFetchPsiphonConfigResult: []byte(`{}`), + } + sess := &mockable.Session{ + MockableOrchestraClient: clnt, + } + tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{ + Dependencies: dependencies, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestNilTunnel(t *testing.T) { + var tunnel *psiphonx.Tunnel + if tunnel.BootstrapTime() != 0 { + t.Fatal("expected zero bootstrap time") + } + if tunnel.SOCKS5ProxyURL() != nil { + t.Fatal("expected nil SOCKS Proxy URL") + } + tunnel.Stop() // must not crash +} + +type FakeDependencies struct { + MkdirAllErr error + RemoveAllErr error + StartErr error +} + +func (fd FakeDependencies) MkdirAll(path string, perm os.FileMode) error { + return fd.MkdirAllErr +} + +func (fd FakeDependencies) RemoveAll(path string) error { + return fd.RemoveAllErr +} + +func (fd FakeDependencies) Start( + ctx context.Context, config []byte, workdir string) (*clientlib.PsiphonTunnel, error) { + return nil, fd.StartErr +} diff --git a/internal/engine/internal/randx/randx.go b/internal/engine/internal/randx/randx.go new file mode 100644 index 0000000..7d240e9 --- /dev/null +++ b/internal/engine/internal/randx/randx.go @@ -0,0 +1,50 @@ +// Package randx contains math/rand extensions +package randx + +import ( + "math/rand" + "time" + "unicode" +) + +const ( + uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lowercase = "abcdefghijklmnopqrstuvwxyz" + letters = uppercase + lowercase +) + +func lettersWithString(n int, letterBytes string) string { + // See https://stackoverflow.com/questions/22892120 + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rnd.Intn(len(letterBytes))] + } + return string(b) +} + +// Letters return a string composed of random letters +func Letters(n int) string { + return lettersWithString(n, letters) +} + +// LettersUppercase return a string composed of random uppercase letters +func LettersUppercase(n int) string { + return lettersWithString(n, uppercase) +} + +// ChangeCapitalization returns a new string where the capitalization +// of each character is changed at random. +func ChangeCapitalization(source string) (dest string) { + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + for _, chr := range source { + if unicode.IsLower(chr) && rnd.Float64() <= 0.5 { + dest += string(unicode.ToUpper(chr)) + } else if unicode.IsUpper(chr) && rnd.Float64() <= 0.5 { + dest += string(unicode.ToLower(chr)) + } else { + dest += string(chr) + } + } + return +} diff --git a/internal/engine/internal/randx/randx_test.go b/internal/engine/internal/randx/randx_test.go new file mode 100644 index 0000000..e8c8417 --- /dev/null +++ b/internal/engine/internal/randx/randx_test.go @@ -0,0 +1,34 @@ +package randx_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/randx" +) + +func TestLetters(t *testing.T) { + str := randx.Letters(1024) + for _, chr := range str { + if (chr >= 'A' && chr <= 'Z') || (chr >= 'a' && chr <= 'z') { + continue + } + t.Fatal("invalid input char") + } +} + +func TestLettersUppercase(t *testing.T) { + str := randx.LettersUppercase(1024) + for _, chr := range str { + if chr >= 'A' && chr <= 'Z' { + continue + } + t.Fatal("invalid input char") + } +} + +func TestChangeCapitalization(t *testing.T) { + str := randx.Letters(2048) + if randx.ChangeCapitalization(str) == str { + t.Fatal("capitalization not changed") + } +} diff --git a/internal/engine/internal/runtimex/runtimex.go b/internal/engine/internal/runtimex/runtimex.go new file mode 100644 index 0000000..79dc5eb --- /dev/null +++ b/internal/engine/internal/runtimex/runtimex.go @@ -0,0 +1,12 @@ +// Package runtimex contains runtime extensions. This package is inspired to the excellent +// github.com/m-lab/rtx package, except that it's simpler. +package runtimex + +import "fmt" + +// PanicOnError panics if err is not nil. +func PanicOnError(err error, message string) { + if err != nil { + panic(fmt.Errorf("%s: %w", message, err)) + } +} diff --git a/internal/engine/internal/runtimex/runtimex_test.go b/internal/engine/internal/runtimex/runtimex_test.go new file mode 100644 index 0000000..7353645 --- /dev/null +++ b/internal/engine/internal/runtimex/runtimex_test.go @@ -0,0 +1,27 @@ +package runtimex_test + +import ( + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" +) + +func TestGood(t *testing.T) { + runtimex.PanicOnError(nil, "antani failed") +} + +func TestBad(t *testing.T) { + expected := errors.New("mocked error") + if !errors.Is(badfunc(expected), expected) { + t.Fatal("not the error we expected") + } +} + +func badfunc(in error) (out error) { + defer func() { + out = recover().(error) + }() + runtimex.PanicOnError(in, "antani failed") + return +} diff --git a/internal/engine/internal/sessionresolver/sessionresolver.go b/internal/engine/internal/sessionresolver/sessionresolver.go new file mode 100644 index 0000000..162ea97 --- /dev/null +++ b/internal/engine/internal/sessionresolver/sessionresolver.go @@ -0,0 +1,85 @@ +// Package sessionresolver contains the resolver used by the session. This +// resolver uses Powerdns DoH by default and falls back on the system +// provided resolver if Powerdns DoH is not working. +package sessionresolver + +import ( + "context" + "fmt" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +// Resolver is the session resolver. +type Resolver struct { + Primary netx.DNSClient + PrimaryFailure *atomicx.Int64 + PrimaryQuery *atomicx.Int64 + Fallback netx.DNSClient + FallbackFailure *atomicx.Int64 + FallbackQuery *atomicx.Int64 +} + +// New creates a new session resolver. +func New(config netx.Config) *Resolver { + primary, err := netx.NewDNSClientWithOverrides(config, + "https://cloudflare.com/dns-query", "dns.cloudflare.com", "", "") + runtimex.PanicOnError(err, "cannot create dns over https resolver") + fallback, err := netx.NewDNSClient(config, "system:///") + runtimex.PanicOnError(err, "cannot create system resolver") + return &Resolver{ + Primary: primary, + PrimaryFailure: atomicx.NewInt64(), + PrimaryQuery: atomicx.NewInt64(), + Fallback: fallback, + FallbackFailure: atomicx.NewInt64(), + FallbackQuery: atomicx.NewInt64(), + } +} + +// CloseIdleConnections closes the idle connections, if any +func (r *Resolver) CloseIdleConnections() { + r.Primary.CloseIdleConnections() + r.Fallback.CloseIdleConnections() +} + +// Stats returns stats about the session resolver. +func (r *Resolver) Stats() string { + return fmt.Sprintf("sessionresolver: failure rate: primary: %d/%d; fallback: %d/%d", + r.PrimaryFailure.Load(), r.PrimaryQuery.Load(), + r.FallbackFailure.Load(), r.FallbackQuery.Load()) +} + +// LookupHost implements Resolver.LookupHost +func (r *Resolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + // Algorithm similar to Firefox TRR2 mode. See: + // https://wiki.mozilla.org/Trusted_Recursive_Resolver#DNS-over-HTTPS_Prefs_in_Firefox + // We use a higher timeout than Firefox's timeout (1.5s) to be on the safe side + // and therefore see to use DoH more often. + r.PrimaryQuery.Add(1) + trr2, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + addrs, err := r.Primary.LookupHost(trr2, hostname) + if err != nil { + r.PrimaryFailure.Add(1) + r.FallbackQuery.Add(1) + addrs, err = r.Fallback.LookupHost(ctx, hostname) + if err != nil { + r.FallbackFailure.Add(1) + } + } + return addrs, err +} + +// Network implements Resolver.Network +func (r *Resolver) Network() string { + return "sessionresolver" +} + +// Address implements Resolver.Address +func (r *Resolver) Address() string { + return "" +} diff --git a/internal/engine/internal/sessionresolver/sessionresolver_test.go b/internal/engine/internal/sessionresolver/sessionresolver_test.go new file mode 100644 index 0000000..727dd26 --- /dev/null +++ b/internal/engine/internal/sessionresolver/sessionresolver_test.go @@ -0,0 +1,31 @@ +package sessionresolver_test + +import ( + "context" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/sessionresolver" + "github.com/ooni/probe-cli/v3/internal/engine/netx" +) + +func TestFallbackWorks(t *testing.T) { + reso := sessionresolver.New(netx.Config{}) + defer reso.CloseIdleConnections() + if reso.Network() != "sessionresolver" { + t.Fatal("unexpected Network") + } + if reso.Address() != "" { + t.Fatal("unexpected Address") + } + addrs, err := reso.LookupHost(context.Background(), "antani.ooni.nu") + if err == nil || !strings.HasSuffix(err.Error(), "no such host") { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs here") + } + if reso.PrimaryFailure.Load() != 1 || reso.FallbackFailure.Load() != 1 { + t.Fatal("not the counters we expected to see here") + } +} diff --git a/internal/engine/internal/tlsx/tlsx.go b/internal/engine/internal/tlsx/tlsx.go new file mode 100644 index 0000000..0b1bbb8 --- /dev/null +++ b/internal/engine/internal/tlsx/tlsx.go @@ -0,0 +1,63 @@ +// Package tlsx contains TLS extensions +package tlsx + +import ( + "crypto/tls" + "fmt" +) + +var ( + tlsVersionString = map[uint16]string{ + tls.VersionSSL30: "SSLv3", + tls.VersionTLS10: "TLSv1", + tls.VersionTLS11: "TLSv1.1", + tls.VersionTLS12: "TLSv1.2", + tls.VersionTLS13: "TLSv1.3", + 0: "", // guarantee correct behaviour + } + + tlsCipherSuiteString = map[uint16]string{ + tls.TLS_RSA_WITH_RC4_128_SHA: "TLS_RSA_WITH_RC4_128_SHA", + tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", + tls.TLS_RSA_WITH_AES_128_CBC_SHA: "TLS_RSA_WITH_AES_128_CBC_SHA", + tls.TLS_RSA_WITH_AES_256_CBC_SHA: "TLS_RSA_WITH_AES_256_CBC_SHA", + tls.TLS_RSA_WITH_AES_128_CBC_SHA256: "TLS_RSA_WITH_AES_128_CBC_SHA256", + tls.TLS_RSA_WITH_AES_128_GCM_SHA256: "TLS_RSA_WITH_AES_128_GCM_SHA256", + tls.TLS_RSA_WITH_AES_256_GCM_SHA384: "TLS_RSA_WITH_AES_256_GCM_SHA384", + tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", + tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA: "TLS_ECDHE_RSA_WITH_RC4_128_SHA", + tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + tls.TLS_AES_128_GCM_SHA256: "TLS_AES_128_GCM_SHA256", + tls.TLS_AES_256_GCM_SHA384: "TLS_AES_256_GCM_SHA384", + tls.TLS_CHACHA20_POLY1305_SHA256: "TLS_CHACHA20_POLY1305_SHA256", + 0: "", // guarantee correct behaviour + } +) + +// VersionString returns a TLS version string. +func VersionString(value uint16) string { + if str, found := tlsVersionString[value]; found { + return str + } + return fmt.Sprintf("TLS_VERSION_UNKNOWN_%d", value) +} + +// CipherSuiteString returns the TLS cipher suite as a string. +func CipherSuiteString(value uint16) string { + if str, found := tlsCipherSuiteString[value]; found { + return str + } + return fmt.Sprintf("TLS_CIPHER_SUITE_UNKNOWN_%d", value) +} diff --git a/internal/engine/internal/tlsx/tlsx_test.go b/internal/engine/internal/tlsx/tlsx_test.go new file mode 100644 index 0000000..5ef4b85 --- /dev/null +++ b/internal/engine/internal/tlsx/tlsx_test.go @@ -0,0 +1,30 @@ +package tlsx + +import ( + "crypto/tls" + "testing" +) + +func TestVersionString(t *testing.T) { + if VersionString(tls.VersionTLS13) != "TLSv1.3" { + t.Fatal("not working for existing version") + } + if VersionString(1) != "TLS_VERSION_UNKNOWN_1" { + t.Fatal("not working for nonexisting version") + } + if VersionString(0) != "" { + t.Fatal("not working for zero version") + } +} + +func TestCipherSuite(t *testing.T) { + if CipherSuiteString(tls.TLS_AES_128_GCM_SHA256) != "TLS_AES_128_GCM_SHA256" { + t.Fatal("not working for existing cipher suite") + } + if CipherSuiteString(1) != "TLS_CIPHER_SUITE_UNKNOWN_1" { + t.Fatal("not working for nonexisting cipher suite") + } + if CipherSuiteString(0) != "" { + t.Fatal("not working for zero cipher suite") + } +} diff --git a/internal/engine/internal/torx/torx.go b/internal/engine/internal/torx/torx.go new file mode 100644 index 0000000..6a1ae46 --- /dev/null +++ b/internal/engine/internal/torx/torx.go @@ -0,0 +1,137 @@ +// Package torx contains code to control tor. +package torx + +import ( + "context" + "fmt" + "net/url" + "path" + "strings" + "time" + + "github.com/cretz/bine/control" + "github.com/cretz/bine/tor" +) + +// Session is the way in which this package sees a Session. +type Session interface { + TempDir() string + TorArgs() []string + TorBinary() string +} + +// TorProcess is a running tor process +type TorProcess interface { + Close() error +} + +// Tunnel is the Tor tunnel +type Tunnel struct { + bootstrapTime time.Duration + instance TorProcess + proxy *url.URL +} + +// BootstrapTime is the bootstrsap time +func (tt *Tunnel) BootstrapTime() (duration time.Duration) { + if tt != nil { + duration = tt.bootstrapTime + } + return +} + +// SOCKS5ProxyURL returns the URL of the SOCKS5 proxy +func (tt *Tunnel) SOCKS5ProxyURL() (url *url.URL) { + if tt != nil { + url = tt.proxy + } + return +} + +// Stop stops the Tor tunnel +func (tt *Tunnel) Stop() { + if tt != nil { + tt.instance.Close() + } +} + +// StartConfig contains the configuration for StartWithConfig +type StartConfig struct { + Sess Session + Start func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) + EnableNetwork func(ctx context.Context, tor *tor.Tor, wait bool) error + GetInfo func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) +} + +// Start starts the tor tunnel +func Start(ctx context.Context, sess Session) (*Tunnel, error) { + return StartWithConfig(ctx, StartConfig{ + Sess: sess, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return tor.Start(ctx, conf) + }, + EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error { + return tor.EnableNetwork(ctx, wait) + }, + GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) { + return ctrl.GetInfo(keys...) + }, + }) +} + +// StartWithConfig is a configurable Start for testing +func StartWithConfig(ctx context.Context, config StartConfig) (*Tunnel, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() // allows to write unit tests using this code + default: + } + logfile := LogFile(config.Sess) + extraArgs := append([]string{}, config.Sess.TorArgs()...) + extraArgs = append(extraArgs, "Log") + extraArgs = append(extraArgs, "notice stderr") + extraArgs = append(extraArgs, "Log") + extraArgs = append(extraArgs, fmt.Sprintf(`notice file %s`, logfile)) + instance, err := config.Start(ctx, &tor.StartConf{ + DataDir: path.Join(config.Sess.TempDir(), "tor"), + ExtraArgs: extraArgs, + ExePath: config.Sess.TorBinary(), + NoHush: true, + }) + if err != nil { + return nil, err + } + instance.StopProcessOnClose = true + start := time.Now() + if err := config.EnableNetwork(ctx, instance, true); err != nil { + instance.Close() + return nil, err + } + stop := time.Now() + // Adapted from + info, err := config.GetInfo(instance.Control, "net/listeners/socks") + if err != nil { + instance.Close() + return nil, err + } + if len(info) != 1 || info[0].Key != "net/listeners/socks" { + instance.Close() + return nil, fmt.Errorf("unable to get socks proxy address") + } + proxyAddress := info[0].Val + if strings.HasPrefix(proxyAddress, "unix:") { + instance.Close() + return nil, fmt.Errorf("tor returned unsupported proxy") + } + return &Tunnel{ + bootstrapTime: stop.Sub(start), + instance: instance, + proxy: &url.URL{Scheme: "socks5", Host: proxyAddress}, + }, nil +} + +// LogFile returns the name of tor logs given a specific session. The file +// is always located somewhere inside the sess.TempDir() directory. +func LogFile(sess Session) string { + return path.Join(sess.TempDir(), "tor.log") +} diff --git a/internal/engine/internal/torx/torx_internal_test.go b/internal/engine/internal/torx/torx_internal_test.go new file mode 100644 index 0000000..8129e21 --- /dev/null +++ b/internal/engine/internal/torx/torx_internal_test.go @@ -0,0 +1,14 @@ +package torx + +import ( + "net/url" + "time" +) + +func NewTunnel(bootstrapTime time.Duration, instance TorProcess, proxy *url.URL) *Tunnel { + return &Tunnel{ + bootstrapTime: bootstrapTime, + instance: instance, + proxy: proxy, + } +} diff --git a/internal/engine/internal/torx/torx_test.go b/internal/engine/internal/torx/torx_test.go new file mode 100644 index 0000000..a7244c7 --- /dev/null +++ b/internal/engine/internal/torx/torx_test.go @@ -0,0 +1,209 @@ +package torx_test + +import ( + "context" + "errors" + "net/url" + "testing" + + "github.com/cretz/bine/control" + "github.com/cretz/bine/tor" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/internal/torx" +) + +type Closer struct { + counter int +} + +func (c *Closer) Close() error { + c.counter++ + return errors.New("mocked mocked mocked") +} + +func TestTunnelNonNil(t *testing.T) { + closer := new(Closer) + proxy := &url.URL{Scheme: "x", Host: "10.0.0.1:443"} + tun := torx.NewTunnel(128, closer, proxy) + if tun.BootstrapTime() != 128 { + t.Fatal("not the bootstrap time we expected") + } + if tun.SOCKS5ProxyURL() != proxy { + t.Fatal("not the url we expected") + } + tun.Stop() + if closer.counter != 1 { + t.Fatal("something went wrong while stopping the tunnel") + } +} + +func TestTunnelNil(t *testing.T) { + var tun *torx.Tunnel + if tun.BootstrapTime() != 0 { + t.Fatal("not the bootstrap time we expected") + } + if tun.SOCKS5ProxyURL() != nil { + t.Fatal("not the url we expected") + } + tun.Stop() // ensure we don't crash +} + +func TestStartWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tun, err := torx.Start(ctx, &mockable.Session{}) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tun != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestStartWithConfigStartFailure(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + tun, err := torx.StartWithConfig(ctx, torx.StartConfig{ + Sess: &mockable.Session{}, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return nil, expected + }, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tun != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestStartWithConfigEnableNetworkFailure(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + tun, err := torx.StartWithConfig(ctx, torx.StartConfig{ + Sess: &mockable.Session{}, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return &tor.Tor{}, nil + }, + EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error { + return expected + }, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tun != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestStartWithConfigGetInfoFailure(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + tun, err := torx.StartWithConfig(ctx, torx.StartConfig{ + Sess: &mockable.Session{}, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return &tor.Tor{}, nil + }, + EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error { + return nil + }, + GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) { + return nil, expected + }, + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if tun != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestStartWithConfigGetInfoInvalidNumberOfKeys(t *testing.T) { + ctx := context.Background() + tun, err := torx.StartWithConfig(ctx, torx.StartConfig{ + Sess: &mockable.Session{}, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return &tor.Tor{}, nil + }, + EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error { + return nil + }, + GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) { + return nil, nil + }, + }) + if err.Error() != "unable to get socks proxy address" { + t.Fatal("not the error we expected") + } + if tun != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestStartWithConfigGetInfoInvalidKey(t *testing.T) { + ctx := context.Background() + tun, err := torx.StartWithConfig(ctx, torx.StartConfig{ + Sess: &mockable.Session{}, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return &tor.Tor{}, nil + }, + EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error { + return nil + }, + GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) { + return []*control.KeyVal{{}}, nil + }, + }) + if err.Error() != "unable to get socks proxy address" { + t.Fatal("not the error we expected") + } + if tun != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestStartWithConfigGetInfoInvalidProxyType(t *testing.T) { + ctx := context.Background() + tun, err := torx.StartWithConfig(ctx, torx.StartConfig{ + Sess: &mockable.Session{}, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return &tor.Tor{}, nil + }, + EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error { + return nil + }, + GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) { + return []*control.KeyVal{{Key: "net/listeners/socks", Val: "127.0.0.1:9050"}}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if tun == nil { + t.Fatal("expected non-nil tunnel here") + } +} + +func TestStartWithConfigSuccess(t *testing.T) { + ctx := context.Background() + tun, err := torx.StartWithConfig(ctx, torx.StartConfig{ + Sess: &mockable.Session{}, + Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) { + return &tor.Tor{}, nil + }, + EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error { + return nil + }, + GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) { + return []*control.KeyVal{{Key: "net/listeners/socks", Val: "unix:/foo/bar"}}, nil + }, + }) + if err.Error() != "tor returned unsupported proxy" { + t.Fatal("not the error we expected") + } + if tun != nil { + t.Fatal("expected nil tunnel here") + } +} diff --git a/internal/engine/internal/tunnel/tunnel.go b/internal/engine/internal/tunnel/tunnel.go new file mode 100644 index 0000000..386a876 --- /dev/null +++ b/internal/engine/internal/tunnel/tunnel.go @@ -0,0 +1,64 @@ +// Package tunnel contains code to create a psiphon or tor tunnel. +package tunnel + +import ( + "context" + "errors" + "net/url" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/psiphonx" + "github.com/ooni/probe-cli/v3/internal/engine/internal/torx" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// Session is the way in which this package sees a Session. +type Session interface { + psiphonx.Session + torx.Session + Logger() model.Logger +} + +// Tunnel is a tunnel used by the session +type Tunnel interface { + BootstrapTime() time.Duration + SOCKS5ProxyURL() *url.URL + Stop() +} + +// Config contains config for the session tunnel. +type Config struct { + Name string + Session Session + WorkDir string +} + +// Start starts a new tunnel by name or returns an error. Note that if you +// pass to this function the "" tunnel, you get back nil, nil. +func Start(ctx context.Context, config Config) (Tunnel, error) { + logger := config.Session.Logger() + switch config.Name { + case "": + logger.Debugf("no tunnel has been requested") + return enforceNilContract(nil, nil) + case "psiphon": + logger.Infof("starting %s tunnel; please be patient...", config.Name) + tun, err := psiphonx.Start(ctx, config.Session, psiphonx.Config{ + WorkDir: config.WorkDir, + }) + return enforceNilContract(tun, err) + case "tor": + logger.Infof("starting %s tunnel; please be patient...", config.Name) + tun, err := torx.Start(ctx, config.Session) + return enforceNilContract(tun, err) + default: + return nil, errors.New("unsupported tunnel") + } +} + +func enforceNilContract(tun Tunnel, err error) (Tunnel, error) { + if err != nil { + return nil, err + } + return tun, nil +} diff --git a/internal/engine/internal/tunnel/tunnel_test.go b/internal/engine/internal/tunnel/tunnel_test.go new file mode 100644 index 0000000..51df5b3 --- /dev/null +++ b/internal/engine/internal/tunnel/tunnel_test.go @@ -0,0 +1,80 @@ +package tunnel_test + +import ( + "context" + "errors" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/internal/tunnel" +) + +func TestNoTunnel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tunnel, err := tunnel.Start(ctx, tunnel.Config{ + Name: "", + Session: &mockable.Session{ + MockableLogger: log.Log, + }, + }) + if err != nil { + t.Fatal(err) + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestPsiphonTunnel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tunnel, err := tunnel.Start(ctx, tunnel.Config{ + Name: "psiphon", + Session: &mockable.Session{ + MockableLogger: log.Log, + }, + }) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestTorTunnel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tunnel, err := tunnel.Start(ctx, tunnel.Config{ + Name: "tor", + Session: &mockable.Session{ + MockableLogger: log.Log, + }, + }) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} + +func TestInvalidTunnel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + tunnel, err := tunnel.Start(ctx, tunnel.Config{ + Name: "antani", + Session: &mockable.Session{ + MockableLogger: log.Log, + }, + }) + if err == nil || err.Error() != "unsupported tunnel" { + t.Fatal("not the error we expected") + } + t.Log(tunnel) + if tunnel != nil { + t.Fatal("expected nil tunnel here") + } +} diff --git a/internal/engine/kvstore.go b/internal/engine/kvstore.go new file mode 100644 index 0000000..2e20229 --- /dev/null +++ b/internal/engine/kvstore.go @@ -0,0 +1,44 @@ +package engine + +import ( + "bytes" + "os" + "path/filepath" + + "github.com/rogpeppe/go-internal/lockedfile" +) + +// KVStore is a simple, atomic key-value store. The user of +// probe-engine should supply an implementation of this interface, +// which will be used by probe-engine to store specific data. +type KVStore interface { + Get(key string) (value []byte, err error) + Set(key string, value []byte) (err error) +} + +// FileSystemKVStore is a directory based KVStore +type FileSystemKVStore struct { + basedir string +} + +// NewFileSystemKVStore creates a new FileSystemKVStore. +func NewFileSystemKVStore(basedir string) (kvs *FileSystemKVStore, err error) { + if err = os.MkdirAll(basedir, 0700); err == nil { + kvs = &FileSystemKVStore{basedir: basedir} + } + return +} + +func (kvs *FileSystemKVStore) filename(key string) string { + return filepath.Join(kvs.basedir, key) +} + +// Get returns the specified key's value +func (kvs *FileSystemKVStore) Get(key string) ([]byte, error) { + return lockedfile.Read(kvs.filename(key)) +} + +// Set sets the value of a specific key +func (kvs *FileSystemKVStore) Set(key string, value []byte) error { + return lockedfile.Write(kvs.filename(key), bytes.NewReader(value), 0600) +} diff --git a/internal/engine/kvstore_test.go b/internal/engine/kvstore_test.go new file mode 100644 index 0000000..75e48af --- /dev/null +++ b/internal/engine/kvstore_test.go @@ -0,0 +1,31 @@ +package engine + +import ( + "bytes" + "path/filepath" + "testing" +) + +func TestKVStoreIntegration(t *testing.T) { + var ( + err error + kvstore KVStore + ) + kvstore, err = NewFileSystemKVStore( + filepath.Join("testdata", "kvstore2"), + ) + if err != nil { + t.Fatal(err) + } + value := []byte("foobar") + if err := kvstore.Set("antani", value); err != nil { + t.Fatal(err) + } + ovalue, err := kvstore.Get("antani") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(ovalue, value) { + t.Fatal("invalid value") + } +} diff --git a/internal/engine/legacy/README.md b/internal/engine/legacy/README.md new file mode 100644 index 0000000..3cfc905 --- /dev/null +++ b/internal/engine/legacy/README.md @@ -0,0 +1,4 @@ +# Package github.com/ooni/probe-engine/legacy + +This package contains legacy code that we will soon remove. When this folder +is empty, it means there's no large, incremental refactoring in progress. diff --git a/internal/engine/legacy/netx/DESIGN.md b/internal/engine/legacy/netx/DESIGN.md new file mode 100644 index 0000000..6bdc364 --- /dev/null +++ b/internal/engine/legacy/netx/DESIGN.md @@ -0,0 +1,400 @@ +# OONI Network Extensions + +| Author | Simone Basso | +|--------------|--------------| +| Last-Updated | 2020-04-02 | +| Status | approved | + +## Introduction + +OONI experiments send and/or receive network traffic to +determine if there is blocking. We want the implementation +of OONI experiments to be as simple as possible. We also +_want to attribute errors to the major network or protocol +operation that caused them_. + +At the same time, _we want an experiment to collect as much +low-level data as possible_. For example, we want to know +whether and when the TLS handshake completed; what certificates +were provided by the server; what TLS version was selected; +and so forth. These bits of information are very useful +to analyze a measurement and better classify it. + +We also want to _automatically or manually run follow-up +measurements where we change some configuration properties +and repeat the measurement_. For example, we may want to +configure DNS over HTTPS (DoH) and then attempt to +fetch again an URL. Or we may want to detect whether +there is SNI bases blocking. This package allows us to +do that in other parts of probe-engine. + +## Rationale + +As we observed [ooni/probe-engine#13]( +https://github.com/ooni/probe-engine/issues/13), every +experiment consists of two separate phases: + +1. measurement gathering + +2. measurement analysis + +During measurement gathering, we perform specific actions +that cause network data to be sent and/or received. During +measurement analysis, we process the measurement on the +device. For some experiments (e.g., Web Connectivity), this +second phase also entails contacting OONI backend services +that provide data useful to complete the analysis. + +This package implements measurement gathering. The analysis +is performed by other packages in probe-engine. The core +design idea is to provide OONI-measurements-aware replacements +for Go standard library interfaces, e.g., the +`http.RoundTripper`. On top of that, we'll create all the +required interfaces to achive the measurement goals mentioned above. + +We are of course writing test templates in `probe-engine` +anyway, because we need additional abstraction, but we can +take advantage of the fact that the API exposed by this package +is stable by definition, because it mimics the stdlib. Also, +for many experiments we can collect information pertaining +to TCP, DNS, TLS, and HTTP with a single call to `netx`. + +This code used to live at `github.com/ooni/netx`. On 2020-03-02 +we merged github.com/ooni/netx@4f8d645bce6466bb into `probe-engine` +because it was more practical and enabled easier refactoring. + +## Definitions + +Consistently with Go's terminology, we define +_HTTP round trip_ the process where we get a request +to send; we find a suitable connection for sending +it, or we create one; we send headers and +possibly body; and we receive response headers. + +We also define _HTTP transaction_ the process starting +with an HTTP round trip and terminating by reading +the full response body. + +We define _netx replacement_ a Go struct of interface that +has the same interface of a Go standard library object +but additionally performs measurements. + +## Enhanced error handling + +This library MUST wrap `error` such that: + +1. we can classify all errors we care about; and + +2. we can map them to major operations. + +The `github.com/ooni/netx/modelx` MUST contain a wrapper for +Go `error` named `ErrWrapper` that is at least like: + +```Go +type ErrWrapper struct { + Failure string // error classification + Operation string // operation that caused error + WrappedErr error // the original error +} + +func (e *ErrWrapper) Error() string { + return e.Failure +} +``` + +Where `Failure` is one of the errors we care about, i.e.: + +- `connection_refused`: ECONNREFUSED +- `connection_reset`: ECONNRESET +- `dns_bogon_error`: detected bogon in DNS reply +- `dns_nxdomain_error`: NXDOMAIN in DNS reply +- `eof_error`: unexpected EOF on connection +- `generic_timeout_error`: some timer has expired +- `ssl_invalid_hostname`: certificate not valid for SNI +- `ssl_unknown_autority`: cannot find CA validating certificate +- `ssl_invalid_certificate`: e.g. certificate expired +- `unknown_failure `: any other error + +Note that we care about bogons in DNS replies because they are +often used to censor specific websites. + +And where `Operation` is one of: + +- `resolve`: domain name resolution +- `connect`: TCP connect +- `tls_handshake`: TLS handshake +- `http_round_trip`: reading/writing HTTP + +The code in this library MUST wrap returned errors such +that we can cast back to `ErrWrapper` during the analysis +phase, using Go 1.13 `errors` library as follows: + +```Go +var wrapper *modelx.ErrWrapper +if errors.As(err, &wrapper) == true { + // Do something with the error +} +``` + +## Netx replacements + +We want to provide netx replacements for the following +interfaces in the Go standard library: + +1. `http.RoundTripper` + +2. `http.Client` + +3. `net.Dialer` + +4. `net.Resolver` + +Accordingly, we'll define the following interfaces in +the `github.com/ooni/probe-engine/netx/modelx` package: + +```Go +type DNSResolver interface { + LookupHost(ctx context.Context, hostname string) ([]string, error) +} + +type Dialer interface { + Dial(network, address string) (net.Conn, error) + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +type TLSDialer interface { + DialTLS(network, address string) (net.Conn, error) + DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) +} +``` + +We won't need an interface for `http.RoundTripper` +because it is already an interface, so we'll just use it. + +Our replacements will implement these interfaces. + +Using an API compatible with Go's standard libary makes +it possible to use, say, our `net.Dialer` replacement with +other libraries. Both `http.Transport` and +`gorilla/websocket`'s `websocket.Dialer` have +functions like `Dial` and `DialContext` that can be +overriden. By overriding such function pointers, +we could use our replacements instead of the standard +libary, thus we could collect measurements while +using third party code to implement specific protocols. + +Also, using interfaces allows us to combine code +quite easily. For example, a resolver that detects +bogons is easily implemented as a wrapper around +another resolve that performs the real resolution. + +## Dispatching events + +The `github.com/ooni/netx/modelx` package will define +an handler for low level events as: + +```Go +type Handler interface { + OnMeasurement(Measurement) +} +``` + +We will provide a mechanism to bind a specific +handler to a `context.Context` such that the handler +will receive all the measurements caused by code +using such context. This mechanism is like: + +```Go +type MeasurementRoot struct { + Beginning time.Time // the "zero" time + Handler Handler // the handler to use +} +``` + +You will be able to assign a `MeasurementRoot` to +a context by using the following function: + +```Go +func WithMeasurementRoot( + ctx context.Context, root *MeasurementRoot) context.Context +``` + +which will return a clone of the original context +that uses the `MeasurementRoot`. Pass this context to +any method of our replacements to get measurements. + +Given such context, or a subcontext, you can get +back the original `MeasurementRoot` using: + +```Go +func ContextMeasurementRoot(ctx context.Context) *MeasurementRoot +``` + +which will return the context `MeasurementRoot` or +`nil` if none is set into the context. This is how our +internal code gets access to the `MeasurementRoot`. + +## Constructing and configuring replacements + +The `github.com/ooni/probe-engine/netx` package MUST provide an API such +that you can construct and configure a `net.Resolver` replacement +as follows: + +```Go +r, err := netx.NewResolverWithoutHandler(dnsNetwork, dnsAddress) +if err != nil { + log.Fatal("cannot configure specifc resolver") +} +var resolver modelx.DNSResolver = r +// now use resolver ... +``` + +where `DNSNetwork` and `DNSAddress` configure the type +of the resolver as follows: + +- when `DNSNetwork` is `""` or `"system"`, `DNSAddress` does +not matter and we use the system resolver + +- when `DNSNetwork` is `"udp"`, `DNSAddress` is the address +or domain name, with optional port, of the DNS server +(e.g., `8.8.8.8:53`) + +- when `DNSNetwork` is `"tcp"`, `DNSAddress` is the address +or domain name, with optional port, of the DNS server +(e.g., `8.8.8.8:53`) + +- when `DNSNetwork` is `"dot"`, `DNSAddress` is the address +or domain name, with optional port, of the DNS server +(e.g., `8.8.8.8:853`) + +- when `DNSNetwork` is `"doh"`, `DNSAddress` is the URL +of the DNS server (e.g. `https://cloudflare-dns.com/dns-query`) + +When the resolve is not the system one, we'll also be able +to emit events when performing resolution. Otherwise, we'll +just emit the `DNSResolveDone` event defined below. + +Any resolver returned by this function may be configured to return the +`dns_bogon_error` if any `LookupHost` lookup returns a bogon IP. + +The package will also contain this function: + +```Go +func ChainResolvers( + primary, secondary modelx.DNSResolver) modelx.DNSResolver +``` + +where you can create a new resolver where `secondary` will be +invoked whenever `primary` fails. This functionality allows +us to be more resilient and bypass automatically certain types +of censorship, e.g., a resolver returning a bogon. + +The `github.com/ooni/probe-engine/netx` package MUST also provide an API such +that you can construct and configure a `net.Dialer` replacement +as follows: + +```Go +d := netx.NewDialerWithoutHandler() +d.SetResolver(resolver) +d.ForceSpecificSNI("www.kernel.org") +d.SetCABundle("/etc/ssl/cert.pem") +d.ForceSkipVerify() +var dialer modelx.Dialer = d +// now use dialer +``` + +where `SetResolver` allows you to change the resolver, +`ForceSpecificSNI` forces the TLS dials to use such SNI +instead of using the provided domain, `SetCABundle` +allows to set a specific CA bundle, and `ForceSkipVerify` +allows to disable certificate verification. All these funcs +MUST NOT be invoked once you're using the dialer. + +The `github.com/ooni/probe-engine/netx` package MUST contain +code so that we can do: + +```Go +t := netx.NewHTTPTransportWithProxyFunc( + http.ProxyFromEnvironment, +) +t.SetResolver(resolver) +t.ForceSpecificSNI("www.kernel.org") +t.SetCABundle("/etc/ssl/cert.pem") +t.ForceSkipVerify() +var transport http.RoundTripper = t +// now use transport +``` + +where the functions have the same semantics as the +namesake functions described before and the same caveats. + +We also have syntactic sugar on top of that and legacy +methods, but this fully describes the design. + +## Structure of events + +The `github.com/ooni/probe-engine/netx/modelx` will contain the +definition of low-level events. We are interested in +knowing the following: + +1. the timing and result of each I/O operation. + +2. the timing of HTTP events occurring during the +lifecycle of an HTTP request. + +3. the timing and result of the TLS handshake including +the negotiated TLS version and other details such as +what certificates the server has provided. + +4. DNS events, e.g. queries and replies, generated +as part of using DoT and DoH. + +We will represent time as a `time.Duration` since the +beginning configured either in the context or when +constructing an object. The `modelx` package will also +define the `Measurement` event as follows: + +```Go +type Measurement struct { + Connect *ConnectEvent + HTTPConnectionReady *HTTPConnectionReadyEvent + HTTPRoundTripDone *HTTPRoundTripDoneEvent + ResolveDone *ResolveDoneEvent + TLSHandshakeDone *TLSHandshakeDoneEvent +} +``` + +The events above MUST always be present, but more +events will likely be available. The structure +will contain a pointer for every event that +we support. The events processing code will check +what pointer or pointers are not `nil` to known +which event or events have occurred. + +To simplify joining events together the following holds: + +1. when we're establishing a new connection there is a nonzero +`DialID` shared by `Connect` and `ResolveDone` + +2. a new connection has a nonzero `ConnID` that is emitted +as part of a successful `Connect` event + +3. during an HTTP transaction there is a nonzero `TransactionID` +shared by `HTTPConnectionReady` and `HTTPRoundTripDone` + +4. if the TLS handshake is invoked by HTTP code it will have a +nonzero `TrasactionID` otherwise a nonzero `ConnID` + +5. the `HTTPConnectionReady` will also see the `ConnID` + +6. when a transaction starts dialing, it will pass its +`TransactionID` to `ResolveDone` and `Connect` + +7. when we're dialing a connection for DoH, we pass the `DialID` +to the `HTTPConnectionReady` event as well + +Because of the following rules, it should always be possible +to bind together events. Also, we define more events than the +above, but they are ancillary to the above events. Also, the +main reason why `HTTPConnectionReady` is here is because it is +the event allowing to bind `ConnID` and `TransactionID`. diff --git a/internal/engine/legacy/netx/connid/connid.go b/internal/engine/legacy/netx/connid/connid.go new file mode 100644 index 0000000..5d37ba7 --- /dev/null +++ b/internal/engine/legacy/netx/connid/connid.go @@ -0,0 +1,31 @@ +// Package connid contains code to generate the connectionID +package connid + +import ( + "net" + "strconv" + "strings" +) + +// Compute computes the connectionID from the local socket address. The zero +// value is conventionally returned to mean "unknown". +func Compute(network, address string) int64 { + _, portstring, err := net.SplitHostPort(address) + if err != nil { + return 0 + } + portnum, err := strconv.Atoi(portstring) + if err != nil { + return 0 + } + if portnum < 0 || portnum > 65535 { + return 0 + } + result := int64(portnum) + if strings.Contains(network, "udp") { + result *= -1 + } else if !strings.Contains(network, "tcp") { + result = 0 + } + return result +} diff --git a/internal/engine/legacy/netx/connid/connid_test.go b/internal/engine/legacy/netx/connid/connid_test.go new file mode 100644 index 0000000..1f0f984 --- /dev/null +++ b/internal/engine/legacy/netx/connid/connid_test.go @@ -0,0 +1,80 @@ +package connid + +import "testing" + +func TestTCP(t *testing.T) { + num := Compute("tcp", "1.2.3.4:6789") + if num != 6789 { + t.Fatal("unexpected result") + } +} + +func TestTCP4(t *testing.T) { + num := Compute("tcp4", "130.192.91.211:34566") + if num != 34566 { + t.Fatal("unexpected result") + } +} + +func TestTCP6(t *testing.T) { + num := Compute("tcp4", "[::1]:4444") + if num != 4444 { + t.Fatal("unexpected result") + } +} + +func TestUDP(t *testing.T) { + num := Compute("udp", "1.2.3.4:6789") + if num != -6789 { + t.Fatal("unexpected result") + } +} + +func TestUDP4(t *testing.T) { + num := Compute("udp4", "130.192.91.211:34566") + if num != -34566 { + t.Fatal("unexpected result") + } +} + +func TestUDP6(t *testing.T) { + num := Compute("udp6", "[::1]:4444") + if num != -4444 { + t.Fatal("unexpected result") + } +} + +func TestInvalidAddress(t *testing.T) { + num := Compute("udp6", "[::1]") + if num != 0 { + t.Fatal("unexpected result") + } +} + +func TestInvalidPort(t *testing.T) { + num := Compute("udp6", "[::1]:antani") + if num != 0 { + t.Fatal("unexpected result") + } +} + +func TestNegativePort(t *testing.T) { + num := Compute("udp6", "[::1]:-1") + if num != 0 { + t.Fatal("unexpected result") + } +} + +func TestLargePort(t *testing.T) { + num := Compute("udp6", "[::1]:65536") + if num != 0 { + t.Fatal("unexpected result") + } +} + +func TestInvalidNetwork(t *testing.T) { + num := Compute("unix", "[::1]:65531") + if num != 0 { + t.Fatal("unexpected result") + } +} diff --git a/internal/engine/legacy/netx/dialer.go b/internal/engine/legacy/netx/dialer.go new file mode 100644 index 0000000..1b0d048 --- /dev/null +++ b/internal/engine/legacy/netx/dialer.go @@ -0,0 +1,203 @@ +// Package netx contains OONI's net extensions. +package netx + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io/ioutil" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +// Dialer performs measurements while dialing. +type Dialer struct { + Beginning time.Time + Handler modelx.Handler + Resolver modelx.DNSResolver + TLSConfig *tls.Config +} + +func newDialer(beginning time.Time, handler modelx.Handler) *Dialer { + return &Dialer{ + Beginning: beginning, + Handler: handler, + Resolver: newResolverSystem(), + TLSConfig: new(tls.Config), + } +} + +// NewDialer creates a new Dialer instance. +func NewDialer() *Dialer { + return newDialer(time.Now(), handlers.NoHandler) +} + +// Dial creates a TCP or UDP connection. See net.Dial docs. +func (d *Dialer) Dial(network, address string) (net.Conn, error) { + return d.DialContext(context.Background(), network, address) +} + +func maybeWithMeasurementRoot( + ctx context.Context, beginning time.Time, handler modelx.Handler, +) context.Context { + if modelx.ContextMeasurementRoot(ctx) != nil { + return ctx + } + return modelx.WithMeasurementRoot(ctx, &modelx.MeasurementRoot{ + Beginning: beginning, + Handler: handler, + }) +} + +// newDNSDialer creates a new DNS dialer using the following chain: +// +// - DNSDialer (topmost) +// - EmitterDialer +// - ErrorWrapperDialer +// - TimeoutDialer +// - ByteCountingDialer +// - net.Dialer +// +// If you have others needs, manually build the chain you need. +func newDNSDialer(resolver dialer.Resolver) dialer.DNSDialer { + return dialer.DNSDialer{ + Dialer: dialer.EmitterDialer{ + Dialer: dialer.ErrorWrapperDialer{ + Dialer: dialer.TimeoutDialer{ + Dialer: dialer.ByteCounterDialer{ + Dialer: new(net.Dialer), + }, + }, + }, + }, + Resolver: resolver, + } +} + +// DialContext is like Dial but the context allows to interrupt a +// pending connection attempt at any time. +func (d *Dialer) DialContext( + ctx context.Context, network, address string, +) (conn net.Conn, err error) { + ctx = maybeWithMeasurementRoot(ctx, d.Beginning, d.Handler) + return newDNSDialer(d.Resolver).DialContext(ctx, network, address) +} + +// DialTLS is like Dial, but creates TLS connections. +func (d *Dialer) DialTLS(network, address string) (net.Conn, error) { + return d.DialTLSContext(context.Background(), network, address) +} + +// newTLSDialer creates a new TLSDialer using: +// +// - EmitterTLSHandshaker (topmost) +// - ErrorWrapperTLSHandshaker +// - TimeoutTLSHandshaker +// - SystemTLSHandshaker +// +// If you have others needs, manually build the chain you need. +func newTLSDialer(d dialer.Dialer, config *tls.Config) dialer.TLSDialer { + return dialer.TLSDialer{ + Config: config, + Dialer: d, + TLSHandshaker: dialer.EmitterTLSHandshaker{ + TLSHandshaker: dialer.ErrorWrapperTLSHandshaker{ + TLSHandshaker: dialer.TimeoutTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + }, + }, + }, + } +} + +// DialTLSContext is like DialTLS, but with context +func (d *Dialer) DialTLSContext( + ctx context.Context, network, address string, +) (net.Conn, error) { + ctx = maybeWithMeasurementRoot(ctx, d.Beginning, d.Handler) + return newTLSDialer( + newDNSDialer(d.Resolver), + d.TLSConfig, + ).DialTLSContext(ctx, network, address) +} + +// SetCABundle configures the dialer to use a specific CA bundle. This +// function is not goroutine safe. Make sure you call it before starting +// to use this specific dialer. +func (d *Dialer) SetCABundle(path string) error { + cert, err := ioutil.ReadFile(path) + if err != nil { + return err + } + pool := x509.NewCertPool() + if pool.AppendCertsFromPEM(cert) == false { + return errors.New("AppendCertsFromPEM failed") + } + d.TLSConfig.RootCAs = pool + return nil +} + +// ForceSpecificSNI forces using a specific SNI. +func (d *Dialer) ForceSpecificSNI(sni string) error { + d.TLSConfig.ServerName = sni + return nil +} + +// ForceSkipVerify forces to skip certificate verification +func (d *Dialer) ForceSkipVerify() error { + d.TLSConfig.InsecureSkipVerify = true + return nil +} + +// ConfigureDNS configures the DNS resolver. The network argument +// selects the type of resolver. The address argument indicates the +// resolver address and depends on the network. +// +// This functionality is not goroutine safe. You should only change +// the DNS settings before starting to use the Dialer. +// +// The following is a list of all the possible network values: +// +// - "": behaves exactly like "system" +// +// - "system": this indicates that Go should use the system resolver +// and prevents us from seeing any DNS packet. The value of the +// address parameter is ignored when using "system". If you do +// not ConfigureDNS, this is the default resolver used. +// +// - "udp": indicates that we should send queries using UDP. In this +// case the address is a host, port UDP endpoint. +// +// - "tcp": like "udp" but we use TCP. +// +// - "dot": we use DNS over TLS (DoT). In this case the address is +// the domain name of the DoT server. +// +// - "doh": we use DNS over HTTPS (DoH). In this case the address is +// the URL of the DoH server. +// +// For example: +// +// d.ConfigureDNS("system", "") +// d.ConfigureDNS("udp", "8.8.8.8:53") +// d.ConfigureDNS("tcp", "8.8.8.8:53") +// d.ConfigureDNS("dot", "dns.quad9.net") +// d.ConfigureDNS("doh", "https://cloudflare-dns.com/dns-query") +func (d *Dialer) ConfigureDNS(network, address string) error { + r, err := newResolver(d.Beginning, d.Handler, network, address) + if err == nil { + d.Resolver = r + } + return err +} + +// SetResolver is a more flexible way of configuring a resolver +// that should perhaps be used instead of ConfigureDNS. +func (d *Dialer) SetResolver(r modelx.DNSResolver) { + d.Resolver = r +} diff --git a/internal/engine/legacy/netx/dialer_test.go b/internal/engine/legacy/netx/dialer_test.go new file mode 100644 index 0000000..7f5e016 --- /dev/null +++ b/internal/engine/legacy/netx/dialer_test.go @@ -0,0 +1,115 @@ +package netx_test + +import ( + "crypto/x509" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx" +) + +func TestDialerDial(t *testing.T) { + dialer := netx.NewDialer() + conn, err := dialer.Dial("tcp", "www.google.com:80") + if err != nil { + t.Fatal(err) + } + conn.Close() +} + +func TestDialerDialWithCustomResolver(t *testing.T) { + dialer := netx.NewDialer() + resolver, err := netx.NewResolver("tcp", "1.1.1.1:53") + if err != nil { + t.Fatal(err) + } + dialer.SetResolver(resolver) + conn, err := dialer.Dial("tcp", "www.google.com:80") + if err != nil { + t.Fatal(err) + } + conn.Close() +} + +func TestDialerDialWithConfigureDNS(t *testing.T) { + dialer := netx.NewDialer() + err := dialer.ConfigureDNS("tcp", "1.1.1.1:53") + if err != nil { + t.Fatal(err) + } + conn, err := dialer.Dial("tcp", "www.google.com:80") + if err != nil { + t.Fatal(err) + } + conn.Close() +} + +func TestDialerDialTLS(t *testing.T) { + dialer := netx.NewDialer() + conn, err := dialer.DialTLS("tcp", "www.google.com:443") + if err != nil { + t.Fatal(err) + } + conn.Close() +} + +func TestDialerDialTLSForceSkipVerify(t *testing.T) { + dialer := netx.NewDialer() + dialer.ForceSkipVerify() + conn, err := dialer.DialTLS("tcp", "self-signed.badssl.com:443") + if err != nil { + t.Fatal(err) + } + conn.Close() +} + +func TestDialerSetCABundleNonexisting(t *testing.T) { + dialer := netx.NewDialer() + err := dialer.SetCABundle("testdata/cacert-nonexistent.pem") + if err == nil { + t.Fatal("expected an error here") + } +} + +func TestDialerSetCABundleInvalid(t *testing.T) { + dialer := netx.NewDialer() + err := dialer.SetCABundle("testdata/cacert-invalid.pem") + if err == nil { + t.Fatal("expected an error here") + } +} + +func TestDialerSetCABundleWAI(t *testing.T) { + dialer := netx.NewDialer() + err := dialer.SetCABundle("testdata/cacert.pem") + if err != nil { + t.Fatal(err) + } + conn, err := dialer.DialTLS("tcp", "www.google.com:443") + if err == nil { + t.Fatal("expected an error here") + } + var target x509.UnknownAuthorityError + if errors.As(err, &target) == false { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected a nil conn here") + } +} + +func TestDialerForceSpecificSNI(t *testing.T) { + dialer := netx.NewDialer() + err := dialer.ForceSpecificSNI("www.facebook.com") + conn, err := dialer.DialTLS("tcp", "www.google.com:443") + if err == nil { + t.Fatal("expected an error here") + } + var target x509.HostnameError + if errors.As(err, &target) == false { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected a nil connection here") + } +} diff --git a/internal/engine/legacy/netx/dialid/dialid.go b/internal/engine/legacy/netx/dialid/dialid.go new file mode 100644 index 0000000..6cfd464 --- /dev/null +++ b/internal/engine/legacy/netx/dialid/dialid.go @@ -0,0 +1,24 @@ +package dialid + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" +) + +type contextkey struct{} + +var id = atomicx.NewInt64() + +// WithDialID returns a copy of ctx with DialID +func WithDialID(ctx context.Context) context.Context { + return context.WithValue( + ctx, contextkey{}, id.Add(1), + ) +} + +// ContextDialID returns the DialID of the context, or zero +func ContextDialID(ctx context.Context) int64 { + id, _ := ctx.Value(contextkey{}).(int64) + return id +} diff --git a/internal/engine/legacy/netx/dialid/dialid_test.go b/internal/engine/legacy/netx/dialid/dialid_test.go new file mode 100644 index 0000000..12c788c --- /dev/null +++ b/internal/engine/legacy/netx/dialid/dialid_test.go @@ -0,0 +1,24 @@ +package dialid + +import ( + "context" + "testing" +) + +func TestGood(t *testing.T) { + ctx := context.Background() + id := ContextDialID(ctx) + if id != 0 { + t.Fatal("unexpected ID for empty context") + } + ctx = WithDialID(ctx) + id = ContextDialID(ctx) + if id != 1 { + t.Fatal("expected ID equal to 1") + } + ctx = WithDialID(ctx) + id = ContextDialID(ctx) + if id != 2 { + t.Fatal("expected ID equal to 2") + } +} diff --git a/internal/engine/legacy/netx/handlers/handlers.go b/internal/engine/legacy/netx/handlers/handlers.go new file mode 100644 index 0000000..9bb8153 --- /dev/null +++ b/internal/engine/legacy/netx/handlers/handlers.go @@ -0,0 +1,52 @@ +// Package handlers contains default modelx.Handler handlers. +package handlers + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" +) + +type stdoutHandler struct{} + +func (stdoutHandler) OnMeasurement(m modelx.Measurement) { + data, err := json.Marshal(m) + runtimex.PanicOnError(err, "unexpected json.Marshal failure") + fmt.Printf("%s\n", string(data)) +} + +// StdoutHandler is a Handler that logs on stdout. +var StdoutHandler stdoutHandler + +type noHandler struct{} + +func (noHandler) OnMeasurement(m modelx.Measurement) { +} + +// NoHandler is a Handler that does not print anything +var NoHandler noHandler + +// SavingHandler saves the events it receives. +type SavingHandler struct { + mu sync.Mutex + v []modelx.Measurement +} + +// OnMeasurement implements modelx.Handler.OnMeasurement +func (sh *SavingHandler) OnMeasurement(ev modelx.Measurement) { + sh.mu.Lock() + sh.v = append(sh.v, ev) + sh.mu.Unlock() +} + +// Read extracts the saved events +func (sh *SavingHandler) Read() []modelx.Measurement { + sh.mu.Lock() + v := sh.v + sh.v = nil + sh.mu.Unlock() + return v +} diff --git a/internal/engine/legacy/netx/handlers/handlers_test.go b/internal/engine/legacy/netx/handlers/handlers_test.go new file mode 100644 index 0000000..f8c49a9 --- /dev/null +++ b/internal/engine/legacy/netx/handlers/handlers_test.go @@ -0,0 +1,19 @@ +package handlers_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" +) + +func TestGood(t *testing.T) { + handlers.NoHandler.OnMeasurement(modelx.Measurement{}) + handlers.StdoutHandler.OnMeasurement(modelx.Measurement{}) + saver := handlers.SavingHandler{} + saver.OnMeasurement(modelx.Measurement{}) + events := saver.Read() + if len(events) != 1 { + t.Fatal("invalid number of events") + } +} diff --git a/internal/engine/legacy/netx/http.go b/internal/engine/legacy/netx/http.go new file mode 100644 index 0000000..d4063f0 --- /dev/null +++ b/internal/engine/legacy/netx/http.go @@ -0,0 +1,206 @@ +package netx + +import ( + "net/http" + "net/url" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/oldhttptransport" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "golang.org/x/net/http2" +) + +// HTTPTransport performs single HTTP transactions and emits +// measurement events as they happen. +type HTTPTransport struct { + Beginning time.Time + Dialer *Dialer + Handler modelx.Handler + Transport *http.Transport + roundTripper http.RoundTripper +} + +func newHTTPTransport( + beginning time.Time, + handler modelx.Handler, + dialer *Dialer, + disableKeepAlives bool, + proxyFunc func(*http.Request) (*url.URL, error), +) *HTTPTransport { + baseTransport := &http.Transport{ + // The following values are copied from Go 1.12 docs and match + // what should be used by the default transport + ExpectContinueTimeout: 1 * time.Second, + IdleConnTimeout: 90 * time.Second, + MaxIdleConns: 100, + Proxy: proxyFunc, + TLSHandshakeTimeout: 10 * time.Second, + DisableKeepAlives: disableKeepAlives, + } + ooniTransport := oldhttptransport.New(baseTransport) + // Configure h2 and make sure that the custom TLSConfig we use for dialing + // is actually compatible with upgrading to h2. (This mainly means we + // need to make sure we include "h2" in the NextProtos array.) Because + // http2.ConfigureTransport only returns error when we have already + // configured http2, it is safe to ignore the return value. + http2.ConfigureTransport(baseTransport) + // Since we're not going to use our dialer for TLS, the main purpose of + // the following line is to make sure ForseSpecificSNI has impact on the + // config we are going to use when doing TLS. The code is as such since + // we used to force net/http through using dialer.DialTLS. + dialer.TLSConfig = baseTransport.TLSClientConfig + // Arrange the configuration such that we always use `dialer` for dialing + // cleartext connections. The net/http code will dial TLS connections. + baseTransport.DialContext = dialer.DialContext + // Better for Cloudflare DNS and also better because we have less + // noisy events and we can better understand what happened. + baseTransport.MaxConnsPerHost = 1 + // The following (1) reduces the number of headers that Go will + // automatically send for us and (2) ensures that we always receive + // back the true headers, such as Content-Length. This change is + // functional to OONI's goal of observing the network. + baseTransport.DisableCompression = true + return &HTTPTransport{ + Beginning: beginning, + Dialer: dialer, + Handler: handler, + Transport: baseTransport, + roundTripper: ooniTransport, + } +} + +// RoundTrip executes a single HTTP transaction, returning +// a Response for the provided Request. +func (t *HTTPTransport) RoundTrip( + req *http.Request, +) (resp *http.Response, err error) { + ctx := maybeWithMeasurementRoot(req.Context(), t.Beginning, t.Handler) + req = req.WithContext(ctx) + resp, err = t.roundTripper.RoundTrip(req) + // For safety wrap the error as modelx.HTTPRoundTripOperation but this + // will only be used if the error chain does not contain any + // other major operation failure. See errorx.ErrWrapper. + err = errorx.SafeErrWrapperBuilder{ + Error: err, + Operation: errorx.HTTPRoundTripOperation, + }.MaybeBuild() + return resp, err +} + +// CloseIdleConnections closes the idle connections. +func (t *HTTPTransport) CloseIdleConnections() { + // Adapted from net/http code + type closeIdler interface { + CloseIdleConnections() + } + if tr, ok := t.roundTripper.(closeIdler); ok { + tr.CloseIdleConnections() + } +} + +// NewHTTPTransportWithProxyFunc creates a transport without any +// handler attached using the specified proxy func. +func NewHTTPTransportWithProxyFunc( + proxyFunc func(*http.Request) (*url.URL, error), +) *HTTPTransport { + return newHTTPTransport(time.Now(), handlers.NoHandler, NewDialer(), false, proxyFunc) +} + +// NewHTTPTransport creates a new HTTP transport. +func NewHTTPTransport() *HTTPTransport { + return NewHTTPTransportWithProxyFunc(http.ProxyFromEnvironment) +} + +// ConfigureDNS is exactly like netx.Dialer.ConfigureDNS. +func (t *HTTPTransport) ConfigureDNS(network, address string) error { + return t.Dialer.ConfigureDNS(network, address) +} + +// SetResolver is exactly like netx.Dialer.SetResolver. +func (t *HTTPTransport) SetResolver(r modelx.DNSResolver) { + t.Dialer.SetResolver(r) +} + +// SetCABundle internally calls netx.Dialer.SetCABundle and +// therefore it has the same caveats and limitations. +func (t *HTTPTransport) SetCABundle(path string) error { + return t.Dialer.SetCABundle(path) +} + +// ForceSpecificSNI forces using a specific SNI. +func (t *HTTPTransport) ForceSpecificSNI(sni string) error { + return t.Dialer.ForceSpecificSNI(sni) +} + +// ForceSkipVerify forces to skip certificate verification +func (t *HTTPTransport) ForceSkipVerify() error { + return t.Dialer.ForceSkipVerify() +} + +// HTTPClient is a replacement for http.HTTPClient. +type HTTPClient struct { + // HTTPClient is the underlying client. Pass this client to existing code + // that expects an *http.HTTPClient. For this reason we can't embed it. + HTTPClient *http.Client + + // Transport is the transport configured by NewClient to be used + // by the HTTPClient field. + Transport *HTTPTransport +} + +// NewHTTPClientWithProxyFunc creates a new client using the +// specified proxyFunc for handling proxying. +func NewHTTPClientWithProxyFunc( + proxyFunc func(*http.Request) (*url.URL, error), +) *HTTPClient { + transport := NewHTTPTransportWithProxyFunc(proxyFunc) + return &HTTPClient{ + HTTPClient: &http.Client{Transport: transport}, + Transport: transport, + } +} + +// NewHTTPClient creates a new client instance. +func NewHTTPClient() *HTTPClient { + return NewHTTPClientWithProxyFunc(http.ProxyFromEnvironment) +} + +// NewHTTPClientWithoutProxy creates a new client instance that +// does not use any kind of proxy. +func NewHTTPClientWithoutProxy() *HTTPClient { + return NewHTTPClientWithProxyFunc(nil) +} + +// ConfigureDNS internally calls netx.Dialer.ConfigureDNS and +// therefore it has the same caveats and limitations. +func (c *HTTPClient) ConfigureDNS(network, address string) error { + return c.Transport.ConfigureDNS(network, address) +} + +// SetResolver internally calls netx.Dialer.SetResolver +func (c *HTTPClient) SetResolver(r modelx.DNSResolver) { + c.Transport.SetResolver(r) +} + +// SetCABundle internally calls netx.Dialer.SetCABundle and +// therefore it has the same caveats and limitations. +func (c *HTTPClient) SetCABundle(path string) error { + return c.Transport.SetCABundle(path) +} + +// ForceSpecificSNI forces using a specific SNI. +func (c *HTTPClient) ForceSpecificSNI(sni string) error { + return c.Transport.ForceSpecificSNI(sni) +} + +// ForceSkipVerify forces to skip certificate verification +func (c *HTTPClient) ForceSkipVerify() error { + return c.Transport.ForceSkipVerify() +} + +// CloseIdleConnections closes the idle connections. +func (c *HTTPClient) CloseIdleConnections() { + c.Transport.CloseIdleConnections() +} diff --git a/internal/engine/legacy/netx/http_test.go b/internal/engine/legacy/netx/http_test.go new file mode 100644 index 0000000..4e8551c --- /dev/null +++ b/internal/engine/legacy/netx/http_test.go @@ -0,0 +1,171 @@ +package netx_test + +import ( + "context" + "crypto/x509" + "errors" + "io/ioutil" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func dowithclient(t *testing.T, client *netx.HTTPClient) { + defer client.CloseIdleConnections() + resp, err := client.HTTPClient.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } +} + +func TestHTTPClient(t *testing.T) { + client := netx.NewHTTPClient() + dowithclient(t, client) +} + +func TestHTTPClientAndTransport(t *testing.T) { + client := netx.NewHTTPClient() + client.Transport = netx.NewHTTPTransport() + dowithclient(t, client) +} + +func TestHTTPClientConfigureDNS(t *testing.T) { + client := netx.NewHTTPClientWithoutProxy() + err := client.ConfigureDNS("udp", "1.1.1.1:53") + if err != nil { + t.Fatal(err) + } + dowithclient(t, client) +} + +func TestHTTPClientSetResolver(t *testing.T) { + client := netx.NewHTTPClientWithoutProxy() + client.SetResolver(new(net.Resolver)) + dowithclient(t, client) +} + +func TestHTTPClientSetCABundle(t *testing.T) { + client := netx.NewHTTPClientWithoutProxy() + err := client.SetCABundle("testdata/cacert.pem") + if err != nil { + t.Fatal(err) + } + resp, err := client.HTTPClient.Get("https://www.google.com") + var target x509.UnknownAuthorityError + if errors.As(err, &target) == false { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected a nil conn here") + } +} + +func TestHTTPClientForceSpecificSNI(t *testing.T) { + client := netx.NewHTTPClientWithoutProxy() + err := client.ForceSpecificSNI("www.facebook.com") + if err != nil { + t.Fatal(err) + } + resp, err := client.HTTPClient.Get("https://www.google.com") + var target x509.HostnameError + if errors.As(err, &target) == false { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected a nil response here") + } +} + +func TestHTTPClientForceSkipVerify(t *testing.T) { + client := netx.NewHTTPClientWithoutProxy() + client.ForceSkipVerify() + resp, err := client.HTTPClient.Get("https://self-signed.badssl.com/") + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non nil response here") + } +} + +func TestHTTPNewClientProxy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(451) + })) + defer server.Close() + client := netx.NewHTTPClientWithoutProxy() + httpProxyTestMain(t, client.HTTPClient, 200) + client = netx.NewHTTPClientWithProxyFunc(func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL) + }) + httpProxyTestMain(t, client.HTTPClient, 451) +} + +const httpProxyTestsURL = "http://explorer.ooni.org" + +func httpProxyTestMain(t *testing.T, client *http.Client, expect int) { + req, err := http.NewRequest("GET", httpProxyTestsURL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != expect { + t.Fatal("unexpected status code") + } +} + +func TestHTTPTransportTimeout(t *testing.T) { + client := &http.Client{Transport: netx.NewHTTPTransport()} + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err == nil { + t.Fatal("expected an error here") + } + if !strings.HasSuffix(err.Error(), errorx.FailureGenericTimeoutError) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil resp here") + } +} + +func TestHTTPTransportFailure(t *testing.T) { + client := &http.Client{Transport: netx.NewHTTPTransport()} + // This fails the request because we attempt to speak cleartext HTTP with + // a server that instead is expecting TLS. + resp, err := client.Get("http://www.google.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if resp != nil { + t.Fatal("expected a nil response here") + } + client.CloseIdleConnections() +} diff --git a/internal/engine/legacy/netx/modelx/modelx.go b/internal/engine/legacy/netx/modelx/modelx.go new file mode 100644 index 0000000..c0f595d --- /dev/null +++ b/internal/engine/legacy/netx/modelx/modelx.go @@ -0,0 +1,699 @@ +// Package modelx contains the data modelx. +package modelx + +import ( + "context" + "crypto/tls" + "crypto/x509" + "math" + "net" + "net/http" + "net/url" + "time" + + "github.com/miekg/dns" +) + +// Measurement contains zero or more events. Do not assume that at any +// time a Measurement will only contain a single event. When a Measurement +// contains an event, the corresponding pointer is non nil. +// +// All events contain a time measurement, `DurationSinceBeginning`, that +// uses a monotonic clock and is relative to a preconfigured "zero". +type Measurement struct { + // DNS events + // + // These are all identifed by a DialID. A ResolveEvent optionally has + // a reference to the TransactionID that started the dial, if any. + ResolveStart *ResolveStartEvent `json:",omitempty"` + DNSQuery *DNSQueryEvent `json:",omitempty"` + DNSReply *DNSReplyEvent `json:",omitempty"` + ResolveDone *ResolveDoneEvent `json:",omitempty"` + + // Syscalls + // + // These are all identified by a ConnID. A ConnectEvent has a reference + // to the DialID that caused this connection to be attempted. + // + // Because they are syscalls, we don't split them in start/done pairs + // but we record the amount of time in which we were blocked. + Connect *ConnectEvent `json:",omitempty"` + Read *ReadEvent `json:",omitempty"` + Write *WriteEvent `json:",omitempty"` + Close *CloseEvent `json:",omitempty"` + + // TLS events + // + // Identified by either ConnID or TransactionID. In the former case + // the TLS handshake is managed by net code, in the latter case it is + // instead managed by Golang's HTTP engine. It should not happen to + // have both ConnID and TransactionID different from zero. + TLSHandshakeStart *TLSHandshakeStartEvent `json:",omitempty"` + TLSHandshakeDone *TLSHandshakeDoneEvent `json:",omitempty"` + + // HTTP roundtrip events + // + // A round trip starts when we need a connection to send a request + // and ends when we've got the response headers or an error. + // + // The identifer here is TransactionID, where the transaction is + // like the round trip except that it terminates when we've finished + // reading the whole response body. + HTTPRoundTripStart *HTTPRoundTripStartEvent `json:",omitempty"` + HTTPConnectionReady *HTTPConnectionReadyEvent `json:",omitempty"` + HTTPRequestHeader *HTTPRequestHeaderEvent `json:",omitempty"` + HTTPRequestHeadersDone *HTTPRequestHeadersDoneEvent `json:",omitempty"` + HTTPRequestDone *HTTPRequestDoneEvent `json:",omitempty"` + HTTPResponseStart *HTTPResponseStartEvent `json:",omitempty"` + HTTPRoundTripDone *HTTPRoundTripDoneEvent `json:",omitempty"` + + // HTTP body events + // + // They are identified by the TransactionID. You are not going to see + // these events if you don't fully read response bodies. But that's + // something you are supposed to do, so you should be fine. + HTTPResponseBodyPart *HTTPResponseBodyPartEvent `json:",omitempty"` + HTTPResponseDone *HTTPResponseDoneEvent `json:",omitempty"` + + // Extension events. + // + // The purpose of these events is to give us some flexibility to + // experiment with message formats before blessing something as + // part of the official API of the library. The intent however is + // to avoid keeping something as an extension for a long time. + Extension *ExtensionEvent `json:",omitempty"` +} + +// CloseEvent is emitted when the CLOSE syscall returns. +type CloseEvent struct { + // ConnID is the identifier of this connection. + ConnID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is the error returned by CLOSE. + Error error + + // SyscallDuration is the number of nanoseconds we were + // blocked waiting for the syscall to return. + SyscallDuration time.Duration +} + +// ConnectEvent is emitted when the CONNECT syscall returns. +type ConnectEvent struct { + // ConnID is the identifier of this connection. + ConnID int64 + + // DialID is the identifier of the dial operation as + // part of which we called CONNECT. + DialID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is the error returned by CONNECT. + Error error + + // Network is the network we're dialing for, e.g. "tcp" + Network string + + // RemoteAddress is the remote IP address we're dialing for + RemoteAddress string + + // SyscallDuration is the number of nanoseconds we were + // blocked waiting for the syscall to return. + SyscallDuration time.Duration + + // TransactionID is the ID of the HTTP transaction that caused the + // current dial to run, or zero if there's no such transaction. + TransactionID int64 `json:",omitempty"` +} + +// DNSQueryEvent is emitted when we send a DNS query. +type DNSQueryEvent struct { + // Data is the raw data we're sending to the server. + Data []byte + + // DialID is the identifier of the dial operation as + // part of which we're sending this query. + DialID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Msg is the parsed message we're sending to the server. + Msg *dns.Msg `json:"-"` +} + +// DNSReplyEvent is emitted when we receive byte that are +// successfully parsed into a DNS reply. +type DNSReplyEvent struct { + // Data is the raw data we've received and parsed. + Data []byte + + // DialID is the identifier of the dial operation as + // part of which we've received this query. + DialID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Msg is the received parsed message. + Msg *dns.Msg `json:"-"` +} + +// ExtensionEvent is emitted by a netx extension. +type ExtensionEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Key is the unique identifier of the event. A good rule of + // thumb is to use `${packageName}.${messageType}`. + Key string + + // Severity of the emitted message ("WARN", "INFO", "DEBUG") + Severity string + + // TransactionID is the identifier of this transaction, provided + // that we have an active one, otherwise is zero. + TransactionID int64 + + // Value is the extension dependent message. This message + // has the only requirement of being JSON serializable. + Value interface{} +} + +// HTTPRoundTripStartEvent is emitted when the HTTP transport +// starts the HTTP "round trip". That is, when the transport +// receives from the HTTP client a request to sent. The round +// trip terminates when we receive headers. What we call the +// "transaction" here starts with this event and does not finish +// until we have also finished receiving the response body. +type HTTPRoundTripStartEvent struct { + // DialID is the identifier of the dial operation that + // caused this round trip to start. Typically, this occures + // when doing DoH. If zero, means that this round trip has + // not been started by any dial operation. + DialID int64 `json:",omitempty"` + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Method is the request method + Method string + + // TransactionID is the identifier of this transaction + TransactionID int64 + + // URL is the request URL + URL string +} + +// HTTPConnectionReadyEvent is emitted when the HTTP transport has got +// a connection which is ready for sending the request. +type HTTPConnectionReadyEvent struct { + // ConnID is the identifier of the connection that is ready. Knowing + // this ID allows you to bind HTTP events to net events. + ConnID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // TransactionID is the identifier of this transaction + TransactionID int64 +} + +// HTTPRequestHeaderEvent is emitted when we have written a header, +// where written typically means just "buffered". +type HTTPRequestHeaderEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Key is the header key + Key string + + // TransactionID is the identifier of this transaction + TransactionID int64 + + // Value is the value/values of this header. + Value []string +} + +// HTTPRequestHeadersDoneEvent is emitted when we have written, or more +// correctly, "buffered" all headers. +type HTTPRequestHeadersDoneEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Headers contain the original request headers. This is included + // here to make this event actionable without needing to join it with + // other events, i.e., to simplify logging. + Headers http.Header + + // Method is the original request method. This is here + // for the same reason of Headers. + Method string + + // TransactionID is the identifier of this transaction + TransactionID int64 + + // URL is the original request URL. This is here + // for the same reason of Headers. We use an object + // rather than a string, because here you want to + // use specific subfields directly for logging. + URL *url.URL +} + +// HTTPRequestDoneEvent is emitted when we have sent the request +// body or there has been any failure in sending the request. +type HTTPRequestDoneEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is non nil if we could not write the request headers or + // some specific part of the body. When this step of writing + // the request fails, of course the whole transaction will fail + // as well. This error however tells you that the issue was + // when sending the request, not when receiving the response. + Error error + + // TransactionID is the identifier of this transaction + TransactionID int64 +} + +// HTTPResponseStartEvent is emitted when we receive the byte from +// the response on the wire. +type HTTPResponseStartEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // TransactionID is the identifier of this transaction + TransactionID int64 +} + +const defaultBodySnapSize int64 = 1 << 20 + +// ComputeBodySnapSize computes the body snap size. If snapSize is negative +// we return MaxInt64. If it's zero we return the default snap size. Otherwise +// the value of snapSize is returned. +func ComputeBodySnapSize(snapSize int64) int64 { + if snapSize < 0 { + snapSize = math.MaxInt64 + } else if snapSize == 0 { + snapSize = defaultBodySnapSize + } + return snapSize +} + +// HTTPRoundTripDoneEvent is emitted at the end of the round trip. Either +// we have an error, or a valid HTTP response. An error could be caused +// either by not being able to send the request or not being able to receive +// the response. Note that here errors are network/TLS/dialing errors or +// protocol violation errors. No status code will cause errors here. +type HTTPRoundTripDoneEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is the overall result of the round trip. If non-nil, checking + // also the result of HTTPResponseDone helps to disambiguate whether the + // error was in sending the request or receiving the response. + Error error + + // RequestBodySnap contains a snap of the request body. We'll + // not read more than SnapSize bytes of the body. Because typically + // you control the request bodies that you send, perhaps think + // about saving them using other means. + RequestBodySnap []byte + + // RequestHeaders contain the original request headers. This is + // included here to make this event actionable without needing to + // join it with other events, as it's too important. + RequestHeaders http.Header + + // RequestMethod is the original request method. This is here + // for the same reason of RequestHeaders. + RequestMethod string + + // RequestURL is the original request URL. This is here + // for the same reason of RequestHeaders. + RequestURL string + + // ResponseBodySnap is like RequestBodySnap but for the response. You + // can still save the whole body by just reading it, if this + // is something that you need to do. We're using the snaps here + // mainly to log small stuff like DoH and redirects. + ResponseBodySnap []byte + + // ResponseHeaders contains the response headers if error is nil. + ResponseHeaders http.Header + + // ResponseProto contains the response protocol + ResponseProto string + + // ResponseStatusCode contains the HTTP status code if error is nil. + ResponseStatusCode int64 + + // MaxBodySnapSize is the maximum size of the bodies snapshot. + MaxBodySnapSize int64 + + // TransactionID is the identifier of this transaction + TransactionID int64 +} + +// HTTPResponseBodyPartEvent is emitted after we have received +// a part of the response body, or an error reading it. Note that +// bytes read here does not necessarily match bytes returned by +// ReadEvent because of (1) transparent gzip decompression by Go, +// (2) HTTP overhead (headers and chunked body), (3) TLS. This +// is the reason why we also want to record the error here rather +// than just recording the error in ReadEvent. +// +// Note that you are not going to see this event if you do not +// drain the response body, which you're supposed to do, tho. +type HTTPResponseBodyPartEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error indicates whether we could not read a part of the body + Error error + + // Data is a reference to the body we've just read. + Data []byte + + // TransactionID is the identifier of this transaction + TransactionID int64 +} + +// HTTPResponseDoneEvent is emitted after we have received the body, +// when the response body is being closed. +// +// Note that you are not going to see this event if you do not +// drain the response body, which you're supposed to do, tho. +type HTTPResponseDoneEvent struct { + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // TransactionID is the identifier of this transaction + TransactionID int64 +} + +// ReadEvent is emitted when the READ/RECV syscall returns. +type ReadEvent struct { + // ConnID is the identifier of this connection. + ConnID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is the error returned by READ/RECV. + Error error + + // NumBytes is the number of bytes received, which may in + // principle also be nonzero on error. + NumBytes int64 + + // SyscallDuration is the number of nanoseconds we were + // blocked waiting for the syscall to return. + SyscallDuration time.Duration +} + +// ResolveStartEvent is emitted when we start resolving a domain name. +type ResolveStartEvent struct { + // DialID is the identifier of the dial operation as + // part of which we're resolving this domain. + DialID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Hostname is the domain name to resolve. + Hostname string + + // TransactionID is the ID of the HTTP transaction that caused the + // current dial to run, or zero if there's no such transaction. + TransactionID int64 `json:",omitempty"` + + // TransportNetwork is the network used by the DNS transport, which + // can be one of "doh", "dot", "tcp", "udp", or "system". + TransportNetwork string + + // TransportAddress is the address used by the DNS transport, which + // is of course relative to the TransportNetwork. + TransportAddress string +} + +// ResolveDoneEvent is emitted when we know the IP addresses of a +// specific domain name, or the resolution failed. +type ResolveDoneEvent struct { + // Addresses is the list of returned addresses (empty on error). + Addresses []string + + // ContainsBogons indicates whether Addresses contains one + // or more IP addresses that classify as bogons. + ContainsBogons bool + + // DialID is the identifier of the dial operation as + // part of which we're resolving this domain. + DialID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is the result of the dial operation. + Error error + + // Hostname is the domain name to resolve. + Hostname string + + // TransactionID is the ID of the HTTP transaction that caused the + // current dial to run, or zero if there's no such transaction. + TransactionID int64 `json:",omitempty"` + + // TransportNetwork is the network used by the DNS transport, which + // can be one of "doh", "dot", "tcp", "udp", or "system". + TransportNetwork string + + // TransportAddress is the address used by the DNS transport, which + // is of course relative to the TransportNetwork. + TransportAddress string +} + +// X509Certificate is an x.509 certificate. +type X509Certificate struct { + // Data contains the certificate bytes in DER format. + Data []byte +} + +// TLSConnectionState contains the TLS connection state. +type TLSConnectionState struct { + CipherSuite uint16 + NegotiatedProtocol string + PeerCertificates []X509Certificate + Version uint16 +} + +// NewTLSConnectionState creates a new TLSConnectionState. +func NewTLSConnectionState(s tls.ConnectionState) TLSConnectionState { + return TLSConnectionState{ + CipherSuite: s.CipherSuite, + NegotiatedProtocol: s.NegotiatedProtocol, + PeerCertificates: SimplifyCerts(s.PeerCertificates), + Version: s.Version, + } +} + +// SimplifyCerts simplifies a certificate chain for archival +func SimplifyCerts(in []*x509.Certificate) (out []X509Certificate) { + for _, cert := range in { + out = append(out, X509Certificate{ + Data: cert.Raw, + }) + } + return +} + +// TLSHandshakeStartEvent is emitted when the TLS handshake starts. +type TLSHandshakeStartEvent struct { + // ConnID is the ID of the connection that started the TLS + // handshake, or zero if we don't know it. Typically, it is + // zero for connections managed by the HTTP transport, for + // which we know instead the TransactionID. + ConnID int64 `json:",omitempty"` + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // SNI is the SNI used when we force a specific SNI. + SNI string + + // TransactionID is the ID of the transaction that started + // this TLS handshake, or zero if we don't know it. Typically, + // it is zero for explicit dials, and it's nonzero instead + // when a connection is managed by HTTP code. + TransactionID int64 `json:",omitempty"` +} + +// TLSHandshakeDoneEvent is emitted when conn.Handshake returns. +type TLSHandshakeDoneEvent struct { + // ConnectionState is the TLS connection state. Depending on the + // error type, some fields may have little meaning. + ConnectionState TLSConnectionState + + // ConnID is the ID of the connection that started the TLS + // handshake, or zero if we don't know it. Typically, it is + // zero for connections managed by the HTTP transport, for + // which we know instead the TransactionID. + ConnID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is the result of the TLS handshake. + Error error + + // TransactionID is the ID of the transaction that started + // this TLS handshake, or zero if we don't know it. Typically, + // it is zero for explicit dials, and it's nonzero instead + // when a connection is managed by HTTP code. + TransactionID int64 +} + +// WriteEvent is emitted when the WRITE/SEND syscall returns. +type WriteEvent struct { + // ConnID is the identifier of this connection. + ConnID int64 + + // DurationSinceBeginning is the number of nanoseconds since + // the time configured as the "zero" time. + DurationSinceBeginning time.Duration + + // Error is the error returned by WRITE/SEND. + Error error + + // NumBytes is the number of bytes sent, which may in + // principle also be nonzero on error. + NumBytes int64 + + // SyscallDuration is the number of nanoseconds we were + // blocked waiting for the syscall to return. + SyscallDuration time.Duration +} + +// Handler handles measurement events. +type Handler interface { + // OnMeasurement is called when an event occurs. There will be no + // events after the code that is using the modified Dialer, Transport, + // or Client is returned. OnMeasurement may be called by background + // goroutines and OnMeasurement calls may happen concurrently. + OnMeasurement(Measurement) +} + +// DNSResolver is a DNS resolver. The *net.Resolver used by Go implements +// this interface, but other implementations are possible. +type DNSResolver interface { + // LookupHost resolves a hostname to a list of IP addresses. + LookupHost(ctx context.Context, hostname string) (addrs []string, err error) +} + +// Dialer is a dialer for network connections. +type Dialer interface { + // Dial dials a new connection + Dial(network, address string) (net.Conn, error) + + // DialContext is like Dial but with context + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// TLSDialer is a dialer for TLS connections. +type TLSDialer interface { + // DialTLS dials a new TLS connection + DialTLS(network, address string) (net.Conn, error) + + // DialTLSContext is like DialTLS but with context + DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// MeasurementRoot is the measurement root. +// +// If you attach this to a context, we'll use it rather than using +// the beginning and hndler configured with resolvers, dialers, HTTP +// clients, and HTTP transports. By attaching a measurement root to +// a context, you can naturally split events by HTTP round trip. +type MeasurementRoot struct { + // Beginning is the "zero" used to compute the elapsed time. + Beginning time.Time + + // Handler is the handler that will handle events. + Handler Handler + + // MaxBodySnapSize is the maximum size after which we'll stop + // reading request and response bodies. They will of course + // be fully transmitted, but we'll save only MaxBodySnapSize + // bytes as part of the event stream. If this value is negative, + // we use math.MaxInt64. If the value is zero, we use a + // reasonable large value. Otherwise, we'll use this value. + MaxBodySnapSize int64 +} + +type measurementRootContextKey struct{} + +type dummyHandler struct{} + +func (*dummyHandler) OnMeasurement(Measurement) {} + +// ContextMeasurementRoot returns the MeasurementRoot configured in the +// provided context, or a nil pointer, if not set. +func ContextMeasurementRoot(ctx context.Context) *MeasurementRoot { + root, _ := ctx.Value(measurementRootContextKey{}).(*MeasurementRoot) + return root +} + +// ContextMeasurementRootOrDefault returns the MeasurementRoot configured in +// the provided context, or a working, dummy, MeasurementRoot otherwise. +func ContextMeasurementRootOrDefault(ctx context.Context) *MeasurementRoot { + root := ContextMeasurementRoot(ctx) + if root == nil { + root = &MeasurementRoot{ + Beginning: time.Now(), + Handler: &dummyHandler{}, + } + } + return root +} + +// WithMeasurementRoot returns a copy of the context with the +// configured MeasurementRoot set. Panics if the provided root +// is a nil pointer, like httptrace.WithClientTrace. +// +// Merging more than one root is not supported. Setting again +// the root is just going to replace the original root. +func WithMeasurementRoot( + ctx context.Context, root *MeasurementRoot, +) context.Context { + if root == nil { + panic("nil measurement root") + } + return context.WithValue( + ctx, measurementRootContextKey{}, root, + ) +} diff --git a/internal/engine/legacy/netx/modelx/modelx_test.go b/internal/engine/legacy/netx/modelx/modelx_test.go new file mode 100644 index 0000000..82deb2e --- /dev/null +++ b/internal/engine/legacy/netx/modelx/modelx_test.go @@ -0,0 +1,86 @@ +package modelx + +import ( + "context" + "crypto/tls" + "errors" + "math" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestNewTLSConnectionState(t *testing.T) { + conn, err := tls.Dial("tcp", "www.google.com:443", nil) + if err != nil { + t.Fatal(err) + } + state := NewTLSConnectionState(conn.ConnectionState()) + if len(state.PeerCertificates) < 1 { + t.Fatal("too few certificates") + } + if state.Version < tls.VersionSSL30 || state.Version > 0x0304 /*tls.VersionTLS13*/ { + t.Fatal("unexpected TLS version") + } +} + +func TestMeasurementRoot(t *testing.T) { + ctx := context.Background() + if ContextMeasurementRoot(ctx) != nil { + t.Fatal("unexpected value for ContextMeasurementRoot") + } + if ContextMeasurementRootOrDefault(ctx) == nil { + t.Fatal("unexpected value ContextMeasurementRootOrDefault") + } + handler := &dummyHandler{} + root := &MeasurementRoot{ + Handler: handler, + Beginning: time.Time{}, + } + ctx = WithMeasurementRoot(ctx, root) + v := ContextMeasurementRoot(ctx) + if v != root { + t.Fatal("unexpected ContextMeasurementRoot value") + } + v = ContextMeasurementRootOrDefault(ctx) + if v != root { + t.Fatal("unexpected ContextMeasurementRoot value") + } +} + +func TestMeasurementRootWithMeasurementRootPanic(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + ctx := context.Background() + ctx = WithMeasurementRoot(ctx, nil) +} + +func TestErrWrapperPublicAPI(t *testing.T) { + child := errors.New("mocked error") + wrapper := &errorx.ErrWrapper{ + Failure: "moobar", + WrappedErr: child, + } + if wrapper.Error() != "moobar" { + t.Fatal("The Error() method is misbehaving") + } + if wrapper.Unwrap() != child { + t.Fatal("The Unwrap() method is misbehaving") + } +} + +func TestComputeBodySnapSize(t *testing.T) { + if ComputeBodySnapSize(-1) != math.MaxInt64 { + t.Fatal("unexpected result") + } + if ComputeBodySnapSize(0) != defaultBodySnapSize { + t.Fatal("unexpected result") + } + if ComputeBodySnapSize(127) != 127 { + t.Fatal("unexpected result") + } +} diff --git a/internal/engine/legacy/netx/oldhttptransport/bodytracer.go b/internal/engine/legacy/netx/oldhttptransport/bodytracer.go new file mode 100644 index 0000000..ed46c64 --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/bodytracer.go @@ -0,0 +1,82 @@ +package oldhttptransport + +import ( + "io" + "net/http" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" +) + +// BodyTracer performs single HTTP transactions and emits +// measurement events as they happen. +type BodyTracer struct { + Transport http.RoundTripper +} + +// NewBodyTracer creates a new Transport. +func NewBodyTracer(roundTripper http.RoundTripper) *BodyTracer { + return &BodyTracer{Transport: roundTripper} +} + +// RoundTrip executes a single HTTP transaction, returning +// a Response for the provided Request. +func (t *BodyTracer) RoundTrip(req *http.Request) (resp *http.Response, err error) { + resp, err = t.Transport.RoundTrip(req) + if err != nil { + return + } + // "The http Client and Transport guarantee that Body is always + // non-nil, even on responses without a body or responses with + // a zero-length body." (from the docs) + resp.Body = &bodyWrapper{ + ReadCloser: resp.Body, + root: modelx.ContextMeasurementRootOrDefault(req.Context()), + tid: transactionid.ContextTransactionID(req.Context()), + } + return +} + +// CloseIdleConnections closes the idle connections. +func (t *BodyTracer) CloseIdleConnections() { + // Adapted from net/http code + type closeIdler interface { + CloseIdleConnections() + } + if tr, ok := t.Transport.(closeIdler); ok { + tr.CloseIdleConnections() + } +} + +type bodyWrapper struct { + io.ReadCloser + root *modelx.MeasurementRoot + tid int64 +} + +func (bw *bodyWrapper) Read(b []byte) (n int, err error) { + n, err = bw.ReadCloser.Read(b) + bw.root.Handler.OnMeasurement(modelx.Measurement{ + HTTPResponseBodyPart: &modelx.HTTPResponseBodyPartEvent{ + // "Read reads up to len(p) bytes into p. It returns the number of + // bytes read (0 <= n <= len(p)) and any error encountered." + Data: b[:n], + Error: err, + DurationSinceBeginning: time.Now().Sub(bw.root.Beginning), + TransactionID: bw.tid, + }, + }) + return +} + +func (bw *bodyWrapper) Close() (err error) { + err = bw.ReadCloser.Close() + bw.root.Handler.OnMeasurement(modelx.Measurement{ + HTTPResponseDone: &modelx.HTTPResponseDoneEvent{ + DurationSinceBeginning: time.Now().Sub(bw.root.Beginning), + TransactionID: bw.tid, + }, + }) + return +} diff --git a/internal/engine/legacy/netx/oldhttptransport/bodytracer_test.go b/internal/engine/legacy/netx/oldhttptransport/bodytracer_test.go new file mode 100644 index 0000000..8d8e2c0 --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/bodytracer_test.go @@ -0,0 +1,39 @@ +package oldhttptransport + +import ( + "io/ioutil" + "net/http" + "testing" +) + +func TestBodyTracerSuccess(t *testing.T) { + client := &http.Client{ + Transport: NewBodyTracer(http.DefaultTransport), + } + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + client.CloseIdleConnections() +} + +func TestBodyTracerFailure(t *testing.T) { + client := &http.Client{ + Transport: NewBodyTracer(http.DefaultTransport), + } + // This fails the request because we attempt to speak cleartext HTTP with + // a server that instead is expecting TLS. + resp, err := client.Get("http://www.google.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if resp != nil { + t.Fatal("expected a nil response here") + } + client.CloseIdleConnections() +} diff --git a/internal/engine/legacy/netx/oldhttptransport/httptransport.go b/internal/engine/legacy/netx/oldhttptransport/httptransport.go new file mode 100644 index 0000000..4ee50be --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/httptransport.go @@ -0,0 +1,43 @@ +// Package oldhttptransport contains HTTP transport extensions. Here we +// define a http.Transport that emits events. +package oldhttptransport + +import ( + "net/http" +) + +// Transport performs single HTTP transactions and emits +// measurement events as they happen. +type Transport struct { + roundTripper http.RoundTripper +} + +// New creates a new Transport. +func New(roundTripper http.RoundTripper) *Transport { + return &Transport{ + roundTripper: NewTransactioner(NewBodyTracer( + NewTraceTripper(roundTripper))), + } +} + +// RoundTrip executes a single HTTP transaction, returning +// a Response for the provided Request. +func (t *Transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + // Make sure we're not sending Go's default User-Agent + // if the user has configured no user agent + if req.Header.Get("User-Agent") == "" { + req.Header["User-Agent"] = nil + } + return t.roundTripper.RoundTrip(req) +} + +// CloseIdleConnections closes the idle connections. +func (t *Transport) CloseIdleConnections() { + // Adapted from net/http code + type closeIdler interface { + CloseIdleConnections() + } + if tr, ok := t.roundTripper.(closeIdler); ok { + tr.CloseIdleConnections() + } +} diff --git a/internal/engine/legacy/netx/oldhttptransport/httptransport_test.go b/internal/engine/legacy/netx/oldhttptransport/httptransport_test.go new file mode 100644 index 0000000..b1c06ae --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/httptransport_test.go @@ -0,0 +1,39 @@ +package oldhttptransport + +import ( + "io/ioutil" + "net/http" + "testing" +) + +func TestGood(t *testing.T) { + client := &http.Client{ + Transport: New(http.DefaultTransport), + } + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + client.CloseIdleConnections() +} + +func TestFailure(t *testing.T) { + client := &http.Client{ + Transport: New(http.DefaultTransport), + } + // This fails the request because we attempt to speak cleartext HTTP with + // a server that instead is expecting TLS. + resp, err := client.Get("http://www.google.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if resp != nil { + t.Fatal("expected a nil response here") + } + client.CloseIdleConnections() +} diff --git a/internal/engine/legacy/netx/oldhttptransport/tracetripper.go b/internal/engine/legacy/netx/oldhttptransport/tracetripper.go new file mode 100644 index 0000000..8280d5f --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/tracetripper.go @@ -0,0 +1,274 @@ +package oldhttptransport + +import ( + "bytes" + "crypto/tls" + "io" + "io/ioutil" + "net/http" + "net/http/httptrace" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/connid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// TraceTripper performs single HTTP transactions. +type TraceTripper struct { + readAllErrs *atomicx.Int64 + readAll func(r io.Reader) ([]byte, error) + roundTripper http.RoundTripper +} + +// NewTraceTripper creates a new Transport. +func NewTraceTripper(roundTripper http.RoundTripper) *TraceTripper { + return &TraceTripper{ + readAllErrs: atomicx.NewInt64(), + readAll: ioutil.ReadAll, + roundTripper: roundTripper, + } +} + +type readCloseWrapper struct { + closer io.Closer + reader io.Reader +} + +func newReadCloseWrapper( + reader io.Reader, closer io.ReadCloser, +) *readCloseWrapper { + return &readCloseWrapper{ + closer: closer, + reader: reader, + } +} + +func (c *readCloseWrapper) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *readCloseWrapper) Close() error { + return c.closer.Close() +} + +func readSnap( + source *io.ReadCloser, limit int64, + readAll func(r io.Reader) ([]byte, error), +) (data []byte, err error) { + data, err = readAll(io.LimitReader(*source, limit)) + if err == nil { + *source = newReadCloseWrapper( + io.MultiReader(bytes.NewReader(data), *source), + *source, + ) + } + return +} + +// RoundTrip executes a single HTTP transaction, returning +// a Response for the provided Request. +func (t *TraceTripper) RoundTrip(req *http.Request) (*http.Response, error) { + root := modelx.ContextMeasurementRootOrDefault(req.Context()) + + tid := transactionid.ContextTransactionID(req.Context()) + root.Handler.OnMeasurement(modelx.Measurement{ + HTTPRoundTripStart: &modelx.HTTPRoundTripStartEvent{ + DialID: dialid.ContextDialID(req.Context()), + DurationSinceBeginning: time.Now().Sub(root.Beginning), + Method: req.Method, + TransactionID: tid, + URL: req.URL.String(), + }, + }) + + var ( + err error + majorOp = errorx.HTTPRoundTripOperation + majorOpMu sync.Mutex + requestBody []byte + requestHeaders = http.Header{} + requestHeadersMu sync.Mutex + snapSize = modelx.ComputeBodySnapSize(root.MaxBodySnapSize) + ) + + // Save a snapshot of the request body + if req.Body != nil { + requestBody, err = readSnap(&req.Body, snapSize, t.readAll) + if err != nil { + return nil, err + } + } + + // Prepare a tracer for delivering events + tracer := &httptrace.ClientTrace{ + TLSHandshakeStart: func() { + majorOpMu.Lock() + majorOp = errorx.TLSHandshakeOperation + majorOpMu.Unlock() + // Event emitted by net/http when DialTLS is not + // configured in the http.Transport + root.Handler.OnMeasurement(modelx.Measurement{ + TLSHandshakeStart: &modelx.TLSHandshakeStartEvent{ + DurationSinceBeginning: time.Now().Sub(root.Beginning), + TransactionID: tid, + }, + }) + }, + TLSHandshakeDone: func(state tls.ConnectionState, err error) { + // Wrapping the error even if we're not returning it because it may + // less confusing to users to see the wrapped name + err = errorx.SafeErrWrapperBuilder{ + Error: err, + Operation: errorx.TLSHandshakeOperation, + TransactionID: tid, + }.MaybeBuild() + durationSinceBeginning := time.Now().Sub(root.Beginning) + // Event emitted by net/http when DialTLS is not + // configured in the http.Transport + root.Handler.OnMeasurement(modelx.Measurement{ + TLSHandshakeDone: &modelx.TLSHandshakeDoneEvent{ + ConnectionState: modelx.NewTLSConnectionState(state), + Error: err, + DurationSinceBeginning: durationSinceBeginning, + TransactionID: tid, + }, + }) + }, + GotConn: func(info httptrace.GotConnInfo) { + majorOpMu.Lock() + majorOp = errorx.HTTPRoundTripOperation + majorOpMu.Unlock() + root.Handler.OnMeasurement(modelx.Measurement{ + HTTPConnectionReady: &modelx.HTTPConnectionReadyEvent{ + ConnID: connid.Compute( + info.Conn.LocalAddr().Network(), + info.Conn.LocalAddr().String(), + ), + DurationSinceBeginning: time.Now().Sub(root.Beginning), + TransactionID: tid, + }, + }) + }, + WroteHeaderField: func(key string, values []string) { + requestHeadersMu.Lock() + // Important: do not set directly into the headers map using + // the [] operator because net/http expects to be able to + // perform normalization of header names! + for _, value := range values { + requestHeaders.Add(key, value) + } + requestHeadersMu.Unlock() + root.Handler.OnMeasurement(modelx.Measurement{ + HTTPRequestHeader: &modelx.HTTPRequestHeaderEvent{ + DurationSinceBeginning: time.Now().Sub(root.Beginning), + Key: key, + TransactionID: tid, + Value: values, + }, + }) + }, + WroteHeaders: func() { + root.Handler.OnMeasurement(modelx.Measurement{ + HTTPRequestHeadersDone: &modelx.HTTPRequestHeadersDoneEvent{ + DurationSinceBeginning: time.Now().Sub(root.Beginning), + Headers: requestHeaders, // [*] + Method: req.Method, // [*] + TransactionID: tid, + URL: req.URL, // [*] + }, + }) + }, + WroteRequest: func(info httptrace.WroteRequestInfo) { + // Wrapping the error even if we're not returning it because it may + // less confusing to users to see the wrapped name + err := errorx.SafeErrWrapperBuilder{ + Error: info.Err, + Operation: errorx.HTTPRoundTripOperation, + TransactionID: tid, + }.MaybeBuild() + root.Handler.OnMeasurement(modelx.Measurement{ + HTTPRequestDone: &modelx.HTTPRequestDoneEvent{ + DurationSinceBeginning: time.Now().Sub(root.Beginning), + Error: err, + TransactionID: tid, + }, + }) + }, + GotFirstResponseByte: func() { + root.Handler.OnMeasurement(modelx.Measurement{ + HTTPResponseStart: &modelx.HTTPResponseStartEvent{ + DurationSinceBeginning: time.Now().Sub(root.Beginning), + TransactionID: tid, + }, + }) + }, + } + + // If we don't have already a tracer this is a toplevel request, so just + // set the tracer. Otherwise, we're doing DoH. We cannot set anothert trace + // because they'd be merged. Instead, replace the existing trace content + // with the new trace and then remember to reset it. + origtracer := httptrace.ContextClientTrace(req.Context()) + if origtracer != nil { + bkp := *origtracer + *origtracer = *tracer + defer func() { + *origtracer = bkp + }() + } else { + req = req.WithContext(httptrace.WithClientTrace(req.Context(), tracer)) + } + + resp, err := t.roundTripper.RoundTrip(req) + err = errorx.SafeErrWrapperBuilder{ + Error: err, + Operation: majorOp, + TransactionID: tid, + }.MaybeBuild() + // [*] Require less event joining work by providing info that + // makes this event alone actionable for OONI + event := &modelx.HTTPRoundTripDoneEvent{ + DurationSinceBeginning: time.Now().Sub(root.Beginning), + Error: err, + RequestBodySnap: requestBody, + RequestHeaders: requestHeaders, // [*] + RequestMethod: req.Method, // [*] + RequestURL: req.URL.String(), // [*] + MaxBodySnapSize: snapSize, + TransactionID: tid, + } + if resp != nil { + event.ResponseHeaders = resp.Header + event.ResponseStatusCode = int64(resp.StatusCode) + event.ResponseProto = resp.Proto + // Save a snapshot of the response body + var data []byte + data, err = readSnap(&resp.Body, snapSize, t.readAll) + if err != nil { + t.readAllErrs.Add(1) + resp = nil // this is how net/http likes it + } else { + event.ResponseBodySnap = data + } + } + root.Handler.OnMeasurement(modelx.Measurement{ + HTTPRoundTripDone: event, + }) + return resp, err +} + +// CloseIdleConnections closes the idle connections. +func (t *TraceTripper) CloseIdleConnections() { + // Adapted from net/http code + type closeIdler interface { + CloseIdleConnections() + } + if tr, ok := t.roundTripper.(closeIdler); ok { + tr.CloseIdleConnections() + } +} diff --git a/internal/engine/legacy/netx/oldhttptransport/tracetripper_test.go b/internal/engine/legacy/netx/oldhttptransport/tracetripper_test.go new file mode 100644 index 0000000..f53e4ab --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/tracetripper_test.go @@ -0,0 +1,272 @@ +package oldhttptransport + +import ( + "bytes" + "context" + "errors" + "io" + "io/ioutil" + "net/http" + "net/http/httptrace" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" +) + +func TestTraceTripperSuccess(t *testing.T) { + client := &http.Client{ + Transport: NewTraceTripper(http.DefaultTransport), + } + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + client.CloseIdleConnections() +} + +type roundTripHandler struct { + roundTrips []*modelx.HTTPRoundTripDoneEvent + mu sync.Mutex +} + +func (h *roundTripHandler) OnMeasurement(m modelx.Measurement) { + if m.HTTPRoundTripDone != nil { + h.mu.Lock() + defer h.mu.Unlock() + h.roundTrips = append(h.roundTrips, m.HTTPRoundTripDone) + } +} + +func TestTraceTripperReadAllFailure(t *testing.T) { + transport := NewTraceTripper(http.DefaultTransport) + transport.readAll = func(r io.Reader) ([]byte, error) { + return nil, io.EOF + } + client := &http.Client{Transport: transport} + resp, err := client.Get("https://google.com") + if err == nil { + t.Fatal("expected an error here") + } + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response here") + } + if transport.readAllErrs.Load() <= 0 { + t.Fatal("not the error we expected") + } + client.CloseIdleConnections() +} + +func TestTraceTripperFailure(t *testing.T) { + client := &http.Client{ + Transport: NewTraceTripper(http.DefaultTransport), + } + // This fails the request because we attempt to speak cleartext HTTP with + // a server that instead is expecting TLS. + resp, err := client.Get("http://www.google.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if resp != nil { + t.Fatal("expected a nil response here") + } + client.CloseIdleConnections() +} + +func TestTraceTripperWithClientTrace(t *testing.T) { + client := &http.Client{ + Transport: NewTraceTripper(http.DefaultTransport), + } + req, err := http.NewRequest("GET", "https://www.kernel.org/", nil) + if err != nil { + t.Fatal(err) + } + req = req.WithContext( + httptrace.WithClientTrace(req.Context(), new(httptrace.ClientTrace)), + ) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected a good response here") + } + resp.Body.Close() + client.CloseIdleConnections() +} + +func TestTraceTripperWithCorrectSnaps(t *testing.T) { + // Prepare a DNS query for dns.google.com A, for which we + // know the answer in terms of well know IP addresses + query := new(dns.Msg) + query.Id = dns.Id() + query.RecursionDesired = true + query.Question = make([]dns.Question, 1) + query.Question[0] = dns.Question{ + Name: dns.Fqdn("dns.google.com"), + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + } + queryData, err := query.Pack() + if err != nil { + t.Fatal(err) + } + + // Prepare a new transport with limited snapshot size and + // use such transport to configure an ordinary client + transport := NewTraceTripper(http.DefaultTransport) + const snapSize = 15 + client := &http.Client{Transport: transport} + + // Prepare a new request for Cloudflare DNS, register + // a handler, issue the request, fetch the response. + req, err := http.NewRequest( + "POST", "https://cloudflare-dns.com/dns-query", bytes.NewReader(queryData), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/dns-message") + handler := &roundTripHandler{} + ctx := modelx.WithMeasurementRoot( + context.Background(), &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: handler, + MaxBodySnapSize: snapSize, + }, + ) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatal("HTTP request failed") + } + + // Read the whole response body, parse it as valid DNS + // reply and verify we obtained what we expected + replyData, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + reply := new(dns.Msg) + err = reply.Unpack(replyData) + if err != nil { + t.Fatal(err) + } + if reply.Rcode != 0 { + t.Fatal("unexpected Rcode") + } + if len(reply.Answer) < 1 { + t.Fatal("no answers?!") + } + found8888, found8844, foundother := false, false, false + for _, answer := range reply.Answer { + if rra, ok := answer.(*dns.A); ok { + ip := rra.A.String() + if ip == "8.8.8.8" { + found8888 = true + } else if ip == "8.8.4.4" { + found8844 = true + } else { + foundother = true + } + } + } + if !found8888 || !found8844 || foundother { + t.Fatal("unexpected reply") + } + + // Finally, make sure we have captured the correct + // snapshots for the request and response bodies + if len(handler.roundTrips) != 1 { + t.Fatal("more round trips than expected") + } + roundTrip := handler.roundTrips[0] + if len(roundTrip.RequestBodySnap) != snapSize { + t.Fatal("unexpected request body snap length") + } + if len(roundTrip.ResponseBodySnap) != snapSize { + t.Fatal("unexpected response body snap length") + } + if !bytes.Equal(roundTrip.RequestBodySnap, queryData[:snapSize]) { + t.Fatal("the request body snap is wrong") + } + if !bytes.Equal(roundTrip.ResponseBodySnap, replyData[:snapSize]) { + t.Fatal("the response body snap is wrong") + } +} + +func TestTraceTripperWithReadAllFailingForBody(t *testing.T) { + // Prepare a DNS query for dns.google.com A, for which we + // know the answer in terms of well know IP addresses + query := new(dns.Msg) + query.Id = dns.Id() + query.RecursionDesired = true + query.Question = make([]dns.Question, 1) + query.Question[0] = dns.Question{ + Name: dns.Fqdn("dns.google.com"), + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + } + queryData, err := query.Pack() + if err != nil { + t.Fatal(err) + } + + // Prepare a new transport with limited snapshot size and + // use such transport to configure an ordinary client + transport := NewTraceTripper(http.DefaultTransport) + errorMocked := errors.New("mocked error") + transport.readAll = func(r io.Reader) ([]byte, error) { + return nil, errorMocked + } + const snapSize = 15 + client := &http.Client{Transport: transport} + + // Prepare a new request for Cloudflare DNS, register + // a handler, issue the request, fetch the response. + req, err := http.NewRequest( + "POST", "https://cloudflare-dns.com/dns-query", bytes.NewReader(queryData), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/dns-message") + handler := &roundTripHandler{} + ctx := modelx.WithMeasurementRoot( + context.Background(), &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: handler, + MaxBodySnapSize: snapSize, + }, + ) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err == nil { + t.Fatal("expected an error here") + } + if !errors.Is(err, errorMocked) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response here") + } + + // Finally, make sure we got something that makes sense + if len(handler.roundTrips) != 0 { + t.Fatal("more round trips than expected") + } +} diff --git a/internal/engine/legacy/netx/oldhttptransport/transactioner.go b/internal/engine/legacy/netx/oldhttptransport/transactioner.go new file mode 100644 index 0000000..4e9c5a1 --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/transactioner.go @@ -0,0 +1,38 @@ +package oldhttptransport + +import ( + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" +) + +// Transactioner performs single HTTP transactions. +type Transactioner struct { + roundTripper http.RoundTripper +} + +// NewTransactioner creates a new Transport. +func NewTransactioner(roundTripper http.RoundTripper) *Transactioner { + return &Transactioner{ + roundTripper: roundTripper, + } +} + +// RoundTrip executes a single HTTP transaction, returning +// a Response for the provided Request. +func (t *Transactioner) RoundTrip(req *http.Request) (*http.Response, error) { + return t.roundTripper.RoundTrip(req.WithContext( + transactionid.WithTransactionID(req.Context()), + )) +} + +// CloseIdleConnections closes the idle connections. +func (t *Transactioner) CloseIdleConnections() { + // Adapted from net/http code + type closeIdler interface { + CloseIdleConnections() + } + if tr, ok := t.roundTripper.(closeIdler); ok { + tr.CloseIdleConnections() + } +} diff --git a/internal/engine/legacy/netx/oldhttptransport/transactioner_test.go b/internal/engine/legacy/netx/oldhttptransport/transactioner_test.go new file mode 100644 index 0000000..1eb9cc0 --- /dev/null +++ b/internal/engine/legacy/netx/oldhttptransport/transactioner_test.go @@ -0,0 +1,57 @@ +package oldhttptransport + +import ( + "io/ioutil" + "net/http" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" +) + +type transactionerCheckTransactionID struct { + roundTripper http.RoundTripper + t *testing.T +} + +func (t *transactionerCheckTransactionID) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + if id := transactionid.ContextTransactionID(ctx); id == 0 { + t.t.Fatal("transaction ID not set") + } + return t.roundTripper.RoundTrip(req) +} + +func TestTransactionerSuccess(t *testing.T) { + client := &http.Client{ + Transport: NewTransactioner(&transactionerCheckTransactionID{ + roundTripper: http.DefaultTransport, + t: t, + }), + } + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + client.CloseIdleConnections() +} + +func TestTransactionerFailure(t *testing.T) { + client := &http.Client{ + Transport: NewTransactioner(http.DefaultTransport), + } + // This fails the request because we attempt to speak cleartext HTTP with + // a server that instead is expecting TLS. + resp, err := client.Get("http://www.google.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if resp != nil { + t.Fatal("expected a nil response here") + } + client.CloseIdleConnections() +} diff --git a/internal/engine/legacy/netx/resolver.go b/internal/engine/legacy/netx/resolver.go new file mode 100644 index 0000000..0b685bf --- /dev/null +++ b/internal/engine/legacy/netx/resolver.go @@ -0,0 +1,178 @@ +package netx + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +var ( + dohClientHandle *http.Client + dohClientOnce sync.Once +) + +func newHTTPClientForDoH(beginning time.Time, handler modelx.Handler) *http.Client { + if handler == handlers.NoHandler { + // A bit of extra complexity for a good reason: if the user is not + // interested into setting a default handler, then it is fine to + // always return the same *http.Client for DoH. This means that we + // don't need to care about closing the connections used by this + // *http.Client, therefore we don't leak resources because we fail + // to close the idle connections. + dohClientOnce.Do(func() { + transport := newHTTPTransport( + time.Now(), + handlers.NoHandler, + newDialer(time.Now(), handler), + false, // DisableKeepAlives + http.ProxyFromEnvironment, + ) + dohClientHandle = &http.Client{Transport: transport} + }) + return dohClientHandle + } + // Otherwise, if the user wants to have a default handler, we + // return a transport that does not leak connections. + transport := newHTTPTransport( + beginning, + handler, + newDialer(beginning, handler), + true, // DisableKeepAlives + http.ProxyFromEnvironment, + ) + return &http.Client{Transport: transport} +} + +func withPort(address, port string) string { + // Handle the case where port was not specified. We have written in + // a bunch of places that we can just pass a domain in this case and + // so we need to gracefully ensure this is still possible. + _, _, err := net.SplitHostPort(address) + if err != nil && strings.Contains(err.Error(), "missing port in address") { + address = net.JoinHostPort(address, port) + } + return address +} + +type resolverWrapper struct { + beginning time.Time + handler modelx.Handler + resolver modelx.DNSResolver +} + +func newResolverWrapper( + beginning time.Time, handler modelx.Handler, + resolver modelx.DNSResolver, +) *resolverWrapper { + return &resolverWrapper{ + beginning: beginning, + handler: handler, + resolver: resolver, + } +} + +// LookupHost returns the IP addresses of a host +func (r *resolverWrapper) LookupHost(ctx context.Context, hostname string) ([]string, error) { + ctx = maybeWithMeasurementRoot(ctx, r.beginning, r.handler) + return r.resolver.LookupHost(ctx, hostname) +} + +func newResolver( + beginning time.Time, handler modelx.Handler, network, address string, +) (modelx.DNSResolver, error) { + // Implementation note: system need to be dealt with + // separately because it doesn't have any transport. + if network == "system" || network == "" { + return newResolverWrapper( + beginning, handler, newResolverSystem()), nil + } + if network == "doh" { + return newResolverWrapper(beginning, handler, newResolverHTTPS( + newHTTPClientForDoH(beginning, handler), address, + )), nil + } + if network == "dot" { + // We need a child dialer here to avoid an endless loop where the + // dialer will ask us to resolve, we'll tell the dialer to dial, it + // will ask us to resolve, ... + return newResolverWrapper(beginning, handler, newResolverTLS( + newDialer(beginning, handler).DialTLSContext, withPort(address, "853"), + )), nil + } + if network == "tcp" { + // Same rationale as above: avoid possible endless loop + return newResolverWrapper(beginning, handler, newResolverTCP( + newDialer(beginning, handler).DialContext, withPort(address, "53"), + )), nil + } + if network == "udp" { + // Same rationale as above: avoid possible endless loop + return newResolverWrapper(beginning, handler, newResolverUDP( + newDialer(beginning, handler), withPort(address, "53"), + )), nil + } + return nil, errors.New("resolver.New: unsupported network value") +} + +// NewResolver creates a standalone Resolver +func NewResolver(network, address string) (modelx.DNSResolver, error) { + return newResolver(time.Now(), handlers.NoHandler, network, address) +} + +type chainWrapperResolver struct { + modelx.DNSResolver +} + +func (r chainWrapperResolver) Network() string { + return "chain" +} + +func (r chainWrapperResolver) Address() string { + return "" +} + +// ChainResolvers chains a primary and a secondary resolver such that +// we can fallback to the secondary if primary is broken. +func ChainResolvers(primary, secondary modelx.DNSResolver) modelx.DNSResolver { + return resolver.ChainResolver{ + Primary: chainWrapperResolver{DNSResolver: primary}, + Secondary: chainWrapperResolver{DNSResolver: secondary}, + } +} + +func resolverWrapResolver(r resolver.Resolver) resolver.EmitterResolver { + return resolver.EmitterResolver{Resolver: resolver.ErrorWrapperResolver{Resolver: r}} +} + +func resolverWrapTransport(txp resolver.RoundTripper) resolver.EmitterResolver { + return resolverWrapResolver(resolver.NewSerialResolver( + resolver.EmitterTransport{RoundTripper: txp})) +} + +func newResolverSystem() resolver.EmitterResolver { + return resolverWrapResolver(resolver.SystemResolver{}) +} + +func newResolverUDP(dialer resolver.Dialer, address string) resolver.EmitterResolver { + return resolverWrapTransport(resolver.NewDNSOverUDP(dialer, address)) +} + +func newResolverTCP(dial resolver.DialContextFunc, address string) resolver.EmitterResolver { + return resolverWrapTransport(resolver.NewDNSOverTCP(dial, address)) +} + +func newResolverTLS(dial resolver.DialContextFunc, address string) resolver.EmitterResolver { + return resolverWrapTransport(resolver.NewDNSOverTLS(dial, address)) +} + +func newResolverHTTPS(client *http.Client, address string) resolver.EmitterResolver { + return resolverWrapTransport(resolver.NewDNSOverHTTPS(client, address)) +} diff --git a/internal/engine/legacy/netx/resolver_internal_test.go b/internal/engine/legacy/netx/resolver_internal_test.go new file mode 100644 index 0000000..68351cc --- /dev/null +++ b/internal/engine/legacy/netx/resolver_internal_test.go @@ -0,0 +1,14 @@ +package netx + +import ( + "net/http" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" +) + +func NewHTTPClientForDoH(beginning time.Time, handler modelx.Handler) *http.Client { + return newHTTPClientForDoH(beginning, handler) +} + +type ChainWrapperResolver = chainWrapperResolver diff --git a/internal/engine/legacy/netx/resolver_test.go b/internal/engine/legacy/netx/resolver_test.go new file mode 100644 index 0000000..f5c4ae3 --- /dev/null +++ b/internal/engine/legacy/netx/resolver_test.go @@ -0,0 +1,168 @@ +package netx_test + +import ( + "context" + "io" + "os" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" +) + +func testresolverquick(t *testing.T, network, address string) { + resolver, err := netx.NewResolver(network, address) + if err != nil { + t.Fatal(err) + } + if resolver == nil { + t.Fatal("expected non-nil resolver here") + } + addrs, err := resolver.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatalf("legacy/netx/resolver_test.go: %+v with %s/%s", err, network, address) + } + if addrs == nil { + t.Fatal("expected non-nil addrs here") + } + var foundquad8 bool + for _, addr := range addrs { + // See https://github.com/ooni/probe-cli/v3/internal/engine/pull/954/checks?check_run_id=1182269025 + if addr == "8.8.8.8" || addr == "2001:4860:4860::8888" { + foundquad8 = true + } + } + if !foundquad8 { + t.Fatalf("did not find 8.8.8.8 in ouput; output=%+v", addrs) + } +} + +func TestNewResolverUDPAddress(t *testing.T) { + testresolverquick(t, "udp", "8.8.8.8:53") +} + +func TestNewResolverUDPAddressNoPort(t *testing.T) { + testresolverquick(t, "udp", "8.8.8.8") +} + +func TestNewResolverUDPDomain(t *testing.T) { + testresolverquick(t, "udp", "dns.google.com:53") +} + +func TestNewResolverUDPDomainNoPort(t *testing.T) { + testresolverquick(t, "udp", "dns.google.com") +} + +func TestNewResolverSystem(t *testing.T) { + testresolverquick(t, "system", "") +} + +func TestNewResolverTCPAddress(t *testing.T) { + testresolverquick(t, "tcp", "8.8.8.8:53") +} + +func TestNewResolverTCPAddressNoPort(t *testing.T) { + testresolverquick(t, "tcp", "8.8.8.8") +} + +func TestNewResolverTCPDomain(t *testing.T) { + testresolverquick(t, "tcp", "dns.google.com:53") +} + +func TestNewResolverTCPDomainNoPort(t *testing.T) { + testresolverquick(t, "tcp", "dns.google.com") +} + +func TestNewResolverDoTAddress(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") == "true" { + t.Skip("this test is not reliable in GitHub actions") + } + testresolverquick(t, "dot", "9.9.9.9:853") +} + +func TestNewResolverDoTAddressNoPort(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") == "true" { + t.Skip("this test is not reliable in GitHub actions") + } + testresolverquick(t, "dot", "9.9.9.9") +} + +func TestNewResolverDoTDomain(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") == "true" { + t.Skip("this test is not reliable in GitHub actions") + } + testresolverquick(t, "dot", "dns.quad9.net:853") +} + +func TestNewResolverDoTDomainNoPort(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") == "true" { + t.Skip("this test is not reliable in GitHub actions") + } + testresolverquick(t, "dot", "dns.quad9.net") +} + +func TestNewResolverDoH(t *testing.T) { + testresolverquick(t, "doh", "https://cloudflare-dns.com/dns-query") +} + +func TestNewResolverInvalid(t *testing.T) { + resolver, err := netx.NewResolver( + "antani", "https://cloudflare-dns.com/dns-query", + ) + if err == nil { + t.Fatal("expected an error here") + } + if resolver != nil { + t.Fatal("expected a nil resolver here") + } +} + +type failingResolver struct{} + +func (failingResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + return nil, io.EOF +} + +func TestChainResolvers(t *testing.T) { + fallback, err := netx.NewResolver("udp", "1.1.1.1:53") + if err != nil { + t.Fatal(err) + } + dialer := netx.NewDialer() + resolver := netx.ChainResolvers(failingResolver{}, fallback) + dialer.SetResolver(resolver) + conn, err := dialer.Dial("tcp", "www.google.com:80") + if err != nil { + t.Fatal(err) // we don't expect error because good resolver is first + } + defer conn.Close() +} + +func TestNewHTTPClientForDoH(t *testing.T) { + first := netx.NewHTTPClientForDoH( + time.Now(), handlers.NoHandler, + ) + second := netx.NewHTTPClientForDoH( + time.Now(), handlers.NoHandler, + ) + if first != second { + t.Fatal("expected to see same client here") + } + third := netx.NewHTTPClientForDoH( + time.Now(), handlers.StdoutHandler, + ) + if first == third { + t.Fatal("expected to see different client here") + } +} + +func TestChainWrapperResolver(t *testing.T) { + r := netx.ChainWrapperResolver{} + if r.Address() != "" { + t.Fatal("invalid Address") + } + if r.Network() != "chain" { + t.Fatal("invalid Network") + } +} diff --git a/internal/engine/legacy/netx/testdata/cacert-invalid.pem b/internal/engine/legacy/netx/testdata/cacert-invalid.pem new file mode 100644 index 0000000..d4edb11 --- /dev/null +++ b/internal/engine/legacy/netx/testdata/cacert-invalid.pem @@ -0,0 +1,13 @@ +# +# The following is a truncated CA bundle for integration testing. This +# will give us confidence that we fail if the file is wrong. +# + +emSign ECC Root CA - C3 +======================= +-----BEGIN CERTIFICATE----- +MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQGEwJVUzETMBEG +A1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxIDAeBgNVBAMTF2VtU2lnbiBF +Q0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQD +ExdlbVNpZ24gRUNDIFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd diff --git a/internal/engine/legacy/netx/testdata/cacert.pem b/internal/engine/legacy/netx/testdata/cacert.pem new file mode 100644 index 0000000..3a96ced --- /dev/null +++ b/internal/engine/legacy/netx/testdata/cacert.pem @@ -0,0 +1,54 @@ +# +# The following is a minimal, valid CA bundle. We do not include +# however the certificates required to validate www.google.com +# and we check in tests that we cannot connect to it and successfully +# complete a TLS handshake. This gives us confidence that we can +# actually override the CA bundle path. +# + +emSign ECC Root CA - C3 +======================= +-----BEGIN CERTIFICATE----- +MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQGEwJVUzETMBEG +A1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxIDAeBgNVBAMTF2VtU2lnbiBF +Q0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQD +ExdlbVNpZ24gRUNDIFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd +6bciMK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4OjavtisIGJAnB9 +SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0OBBYEFPtaSNCAIEDyqOkA +B2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gA +MGUCMQC02C8Cif22TGK6Q04ThHK1rt0c3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwU +ZOR8loMRnLDRWmFLpg9J0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ== +-----END CERTIFICATE----- + +Hongkong Post Root CA 3 +======================= +-----BEGIN CERTIFICATE----- +MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQELBQAwbzELMAkG +A1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJSG9uZyBLb25nMRYwFAYDVQQK +Ew1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25na29uZyBQb3N0IFJvb3QgQ0EgMzAeFw0xNzA2 +MDMwMjI5NDZaFw00MjA2MDMwMjI5NDZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtv +bmcxEjAQBgNVBAcTCUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMX +SG9uZ2tvbmcgUG9zdCBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz +iNfqzg8gTr7m1gNt7ln8wlffKWihgw4+aMdoWJwcYEuJQwy51BWy7sFOdem1p+/l6TWZ5Mwc50tf +jTMwIDNT2aa71T4Tjukfh0mtUC1Qyhi+AViiE3CWu4mIVoBc+L0sPOFMV4i707mV78vH9toxdCim +5lSJ9UExyuUmGs2C4HDaOym71QP1mbpV9WTRYA6ziUm4ii8F0oRFKHyPaFASePwLtVPLwpgchKOe +sL4jpNrcyCse2m5FHomY2vkALgbpDDtw1VAliJnLzXNg99X/NWfFobxeq81KuEXryGgeDQ0URhLj +0mRiikKYvLTGCAj4/ahMZJx2Ab0vqWwzD9g/KLg8aQFChn5pwckGyuV6RmXpwtZQQS4/t+TtbNe/ +JgERohYpSms0BpDsE9K2+2p20jzt8NYt3eEV7KObLyzJPivkaTv/ciWxNoZbx39ri1UbSsUgYT2u +y1DhCDq+sI9jQVMwCFk8mB13umOResoQUGC/8Ne8lYePl8X+l2oBlKN8W4UdKjk60FSh0Tlxnf0h ++bV78OLgAo9uliQlLKAeLKjEiafv7ZkGL7YKTE/bosw3Gq9HhS2KX8Q0NEwA/RiTZxPRN+ZItIsG +xVd7GYYKecsAyVKvQv83j+GjHno9UKtjBucVtT+2RTeUN7F+8kjDf8V1/peNRY8apxpyKBpADwID +AQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQXnc0e +i9Y5K3DTXNSguB+wAPzFYTAdBgNVHQ4EFgQUF53NHovWOStw01zUoLgfsAD8xWEwDQYJKoZIhvcN +AQELBQADggIBAFbVe27mIgHSQpsY1Q7XZiNc4/6gx5LS6ZStS6LG7BJ8dNVI0lkUmcDrudHr9Egw +W62nV3OZqdPlt9EuWSRY3GguLmLYauRwCy0gUCCkMpXRAJi70/33MvJJrsZ64Ee+bs7Lo3I6LWld +y8joRTnU+kLBEUx3XZL7av9YROXrgZ6voJmtvqkBZss4HTzfQx/0TW60uhdG/H39h4F5ag0zD/ov ++BS5gLNdTaqX4fnkGMX41TiMJjz98iji7lpJiCzfeT2OnpA8vUFKOt1b9pq0zj8lMH8yfaIDlNDc +eqFS3m6TjRgm/VWsvY+b0s+v54Ysyx8Jb6NvqYTUc79NoXQbTiNg8swOqn+knEwlqLJmOzj/2ZQw +9nKEvmhVEA/GcywWaZMH/rFF7buiVWqw2rVKAiUnhde3t4ZEFolsgCs+l6mc1X5VTMbeRRAc6uk7 +nwNT7u56AQIWeNTowr5GdogTPyK7SBIdUgC0An4hGh6cJfTzPV4e0hz5sy229zdcxsshTrD3mUcY +hcErulWuBurQB7Lcq9CClnXO0lD+mefPL5/ndtFhKvshuzHQqp9HpLIiyhY6UFfEW0NnxWViA0kB +60PZ2Pierc+xYw5F9KBaLJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fq +dBb9HxEGmpv0 +-----END CERTIFICATE----- diff --git a/internal/engine/legacy/netx/transactionid/transactionid.go b/internal/engine/legacy/netx/transactionid/transactionid.go new file mode 100644 index 0000000..91f5168 --- /dev/null +++ b/internal/engine/legacy/netx/transactionid/transactionid.go @@ -0,0 +1,25 @@ +// Package transactionid contains code to share the transactionID +package transactionid + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" +) + +type contextkey struct{} + +var id = atomicx.NewInt64() + +// WithTransactionID returns a copy of ctx with TransactionID +func WithTransactionID(ctx context.Context) context.Context { + return context.WithValue( + ctx, contextkey{}, id.Add(1), + ) +} + +// ContextTransactionID returns the TransactionID of the context, or zero +func ContextTransactionID(ctx context.Context) int64 { + id, _ := ctx.Value(contextkey{}).(int64) + return id +} diff --git a/internal/engine/legacy/netx/transactionid/transactionid_test.go b/internal/engine/legacy/netx/transactionid/transactionid_test.go new file mode 100644 index 0000000..26e8889 --- /dev/null +++ b/internal/engine/legacy/netx/transactionid/transactionid_test.go @@ -0,0 +1,24 @@ +package transactionid + +import ( + "context" + "testing" +) + +func TestGood(t *testing.T) { + ctx := context.Background() + id := ContextTransactionID(ctx) + if id != 0 { + t.Fatal("unexpected ID for empty context") + } + ctx = WithTransactionID(ctx) + id = ContextTransactionID(ctx) + if id != 1 { + t.Fatal("expected ID equal to 1") + } + ctx = WithTransactionID(ctx) + id = ContextTransactionID(ctx) + if id != 2 { + t.Fatal("expected ID equal to 2") + } +} diff --git a/internal/engine/legacy/netxlogger/netxlogger.go b/internal/engine/legacy/netxlogger/netxlogger.go new file mode 100644 index 0000000..95ba796 --- /dev/null +++ b/internal/engine/legacy/netxlogger/netxlogger.go @@ -0,0 +1,173 @@ +// Package netxlogger is a logger for netx events. +// +// This package is a fork of github.com/ooni/netx/x/logger where +// we applied ooni/probe-engine specific customisations. +package netxlogger + +import ( + "net/http" + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" +) + +// Logger is the interface we expect from a logger +type Logger interface { + Debug(msg string) + Debugf(format string, v ...interface{}) +} + +// Handler is a handler that logs events. +type Handler struct { + logger Logger +} + +// NewHandler returns a new logging handler. +func NewHandler(logger Logger) *Handler { + return &Handler{logger: logger} +} + +// OnMeasurement logs the specific measurement +func (h *Handler) OnMeasurement(m modelx.Measurement) { + // DNS + if m.ResolveStart != nil { + h.logger.Debugf( + "[httpTxID: %d] resolving: %s", + m.ResolveStart.TransactionID, + m.ResolveStart.Hostname, + ) + } + if m.ResolveDone != nil { + h.logger.Debugf( + "[httpTxID: %d] resolve done: %s, %s", + m.ResolveDone.TransactionID, + fmtError(m.ResolveDone.Error), + m.ResolveDone.Addresses, + ) + } + + // Syscalls + if m.Connect != nil { + h.logger.Debugf( + "[httpTxID: %d] connect done: %s, %s (rtt=%s)", + m.Connect.TransactionID, + fmtError(m.Connect.Error), + m.Connect.RemoteAddress, + m.Connect.SyscallDuration, + ) + } + + // TLS + if m.TLSHandshakeStart != nil { + h.logger.Debugf( + "[httpTxID: %d] TLS handshake: (forceSNI='%s')", + m.TLSHandshakeStart.TransactionID, + m.TLSHandshakeStart.SNI, + ) + } + if m.TLSHandshakeDone != nil { + h.logger.Debugf( + "[httpTxID: %d] TLS done: %s, %s (alpn='%s')", + m.TLSHandshakeDone.TransactionID, + fmtError(m.TLSHandshakeDone.Error), + tlsx.VersionString(m.TLSHandshakeDone.ConnectionState.Version), + m.TLSHandshakeDone.ConnectionState.NegotiatedProtocol, + ) + } + + // HTTP round trip + if m.HTTPRequestHeadersDone != nil { + proto := "HTTP/1.1" + for key := range m.HTTPRequestHeadersDone.Headers { + if strings.HasPrefix(key, ":") { + proto = "HTTP/2.0" + break + } + } + h.logger.Debugf( + "[httpTxID: %d] > %s %s %s", + m.HTTPRequestHeadersDone.TransactionID, + m.HTTPRequestHeadersDone.Method, + m.HTTPRequestHeadersDone.URL.RequestURI(), + proto, + ) + if proto == "HTTP/2.0" { + h.logger.Debugf( + "[httpTxID: %d] > Host: %s", + m.HTTPRequestHeadersDone.TransactionID, + m.HTTPRequestHeadersDone.URL.Host, + ) + } + for key, values := range m.HTTPRequestHeadersDone.Headers { + if strings.HasPrefix(key, ":") { + continue + } + for _, value := range values { + h.logger.Debugf( + "[httpTxID: %d] > %s: %s", + m.HTTPRequestHeadersDone.TransactionID, + key, value, + ) + } + } + h.logger.Debugf( + "[httpTxID: %d] >", m.HTTPRequestHeadersDone.TransactionID) + } + if m.HTTPRequestDone != nil { + h.logger.Debugf( + "[httpTxID: %d] request sent; waiting for response", + m.HTTPRequestDone.TransactionID, + ) + } + if m.HTTPResponseStart != nil { + h.logger.Debugf( + "[httpTxID: %d] start receiving response", + m.HTTPResponseStart.TransactionID, + ) + } + if m.HTTPRoundTripDone != nil && m.HTTPRoundTripDone.Error == nil { + h.logger.Debugf( + "[httpTxID: %d] < %s %d %s", + m.HTTPRoundTripDone.TransactionID, + m.HTTPRoundTripDone.ResponseProto, + m.HTTPRoundTripDone.ResponseStatusCode, + http.StatusText(int(m.HTTPRoundTripDone.ResponseStatusCode)), + ) + for key, values := range m.HTTPRoundTripDone.ResponseHeaders { + for _, value := range values { + h.logger.Debugf( + "[httpTxID: %d] < %s: %s", + m.HTTPRoundTripDone.TransactionID, + key, value, + ) + } + } + h.logger.Debugf( + "[httpTxID: %d] <", m.HTTPRoundTripDone.TransactionID) + } + + // HTTP response body + if m.HTTPResponseBodyPart != nil { + h.logger.Debugf( + "[httpTxID: %d] body part: %s, %d", + m.HTTPResponseBodyPart.TransactionID, + fmtError(m.HTTPResponseBodyPart.Error), + len(m.HTTPResponseBodyPart.Data), + ) + } + if m.HTTPResponseDone != nil { + h.logger.Debugf( + "[httpTxID: %d] end of response", + m.HTTPResponseDone.TransactionID, + ) + } +} + +func fmtError(err error) (s string) { + s = "success" + if err != nil { + s = err.Error() + } + return +} diff --git a/internal/engine/legacy/netxlogger/netxlogger_test.go b/internal/engine/legacy/netxlogger/netxlogger_test.go new file mode 100644 index 0000000..00dd38b --- /dev/null +++ b/internal/engine/legacy/netxlogger/netxlogger_test.go @@ -0,0 +1,40 @@ +package netxlogger + +import ( + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/apex/log" + "github.com/apex/log/handlers/discard" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" +) + +func TestGood(t *testing.T) { + log.SetHandler(discard.Default) + client := netx.NewHTTPClient() + client.ConfigureDNS("udp", "dns.google.com:53") + req, err := http.NewRequest("GET", "http://www.facebook.com", nil) + if err != nil { + t.Fatal(err) + } + req = req.WithContext(modelx.WithMeasurementRoot(req.Context(), &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: NewHandler(log.Log), + })) + resp, err := client.HTTPClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non-nil resp here") + } + defer resp.Body.Close() + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + client.HTTPClient.CloseIdleConnections() +} diff --git a/internal/engine/legacy/oonidatamodel/oonidatamodel.go b/internal/engine/legacy/oonidatamodel/oonidatamodel.go new file mode 100644 index 0000000..949269d --- /dev/null +++ b/internal/engine/legacy/oonidatamodel/oonidatamodel.go @@ -0,0 +1,509 @@ +// Package oonidatamodel contains the OONI data model. +// +// The input of this package is data generated by netx and the +// output is a format consistent with OONI specs. +// +// Deprecated by the archival package. +package oonidatamodel + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/oonitemplates" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// ExtSpec describes a data format extension +type ExtSpec struct { + Name string // extension name + V int64 // extension version +} + +// AddTo adds the current ExtSpec to the specified measurement +func (spec ExtSpec) AddTo(m *model.Measurement) { + if m.Extensions == nil { + m.Extensions = make(map[string]int64) + } + m.Extensions[spec.Name] = spec.V +} + +var ( + // ExtDNS is the version of df-002-dnst.md + ExtDNS = ExtSpec{Name: "dnst", V: 0} + + // ExtNetevents is the version of df-008-netevents.md + ExtNetevents = ExtSpec{Name: "netevents", V: 0} + + // ExtHTTP is the version of df-001-httpt.md + ExtHTTP = ExtSpec{Name: "httpt", V: 0} + + // ExtTCPConnect is the version of df-005-tcpconnect.md + ExtTCPConnect = ExtSpec{Name: "tcpconnect", V: 0} + + // ExtTLSHandshake is the version of df-006-tlshandshake.md + ExtTLSHandshake = ExtSpec{Name: "tlshandshake", V: 0} +) + +// TCPConnectStatus contains the TCP connect status. +type TCPConnectStatus struct { + Failure *string `json:"failure"` + Success bool `json:"success"` +} + +// TCPConnectEntry contains one of the entries that are part +// of the "tcp_connect" key of a OONI report. +type TCPConnectEntry struct { + ConnID int64 `json:"conn_id,omitempty"` + DialID int64 `json:"dial_id,omitempty"` + IP string `json:"ip"` + Port int `json:"port"` + Status TCPConnectStatus `json:"status"` + T float64 `json:"t"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// TCPConnectList is a list of TCPConnectEntry +type TCPConnectList []TCPConnectEntry + +// NewTCPConnectList creates a new TCPConnectList +func NewTCPConnectList(results oonitemplates.Results) TCPConnectList { + var out TCPConnectList + for _, connect := range results.Connects { + // We assume Go is passing us legit data structs + ip, sport, _ := net.SplitHostPort(connect.RemoteAddress) + iport, _ := strconv.Atoi(sport) + out = append(out, TCPConnectEntry{ + ConnID: connect.ConnID, + DialID: connect.DialID, + IP: ip, + Port: iport, + Status: TCPConnectStatus{ + Failure: makeFailure(connect.Error), + Success: connect.Error == nil, + }, + T: connect.DurationSinceBeginning.Seconds(), + TransactionID: connect.TransactionID, + }) + } + return out +} + +func makeFailure(err error) (s *string) { + if err != nil { + serio := err.Error() + s = &serio + } + return +} + +// HTTPTor contains Tor information +type HTTPTor struct { + ExitIP *string `json:"exit_ip"` + ExitName *string `json:"exit_name"` + IsTor bool `json:"is_tor"` +} + +// MaybeBinaryValue is a possibly binary string. We use this helper class +// to define a custom JSON encoder that allows us to choose the proper +// representation depending on whether the Value field is valid UTF-8 or not. +type MaybeBinaryValue struct { + Value string +} + +// MarshalJSON marshals a string-like to JSON following the OONI spec that +// says that UTF-8 content is represened as string and non-UTF-8 content is +// instead represented using `{"format":"base64","data":"..."}`. +func (hb MaybeBinaryValue) MarshalJSON() ([]byte, error) { + if utf8.ValidString(hb.Value) { + return json.Marshal(hb.Value) + } + er := make(map[string]string) + er["format"] = "base64" + er["data"] = base64.StdEncoding.EncodeToString([]byte(hb.Value)) + return json.Marshal(er) +} + +// UnmarshalJSON is the opposite of MarshalJSON. +func (hb *MaybeBinaryValue) UnmarshalJSON(d []byte) error { + if err := json.Unmarshal(d, &hb.Value); err == nil { + return nil + } + er := make(map[string]string) + if err := json.Unmarshal(d, &er); err != nil { + return err + } + if v, ok := er["format"]; !ok || v != "base64" { + return errors.New("missing or invalid format field") + } + if _, ok := er["data"]; !ok { + return errors.New("missing data field") + } + b64, err := base64.StdEncoding.DecodeString(er["data"]) + if err != nil { + return err + } + hb.Value = string(b64) + return nil +} + +// HTTPBody is an HTTP body. As an implementation note, this type must be +// an alias for the MaybeBinaryValue type, otherwise the specific serialisation +// mechanism implemented by MaybeBinaryValue is not working. +type HTTPBody = MaybeBinaryValue + +// HTTPHeaders contains HTTP headers. This headers representation is +// deprecated in favour of HTTPHeadersList since data format 0.3.0. +type HTTPHeaders map[string]MaybeBinaryValue + +// HTTPHeader is a single HTTP header. +type HTTPHeader struct { + Key string + Value MaybeBinaryValue +} + +// MarshalJSON marshals a single HTTP header to a tuple where the first +// element is a string and the second element is maybe-binary data. +func (hh HTTPHeader) MarshalJSON() ([]byte, error) { + if utf8.ValidString(hh.Value.Value) { + return json.Marshal([]string{hh.Key, hh.Value.Value}) + } + value := make(map[string]string) + value["format"] = "base64" + value["data"] = base64.StdEncoding.EncodeToString([]byte(hh.Value.Value)) + return json.Marshal([]interface{}{hh.Key, value}) +} + +// UnmarshalJSON is the opposite of MarshalJSON. +func (hh *HTTPHeader) UnmarshalJSON(d []byte) error { + var pair []interface{} + if err := json.Unmarshal(d, &pair); err != nil { + return err + } + if len(pair) != 2 { + return errors.New("unexpected pair length") + } + key, ok := pair[0].(string) + if !ok { + return errors.New("the key is not a string") + } + value, ok := pair[1].(string) + if !ok { + mapvalue, ok := pair[1].(map[string]interface{}) + if !ok { + return errors.New("the value is neither a string nor a map[string]interface{}") + } + if _, ok := mapvalue["format"]; !ok { + return errors.New("missing format") + } + if v, ok := mapvalue["format"].(string); !ok || v != "base64" { + return errors.New("invalid format") + } + if _, ok := mapvalue["data"]; !ok { + return errors.New("missing data field") + } + v, ok := mapvalue["data"].(string) + if !ok { + return errors.New("the data field is not a string") + } + b64, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return err + } + value = string(b64) + } + hh.Key, hh.Value = key, MaybeBinaryValue{Value: value} + return nil +} + +// HTTPHeadersList is a list of headers. +type HTTPHeadersList []HTTPHeader + +// HTTPRequest contains an HTTP request. +// +// Headers are a map in Web Connectivity data format but +// we have added support for a list since data format version +// equal to 0.2.1 (later renamed to 0.3.0). +type HTTPRequest struct { + Body HTTPBody `json:"body"` + BodyIsTruncated bool `json:"body_is_truncated"` + HeadersList HTTPHeadersList `json:"headers_list"` + Headers HTTPHeaders `json:"headers"` + Method string `json:"method"` + Tor HTTPTor `json:"tor"` + URL string `json:"url"` +} + +// HTTPResponse contains an HTTP response. +// +// Headers are a map in Web Connectivity data format but +// we have added support for a list since data format version +// equal to 0.2.1 (later renamed to 0.3.0). +type HTTPResponse struct { + Body HTTPBody `json:"body"` + BodyIsTruncated bool `json:"body_is_truncated"` + Code int64 `json:"code"` + HeadersList HTTPHeadersList `json:"headers_list"` + Headers HTTPHeaders `json:"headers"` +} + +// RequestEntry is one of the entries that are part of +// the "requests" key of a OONI report. +type RequestEntry struct { + Failure *string `json:"failure"` + Request HTTPRequest `json:"request"` + Response HTTPResponse `json:"response"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// RequestList is a list of RequestEntry +type RequestList []RequestEntry + +func addheaders( + source http.Header, + destList *HTTPHeadersList, + destMap *HTTPHeaders, +) { + for key, values := range source { + for index, value := range values { + value := MaybeBinaryValue{Value: value} + // With the map representation we can only represent a single + // value for every key. Hence the list representation. + if index == 0 { + (*destMap)[key] = value + } + *destList = append(*destList, HTTPHeader{ + Key: key, + Value: value, + }) + } + } +} + +// NewRequestList returns the list for "requests" +func NewRequestList(results oonitemplates.Results) RequestList { + var out RequestList + in := results.HTTPRequests + // OONI's data format wants more recent request first + for idx := len(in) - 1; idx >= 0; idx-- { + var entry RequestEntry + entry.Failure = makeFailure(in[idx].Error) + entry.Request.Headers = make(HTTPHeaders) + addheaders( + in[idx].RequestHeaders, &entry.Request.HeadersList, + &entry.Request.Headers, + ) + entry.Request.Method = in[idx].RequestMethod + entry.Request.URL = in[idx].RequestURL + entry.Request.Body.Value = string(in[idx].RequestBodySnap) + entry.Request.BodyIsTruncated = in[idx].MaxBodySnapSize > 0 && + int64(len(in[idx].RequestBodySnap)) >= in[idx].MaxBodySnapSize + entry.Response.Headers = make(HTTPHeaders) + addheaders( + in[idx].ResponseHeaders, &entry.Response.HeadersList, + &entry.Response.Headers, + ) + entry.Response.Code = in[idx].ResponseStatusCode + entry.Response.Body.Value = string(in[idx].ResponseBodySnap) + entry.Response.BodyIsTruncated = in[idx].MaxBodySnapSize > 0 && + int64(len(in[idx].ResponseBodySnap)) >= in[idx].MaxBodySnapSize + entry.TransactionID = in[idx].TransactionID + out = append(out, entry) + } + return out +} + +// DNSAnswerEntry is the answer to a DNS query +type DNSAnswerEntry struct { + AnswerType string `json:"answer_type"` + Hostname string `json:"hostname,omitempty"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + TTL *uint32 `json:"ttl"` +} + +// DNSQueryEntry is a DNS query with possibly an answer +type DNSQueryEntry struct { + Answers []DNSAnswerEntry `json:"answers"` + DialID int64 `json:"dial_id,omitempty"` + Engine string `json:"engine"` + Failure *string `json:"failure"` + Hostname string `json:"hostname"` + QueryType string `json:"query_type"` + ResolverHostname *string `json:"resolver_hostname"` + ResolverPort *string `json:"resolver_port"` + ResolverAddress string `json:"resolver_address"` + T float64 `json:"t"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +type ( + // DNSQueriesList is a list of DNS queries + DNSQueriesList []DNSQueryEntry + dnsQueryType string +) + +// NewDNSQueriesList returns a list of DNS queries. +func NewDNSQueriesList(results oonitemplates.Results) DNSQueriesList { + // TODO(bassosimone): add support for CNAME lookups. + var out DNSQueriesList + for _, resolve := range results.Resolves { + for _, qtype := range []dnsQueryType{"A", "AAAA"} { + entry := qtype.makequeryentry(resolve) + for _, addr := range resolve.Addresses { + if qtype.ipoftype(addr) { + entry.Answers = append(entry.Answers, qtype.makeanswerentry(addr)) + } + } + out = append(out, entry) + } + } + return out +} + +func (qtype dnsQueryType) ipoftype(addr string) bool { + switch qtype { + case "A": + return strings.Contains(addr, ":") == false + case "AAAA": + return strings.Contains(addr, ":") == true + } + return false +} + +func (qtype dnsQueryType) makeanswerentry(addr string) DNSAnswerEntry { + answer := DNSAnswerEntry{AnswerType: string(qtype)} + switch qtype { + case "A": + answer.IPv4 = addr + case "AAAA": + answer.IPv6 = addr + } + return answer +} + +func (qtype dnsQueryType) makequeryentry(resolve *modelx.ResolveDoneEvent) DNSQueryEntry { + return DNSQueryEntry{ + DialID: resolve.DialID, + Engine: resolve.TransportNetwork, + Failure: makeFailure(resolve.Error), + Hostname: resolve.Hostname, + QueryType: string(qtype), + ResolverAddress: resolve.TransportAddress, + T: resolve.DurationSinceBeginning.Seconds(), + TransactionID: resolve.TransactionID, + } +} + +// NetworkEvent is a network event. +type NetworkEvent struct { + Address string `json:"address,omitempty"` + ConnID int64 `json:"conn_id,omitempty"` + DialID int64 `json:"dial_id,omitempty"` + Failure *string `json:"failure"` + NumBytes int64 `json:"num_bytes,omitempty"` + Operation string `json:"operation"` + Proto string `json:"proto"` + T float64 `json:"t"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// NetworkEventsList is a list of network events. +type NetworkEventsList []*NetworkEvent + +var protocolName = map[bool]string{ + true: "tcp", + false: "udp", +} + +// NewNetworkEventsList returns a list of DNS queries. +func NewNetworkEventsList(results oonitemplates.Results) NetworkEventsList { + var out NetworkEventsList + for _, in := range results.NetworkEvents { + if in.Connect != nil { + out = append(out, &NetworkEvent{ + Address: in.Connect.RemoteAddress, + ConnID: in.Connect.ConnID, + DialID: in.Connect.DialID, + Failure: makeFailure(in.Connect.Error), + Operation: errorx.ConnectOperation, + Proto: protocolName[in.Connect.ConnID >= 0], + T: in.Connect.DurationSinceBeginning.Seconds(), + TransactionID: in.Connect.TransactionID, + }) + // fallthrough + } + if in.Read != nil { + out = append(out, &NetworkEvent{ + ConnID: in.Read.ConnID, + Failure: makeFailure(in.Read.Error), + Operation: errorx.ReadOperation, + NumBytes: in.Read.NumBytes, + Proto: protocolName[in.Read.ConnID >= 0], + T: in.Read.DurationSinceBeginning.Seconds(), + }) + // fallthrough + } + if in.Write != nil { + out = append(out, &NetworkEvent{ + ConnID: in.Write.ConnID, + Failure: makeFailure(in.Write.Error), + Operation: errorx.WriteOperation, + NumBytes: in.Write.NumBytes, + Proto: protocolName[in.Write.ConnID >= 0], + T: in.Write.DurationSinceBeginning.Seconds(), + }) + // fallthrough + } + } + return out +} + +// TLSHandshake contains TLS handshake data +type TLSHandshake struct { + CipherSuite string `json:"cipher_suite"` + ConnID int64 `json:"conn_id,omitempty"` + Failure *string `json:"failure"` + NegotiatedProtocol string `json:"negotiated_protocol"` + PeerCertificates []MaybeBinaryValue `json:"peer_certificates"` + T float64 `json:"t"` + TLSVersion string `json:"tls_version"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// TLSHandshakesList is a list of TLS handshakes +type TLSHandshakesList []TLSHandshake + +// NewTLSHandshakesList creates a new TLSHandshakesList +func NewTLSHandshakesList(results oonitemplates.Results) TLSHandshakesList { + var out TLSHandshakesList + for _, in := range results.TLSHandshakes { + out = append(out, TLSHandshake{ + CipherSuite: tlsx.CipherSuiteString(in.ConnectionState.CipherSuite), + ConnID: in.ConnID, + Failure: makeFailure(in.Error), + NegotiatedProtocol: in.ConnectionState.NegotiatedProtocol, + PeerCertificates: makePeerCerts(in.ConnectionState.PeerCertificates), + T: in.DurationSinceBeginning.Seconds(), + TLSVersion: tlsx.VersionString(in.ConnectionState.Version), + TransactionID: in.TransactionID, + }) + } + return out +} + +func makePeerCerts(in []modelx.X509Certificate) (out []MaybeBinaryValue) { + for _, e := range in { + out = append(out, MaybeBinaryValue{Value: string(e.Data)}) + } + return +} diff --git a/internal/engine/legacy/oonidatamodel/oonidatamodel_test.go b/internal/engine/legacy/oonidatamodel/oonidatamodel_test.go new file mode 100644 index 0000000..e9f2339 --- /dev/null +++ b/internal/engine/legacy/oonidatamodel/oonidatamodel_test.go @@ -0,0 +1,1157 @@ +package oonidatamodel + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "net/http" + "reflect" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/oonitemplates" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestNewTCPConnectListEmpty(t *testing.T) { + out := NewTCPConnectList(oonitemplates.Results{}) + if len(out) != 0 { + t.Fatal("unexpected output length") + } +} + +func TestNewTCPConnectListSuccess(t *testing.T) { + out := NewTCPConnectList(oonitemplates.Results{ + Connects: []*modelx.ConnectEvent{ + { + RemoteAddress: "8.8.8.8:53", + }, + { + RemoteAddress: "8.8.4.4:853", + }, + }, + }) + if len(out) != 2 { + t.Fatal("unexpected output length") + } + if out[0].IP != "8.8.8.8" { + t.Fatal("unexpected out[0].IP") + } + if out[0].Port != 53 { + t.Fatal("unexpected out[0].Port") + } + if out[0].Status.Failure != nil { + t.Fatal("unexpected out[0].Failure") + } + if out[0].Status.Success != true { + t.Fatal("unexpected out[0].Success") + } + if out[1].IP != "8.8.4.4" { + t.Fatal("unexpected out[1].IP") + } + if out[1].Port != 853 { + t.Fatal("unexpected out[1].Port") + } + if out[1].Status.Failure != nil { + t.Fatal("unexpected out[0].Failure") + } + if out[1].Status.Success != true { + t.Fatal("unexpected out[0].Success") + } +} + +func TestNewTCPConnectListFailure(t *testing.T) { + out := NewTCPConnectList(oonitemplates.Results{ + Connects: []*modelx.ConnectEvent{ + { + RemoteAddress: "8.8.8.8:53", + Error: errors.New(errorx.FailureConnectionReset), + }, + }, + }) + if len(out) != 1 { + t.Fatal("unexpected output length") + } + if out[0].IP != "8.8.8.8" { + t.Fatal("unexpected out[0].IP") + } + if out[0].Port != 53 { + t.Fatal("unexpected out[0].Port") + } + if *out[0].Status.Failure != errorx.FailureConnectionReset { + t.Fatal("unexpected out[0].Failure") + } + if out[0].Status.Success != false { + t.Fatal("unexpected out[0].Success") + } +} + +func TestNewTCPConnectListInvalidInput(t *testing.T) { + out := NewTCPConnectList(oonitemplates.Results{ + Connects: []*modelx.ConnectEvent{ + { + RemoteAddress: "8.8.8.8", + Error: errors.New(errorx.FailureConnectionReset), + }, + }, + }) + if len(out) != 1 { + t.Fatal("unexpected output length") + } + if out[0].IP != "" { + t.Fatal("unexpected out[0].IP") + } + if out[0].Port != 0 { + t.Fatal("unexpected out[0].Port") + } + if *out[0].Status.Failure != errorx.FailureConnectionReset { + t.Fatal("unexpected out[0].Failure") + } + if out[0].Status.Success != false { + t.Fatal("unexpected out[0].Success") + } +} + +func TestNewRequestsListEmptyList(t *testing.T) { + out := NewRequestList(oonitemplates.Results{}) + if len(out) != 0 { + t.Fatal("unexpected output length") + } +} + +func TestNewRequestsListGood(t *testing.T) { + out := NewRequestList(oonitemplates.Results{ + HTTPRequests: []*modelx.HTTPRoundTripDoneEvent{ + // need two requests to test that order is inverted + { + RequestBodySnap: []byte("abcdefx"), + RequestHeaders: http.Header{ + "Content-Type": []string{ + "text/plain", + "foobar", + }, + "Content-Length": []string{ + "17", + }, + }, + RequestMethod: "GET", + RequestURL: "http://x.org/", + ResponseBodySnap: []byte("abcdef"), + ResponseHeaders: http.Header{ + "Content-Type": []string{ + "application/json", + "foobaz", + }, + "Server": []string{ + "antani", + }, + "Content-Length": []string{ + "14", + }, + }, + ResponseStatusCode: 451, + MaxBodySnapSize: 10, + }, + { + Error: errors.New("antani"), + }, + }, + }) + if len(out) != 2 { + t.Fatal("unexpected output length") + } + + if *out[0].Failure != "antani" { + t.Fatal("unexpected out[0].Failure") + } + if out[0].Request.Body.Value != "" { + t.Fatal("unexpected out[0].Request.Body.Value") + } + if len(out[0].Request.Headers) != 0 { + t.Fatal("unexpected out[0].Request.Headers") + } + if out[0].Request.Method != "" { + t.Fatal("unexpected out[0].Request.Method") + } + if out[0].Request.URL != "" { + t.Fatal("unexpected out[0].Request.URL") + } + if out[0].Request.BodyIsTruncated != false { + t.Fatal("unexpected out[0].Request.BodyIsTruncated") + } + if out[0].Response.Body.Value != "" { + t.Fatal("unexpected out[0].Response.Body.Value") + } + if out[0].Response.Code != 0 { + t.Fatal("unexpected out[0].Response.Code") + } + if len(out[0].Response.Headers) != 0 { + t.Fatal("unexpected out[0].Response.Headers") + } + if out[0].Response.BodyIsTruncated != false { + t.Fatal("unexpected out[0].Response.BodyIsTruncated") + } + + if out[1].Failure != nil { + t.Fatal("unexpected out[1].Failure") + } + if out[1].Request.Body.Value != "abcdefx" { + t.Fatal("unexpected out[1].Request.Body.Value") + } + if len(out[1].Request.Headers) != 2 { + t.Fatal("unexpected out[1].Request.Headers") + } + if out[1].Request.Headers["Content-Type"].Value != "text/plain" { + t.Fatal("unexpected out[1].Request.Headers Content-Type value") + } + if out[1].Request.Headers["Content-Length"].Value != "17" { + t.Fatal("unexpected out[1].Request.Headers Content-Length value") + } + var ( + requestHasTextPlain bool + requestHasFoobar bool + requestHasContentLength bool + requestHasOther int64 + ) + for _, header := range out[1].Request.HeadersList { + if header.Key == "Content-Type" { + if header.Value.Value == "text/plain" { + requestHasTextPlain = true + } else if header.Value.Value == "foobar" { + requestHasFoobar = true + } else { + requestHasOther++ + } + } else if header.Key == "Content-Length" { + if header.Value.Value == "17" { + requestHasContentLength = true + } else { + requestHasOther++ + } + } else { + requestHasOther++ + } + } + if !requestHasTextPlain { + t.Fatal("missing text/plain for request") + } + if !requestHasFoobar { + t.Fatal("missing foobar for request") + } + if !requestHasContentLength { + t.Fatal("missing content_length for request") + } + if requestHasOther != 0 { + t.Fatal("seen something unexpected") + } + if out[1].Request.Method != "GET" { + t.Fatal("unexpected out[1].Request.Method") + } + if out[1].Request.URL != "http://x.org/" { + t.Fatal("unexpected out[1].Request.URL") + } + if out[1].Request.BodyIsTruncated != false { + t.Fatal("unexpected out[1].Request.BodyIsTruncated") + } + + if out[1].Response.Body.Value != "abcdef" { + t.Fatal("unexpected out[1].Response.Body.Value") + } + if out[1].Response.Code != 451 { + t.Fatal("unexpected out[1].Response.Code") + } + if len(out[1].Response.Headers) != 3 { + t.Fatal("unexpected out[1].Response.Headers") + } + if out[1].Response.Headers["Content-Type"].Value != "application/json" { + t.Fatal("unexpected out[1].Response.Headers Content-Type value") + } + if out[1].Response.Headers["Server"].Value != "antani" { + t.Fatal("unexpected out[1].Response.Headers Server value") + } + if out[1].Response.Headers["Content-Length"].Value != "14" { + t.Fatal("unexpected out[1].Response.Headers Content-Length value") + } + var ( + responseHasApplicationJSON bool + responseHasFoobaz bool + responseHasServer bool + responseHasContentLength bool + responseHasOther int64 + ) + for _, header := range out[1].Response.HeadersList { + if header.Key == "Content-Type" { + if header.Value.Value == "application/json" { + responseHasApplicationJSON = true + } else if header.Value.Value == "foobaz" { + responseHasFoobaz = true + } else { + responseHasOther++ + } + } else if header.Key == "Content-Length" { + if header.Value.Value == "14" { + responseHasContentLength = true + } else { + responseHasOther++ + } + } else if header.Key == "Server" { + if header.Value.Value == "antani" { + responseHasServer = true + } else { + responseHasOther++ + } + } else { + responseHasOther++ + } + } + if !responseHasApplicationJSON { + t.Fatal("missing application/json for response") + } + if !responseHasFoobaz { + t.Fatal("missing foobaz for response") + } + if !responseHasContentLength { + t.Fatal("missing content_length for response") + } + if !responseHasServer { + t.Fatal("missing server for response") + } + if responseHasOther != 0 { + t.Fatal("seen something unexpected") + } + if out[1].Response.BodyIsTruncated != false { + t.Fatal("unexpected out[1].Response.BodyIsTruncated") + } +} + +func TestNewRequestsSnaps(t *testing.T) { + out := NewRequestList(oonitemplates.Results{ + HTTPRequests: []*modelx.HTTPRoundTripDoneEvent{ + { + RequestBodySnap: []byte("abcd"), + MaxBodySnapSize: 4, + ResponseBodySnap: []byte("defg"), + }, + }, + }) + if len(out) != 1 { + t.Fatal("unexpected output length") + } + if out[0].Request.BodyIsTruncated != true { + t.Fatal("wrong out[0].Request.BodyIsTruncated") + } + if out[0].Response.BodyIsTruncated != true { + t.Fatal("wrong out[0].Response.BodyIsTruncated") + } +} + +func TestMarshalUnmarshalHTTPBodyString(t *testing.T) { + mbv := HTTPBody{ + Value: "1234", + } + data, err := json.Marshal(mbv) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, []byte(`"1234"`)) { + t.Fatal("result is unexpected") + } + var newbody HTTPBody + if err := json.Unmarshal(data, &newbody); err != nil { + t.Fatal(err) + } + if newbody.Value != mbv.Value { + t.Fatal("string value mistmatch") + } +} + +var binaryInput = []uint8{ + 0x57, 0xe5, 0x79, 0xfb, 0xa6, 0xbb, 0x0d, 0xbc, 0xce, 0xbd, 0xa7, 0xa0, + 0xba, 0xa4, 0x78, 0x78, 0x12, 0x59, 0xee, 0x68, 0x39, 0xa4, 0x07, 0x98, + 0xc5, 0x3e, 0xbc, 0x55, 0xcb, 0xfe, 0x34, 0x3c, 0x7e, 0x1b, 0x5a, 0xb3, + 0x22, 0x9d, 0xc1, 0x2d, 0x6e, 0xca, 0x5b, 0xf1, 0x10, 0x25, 0x47, 0x1e, + 0x44, 0xe2, 0x2d, 0x60, 0x08, 0xea, 0xb0, 0x0a, 0xcc, 0x05, 0x48, 0xa0, + 0xf5, 0x78, 0x38, 0xf0, 0xdb, 0x3f, 0x9d, 0x9f, 0x25, 0x6f, 0x89, 0x00, + 0x96, 0x93, 0xaf, 0x43, 0xac, 0x4d, 0xc9, 0xac, 0x13, 0xdb, 0x22, 0xbe, + 0x7a, 0x7d, 0xd9, 0x24, 0xa2, 0x52, 0x69, 0xd8, 0x89, 0xc1, 0xd1, 0x57, + 0xaa, 0x04, 0x2b, 0xa2, 0xd8, 0xb1, 0x19, 0xf6, 0xd5, 0x11, 0x39, 0xbb, + 0x80, 0xcf, 0x86, 0xf9, 0x5f, 0x9d, 0x8c, 0xab, 0xf5, 0xc5, 0x74, 0x24, + 0x3a, 0xa2, 0xd4, 0x40, 0x4e, 0xd7, 0x10, 0x1f, +} + +func TestMarshalUnmarshalHTTPBodyBinary(t *testing.T) { + mbv := HTTPBody{ + Value: string(binaryInput), + } + data, err := json.Marshal(mbv) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, []byte(`{"data":"V+V5+6a7DbzOvaeguqR4eBJZ7mg5pAeYxT68Vcv+NDx+G1qzIp3BLW7KW/EQJUceROItYAjqsArMBUig9Xg48Ns/nZ8lb4kAlpOvQ6xNyawT2yK+en3ZJKJSadiJwdFXqgQrotixGfbVETm7gM+G+V+djKv1xXQkOqLUQE7XEB8=","format":"base64"}`)) { + t.Fatal("result is unexpected") + } + var newbody HTTPBody + if err := json.Unmarshal(data, &newbody); err != nil { + t.Fatal(err) + } + if newbody.Value != mbv.Value { + t.Fatal("string value mistmatch") + } +} + +func TestMaybeBinaryValueUnmarshalJSON(t *testing.T) { + t.Run("when the code is not a map or string", func(t *testing.T) { + var ( + mbv MaybeBinaryValue + input = []byte("[1, 2, 3, 4]") + ) + if err := json.Unmarshal(input, &mbv); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the format field is missing", func(t *testing.T) { + var ( + mbv MaybeBinaryValue + input = []byte("{}") + ) + if err := json.Unmarshal(input, &mbv); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the format field is invalid", func(t *testing.T) { + var ( + mbv MaybeBinaryValue + input = []byte(`{"format":"antani"}`) + ) + if err := json.Unmarshal(input, &mbv); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the data field is missing", func(t *testing.T) { + var ( + mbv MaybeBinaryValue + input = []byte(`{"format":"base64"}`) + ) + if err := json.Unmarshal(input, &mbv); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the data field is not base64", func(t *testing.T) { + var ( + mbv MaybeBinaryValue + input = []byte(`{"format":"base64","data":"antani"}`) + ) + if err := json.Unmarshal(input, &mbv); err == nil { + t.Fatal("expected an error here") + } + }) +} + +func TestMarshalUnmarshalHTTPHeaderString(t *testing.T) { + mbh := HTTPHeadersList{ + HTTPHeader{ + Key: "Content-Type", + Value: MaybeBinaryValue{ + Value: "application/json", + }, + }, + HTTPHeader{ + Key: "Content-Type", + Value: MaybeBinaryValue{ + Value: "antani", + }, + }, + HTTPHeader{ + Key: "Content-Length", + Value: MaybeBinaryValue{ + Value: "17", + }, + }, + } + data, err := json.Marshal(mbh) + if err != nil { + t.Fatal(err) + } + expected := []byte( + `[["Content-Type","application/json"],["Content-Type","antani"],["Content-Length","17"]]`, + ) + if !bytes.Equal(data, expected) { + t.Fatal("result is unexpected") + } + var newlist HTTPHeadersList + if err := json.Unmarshal(data, &newlist); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(mbh, newlist) { + t.Fatal("result mismatch") + } +} + +func TestMarshalUnmarshalHTTPHeaderBinary(t *testing.T) { + mbh := HTTPHeadersList{ + HTTPHeader{ + Key: "Content-Type", + Value: MaybeBinaryValue{ + Value: "application/json", + }, + }, + HTTPHeader{ + Key: "Content-Type", + Value: MaybeBinaryValue{ + Value: string(binaryInput), + }, + }, + HTTPHeader{ + Key: "Content-Length", + Value: MaybeBinaryValue{ + Value: "17", + }, + }, + } + data, err := json.Marshal(mbh) + if err != nil { + t.Fatal(err) + } + expected := []byte( + `[["Content-Type","application/json"],["Content-Type",{"data":"V+V5+6a7DbzOvaeguqR4eBJZ7mg5pAeYxT68Vcv+NDx+G1qzIp3BLW7KW/EQJUceROItYAjqsArMBUig9Xg48Ns/nZ8lb4kAlpOvQ6xNyawT2yK+en3ZJKJSadiJwdFXqgQrotixGfbVETm7gM+G+V+djKv1xXQkOqLUQE7XEB8=","format":"base64"}],["Content-Length","17"]]`, + ) + if !bytes.Equal(data, expected) { + t.Fatal("result is unexpected") + } + var newlist HTTPHeadersList + if err := json.Unmarshal(data, &newlist); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(mbh, newlist) { + t.Fatal("result mismatch") + } +} + +func TestHTTPHeaderUnmarshalJSON(t *testing.T) { + t.Run("when the code is not a list", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`{"foo":1}`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the pair length is not two", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte("[1,2,3]") + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the first element is not a string", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`[1, "antani"]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the second element is not map[string]interface{}", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", ["base64", "foo"]]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the format field is missing", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", {}]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the format field is not a string", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", {"format":1}]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the format field is invalid", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", {"format":"antani"}]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the data field is missing", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", {"format":"base64"}]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the data field is not a string", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", {"format":"base64","data":10}]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the data field is not base64", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", {"format":"base64","data":"antani"}]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the data field is not base64", func(t *testing.T) { + var ( + hh HTTPHeader + input = []byte(`["antani", {"format":"base64","data":"antani"}]`) + ) + if err := json.Unmarshal(input, &hh); err == nil { + t.Fatal("expected an error here") + } + }) +} + +func TestNewDNSQueriesListEmpty(t *testing.T) { + out := NewDNSQueriesList(oonitemplates.Results{}) + if len(out) != 0 { + t.Fatal("unexpected output length") + } +} + +func TestNewDNSQueriesListSuccess(t *testing.T) { + out := NewDNSQueriesList(oonitemplates.Results{ + Resolves: []*modelx.ResolveDoneEvent{ + { + Addresses: []string{ + "8.8.4.4", "2001:4860:4860::8888", + }, + Hostname: "dns.google", + TransportNetwork: "system", + }, + { + Error: errors.New(errorx.FailureDNSNXDOMAINError), + Hostname: "dns.googlex", + TransportNetwork: "system", + }, + }, + }) + if len(out) != 4 { + t.Fatal("unexpected output length") + } + var ( + foundDNSGoogleA bool + foundDNSGoogleAAAA bool + foundErrorA bool + foundErrorAAAA bool + foundOther bool + ) + for _, e := range out { + switch e.Hostname { + case "dns.google": + switch e.QueryType { + case "A": + foundDNSGoogleA = true + if err := dnscheckgood(e); err != nil { + t.Fatal(err) + } + case "AAAA": + foundDNSGoogleAAAA = true + if err := dnscheckgood(e); err != nil { + t.Fatal(err) + } + default: + foundOther = true + } + case "dns.googlex": + switch e.QueryType { + case "A": + foundErrorA = true + if err := dnscheckbad(e); err != nil { + t.Fatal(err) + } + case "AAAA": + foundErrorAAAA = true + if err := dnscheckbad(e); err != nil { + t.Fatal(err) + } + default: + foundOther = true + } + default: + foundOther = true + } + } + if foundDNSGoogleA == false { + t.Fatal("missing A for dns.google") + } + if foundDNSGoogleAAAA == false { + t.Fatal("missing AAAA for dns.google") + } + if foundErrorA == false { + t.Fatal("missing A for invalid domain") + } + if foundErrorAAAA == false { + t.Fatal("missing AAAA for invalid domain") + } + if foundOther == true { + t.Fatal("seen something unexpected") + } +} + +func dnscheckgood(e DNSQueryEntry) error { + if len(e.Answers) != 1 { + return errors.New("unexpected number of answers") + } + if e.Engine != "system" { + return errors.New("invalid engine") + } + if e.Failure != nil { + return errors.New("invalid failure") + } + if e.Hostname != "dns.google" { + return errors.New("invalid hostname") + } + switch e.QueryType { + case "A", "AAAA": + default: + return errors.New("invalid query type") + } + if e.Answers[0].AnswerType != e.QueryType { + return errors.New("AnswerType mismatch") + } + switch e.QueryType { + case "A": + if e.Answers[0].IPv4 != "8.8.4.4" { + return errors.New("unexpected IPv4 entry") + } + case "AAAA": + if e.Answers[0].IPv6 != "2001:4860:4860::8888" { + return errors.New("unexpected IPv6 entry") + } + } + if e.ResolverHostname != nil { + return errors.New("invalid resolver hostname") + } + if e.ResolverPort != nil { + return errors.New("invalid resolver port") + } + if e.ResolverAddress != "" { + return errors.New("invalid resolver address") + } + return nil +} + +func dnscheckbad(e DNSQueryEntry) error { + if len(e.Answers) != 0 { + return errors.New("unexpected number of answers") + } + if e.Engine != "system" { + return errors.New("invalid engine") + } + if *e.Failure != errorx.FailureDNSNXDOMAINError { + return errors.New("invalid failure") + } + if e.Hostname != "dns.googlex" { + return errors.New("invalid hostname") + } + switch e.QueryType { + case "A", "AAAA": + default: + return errors.New("invalid query type") + } + if e.ResolverHostname != nil { + return errors.New("invalid resolver hostname") + } + if e.ResolverPort != nil { + return errors.New("invalid resolver port") + } + if e.ResolverAddress != "" { + return errors.New("invalid resolver address") + } + return nil +} + +func TestDNSQueryTypeIPOfType(t *testing.T) { + qtype := dnsQueryType("ANTANI") + if qtype.ipoftype("8.8.8.8") == true { + t.Fatal("ipoftype misbehaving") + } +} + +func TestNewNetworkEventsListEmpty(t *testing.T) { + out := NewNetworkEventsList(oonitemplates.Results{}) + if len(out) != 0 { + t.Fatal("unexpected output length") + } +} + +func TestNewNetworkEventsListNoSuitableEvents(t *testing.T) { + out := NewNetworkEventsList(oonitemplates.Results{ + NetworkEvents: []*modelx.Measurement{ + {}, + {}, + {}, + }, + }) + if len(out) != 0 { + t.Fatal("unexpected output length") + } +} + +func TestNewNetworkEventsListGood(t *testing.T) { + out := NewNetworkEventsList(oonitemplates.Results{ + NetworkEvents: []*modelx.Measurement{ + { + Connect: &modelx.ConnectEvent{ + ConnID: 555, + DurationSinceBeginning: 10 * time.Millisecond, + DialID: 17, + RemoteAddress: "1.1.1.1:443", + }, + }, + { + Read: &modelx.ReadEvent{ + ConnID: 555, + DurationSinceBeginning: 20 * time.Millisecond, + NumBytes: 1789, + }, + }, + { + Write: &modelx.WriteEvent{ + ConnID: 555, + DurationSinceBeginning: 30 * time.Millisecond, + NumBytes: 17714, + }, + }, + }, + }) + if len(out) != 3 { + t.Fatal("unexpected output length") + } + + if out[0].Address != "1.1.1.1:443" { + t.Fatal("wrong out[0].Address") + } + if out[0].ConnID != 555 { + t.Fatal("wrong out[0].ConnID") + } + if out[0].DialID != 17 { + t.Fatal("wrong out[0].DialID") + } + if out[0].Failure != nil { + t.Fatal("wrong out[0].Failure") + } + if out[0].NumBytes != 0 { + t.Fatal("wrong out[0].NumBytes") + } + if out[0].Operation != errorx.ConnectOperation { + t.Fatal("wrong out[0].Operation") + } + if out[0].Proto != "tcp" { + t.Fatal("wrong out[0].Proto") + } + if !floatEquals(out[0].T, 0.010) { + t.Fatal("wrong out[0].T") + } + + if out[1].Address != "" { + t.Fatal("wrong out[1].Address") + } + if out[1].ConnID != 555 { + t.Fatal("wrong out[1].ConnID") + } + if out[1].DialID != 0 { + t.Fatal("wrong out[1].DialID") + } + if out[1].Failure != nil { + t.Fatal("wrong out[1].Failure") + } + if out[1].NumBytes != 1789 { + t.Fatal("wrong out[1].NumBytes") + } + if out[1].Operation != errorx.ReadOperation { + t.Fatal("wrong out[1].Operation") + } + if out[1].Proto != "tcp" { + t.Fatal("wrong out[1].Proto") + } + if !floatEquals(out[1].T, 0.020) { + t.Fatal("wrong out[1].T") + } + + if out[2].Address != "" { + t.Fatal("wrong out[2].Address") + } + if out[2].ConnID != 555 { + t.Fatal("wrong out[2].ConnID") + } + if out[2].DialID != 0 { + t.Fatal("wrong out[2].DialID") + } + if out[2].Failure != nil { + t.Fatal("wrong out[2].Failure") + } + if out[2].NumBytes != 17714 { + t.Fatal("wrong out[2].NumBytes") + } + if out[2].Operation != errorx.WriteOperation { + t.Fatal("wrong out[2].Operation") + } + if out[2].Proto != "tcp" { + t.Fatal("wrong out[2].Proto") + } + if !floatEquals(out[2].T, 0.030) { + t.Fatal("wrong out[2].T") + } +} + +func TestNewNetworkEventsListGoodUDPAndErrors(t *testing.T) { + out := NewNetworkEventsList(oonitemplates.Results{ + NetworkEvents: []*modelx.Measurement{ + { + Connect: &modelx.ConnectEvent{ + ConnID: -555, + DurationSinceBeginning: 10 * time.Millisecond, + DialID: 17, + Error: errors.New("mocked error"), + RemoteAddress: "1.1.1.1:443", + }, + }, + { + Read: &modelx.ReadEvent{ + ConnID: -555, + DurationSinceBeginning: 20 * time.Millisecond, + Error: errors.New("mocked error"), + NumBytes: 1789, + }, + }, + { + Write: &modelx.WriteEvent{ + ConnID: -555, + DurationSinceBeginning: 30 * time.Millisecond, + Error: errors.New("mocked error"), + NumBytes: 17714, + }, + }, + }, + }) + if len(out) != 3 { + t.Fatal("unexpected output length") + } + + if out[0].Address != "1.1.1.1:443" { + t.Fatal("wrong out[0].Address") + } + if out[0].ConnID != -555 { + t.Fatal("wrong out[0].ConnID") + } + if out[0].DialID != 17 { + t.Fatal("wrong out[0].DialID") + } + if *out[0].Failure != "mocked error" { + t.Fatal("wrong out[0].Failure") + } + if out[0].NumBytes != 0 { + t.Fatal("wrong out[0].NumBytes") + } + if out[0].Operation != errorx.ConnectOperation { + t.Fatal("wrong out[0].Operation") + } + if out[0].Proto != "udp" { + t.Fatal("wrong out[0].Proto") + } + if !floatEquals(out[0].T, 0.010) { + t.Fatal("wrong out[0].T") + } + + if out[1].Address != "" { + t.Fatal("wrong out[1].Address") + } + if out[1].ConnID != -555 { + t.Fatal("wrong out[1].ConnID") + } + if out[1].DialID != 0 { + t.Fatal("wrong out[1].DialID") + } + if *out[1].Failure != "mocked error" { + t.Fatal("wrong out[1].Failure") + } + if out[1].NumBytes != 1789 { + t.Fatal("wrong out[1].NumBytes") + } + if out[1].Operation != errorx.ReadOperation { + t.Fatal("wrong out[1].Operation") + } + if out[1].Proto != "udp" { + t.Fatal("wrong out[1].Proto") + } + if !floatEquals(out[1].T, 0.020) { + t.Fatal("wrong out[1].T") + } + + if out[2].Address != "" { + t.Fatal("wrong out[2].Address") + } + if out[2].ConnID != -555 { + t.Fatal("wrong out[2].ConnID") + } + if out[2].DialID != 0 { + t.Fatal("wrong out[2].DialID") + } + if *out[2].Failure != "mocked error" { + t.Fatal("wrong out[2].Failure") + } + if out[2].NumBytes != 17714 { + t.Fatal("wrong out[2].NumBytes") + } + if out[2].Operation != errorx.WriteOperation { + t.Fatal("wrong out[2].Operation") + } + if out[2].Proto != "udp" { + t.Fatal("wrong out[2].Proto") + } + if !floatEquals(out[2].T, 0.030) { + t.Fatal("wrong out[2].T") + } +} + +func floatEquals(a, b float64) bool { + const c = 1e-03 + return (a-b) < c && (b-a) < c +} + +func TestNewTLSHandshakesListEmpty(t *testing.T) { + out := NewTLSHandshakesList(oonitemplates.Results{}) + if len(out) != 0 { + t.Fatal("unexpected output length") + } +} + +func TestNewTLSHandshakesListSuccess(t *testing.T) { + out := NewTLSHandshakesList(oonitemplates.Results{ + TLSHandshakes: []*modelx.TLSHandshakeDoneEvent{ + {}, + { + ConnID: 12345, + Error: errors.New("mocked error"), + }, + { + ConnectionState: modelx.TLSConnectionState{ + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + NegotiatedProtocol: "h2", + PeerCertificates: []modelx.X509Certificate{ + { + Data: []byte("deadbeef"), + }, + { + Data: []byte("abad1dea"), + }, + }, + Version: tls.VersionTLS11, + }, + DurationSinceBeginning: 10 * time.Millisecond, + }, + }, + }) + if len(out) != 3 { + t.Fatal("unexpected output length") + } + + if out[0].CipherSuite != "" { + t.Fatal("invalid out[0].CipherSuite") + } + if out[0].ConnID != 0 { + t.Fatal("invalid out[0].ConnID") + } + if out[0].Failure != nil { + t.Fatal("invalid out[0].Failure") + } + if out[0].NegotiatedProtocol != "" { + t.Fatal("invalid out[0].NegotiatedProtocol") + } + if len(out[0].PeerCertificates) != 0 { + t.Fatal("invalid out[0].PeerCertificates") + } + if !floatEquals(out[0].T, 0) { + t.Fatal("invalid out[0].T") + } + if out[0].TLSVersion != "" { + t.Fatal("invalid out[0].TLSVersion") + } + + if out[1].CipherSuite != "" { + t.Fatal("invalid out[1].CipherSuite") + } + if out[1].ConnID != 12345 { + t.Fatal("invalid out[1].ConnID") + } + if *out[1].Failure != "mocked error" { + t.Fatal("invalid out[1].Failure") + } + if out[1].NegotiatedProtocol != "" { + t.Fatal("invalid out[1].NegotiatedProtocol") + } + if len(out[1].PeerCertificates) != 0 { + t.Fatal("invalid out[1].PeerCertificates") + } + if !floatEquals(out[1].T, 0) { + t.Fatal("invalid out[1].T") + } + if out[1].TLSVersion != "" { + t.Fatal("invalid out[1].TLSVersion") + } + + if out[2].CipherSuite != "TLS_AES_128_GCM_SHA256" { + t.Fatal("invalid out[2].CipherSuite") + } + if out[2].ConnID != 0 { + t.Fatal("invalid out[2].ConnID") + } + if out[2].Failure != nil { + t.Fatal("invalid out[2].Failure") + } + if out[2].NegotiatedProtocol != "h2" { + t.Fatal("invalid out[2].NegotiatedProtocol") + } + if len(out[2].PeerCertificates) != 2 { + t.Fatal("invalid out[2].PeerCertificates") + } + if !floatEquals(out[2].T, 0.010) { + t.Fatal("invalid out[2].T") + } + if out[2].TLSVersion != "TLSv1.1" { + t.Fatal("invalid out[2].TLSVersion") + } + + for idx, mbv := range out[2].PeerCertificates { + if idx == 0 && mbv.Value != "deadbeef" { + t.Fatal("invalid first certificate") + } + if idx == 1 && mbv.Value != "abad1dea" { + t.Fatal("invalid second certificate") + } + if idx < 0 || idx > 1 { + t.Fatal("invalid index") + } + } +} diff --git a/internal/engine/legacy/oonitemplates/oonitemplates.go b/internal/engine/legacy/oonitemplates/oonitemplates.go new file mode 100644 index 0000000..2a6c7e9 --- /dev/null +++ b/internal/engine/legacy/oonitemplates/oonitemplates.go @@ -0,0 +1,607 @@ +// Package oonitemplates contains templates for experiments. +// +// Every experiment should possibly be based on code inside of +// this package. In the future we should perhaps unify the code +// in here with the code in oonidatamodel. +// +// This has been forked from ooni/netx/x/porcelain because it was +// causing too much changes to keep this code in there. +package oonitemplates + +import ( + "context" + "io" + "io/ioutil" + "math/rand" + "net" + "net/http" + "net/url" + "sync" + "time" + + goptlib "git.torproject.org/pluggable-transports/goptlib.git" + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "gitlab.com/yawning/obfs4.git/transports" + obfs4base "gitlab.com/yawning/obfs4.git/transports/base" +) + +type channelHandler struct { + ch chan<- modelx.Measurement + lateWrites *atomicx.Int64 +} + +func newChannelHandler(ch chan<- modelx.Measurement) *channelHandler { + return &channelHandler{ + ch: ch, + lateWrites: atomicx.NewInt64(), + } +} + +func (h *channelHandler) OnMeasurement(m modelx.Measurement) { + // Implementation note: when we're closing idle connections it + // may be that they're closed once we have stopped reading + // therefore (1) we MUST NOT close the channel to signal that + // we're done BECAUSE THIS IS A LIE and (2) we MUST instead + // arrange here for non-blocking sends. + select { + case h.ch <- m: + case <-time.After(100 * time.Millisecond): + h.lateWrites.Add(1) + } +} + +// Results contains the results of every operation that we care +// about and information on the number of bytes received and sent. +// When counting the number of bytes sent and received, we do not +// take into account domain name resolutions performed using the +// system resolver. We estimated that using heuristics with MK but +// we currently don't have a good solution. TODO(bassosimone): this +// can be improved by emitting estimates when we know that we are +// using the system resolver, so we can pick up estimates here. +type Results struct { + Connects []*modelx.ConnectEvent + HTTPRequests []*modelx.HTTPRoundTripDoneEvent + NetworkEvents []*modelx.Measurement + Resolves []*modelx.ResolveDoneEvent + TLSHandshakes []*modelx.TLSHandshakeDoneEvent +} + +type connmapper struct { + counter int64 + mu sync.Mutex + once sync.Once + table map[int64]int64 +} + +// scramble maps a ConnID to a different number to avoid emitting +// the port numbers. We preserve the sign because it's used to +// distinguish between TCP (positive) and UDP (negative). A special +// case is zero, which is always mapped to zero, since the zero +// port means "unspecified" in netx code. +func (m *connmapper) scramble(cid int64) int64 { + m.once.Do(func() { + m.table = make(map[int64]int64) + m.table[0] = 0 // means unspecified in netx + }) + // See https://stackoverflow.com/a/38140573/4354461 + m.mu.Lock() + defer m.mu.Unlock() + if value, found := m.table[cid]; found == true { + return value + } + var factor int64 = 1 + if cid < 0 { + factor = -1 + } + m.counter++ // we must never emit zero + value := factor * m.counter + m.table[cid] = value + return value +} + +// cm is the global connmapper +var cm connmapper + +func (r *Results) onMeasurement(m modelx.Measurement, lowLevel bool) { + if m.Connect != nil { + m.Connect.ConnID = cm.scramble(m.Connect.ConnID) + r.Connects = append(r.Connects, m.Connect) + if lowLevel { + r.NetworkEvents = append(r.NetworkEvents, &m) + } + } + if m.HTTPRoundTripDone != nil { + r.HTTPRequests = append(r.HTTPRequests, m.HTTPRoundTripDone) + } + if m.ResolveDone != nil { + r.Resolves = append(r.Resolves, m.ResolveDone) + } + if m.TLSHandshakeDone != nil { + m.TLSHandshakeDone.ConnID = cm.scramble(m.TLSHandshakeDone.ConnID) + r.TLSHandshakes = append(r.TLSHandshakes, m.TLSHandshakeDone) + } + if m.Read != nil { + m.Read.ConnID = cm.scramble(m.Read.ConnID) + if lowLevel { + r.NetworkEvents = append(r.NetworkEvents, &m) + } + } + if m.Write != nil { + m.Write.ConnID = cm.scramble(m.Write.ConnID) + if lowLevel { + r.NetworkEvents = append(r.NetworkEvents, &m) + } + } +} + +func (r *Results) collect( + output <-chan modelx.Measurement, + handler modelx.Handler, + main func(), + lowLevel bool, +) { + if handler == nil { + handler = handlers.NoHandler + } + done := make(chan interface{}) + go func() { + defer close(done) + main() + }() + for { + select { + case m := <-output: + handler.OnMeasurement(m) + r.onMeasurement(m, lowLevel) + case <-done: + return + } + } +} + +type dnsFallback struct { + network, address string +} + +func configureDNS(seed int64, network, address string) (modelx.DNSResolver, error) { + resolver, err := netx.NewResolver(network, address) + if err != nil { + return nil, err + } + fallbacks := []dnsFallback{ + dnsFallback{ + network: "doh", + address: "https://cloudflare-dns.com/dns-query", + }, + dnsFallback{ + network: "doh", + address: "https://dns.google/dns-query", + }, + dnsFallback{ + network: "dot", + address: "8.8.8.8:853", + }, + dnsFallback{ + network: "dot", + address: "8.8.4.4:853", + }, + dnsFallback{ + network: "dot", + address: "1.1.1.1:853", + }, + dnsFallback{ + network: "dot", + address: "9.9.9.9:853", + }, + } + random := rand.New(rand.NewSource(seed)) + random.Shuffle(len(fallbacks), func(i, j int) { + fallbacks[i], fallbacks[j] = fallbacks[j], fallbacks[i] + }) + var configured int + for i := 0; configured < 2 && i < len(fallbacks); i++ { + if fallbacks[i].network == network { + continue + } + var fallback modelx.DNSResolver + fallback, err = netx.NewResolver(fallbacks[i].network, fallbacks[i].address) + runtimex.PanicOnError(err, "porcelain: invalid fallbacks table") + resolver = netx.ChainResolvers(resolver, fallback) + configured++ + } + return resolver, nil +} + +// DNSLookupConfig contains DNSLookup settings. +type DNSLookupConfig struct { + Beginning time.Time + Handler modelx.Handler + Hostname string + ServerAddress string + ServerNetwork string +} + +// DNSLookupResults contains the results of a DNSLookup +type DNSLookupResults struct { + TestKeys Results + Addresses []string + Error error +} + +// DNSLookup performs a DNS lookup. +func DNSLookup( + ctx context.Context, config DNSLookupConfig, +) *DNSLookupResults { + var ( + mu sync.Mutex + results = new(DNSLookupResults) + ) + if config.Beginning.IsZero() { + config.Beginning = time.Now() + } + channel := make(chan modelx.Measurement) + root := &modelx.MeasurementRoot{ + Beginning: config.Beginning, + Handler: newChannelHandler(channel), + } + ctx = modelx.WithMeasurementRoot(ctx, root) + resolver, err := netx.NewResolver(config.ServerNetwork, config.ServerAddress) + if err != nil { + results.Error = err + return results + } + results.TestKeys.collect(channel, config.Handler, func() { + addrs, err := resolver.LookupHost(ctx, config.Hostname) + mu.Lock() + defer mu.Unlock() + results.Addresses, results.Error = addrs, err + }, false) + return results +} + +// HTTPDoConfig contains HTTPDo settings. +type HTTPDoConfig struct { + Accept string + AcceptLanguage string + Beginning time.Time + Body []byte + DNSServerAddress string + DNSServerNetwork string + Handler modelx.Handler + InsecureSkipVerify bool + Method string + ProxyFunc func(*http.Request) (*url.URL, error) + URL string + UserAgent string + + // MaxEventsBodySnapSize controls the snap size that + // we're using for bodies returned as modelx.Measurement. + // + // Same rules as modelx.MeasurementRoot.MaxBodySnapSize. + MaxEventsBodySnapSize int64 + + // MaxResponseBodySnapSize controls the snap size that + // we're using for the HTTPDoResults.BodySnap. + // + // Same rules as modelx.MeasurementRoot.MaxBodySnapSize. + MaxResponseBodySnapSize int64 +} + +// HTTPDoResults contains the results of a HTTPDo +type HTTPDoResults struct { + TestKeys Results + StatusCode int64 + Headers http.Header + BodySnap []byte + Error error +} + +// HTTPDo performs a HTTP request +func HTTPDo( + origCtx context.Context, config HTTPDoConfig, +) *HTTPDoResults { + var ( + mu sync.Mutex + results = new(HTTPDoResults) + ) + if config.Beginning.IsZero() { + config.Beginning = time.Now() + } + channel := make(chan modelx.Measurement) + // TODO(bassosimone): tell client to use specific CA bundle? + root := &modelx.MeasurementRoot{ + Beginning: config.Beginning, + Handler: newChannelHandler(channel), + MaxBodySnapSize: config.MaxEventsBodySnapSize, + } + ctx := modelx.WithMeasurementRoot(origCtx, root) + client := netx.NewHTTPClientWithProxyFunc(config.ProxyFunc) + resolver, err := configureDNS( + time.Now().UnixNano(), + config.DNSServerNetwork, + config.DNSServerAddress, + ) + if err != nil { + results.Error = err + return results + } + client.SetResolver(resolver) + if config.InsecureSkipVerify { + client.ForceSkipVerify() + } + // TODO(bassosimone): implement sending body + req, err := http.NewRequest(config.Method, config.URL, nil) + if err != nil { + results.Error = err + return results + } + if config.Accept != "" { + req.Header.Set("Accept", config.Accept) + } + if config.AcceptLanguage != "" { + req.Header.Set("Accept-Language", config.AcceptLanguage) + } + req.Header.Set("User-Agent", config.UserAgent) + req = req.WithContext(ctx) + results.TestKeys.collect(channel, config.Handler, func() { + defer client.HTTPClient.CloseIdleConnections() + resp, err := client.HTTPClient.Do(req) + if err != nil { + mu.Lock() + results.Error = err + mu.Unlock() + return + } + mu.Lock() + results.StatusCode = int64(resp.StatusCode) + results.Headers = resp.Header + mu.Unlock() + defer resp.Body.Close() + reader := io.LimitReader( + resp.Body, modelx.ComputeBodySnapSize( + config.MaxResponseBodySnapSize, + ), + ) + data, err := ioutil.ReadAll(reader) + mu.Lock() + results.BodySnap, results.Error = data, err + mu.Unlock() + }, false) + return results +} + +// TLSConnectConfig contains TLSConnect settings. +type TLSConnectConfig struct { + Address string + Beginning time.Time + DNSServerAddress string + DNSServerNetwork string + Handler modelx.Handler + InsecureSkipVerify bool + SNI string +} + +// TLSConnectResults contains the results of a TLSConnect +type TLSConnectResults struct { + TestKeys Results + Error error +} + +// TLSConnect performs a TLS connect. +func TLSConnect( + ctx context.Context, config TLSConnectConfig, +) *TLSConnectResults { + var ( + mu sync.Mutex + results = new(TLSConnectResults) + ) + if config.Beginning.IsZero() { + config.Beginning = time.Now() + } + channel := make(chan modelx.Measurement) + root := &modelx.MeasurementRoot{ + Beginning: config.Beginning, + Handler: newChannelHandler(channel), + } + ctx = modelx.WithMeasurementRoot(ctx, root) + dialer := netx.NewDialer() + // TODO(bassosimone): tell dialer to use specific CA bundle? + resolver, err := configureDNS( + time.Now().UnixNano(), + config.DNSServerNetwork, + config.DNSServerAddress, + ) + if err != nil { + results.Error = err + return results + } + dialer.SetResolver(resolver) + if config.InsecureSkipVerify { + dialer.ForceSkipVerify() + } + // TODO(bassosimone): can this call really fail? + dialer.ForceSpecificSNI(config.SNI) + results.TestKeys.collect(channel, config.Handler, func() { + conn, err := dialer.DialTLSContext(ctx, "tcp", config.Address) + if conn != nil { + defer conn.Close() + } + mu.Lock() + defer mu.Unlock() + results.Error = err + }, true) + return results +} + +// TCPConnectConfig contains TCPConnect settings. +type TCPConnectConfig struct { + Address string + Beginning time.Time + DNSServerAddress string + DNSServerNetwork string + Handler modelx.Handler +} + +// TCPConnectResults contains the results of a TCPConnect +type TCPConnectResults struct { + TestKeys Results + Error error +} + +// TCPConnect performs a TCP connect. +func TCPConnect( + ctx context.Context, config TCPConnectConfig, +) *TCPConnectResults { + var ( + mu sync.Mutex + results = new(TCPConnectResults) + ) + if config.Beginning.IsZero() { + config.Beginning = time.Now() + } + channel := make(chan modelx.Measurement) + root := &modelx.MeasurementRoot{ + Beginning: config.Beginning, + Handler: newChannelHandler(channel), + } + ctx = modelx.WithMeasurementRoot(ctx, root) + dialer := netx.NewDialer() + // TODO(bassosimone): tell dialer to use specific CA bundle? + resolver, err := configureDNS( + time.Now().UnixNano(), + config.DNSServerNetwork, + config.DNSServerAddress, + ) + if err != nil { + results.Error = err + return results + } + dialer.SetResolver(resolver) + results.TestKeys.collect(channel, config.Handler, func() { + conn, err := dialer.DialContext(ctx, "tcp", config.Address) + if conn != nil { + defer conn.Close() + } + mu.Lock() + defer mu.Unlock() + results.Error = err + }, false) + return results +} + +func init() { + runtimex.PanicOnError(transports.Init(), "transport.Init() failed") +} + +// OBFS4ConnectConfig contains OBFS4Connect settings. +type OBFS4ConnectConfig struct { + Address string + Beginning time.Time + DNSServerAddress string + DNSServerNetwork string + Handler modelx.Handler + Params goptlib.Args + StateBaseDir string + Timeout time.Duration + ioutilTempDir func(dir string, prefix string) (string, error) + transportsGet func(name string) obfs4base.Transport + setDeadline func(net.Conn, time.Time) error +} + +// OBFS4ConnectResults contains the results of a OBFS4Connect +type OBFS4ConnectResults struct { + TestKeys Results + Error error +} + +// OBFS4Connect performs a TCP connect. +func OBFS4Connect( + ctx context.Context, config OBFS4ConnectConfig, +) *OBFS4ConnectResults { + var ( + mu sync.Mutex + results = new(OBFS4ConnectResults) + ) + if config.Beginning.IsZero() { + config.Beginning = time.Now() + } + channel := make(chan modelx.Measurement) + root := &modelx.MeasurementRoot{ + Beginning: config.Beginning, + Handler: newChannelHandler(channel), + } + ctx = modelx.WithMeasurementRoot(ctx, root) + dialer := netx.NewDialer() + // TODO(bassosimone): tell dialer to use specific CA bundle? + resolver, err := configureDNS( + time.Now().UnixNano(), + config.DNSServerNetwork, + config.DNSServerAddress, + ) + if err != nil { + results.Error = err + return results + } + dialer.SetResolver(resolver) + transportsGet := config.transportsGet + if transportsGet == nil { + transportsGet = transports.Get + } + txp := transportsGet("obfs4") + ioutilTempDir := config.ioutilTempDir + if ioutilTempDir == nil { + ioutilTempDir = ioutil.TempDir + } + dirname, err := ioutilTempDir(config.StateBaseDir, "obfs4") + if err != nil { + results.Error = err + return results + } + factory, err := txp.ClientFactory(dirname) + if err != nil { + results.Error = err + return results + } + parsedargs, err := factory.ParseArgs(&config.Params) + if err != nil { + results.Error = err + return results + } + results.TestKeys.collect(channel, config.Handler, func() { + dialfunc := func(network, address string) (net.Conn, error) { + conn, err := dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + // I didn't immediately see an API for limiting in time the + // duration of the handshake, so let's set a deadline. + timeout := config.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + setDeadline := config.setDeadline + if setDeadline == nil { + setDeadline = func(conn net.Conn, t time.Time) error { + return conn.SetDeadline(t) + } + } + if err := setDeadline(conn, time.Now().Add(timeout)); err != nil { + conn.Close() + return nil, err + } + return conn, nil + } + conn, err := factory.Dial("tcp", config.Address, dialfunc, parsedargs) + if conn != nil { + defer conn.Close() + } + mu.Lock() + defer mu.Unlock() + results.Error = err + }, true) + return results +} diff --git a/internal/engine/legacy/oonitemplates/oonitemplates_test.go b/internal/engine/legacy/oonitemplates/oonitemplates_test.go new file mode 100644 index 0000000..66acff4 --- /dev/null +++ b/internal/engine/legacy/oonitemplates/oonitemplates_test.go @@ -0,0 +1,408 @@ +package oonitemplates + +import ( + "context" + "errors" + "net" + "strings" + "sync" + "testing" + "time" + + goptlib "git.torproject.org/pluggable-transports/goptlib.git" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "gitlab.com/yawning/obfs4.git/transports" + obfs4base "gitlab.com/yawning/obfs4.git/transports/base" +) + +func TestChannelHandlerWriteLateOnChannel(t *testing.T) { + handler := newChannelHandler(make(chan modelx.Measurement)) + var waitgroup sync.WaitGroup + waitgroup.Add(1) + go func() { + time.Sleep(1 * time.Second) + handler.OnMeasurement(modelx.Measurement{}) + waitgroup.Done() + }() + waitgroup.Wait() + if handler.lateWrites.Load() != 1 { + t.Fatal("unexpected lateWrites value") + } +} + +func TestDNSLookupGood(t *testing.T) { + ctx := context.Background() + results := DNSLookup(ctx, DNSLookupConfig{ + Hostname: "ooni.io", + }) + if results.Error != nil { + t.Fatal(results.Error) + } + if len(results.Addresses) < 1 { + t.Fatal("no addresses returned?!") + } +} + +func TestDNSLookupCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout( + context.Background(), time.Microsecond, + ) + defer cancel() + results := DNSLookup(ctx, DNSLookupConfig{ + Hostname: "ooni.io", + }) + if results.Error == nil { + t.Fatal("expected an error here") + } + if results.Error.Error() != errorx.FailureGenericTimeoutError { + t.Fatal("not the error we expected") + } + if len(results.Addresses) > 0 { + t.Fatal("addresses returned?!") + } +} + +func TestDNSLookupUnknownDNS(t *testing.T) { + ctx := context.Background() + results := DNSLookup(ctx, DNSLookupConfig{ + Hostname: "ooni.io", + ServerNetwork: "antani", + }) + if !strings.HasSuffix(results.Error.Error(), "unsupported network value") { + t.Fatal("expected a different error here") + } +} + +func TestHTTPDoGood(t *testing.T) { + ctx := context.Background() + results := HTTPDo(ctx, HTTPDoConfig{ + Accept: "*/*", + AcceptLanguage: "en", + URL: "http://ooni.io", + }) + if results.Error != nil { + t.Fatal(results.Error) + } + if results.StatusCode != 200 { + t.Fatal("request failed?!") + } + if len(results.Headers) < 1 { + t.Fatal("no headers?!") + } + if len(results.BodySnap) < 1 { + t.Fatal("no body?!") + } +} + +func TestHTTPDoUnknownDNS(t *testing.T) { + ctx := context.Background() + results := HTTPDo(ctx, HTTPDoConfig{ + URL: "http://ooni.io", + DNSServerNetwork: "antani", + }) + if !strings.HasSuffix(results.Error.Error(), "unsupported network value") { + t.Fatal("not the error that we expected") + } +} + +func TestHTTPDoForceSkipVerify(t *testing.T) { + ctx := context.Background() + results := HTTPDo(ctx, HTTPDoConfig{ + URL: "https://self-signed.badssl.com/", + InsecureSkipVerify: true, + }) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestHTTPDoRoundTripError(t *testing.T) { + ctx := context.Background() + results := HTTPDo(ctx, HTTPDoConfig{ + URL: "http://ooni.io:443", // 443 with http + }) + if results.Error == nil { + t.Fatal("expected an error here") + } +} + +func TestHTTPDoBadURL(t *testing.T) { + ctx := context.Background() + results := HTTPDo(ctx, HTTPDoConfig{ + URL: "\t", + }) + if !strings.HasSuffix(results.Error.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } +} + +func TestTLSConnectGood(t *testing.T) { + ctx := context.Background() + results := TLSConnect(ctx, TLSConnectConfig{ + Address: "ooni.io:443", + }) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestTLSConnectGoodWithDoT(t *testing.T) { + ctx := context.Background() + results := TLSConnect(ctx, TLSConnectConfig{ + Address: "ooni.io:443", + DNSServerNetwork: "dot", + DNSServerAddress: "9.9.9.9:853", + }) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestTLSConnectCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout( + context.Background(), time.Microsecond, + ) + defer cancel() + results := TLSConnect(ctx, TLSConnectConfig{ + Address: "ooni.io:443", + }) + if results.Error == nil { + t.Fatal("expected an error here") + } + if results.Error.Error() != errorx.FailureGenericTimeoutError { + t.Fatal("not the error we expected") + } +} + +func TestTLSConnectUnknownDNS(t *testing.T) { + ctx := context.Background() + results := TLSConnect(ctx, TLSConnectConfig{ + Address: "ooni.io:443", + DNSServerNetwork: "antani", + }) + if !strings.HasSuffix(results.Error.Error(), "unsupported network value") { + t.Fatal("not the error that we expected") + } +} + +func TestTLSConnectForceSkipVerify(t *testing.T) { + ctx := context.Background() + results := TLSConnect(ctx, TLSConnectConfig{ + Address: "self-signed.badssl.com:443", + InsecureSkipVerify: true, + }) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestBodySnapSizes(t *testing.T) { + const ( + maxEventsBodySnapSize = 1 << 7 + maxResponseBodySnapSize = 1 << 8 + ) + ctx := context.Background() + results := HTTPDo(ctx, HTTPDoConfig{ + URL: "https://ooni.org", + MaxEventsBodySnapSize: maxEventsBodySnapSize, + MaxResponseBodySnapSize: maxResponseBodySnapSize, + }) + if results.Error != nil { + t.Fatal(results.Error) + } + if results.StatusCode != 200 { + t.Fatal("request failed?!") + } + if len(results.Headers) < 1 { + t.Fatal("no headers?!") + } + if len(results.BodySnap) != maxResponseBodySnapSize { + t.Fatal("invalid response body snap size") + } + if results.TestKeys.HTTPRequests == nil { + t.Fatal("no HTTPRequests?!") + } + for _, req := range results.TestKeys.HTTPRequests { + if len(req.ResponseBodySnap) != maxEventsBodySnapSize { + t.Fatal("invalid length of ResponseBodySnap") + } + if req.MaxBodySnapSize != maxEventsBodySnapSize { + t.Fatal("unexpected value of MaxBodySnapSize") + } + } +} + +func TestTCPConnectGood(t *testing.T) { + ctx := context.Background() + results := TCPConnect(ctx, TCPConnectConfig{ + Address: "ooni.io:443", + }) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestTCPConnectGoodWithDoT(t *testing.T) { + ctx := context.Background() + results := TCPConnect(ctx, TCPConnectConfig{ + Address: "ooni.io:443", + DNSServerNetwork: "dot", + DNSServerAddress: "9.9.9.9:853", + }) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestTCPConnectUnknownDNS(t *testing.T) { + ctx := context.Background() + results := TCPConnect(ctx, TCPConnectConfig{ + Address: "ooni.io:443", + DNSServerNetwork: "antani", + }) + if !strings.HasSuffix(results.Error.Error(), "unsupported network value") { + t.Fatal("not the error that we expected") + } +} + +func obfs4config() OBFS4ConnectConfig { + // TODO(bassosimone): this is a public working bridge we have found + // with @hellais. We should ask @phw whether there is some obfs4 bridge + // dedicated to integration testing that we should use instead. + return OBFS4ConnectConfig{ + Address: "109.105.109.165:10527", + StateBaseDir: "../../testdata/", + Params: map[string][]string{ + "cert": []string{ + "Bvg/itxeL4TWKLP6N1MaQzSOC6tcRIBv6q57DYAZc3b2AzuM+/TfB7mqTFEfXILCjEwzVA", + }, + "iat-mode": []string{"1"}, + }, + } +} + +func TestOBFS4ConnectGood(t *testing.T) { + ctx := context.Background() + results := OBFS4Connect(ctx, obfs4config()) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestOBFS4ConnectGoodWithDoT(t *testing.T) { + ctx := context.Background() + config := obfs4config() + config.DNSServerNetwork = "dot" + config.DNSServerAddress = "9.9.9.9:853" + results := OBFS4Connect(ctx, config) + if results.Error != nil { + t.Fatal(results.Error) + } +} + +func TestOBFS4ConnectUnknownDNS(t *testing.T) { + ctx := context.Background() + config := obfs4config() + config.DNSServerNetwork = "antani" + results := OBFS4Connect(ctx, config) + if !strings.HasSuffix(results.Error.Error(), "unsupported network value") { + t.Fatal("not the error that we expected") + } +} + +func TestOBFS4IoutilTempDirError(t *testing.T) { + ctx := context.Background() + config := obfs4config() + expected := errors.New("mocked error") + config.ioutilTempDir = func(dir, prefix string) (string, error) { + return "", expected + } + results := OBFS4Connect(ctx, config) + if !errors.Is(results.Error, expected) { + t.Fatal("not the error that we expected") + } +} + +func TestOBFS4ClientFactoryError(t *testing.T) { + ctx := context.Background() + config := obfs4config() + config.transportsGet = func(name string) obfs4base.Transport { + txp := transports.Get(name) + if name == "obfs4" && txp != nil { + txp = &faketransport{txp: txp} + } + return txp + } + results := OBFS4Connect(ctx, config) + if results.Error.Error() != "mocked ClientFactory error" { + t.Fatal("not the error we expected") + } +} + +func TestOBFS4ParseArgsError(t *testing.T) { + ctx := context.Background() + config := obfs4config() + config.Params = make(map[string][]string) // cause ParseArgs error + results := OBFS4Connect(ctx, config) + if results.Error.Error() != "missing argument 'node-id'" { + t.Fatal("not the error we expected") + } +} + +func TestOBFS4DialContextError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // should cause DialContex to fail + config := obfs4config() + results := OBFS4Connect(ctx, config) + if results.Error.Error() != "interrupted" { + t.Fatal("not the error we expected") + } +} + +func TestOBFS4SetDeadlineError(t *testing.T) { + ctx := context.Background() + config := obfs4config() + config.setDeadline = func(net.Conn, time.Time) error { + return errors.New("mocked error") + } + results := OBFS4Connect(ctx, config) + if !strings.HasSuffix(results.Error.Error(), "mocked error") { + t.Fatal("not the error we expected") + } +} + +type faketransport struct { + txp obfs4base.Transport +} + +func (txp *faketransport) Name() string { + return txp.Name() +} + +func (txp *faketransport) ClientFactory(stateDir string) (obfs4base.ClientFactory, error) { + return nil, errors.New("mocked ClientFactory error") +} + +func (txp *faketransport) ServerFactory(stateDir string, args *goptlib.Args) (obfs4base.ServerFactory, error) { + return txp.ServerFactory(stateDir, args) +} + +func TestConnmapper(t *testing.T) { + var mapper connmapper + if mapper.scramble(-1) >= 0 { + t.Fatal("unexpected value for negative input") + } + if mapper.scramble(1234) != 2 { + t.Fatal("unexpected second value") + } + if mapper.scramble(12) != 3 { + t.Fatal("unexpected third value") + } + if mapper.scramble(12) != mapper.scramble(12) { + t.Fatal("not idempotent") + } + if mapper.scramble(0) != 0 { + t.Fatal("unexpected value for zero input") + } +} diff --git a/internal/engine/libminiooni/.gitignore b/internal/engine/libminiooni/.gitignore new file mode 100644 index 0000000..854297a --- /dev/null +++ b/internal/engine/libminiooni/.gitignore @@ -0,0 +1 @@ +/report.jsonl diff --git a/internal/engine/libminiooni/README.md b/internal/engine/libminiooni/README.md new file mode 100644 index 0000000..53046a9 --- /dev/null +++ b/internal/engine/libminiooni/README.md @@ -0,0 +1,14 @@ +# Package github.com/ooni/probe-engine/libminiooni + +Package libminiooni implements the cmd/miniooni CLI. Miniooni is our +experimental client used for research and QA testing. + +This CLI has CLI options that do not conflict with Measurement Kit +v0.10.x CLI options. There are some options conflict with the legacy +OONI Probe CLI options. Perfect backwards compatibility is not a +design goal for miniooni. Rather, we aim to have as little conflict +as possible such that we can run side by side QA checks. + +We extracted this package from cmd/miniooni to allow us to further +integrate the miniooni CLI into other binaries (see for example the +code at github.com/bassosimone/aladdin). diff --git a/internal/engine/libminiooni/libminiooni.go b/internal/engine/libminiooni/libminiooni.go new file mode 100644 index 0000000..b60d2f0 --- /dev/null +++ b/internal/engine/libminiooni/libminiooni.go @@ -0,0 +1,442 @@ +// Package libminiooni implements the cmd/miniooni CLI. Miniooni is our +// experimental client used for research and QA testing. +// +// This CLI has CLI options that do not conflict with Measurement Kit +// v0.10.x CLI options. There are some options conflict with the legacy +// OONI Probe CLI options. Perfect backwards compatibility is not a +// design goal for miniooni. Rather, we aim to have as little conflict +// as possible such that we can run side by side QA checks. +// +// We extracted this package from cmd/miniooni to allow us to further +// integrate the miniooni CLI into other binaries (see for example the +// code at github.com/bassosimone/aladdin). +package libminiooni + +import ( + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "math/rand" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/apex/log" + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/internal/humanizex" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" + "github.com/ooni/probe-cli/v3/internal/engine/version" + "github.com/pborman/getopt/v2" +) + +// Options contains the options you can set from the CLI. +type Options struct { + Annotations []string + ExtraOptions []string + HomeDir string + Inputs []string + InputFilePaths []string + NoJSON bool + NoCollector bool + ProbeServicesURL string + Proxy string + Random bool + ReportFile string + SelfCensorSpec string + TorArgs []string + TorBinary string + Tunnel string + Verbose bool + Yes bool +} + +const ( + softwareName = "miniooni" + softwareVersion = version.Version +) + +var ( + globalOptions Options + startTime = time.Now() +) + +func init() { + getopt.FlagLong( + &globalOptions.Annotations, "annotation", 'A', "Add annotaton", "KEY=VALUE", + ) + getopt.FlagLong( + &globalOptions.ExtraOptions, "option", 'O', + "Pass an option to the experiment", "KEY=VALUE", + ) + getopt.FlagLong( + &globalOptions.InputFilePaths, "input-file", 'f', + "Path to input file to supply test-dependent input. File must contain one input per line.", "PATH", + ) + getopt.FlagLong( + &globalOptions.HomeDir, "home", 0, + "Force specific home directory", "PATH", + ) + getopt.FlagLong( + &globalOptions.Inputs, "input", 'i', + "Add test-dependent input to the test input", "INPUT", + ) + getopt.FlagLong( + &globalOptions.NoJSON, "no-json", 'N', "Disable writing to disk", + ) + getopt.FlagLong( + &globalOptions.NoCollector, "no-collector", 'n', "Don't use a collector", + ) + getopt.FlagLong( + &globalOptions.ProbeServicesURL, "probe-services", 0, + "Set the URL of the probe-services instance you want to use", "URL", + ) + getopt.FlagLong( + &globalOptions.Proxy, "proxy", 0, "Set the proxy URL", "URL", + ) + getopt.FlagLong( + &globalOptions.Random, "random", 0, "Randomize inputs", + ) + getopt.FlagLong( + &globalOptions.ReportFile, "reportfile", 'o', + "Set the report file path", "PATH", + ) + getopt.FlagLong( + &globalOptions.SelfCensorSpec, "self-censor-spec", 0, + "Enable and configure self censorship", "JSON", + ) + getopt.FlagLong( + &globalOptions.TorArgs, "tor-args", 0, + "Extra args for tor binary (may be specified multiple times)", + ) + getopt.FlagLong( + &globalOptions.TorBinary, "tor-binary", 0, + "Specify path to a specific tor binary", + ) + getopt.FlagLong( + &globalOptions.Tunnel, "tunnel", 0, + "Name of the tunnel to use (one of `tor`, `psiphon`)", + ) + getopt.FlagLong( + &globalOptions.Verbose, "verbose", 'v', "Increase verbosity", + ) + getopt.FlagLong( + &globalOptions.Yes, "yes", 0, "I accept the risk of running OONI", + ) +} + +func fatalWithString(msg string) { + panic(msg) +} + +func fatalIfFalse(cond bool, msg string) { + if !cond { + panic(msg) + } +} + +// Main is the main function of miniooni. This function parses the command line +// options and uses a global state. Use MainWithConfiguration if you want to avoid +// using any global state and relying on command line options. +// +// This function will panic in case of a fatal error. It is up to you that +// integrate this function to either handle the panic of ignore it. +func Main() { + getopt.Parse() + fatalIfFalse(len(getopt.Args()) == 1, "Missing experiment name") + MainWithConfiguration(getopt.Arg(0), globalOptions) +} + +func split(s string) (string, string, error) { + v := strings.SplitN(s, "=", 2) + if len(v) != 2 { + return "", "", errors.New("invalid key-value pair") + } + return v[0], v[1], nil +} + +func fatalOnError(err error, msg string) { + if err != nil { + log.WithError(err).Warn(msg) + panic(msg) + } +} + +func warnOnError(err error, msg string) { + if err != nil { + log.WithError(err).Warn(msg) + } +} + +func mustMakeMap(input []string) (output map[string]string) { + output = make(map[string]string) + for _, opt := range input { + key, value, err := split(opt) + fatalOnError(err, "cannot split key-value pair") + output[key] = value + } + return +} + +func mustParseURL(URL string) *url.URL { + rv, err := url.Parse(URL) + fatalOnError(err, "cannot parse URL") + return rv +} + +type logHandler struct { + io.Writer +} + +func (h *logHandler) HandleLog(e *log.Entry) (err error) { + s := fmt.Sprintf("[%14.6f] <%s> %s", time.Since(startTime).Seconds(), e.Level, e.Message) + if len(e.Fields) > 0 { + s += fmt.Sprintf(": %+v", e.Fields) + } + s += "\n" + _, err = h.Writer.Write([]byte(s)) + return +} + +// See https://gist.github.com/miguelmota/f30a04a6d64bd52d7ab59ea8d95e54da +func gethomedir(optionsHome string) string { + if optionsHome != "" { + return optionsHome + } + if runtime.GOOS == "windows" { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + if runtime.GOOS == "linux" { + home := os.Getenv("XDG_CONFIG_HOME") + if home != "" { + return home + } + // fallthrough + } + return os.Getenv("HOME") +} + +const riskOfRunningOONI = ` +Do you consent to OONI Probe data collection? + +OONI Probe collects evidence of internet censorship and measures +network performance: + +- OONI Probe will likely test objectionable sites and services; + +- Anyone monitoring your internet activity (such as a government +or Internet provider) may be able to tell that you are using OONI Probe; + +- The network data you collect will be published automatically +unless you use miniooni's -n command line flag. + +To learn more, see https://ooni.org/about/risks/. + +If you're onboard, re-run the same command and add the --yes flag, to +indicate that you understand the risks. This will create an empty file +named 'consent' in $HOME/.miniooni, meaning that we know you opted in +and we will not ask you this question again. + +` + +func canOpen(filepath string) bool { + stat, err := os.Stat(filepath) + return err == nil && stat.Mode().IsRegular() +} + +func maybeWriteConsentFile(yes bool, filepath string) (err error) { + if yes { + err = ioutil.WriteFile(filepath, []byte("\n"), 0644) + } + return +} + +// MainWithConfiguration is the miniooni main with a specific configuration +// represented by the experiment name and the current options. +// +// This function will panic in case of a fatal error. It is up to you that +// integrate this function to either handle the panic of ignore it. +func MainWithConfiguration(experimentName string, currentOptions Options) { + ctx := context.Background() + + extraOptions := mustMakeMap(currentOptions.ExtraOptions) + annotations := mustMakeMap(currentOptions.Annotations) + + err := selfcensor.MaybeEnable(currentOptions.SelfCensorSpec) + fatalOnError(err, "cannot parse --self-censor-spec argument") + + logger := &log.Logger{Level: log.InfoLevel, Handler: &logHandler{Writer: os.Stderr}} + if currentOptions.Verbose { + logger.Level = log.DebugLevel + } + if currentOptions.ReportFile == "" { + currentOptions.ReportFile = "report.jsonl" + } + log.Log = logger + + homeDir := gethomedir(currentOptions.HomeDir) + fatalIfFalse(homeDir != "", "home directory is empty") + miniooniDir := path.Join(homeDir, ".miniooni") + assetsDir := path.Join(miniooniDir, "assets") + err = os.MkdirAll(assetsDir, 0700) + fatalOnError(err, "cannot create assets directory") + log.Debugf("miniooni state directory: %s", miniooniDir) + + consentFile := path.Join(miniooniDir, "informed") + fatalOnError(maybeWriteConsentFile(currentOptions.Yes, consentFile), + "cannot write informed consent file") + fatalIfFalse(canOpen(consentFile), riskOfRunningOONI) + log.Info("miniooni home directory: $HOME/.miniooni") + + var proxyURL *url.URL + if currentOptions.Proxy != "" { + proxyURL = mustParseURL(currentOptions.Proxy) + } + + kvstore2dir := filepath.Join(miniooniDir, "kvstore2") + kvstore, err := engine.NewFileSystemKVStore(kvstore2dir) + fatalOnError(err, "cannot create kvstore2 directory") + + config := engine.SessionConfig{ + AssetsDir: assetsDir, + KVStore: kvstore, + Logger: logger, + ProxyURL: proxyURL, + SoftwareName: softwareName, + SoftwareVersion: softwareVersion, + TorArgs: currentOptions.TorArgs, + TorBinary: currentOptions.TorBinary, + } + if currentOptions.ProbeServicesURL != "" { + config.AvailableProbeServices = []model.Service{{ + Address: currentOptions.ProbeServicesURL, + Type: "https", + }} + } + + sess, err := engine.NewSession(config) + fatalOnError(err, "cannot create measurement session") + defer func() { + sess.Close() + log.Infof("whole session: recv %s, sent %s", + humanizex.SI(sess.KibiBytesReceived()*1024, "byte"), + humanizex.SI(sess.KibiBytesSent()*1024, "byte"), + ) + }() + log.Debugf("miniooni temporary directory: %s", sess.TempDir()) + + err = sess.MaybeStartTunnel(context.Background(), currentOptions.Tunnel) + fatalOnError(err, "cannot start session tunnel") + + log.Info("Looking up OONI backends; please be patient...") + err = sess.MaybeLookupBackends() + fatalOnError(err, "cannot lookup OONI backends") + log.Info("Looking up your location; please be patient...") + err = sess.MaybeLookupLocation() + fatalOnError(err, "cannot lookup your location") + log.Debugf("- IP: %s", sess.ProbeIP()) + log.Infof("- country: %s", sess.ProbeCC()) + log.Infof("- network: %s (%s)", sess.ProbeNetworkName(), sess.ProbeASNString()) + log.Infof("- resolver's IP: %s", sess.ResolverIP()) + log.Infof("- resolver's network: %s (%s)", sess.ResolverNetworkName(), + sess.ResolverASNString()) + + builder, err := sess.NewExperimentBuilder(experimentName) + fatalOnError(err, "cannot create experiment builder") + + inputLoader := engine.NewInputLoader(engine.InputLoaderConfig{ + StaticInputs: currentOptions.Inputs, + SourceFiles: currentOptions.InputFilePaths, + InputPolicy: builder.InputPolicy(), + Session: sess, + URLLimit: 17, + }) + inputs, err := inputLoader.Load(context.Background()) + fatalOnError(err, "cannot load inputs") + + if currentOptions.Random { + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + rnd.Shuffle(len(inputs), func(i, j int) { + inputs[i], inputs[j] = inputs[j], inputs[i] + }) + } + + err = builder.SetOptionsGuessType(extraOptions) + fatalOnError(err, "cannot parse extraOptions") + + experiment := builder.NewExperiment() + defer func() { + log.Infof("experiment: recv %s, sent %s", + humanizex.SI(experiment.KibiBytesReceived()*1024, "byte"), + humanizex.SI(experiment.KibiBytesSent()*1024, "byte"), + ) + }() + + submitter, err := engine.NewSubmitter(ctx, engine.SubmitterConfig{ + Enabled: currentOptions.NoCollector == false, + Session: sess, + Logger: log.Log, + }) + fatalOnError(err, "cannot create submitter") + + saver, err := engine.NewSaver(engine.SaverConfig{ + Enabled: currentOptions.NoJSON == false, + Experiment: experiment, + FilePath: currentOptions.ReportFile, + Logger: log.Log, + }) + fatalOnError(err, "cannot create saver") + + inputProcessor := engine.InputProcessor{ + Annotations: annotations, + Experiment: &experimentWrapper{ + child: engine.NewInputProcessorExperimentWrapper(experiment), + total: len(inputs), + }, + Inputs: inputs, + Options: currentOptions.ExtraOptions, + Saver: engine.NewInputProcessorSaverWrapper(saver), + Submitter: submitterWrapper{ + child: engine.NewInputProcessorSubmitterWrapper(submitter), + }, + } + err = inputProcessor.Run(ctx) + fatalOnError(err, "inputProcessor.Run failed") +} + +type experimentWrapper struct { + child engine.InputProcessorExperimentWrapper + total int +} + +func (ew *experimentWrapper) MeasureWithContext( + ctx context.Context, idx int, input string) (*model.Measurement, error) { + if input != "" { + log.Infof("[%d/%d] running with input: %s", idx+1, ew.total, input) + } + measurement, err := ew.child.MeasureWithContext(ctx, idx, input) + warnOnError(err, "measurement failed") + // policy: we do not stop the loop if the measurement fails + return measurement, nil +} + +type submitterWrapper struct { + child engine.InputProcessorSubmitterWrapper +} + +func (sw submitterWrapper) Submit(ctx context.Context, idx int, m *model.Measurement) error { + err := sw.child.Submit(ctx, idx, m) + warnOnError(err, "submitting measurement failed") + // policy: we do not stop the loop if measurement submission fails + return nil +} diff --git a/internal/engine/libminiooni/libminiooni_integration_test.go b/internal/engine/libminiooni/libminiooni_integration_test.go new file mode 100644 index 0000000..89f0873 --- /dev/null +++ b/internal/engine/libminiooni/libminiooni_integration_test.go @@ -0,0 +1,16 @@ +package libminiooni_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/libminiooni" +) + +func TestSimple(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + libminiooni.MainWithConfiguration("example", libminiooni.Options{ + Yes: true, + }) +} diff --git a/internal/engine/libooniffi/.gitignore b/internal/engine/libooniffi/.gitignore new file mode 100644 index 0000000..b1d7208 --- /dev/null +++ b/internal/engine/libooniffi/.gitignore @@ -0,0 +1,8 @@ +/asn.mmdb +/ca-bundle.pem +/country.mmdb +/ffirun +/ffirun.exe +/libooniffi.dll +/libooniffi.h +/libooniffi.so diff --git a/internal/engine/libooniffi/README.md b/internal/engine/libooniffi/README.md new file mode 100644 index 0000000..6558f43 --- /dev/null +++ b/internal/engine/libooniffi/README.md @@ -0,0 +1,16 @@ +# Directory github.com/ooni/probe-engine/libooniffi + +This directory contains code to generate shared/static libraries with +a Measurement Kit compatible ABI. To this end, we wrap the [oonimkall]( +../oonimkall) API with a simple C API. + +The generated libraries have a Measurement Kit compatible ABI. You can +also instruct your compiler so that [ooniffi.h](ooniffi.h) defines macros +that make code written for Measurement Kit compile and work. To this +end, please see comments insider [ooniffi.h](ooniffi.h). + +To see how we compile this library for several systems, please take a +look at [libooniffi.yml](../.github/workflows/libooniffi.yml). + +This is not used in any OONI product. We may break something +in ooniffi without noticing it. Please, be aware of that. diff --git a/internal/engine/libooniffi/buildtest.bash b/internal/engine/libooniffi/buildtest.bash new file mode 100755 index 0000000..39bebe5 --- /dev/null +++ b/internal/engine/libooniffi/buildtest.bash @@ -0,0 +1,26 @@ +#!/bin/bash +set -e +cd $(dirname $0) +case $1 in + darwin) + set -x + go build -ldflags '-s -w' -buildmode=c-shared -o libooniffi.so . + clang++ -std=c++11 -Wall -Wextra -I. -L. -o ffirun -looniffi ./testdata/ffirun.cpp + ./ffirun testdata/webconnectivity.json + ;; + linux) + set -x + go build -ldflags '-s -w' -buildmode=c-shared -o libooniffi.so . + g++ -std=c++11 -Wall -Wextra -I. -L. -o ffirun ./testdata/ffirun.cpp -looniffi + LD_LIBRARY_PATH=. ./ffirun testdata/webconnectivity.json + ;; + windows) + set -x + go build -ldflags '-s -w' -buildmode=c-shared -o libooniffi.dll . + x86_64-w64-mingw32-g++ -std=c++11 -Wall -Wextra -I. -L. -o ffirun.exe -looniffi ./testdata/ffirun.cpp + ./ffirun.exe testdata/webconnectivity.json + ;; + *) + echo "usage: $0 darwin|linux|windows" 1>&2 + exit 1 +esac diff --git a/internal/engine/libooniffi/ooniffi.c b/internal/engine/libooniffi/ooniffi.c new file mode 100644 index 0000000..17e99ef --- /dev/null +++ b/internal/engine/libooniffi/ooniffi.c @@ -0,0 +1,17 @@ +#include "ooniffi.h" + +#include "_cgo_export.h" + +ooniffi_task_t *ooniffi_task_start(const char *settings) { + /* Implementation note: Go does not have the concept of const but + we know that the code is just making a copy of settings. */ + return ooniffi_task_start_((char *)settings); +} + +const char *ooniffi_event_serialization(ooniffi_event_t *event) { + /* Implementation note: Go does not have the concept of const but + we want to return const to very clearly communicate that the + returned string is owned by the event. This is what tools like + python's ctypes and SWIG expect from us. */ + return (const char *)ooniffi_event_serialization_(event); +} diff --git a/internal/engine/libooniffi/ooniffi.def b/internal/engine/libooniffi/ooniffi.def new file mode 100644 index 0000000..55c5de8 --- /dev/null +++ b/internal/engine/libooniffi/ooniffi.def @@ -0,0 +1,7 @@ +EXPORTS +ooniffi_task_start +ooniffi_task_is_done +ooniffi_task_wait_for_next_event +ooniffi_event_serialization +ooniffi_event_destroy +ooniffi_task_destroy diff --git a/internal/engine/libooniffi/ooniffi.go b/internal/engine/libooniffi/ooniffi.go new file mode 100644 index 0000000..025c7cf --- /dev/null +++ b/internal/engine/libooniffi/ooniffi.go @@ -0,0 +1,152 @@ +package main + +import ( + //#include "ooniffi.h" + // + //#include + //#include + // + //struct ooniffi_task_ { + // int64_t Handle; + //}; + // + // struct ooniffi_event_ { + // char *String; + //}; + "C" + "sync" + "unsafe" + + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall" +) + +var ( + idx C.int64_t + m = make(map[C.int64_t]*oonimkall.Task) + mu sync.Mutex +) + +func cstring(s string) *C.char { + return C.CString(s) +} + +func freestring(s *C.char) { + C.free(unsafe.Pointer(s)) +} + +func gostring(s *C.char) string { + return C.GoString(s) +} + +const maxIdx = C.INT64_MAX + +//export ooniffi_task_start_ +func ooniffi_task_start_(settings *C.char) *C.ooniffi_task_t { + if settings == nil { + return nil + } + tp, err := oonimkall.StartTask(gostring(settings)) + if err != nil { + return nil + } + mu.Lock() + defer mu.Unlock() + // TODO(bassosimone): the following if is basic protection against + // undefined behaviour, i.e., the counter wrapping around. A much + // better strategy would probably be to restart from 0. However it's + // also unclear if any device could run that many tests, so... + if idx >= maxIdx { + return nil + } + handle := idx + idx++ + m[handle] = tp + task := (*C.ooniffi_task_t)(C.malloc(C.sizeof_ooniffi_task_t)) + task.Handle = handle + return task +} + +func setmaxidx() C.int64_t { + o := idx + idx = maxIdx + return o +} + +func restoreidx(v C.int64_t) { + idx = v +} + +//export ooniffi_task_wait_for_next_event +func ooniffi_task_wait_for_next_event(task *C.ooniffi_task_t) (event *C.ooniffi_event_t) { + if task != nil { + mu.Lock() + tp := m[task.Handle] + mu.Unlock() + if tp != nil { + event = (*C.ooniffi_event_t)(C.malloc(C.sizeof_ooniffi_event_t)) + event.String = cstring(tp.WaitForNextEvent()) + } + } + return +} + +//export ooniffi_task_is_done +func ooniffi_task_is_done(task *C.ooniffi_task_t) C.int { + var isdone C.int = 1 + if task != nil { + mu.Lock() + if tp := m[task.Handle]; tp != nil && !tp.IsDone() { + isdone = 0 + } + mu.Unlock() + } + return isdone +} + +//export ooniffi_task_interrupt +func ooniffi_task_interrupt(task *C.ooniffi_task_t) { + if task != nil { + mu.Lock() + if tp := m[task.Handle]; tp != nil { + tp.Interrupt() + } + mu.Unlock() + } +} + +//export ooniffi_event_serialization_ +func ooniffi_event_serialization_(event *C.ooniffi_event_t) (s *C.char) { + if event != nil { + s = event.String + } + return +} + +//export ooniffi_event_destroy +func ooniffi_event_destroy(event *C.ooniffi_event_t) { + if event != nil { + C.free(unsafe.Pointer(event.String)) + C.free(unsafe.Pointer(event)) + } +} + +//export ooniffi_task_destroy +func ooniffi_task_destroy(task *C.ooniffi_task_t) { + if task != nil { + mu.Lock() + tp := m[task.Handle] + delete(m, task.Handle) + mu.Unlock() + C.free(unsafe.Pointer(task)) + if tp != nil { // drain task if needed + tp.Interrupt() + go func() { + for !tp.IsDone() { + tp.WaitForNextEvent() + } + }() + } + } +} + +func main() {} diff --git a/internal/engine/libooniffi/ooniffi.h b/internal/engine/libooniffi/ooniffi.h new file mode 100644 index 0000000..169196e --- /dev/null +++ b/internal/engine/libooniffi/ooniffi.h @@ -0,0 +1,51 @@ +#ifndef INCLUDE_OONIFFI_H_ +#define INCLUDE_OONIFFI_H_ + +#include +#include + +/* + * ABI compatible with Measurement Kit v0.10.11 [1]. + * + * Just replace `mk_` with `ooniffi_` and recompile. + * + * .. [1] https://github.com/measurement-kit/measurement-kit/tree/v0.10.11/ + * + * This is not used in any OONI product. We may break something + * in ooniffi without noticing it. Please, be aware of that. + */ + +typedef struct ooniffi_task_ ooniffi_task_t; +typedef struct ooniffi_event_ ooniffi_event_t; + +#ifdef __cplusplus +extern "C" { +#endif + +extern ooniffi_task_t *ooniffi_task_start(const char *settings); +extern ooniffi_event_t *ooniffi_task_wait_for_next_event(ooniffi_task_t *task); +extern int ooniffi_task_is_done(ooniffi_task_t *task); +extern void ooniffi_task_interrupt(ooniffi_task_t *task); +extern const char *ooniffi_event_serialization(ooniffi_event_t *str); +extern void ooniffi_event_destroy(ooniffi_event_t *str); +extern void ooniffi_task_destroy(ooniffi_task_t *task); + +#ifdef __cplusplus +} +#endif + +/* + * Define OONIFFI_EMULATE_MK_API to provide a MK-compatible API at + * compile time that will map to ooniffi's own API. + */ +#ifdef OONIFFI_EMULATE_MK_API +#define mk_task_start ooniffi_task_start +#define mk_task_wait_for_next_event ooniffi_task_wait_for_next_event +#define mk_task_is_done ooniffi_task_is_done +#define mk_task_interrupt ooniffi_task_interrupt +#define mk_event_serialization ooniffi_event_serialization +#define mk_event_destroy ooniffi_event_destroy +#define mk_task_destroy ooniffi_task_destroy +#endif + +#endif /* INCLUDE_OONIFFI_H_ */ diff --git a/internal/engine/libooniffi/ooniffi_test.go b/internal/engine/libooniffi/ooniffi_test.go new file mode 100644 index 0000000..1daa170 --- /dev/null +++ b/internal/engine/libooniffi/ooniffi_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "testing" +) + +func TestTaskStartNullPointer(t *testing.T) { + if ooniffi_task_start_(nil) != nil { + t.Fatal("expected nil result here") + } +} + +func TestTaskStartInvalidJSON(t *testing.T) { + settings := cstring("{") + defer freestring(settings) + if ooniffi_task_start_(settings) != nil { + t.Fatal("expected nil result here") + } +} + +func TestTaskStartIdxWrapping(t *testing.T) { + settings := cstring(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "temp_dir": "../testdata/oonimkall/tmp" + }`) + defer freestring(settings) + o := setmaxidx() + // do twice and see if it's idempotent + if task := ooniffi_task_start_(settings); task != nil { + t.Fatal("expected nil task here") + } + if task := ooniffi_task_start_(settings); task != nil { + t.Fatal("expected nil task here") + } + restoreidx(o) +} + +func TestTaskWaitForNextEventNullPointer(t *testing.T) { + if ooniffi_task_wait_for_next_event(nil) != nil { + t.Fatal("expected nil result here") + } +} + +func TestTaskIsDoneNullPointer(t *testing.T) { + if ooniffi_task_is_done(nil) == 0 { + t.Fatal("expected true-ish result here") + } +} + +func TestTaskInterruptNullPointer(t *testing.T) { + ooniffi_task_interrupt(nil) // mainly: we don't crash :^) +} + +func TestEventSerializationNullPointer(t *testing.T) { + if ooniffi_event_serialization_(nil) != nil { + t.Fatal("expected nil result here") + } +} + +func TestEventDestroyNullPointer(t *testing.T) { + ooniffi_event_destroy(nil) // mainly: we don't crash +} + +func TestTaskDestroyNullPointer(t *testing.T) { + ooniffi_task_destroy(nil) // mainly: we don't crash +} + +func TestExampleNormalUsage(t *testing.T) { + settings := cstring(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "temp_dir": "../testdata/oonimkall/tmp" + }`) + defer freestring(settings) + task := ooniffi_task_start_(settings) + if task == nil { + t.Fatal("expected non-nil task here") + } + for ooniffi_task_is_done(task) == 0 { + event := ooniffi_task_wait_for_next_event(task) + t.Logf("%s", gostring(ooniffi_event_serialization_(event))) + ooniffi_event_destroy(event) + } + ooniffi_task_destroy(task) +} + +func TestExampleInterruptAndDestroy(t *testing.T) { + settings := cstring(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "temp_dir": "../testdata/oonimkall/tmp" + }`) + defer freestring(settings) + task := ooniffi_task_start_(settings) + if task == nil { + t.Fatal("expected non-nil task here") + } + ooniffi_task_interrupt(task) + ooniffi_task_destroy(task) +} + +func TestExampleDestroyImmediately(t *testing.T) { + settings := cstring(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "temp_dir": "../testdata/oonimkall/tmp" + }`) + defer freestring(settings) + task := ooniffi_task_start_(settings) + if task == nil { + t.Fatal("expected non-nil task here") + } + ooniffi_task_destroy(task) +} diff --git a/internal/engine/libooniffi/testdata/ffirun.cpp b/internal/engine/libooniffi/testdata/ffirun.cpp new file mode 100644 index 0000000..f606688 --- /dev/null +++ b/internal/engine/libooniffi/testdata/ffirun.cpp @@ -0,0 +1,43 @@ +#include + +#include +#include +#include +#include + +#define OONIFFI_EMULATE_MK_API +#include "ooniffi.h" + +int main(int argc, char **argv) { + if (argc != 2) { + std::clog << "usage: ffirun /path/to/json/settings" << std::endl; + exit(1); + } + std::ifstream filep(argv[1]); + if (!filep.good()) { + std::clog << "fatal: cannot open settings file" << std::endl; + exit(1); + } + std::string settings((std::istreambuf_iterator(filep)), + std::istreambuf_iterator()); + auto taskp = mk_task_start(settings.c_str()); + if (taskp == nullptr) { + std::clog << "fatal: cannot start task" << std::endl; + exit(1); + } + while (!mk_task_is_done(taskp)) { + auto evp = mk_task_wait_for_next_event(taskp); + if (evp == nullptr) { + std::clog << "warning: cannot wait for next event" << std::endl; + break; + } + auto evstr = mk_event_serialization(evp); + if (evstr != nullptr) { + std::cout << evstr << std::endl; + } else { + std::clog << "warning: cannot get event serialization" << std::endl; + } + mk_event_destroy(evp); + } + mk_task_destroy(taskp); +} diff --git a/internal/engine/libooniffi/testdata/webconnectivity.json b/internal/engine/libooniffi/testdata/webconnectivity.json new file mode 100644 index 0000000..791e58e --- /dev/null +++ b/internal/engine/libooniffi/testdata/webconnectivity.json @@ -0,0 +1,17 @@ +{ + "assets_dir": ".", + "inputs": [ + "https://www.example.com", + "https://www.example.org" + ], + "name": "WebConnectivity", + "log_level": "INFO", + "options": { + "no_collector": true, + "software_name": "ooniffi", + "software_version": "0.1.0-dev" + }, + "state_dir": ".", + "temp_dir": ".", + "version": 1 +} diff --git a/internal/engine/model/README.md b/internal/engine/model/README.md new file mode 100644 index 0000000..a3cf1c1 --- /dev/null +++ b/internal/engine/model/README.md @@ -0,0 +1,3 @@ +# Package github.com/ooni/probe-engine/model + +Shared data structures and interfaces. diff --git a/internal/engine/model/checkinconfig.go b/internal/engine/model/checkinconfig.go new file mode 100644 index 0000000..1bdd646 --- /dev/null +++ b/internal/engine/model/checkinconfig.go @@ -0,0 +1,19 @@ +package model + +// CheckInConfigWebConnectivity is the configuration for the WebConnectivity test +type CheckInConfigWebConnectivity struct { + CategoryCodes []string `json:"category_codes"` // CategoryCodes is an array of category codes +} + +// CheckInConfig contains configuration for calling the checkin API. +type CheckInConfig struct { + Charging bool `json:"charging"` // Charging indicate if the phone is actually charging + OnWiFi bool `json:"on_wifi"` // OnWiFi indicate if the phone is actually connected to a WiFi network + Platform string `json:"platform"` // Platform of the probe + ProbeASN string `json:"probe_asn"` // ProbeASN is the probe country code + ProbeCC string `json:"probe_cc"` // ProbeCC is the probe country code + RunType string `json:"run_type"` // RunType + SoftwareName string `json:"software_name"` // SoftwareName of the probe + SoftwareVersion string `json:"software_version"` // SoftwareVersion of the probe + WebConnectivity CheckInConfigWebConnectivity `json:"web_connectivity"` // WebConnectivity class contain an array of categories +} diff --git a/internal/engine/model/checkininfo.go b/internal/engine/model/checkininfo.go new file mode 100644 index 0000000..211e45e --- /dev/null +++ b/internal/engine/model/checkininfo.go @@ -0,0 +1,12 @@ +package model + +// CheckInInfoWebConnectivity contains the array of URLs returned by the checkin API +type CheckInInfoWebConnectivity struct { + ReportID string `json:"report_id"` + URLs []URLInfo `json:"urls"` +} + +// CheckInInfo contains the return test objects from the checkin API +type CheckInInfo struct { + WebConnectivity *CheckInInfoWebConnectivity `json:"web_connectivity"` +} diff --git a/internal/engine/model/experiment.go b/internal/engine/model/experiment.go new file mode 100644 index 0000000..18e8add --- /dev/null +++ b/internal/engine/model/experiment.go @@ -0,0 +1,75 @@ +package model + +import ( + "context" + "net/http" +) + +// ExperimentOrchestraClient is the experiment's view of +// a client for querying the OONI orchestra API. +type ExperimentOrchestraClient interface { + FetchPsiphonConfig(ctx context.Context) ([]byte, error) + FetchTorTargets(ctx context.Context, cc string) (map[string]TorTarget, error) + FetchURLList(ctx context.Context, config URLListConfig) ([]URLInfo, error) +} + +// ExperimentSession is the experiment's view of a session. +type ExperimentSession interface { + ASNDatabasePath() string + GetTestHelpersByName(name string) ([]Service, bool) + DefaultHTTPClient() *http.Client + Logger() Logger + NewOrchestraClient(ctx context.Context) (ExperimentOrchestraClient, error) + ProbeCC() string + ResolverIP() string + TempDir() string + TorArgs() []string + TorBinary() string + UserAgent() string +} + +// ExperimentCallbacks contains experiment event-handling callbacks +type ExperimentCallbacks interface { + // OnProgress provides information about an experiment progress. + OnProgress(percentage float64, message string) +} + +// PrinterCallbacks is the default event handler +type PrinterCallbacks struct { + Logger +} + +// NewPrinterCallbacks returns a new default callback handler +func NewPrinterCallbacks(logger Logger) PrinterCallbacks { + return PrinterCallbacks{Logger: logger} +} + +// OnProgress provides information about an experiment progress. +func (d PrinterCallbacks) OnProgress(percentage float64, message string) { + d.Logger.Infof("[%5.1f%%] %s", percentage*100, message) +} + +// ExperimentMeasurer is the interface that allows to run a +// measurement for a specific experiment. +type ExperimentMeasurer interface { + // ExperimentName returns the experiment name. + ExperimentName() string + + // ExperimentVersion returns the experiment version. + ExperimentVersion() string + + // Run runs the experiment with the specified context, session, + // measurement, and experiment calbacks. This method should only + // return an error in case the experiment could not run (e.g., + // a required input is missing). Otherwise, the code should just + // set the relevant OONI error inside of the measurmeent and + // return nil. This is important because the caller may not submit + // the measurement if this method returns an error. + Run( + ctx context.Context, sess ExperimentSession, + measurement *Measurement, callbacks ExperimentCallbacks, + ) error + + // GetSummaryKeys returns summary keys expected by ooni/probe-cli. + GetSummaryKeys(*Measurement) (interface{}, error) +} diff --git a/internal/engine/model/experiment_test.go b/internal/engine/model/experiment_test.go new file mode 100644 index 0000000..e1f1e96 --- /dev/null +++ b/internal/engine/model/experiment_test.go @@ -0,0 +1,13 @@ +package model_test + +import ( + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestPrinterCallbacksCallbacks(t *testing.T) { + printer := model.NewPrinterCallbacks(log.Log) + printer.OnProgress(0.4, "progress") +} diff --git a/internal/engine/model/keyvaluestore.go b/internal/engine/model/keyvaluestore.go new file mode 100644 index 0000000..f10df93 --- /dev/null +++ b/internal/engine/model/keyvaluestore.go @@ -0,0 +1,7 @@ +package model + +// KeyValueStore is a key-value store used by the session. +type KeyValueStore interface { + Get(key string) (value []byte, err error) + Set(key string, value []byte) (err error) +} diff --git a/internal/engine/model/logger.go b/internal/engine/model/logger.go new file mode 100644 index 0000000..54cbeae --- /dev/null +++ b/internal/engine/model/logger.go @@ -0,0 +1,40 @@ +package model + +// Logger defines the common interface that a logger should have. It is +// out of the box compatible with `log.Log` in `apex/log`. +type Logger interface { + // Debug emits a debug message. + Debug(msg string) + + // Debugf formats and emits a debug message. + Debugf(format string, v ...interface{}) + + // Info emits an informational message. + Info(msg string) + + // Infof format and emits an informational message. + Infof(format string, v ...interface{}) + + // Warn emits a warning message. + Warn(msg string) + + // Warnf formats and emits a warning message. + Warnf(format string, v ...interface{}) +} + +// DiscardLogger is a logger that discards its input +var DiscardLogger Logger = logDiscarder{} + +type logDiscarder struct{} + +func (logDiscarder) Debug(msg string) {} + +func (logDiscarder) Debugf(format string, v ...interface{}) {} + +func (logDiscarder) Info(msg string) {} + +func (logDiscarder) Infof(format string, v ...interface{}) {} + +func (logDiscarder) Warn(msg string) {} + +func (logDiscarder) Warnf(format string, v ...interface{}) {} diff --git a/internal/engine/model/measurement.go b/internal/engine/model/measurement.go new file mode 100644 index 0000000..6ccad4a --- /dev/null +++ b/internal/engine/model/measurement.go @@ -0,0 +1,126 @@ +package model + +import ( + "encoding/json" + "time" +) + +// MeasurementTarget is the target of a OONI measurement. +type MeasurementTarget string + +// MarshalJSON serializes the MeasurementTarget. +func (t MeasurementTarget) MarshalJSON() ([]byte, error) { + if t == "" { + return json.Marshal(nil) + } + return json.Marshal(string(t)) +} + +// Measurement is a OONI measurement. +// +// This structure is compatible with the definition of the base data format in +// https://github.com/ooni/spec/blob/master/data-formats/df-000-base.md. +type Measurement struct { + // Annotations contains results annotations + Annotations map[string]string `json:"annotations,omitempty"` + + // DataFormatVersion is the version of the data format + DataFormatVersion string `json:"data_format_version"` + + // Extensions contains information about the extensions included + // into the test_keys of this measurement. + Extensions map[string]int64 `json:"extensions,omitempty"` + + // ID is the locally generated measurement ID + ID string `json:"id,omitempty"` + + // Input is the measurement input + Input MeasurementTarget `json:"input"` + + // InputHashes contains input hashes + InputHashes []string `json:"input_hashes,omitempty"` + + // MeasurementStartTime is the time when the measurement started + MeasurementStartTime string `json:"measurement_start_time"` + + // MeasurementStartTimeSaved is the moment in time when we + // started the measurement. This is not included into the JSON + // and is only used within probe-engine as a "zero" time. + MeasurementStartTimeSaved time.Time `json:"-"` + + // Options contains command line options + Options []string `json:"options,omitempty"` + + // ProbeASN contains the probe autonomous system number + ProbeASN string `json:"probe_asn"` + + // ProbeCC contains the probe country code + ProbeCC string `json:"probe_cc"` + + // ProbeCity contains the probe city + ProbeCity string `json:"probe_city,omitempty"` + + // ProbeIP contains the probe IP + ProbeIP string `json:"probe_ip,omitempty"` + + // ProbeNetworkName contains the probe network name + ProbeNetworkName string `json:"probe_network_name"` + + // ReportID contains the report ID + ReportID string `json:"report_id"` + + // ResolverASN is the ASN of the resolver + ResolverASN string `json:"resolver_asn"` + + // ResolverIP is the resolver IP + ResolverIP string `json:"resolver_ip"` + + // ResolverNetworkName is the network name of the resolver. + ResolverNetworkName string `json:"resolver_network_name"` + + // SoftwareName contains the software name + SoftwareName string `json:"software_name"` + + // SoftwareVersion contains the software version + SoftwareVersion string `json:"software_version"` + + // TestHelpers contains the test helpers. It seems this structure is more + // complex than we would like. In particular, using a map from string to + // string does not fit into the web_connectivity use case. Hence, for now + // we're going to represent this using interface{}. In going forward we + // may probably want to have more uniform test helpers. + TestHelpers map[string]interface{} `json:"test_helpers,omitempty"` + + // TestKeys contains the real test result. This field is opaque because + // each experiment will insert here a different structure. + TestKeys interface{} `json:"test_keys"` + + // TestName contains the test name + TestName string `json:"test_name"` + + // MeasurementRuntime contains the measurement runtime. The JSON name + // is test_runtime because this is the name expected by the OONI backend + // even though that name is clearly a misleading one. + MeasurementRuntime float64 `json:"test_runtime"` + + // TestStartTime contains the test start time + TestStartTime string `json:"test_start_time"` + + // TestVersion contains the test version + TestVersion string `json:"test_version"` +} + +// AddAnnotations adds the annotations from input to m.Annotations. +func (m *Measurement) AddAnnotations(input map[string]string) { + for key, value := range input { + m.AddAnnotation(key, value) + } +} + +// AddAnnotation adds a single annotations to m.Annotations. +func (m *Measurement) AddAnnotation(key, value string) { + if m.Annotations == nil { + m.Annotations = make(map[string]string) + } + m.Annotations[key] = value +} diff --git a/internal/engine/model/model.go b/internal/engine/model/model.go new file mode 100644 index 0000000..af7a40d --- /dev/null +++ b/internal/engine/model/model.go @@ -0,0 +1,7 @@ +// Package model defines shared data structures and interfaces. +package model + +const ( + // DefaultProbeIP is the default probe IP. + DefaultProbeIP = "127.0.0.1" +) diff --git a/internal/engine/model/model_test.go b/internal/engine/model/model_test.go new file mode 100644 index 0000000..18915be --- /dev/null +++ b/internal/engine/model/model_test.go @@ -0,0 +1,223 @@ +package model_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestMeasurementTargetMarshalJSON(t *testing.T) { + var mt model.MeasurementTarget + data, err := json.Marshal(mt) + if err != nil { + t.Fatal(err) + } + if string(data) != "null" { + t.Fatal("unexpected serialization") + } + mt = "xx" + data, err = json.Marshal(mt) + if err != nil { + t.Fatal(err) + } + if string(data) != `"xx"` { + t.Fatal("unexpected serialization") + } +} + +type fakeTestKeys struct { + ClientResolver string `json:"client_resolver"` + Body string `json:"body"` +} + +func TestAddAnnotations(t *testing.T) { + m := &model.Measurement{} + m.AddAnnotations(map[string]string{ + "foo": "bar", + "f": "b", + }) + m.AddAnnotations(map[string]string{ + "foobar": "bar", + "f": "b", + }) + if len(m.Annotations) != 3 { + t.Fatal("unexpected number of annotations") + } + if m.Annotations["foo"] != "bar" { + t.Fatal("unexpected annotation") + } + if m.Annotations["f"] != "b" { + t.Fatal("unexpected annotation") + } + if m.Annotations["foobar"] != "bar" { + t.Fatal("unexpected annotation") + } +} + +type makeMeasurementConfig struct { + ProbeIP string + ProbeASN string + ProbeNetworkName string + ProbeCC string + ResolverIP string + ResolverNetworkName string + ResolverASN string +} + +func makeMeasurement(config makeMeasurementConfig) model.Measurement { + return model.Measurement{ + DataFormatVersion: "0.3.0", + ID: "bdd20d7a-bba5-40dd-a111-9863d7908572", + MeasurementStartTime: "2018-11-01 15:33:20", + ProbeIP: config.ProbeIP, + ProbeASN: config.ProbeASN, + ProbeNetworkName: config.ProbeNetworkName, + ProbeCC: config.ProbeCC, + ReportID: "", + ResolverIP: config.ResolverIP, + ResolverNetworkName: config.ResolverNetworkName, + ResolverASN: config.ResolverASN, + SoftwareName: "probe-engine", + SoftwareVersion: "0.1.0", + TestKeys: &fakeTestKeys{ + ClientResolver: "91.80.37.104", + Body: fmt.Sprintf(` + Your IP is %s +

Hey you, I see your IP and it's %s!

+ `, config.ProbeIP, config.ProbeIP), + }, + TestName: "dummy", + MeasurementRuntime: 5.0565230846405, + TestStartTime: "2018-11-01 15:33:17", + TestVersion: "0.1.0", + } +} + +func TestScrubWeAreScrubbing(t *testing.T) { + config := makeMeasurementConfig{ + ProbeIP: "130.192.91.211", + ProbeASN: "AS137", + ProbeCC: "IT", + ProbeNetworkName: "Vodafone Italia S.p.A.", + ResolverIP: "8.8.8.8", + ResolverNetworkName: "Google LLC", + ResolverASN: "AS12345", + } + m := makeMeasurement(config) + if err := m.Scrub(config.ProbeIP); err != nil { + t.Fatal(err) + } + if m.ProbeASN != config.ProbeASN { + t.Fatal("ProbeASN has been scrubbed") + } + if m.ProbeCC != config.ProbeCC { + t.Fatal("ProbeCC has been scrubbed") + } + if m.ProbeIP == config.ProbeIP { + t.Fatal("ProbeIP HAS NOT been scrubbed") + } + if m.ProbeNetworkName != config.ProbeNetworkName { + t.Fatal("ProbeNetworkName has been scrubbed") + } + if m.ResolverIP != config.ResolverIP { + t.Fatal("ResolverIP has been scrubbed") + } + if m.ResolverNetworkName != config.ResolverNetworkName { + t.Fatal("ResolverNetworkName has been scrubbed") + } + if m.ResolverASN != config.ResolverASN { + t.Fatal("ResolverASN has been scrubbed") + } + data, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if bytes.Count(data, []byte(config.ProbeIP)) != 0 { + t.Fatal("ProbeIP not fully redacted") + } +} + +func TestScrubNoScrubbingRequired(t *testing.T) { + config := makeMeasurementConfig{ + ProbeIP: "130.192.91.211", + ProbeASN: "AS137", + ProbeCC: "IT", + ProbeNetworkName: "Vodafone Italia S.p.A.", + ResolverIP: "8.8.8.8", + ResolverNetworkName: "Google LLC", + ResolverASN: "AS12345", + } + m := makeMeasurement(config) + m.TestKeys.(*fakeTestKeys).Body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + if err := m.Scrub(config.ProbeIP); err != nil { + t.Fatal(err) + } + if m.ProbeASN != config.ProbeASN { + t.Fatal("ProbeASN has been scrubbed") + } + if m.ProbeCC != config.ProbeCC { + t.Fatal("ProbeCC has been scrubbed") + } + if m.ProbeIP == config.ProbeIP { + t.Fatal("ProbeIP HAS NOT been scrubbed") + } + if m.ProbeNetworkName != config.ProbeNetworkName { + t.Fatal("ProbeNetworkName has been scrubbed") + } + if m.ResolverIP != config.ResolverIP { + t.Fatal("ResolverIP has been scrubbed") + } + if m.ResolverNetworkName != config.ResolverNetworkName { + t.Fatal("ResolverNetworkName has been scrubbed") + } + if m.ResolverASN != config.ResolverASN { + t.Fatal("ResolverASN has been scrubbed") + } + data, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if bytes.Count(data, []byte(model.Scrubbed)) > 0 { + t.Fatal("We should not see any scrubbing") + } +} + +func TestScrubInvalidIP(t *testing.T) { + m := &model.Measurement{ + ProbeASN: "AS1234", + ProbeCC: "IT", + } + err := m.Scrub("") // invalid IP + if !errors.Is(err, model.ErrInvalidProbeIP) { + t.Fatal("not the error we expected") + } +} + +func TestScrubMarshalError(t *testing.T) { + expected := errors.New("mocked error") + m := &model.Measurement{ + ProbeASN: "AS1234", + ProbeCC: "IT", + } + err := m.MaybeRewriteTestKeys( + "8.8.8.8", func(v interface{}) ([]byte, error) { + return nil, expected + }) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestDiscardLoggerWorksAsIntended(t *testing.T) { + logger := model.DiscardLogger + logger.Debug("foo") + logger.Debugf("%s", "foo") + logger.Info("foo") + logger.Infof("%s", "foo") + logger.Warn("foo") + logger.Warnf("%s", "foo") +} diff --git a/internal/engine/model/scrub.go b/internal/engine/model/scrub.go new file mode 100644 index 0000000..da754d2 --- /dev/null +++ b/internal/engine/model/scrub.go @@ -0,0 +1,49 @@ +package model + +import ( + "bytes" + "encoding/json" + "errors" + "net" +) + +// ErrInvalidProbeIP indicates that we're dealing with a string that +// is not the valid serialization of an IP address. +var ErrInvalidProbeIP = errors.New("model: invalid probe IP") + +// Scrub scrubs the probeIP out of the measurement. +func (m *Measurement) Scrub(probeIP string) (err error) { + // We now behave like we can share everything except the + // probe IP, which we instead cannot ever share + m.ProbeIP = DefaultProbeIP + return m.MaybeRewriteTestKeys(probeIP, json.Marshal) +} + +// Scrubbed is the string that replaces IP addresses. +const Scrubbed = `[scrubbed]` + +// MaybeRewriteTestKeys is the function called by Scrub that +// ensures that m's serialization doesn't include the IP +func (m *Measurement) MaybeRewriteTestKeys( + currentIP string, marshal func(interface{}) ([]byte, error)) error { + if net.ParseIP(currentIP) == nil { + return ErrInvalidProbeIP + } + data, err := marshal(m.TestKeys) + if err != nil { + return err + } + // The check using Count is to save an unnecessary copy performed by + // ReplaceAll when there are no matches into the body. This is what + // we would like the common case to be, meaning that the code has done + // its job correctly and has not leaked the IP. + bpip := []byte(currentIP) + if bytes.Count(data, bpip) <= 0 { + return nil + } + data = bytes.ReplaceAll(data, bpip, []byte(Scrubbed)) + // We add an annotation such that hopefully later we can measure the + // number of cases where we failed to sanitize properly. + m.AddAnnotation("_probe_engine_sanitize_test_keys", "true") + return json.Unmarshal(data, &m.TestKeys) +} diff --git a/internal/engine/model/service.go b/internal/engine/model/service.go new file mode 100644 index 0000000..19fb186 --- /dev/null +++ b/internal/engine/model/service.go @@ -0,0 +1,17 @@ +package model + +// Service describes a backend service. +// +// The fields of this struct have the meaning described in v2.0.0 of the OONI +// bouncer specification defined by +// https://github.com/ooni/spec/blob/master/backends/bk-004-bouncer.md. +type Service struct { + // Address is the address of the server. + Address string `json:"address"` + + // Type is the type of the service. + Type string `json:"type"` + + // Front is the front to use with "cloudfront" type entries. + Front string `json:"front,omitempty"` +} diff --git a/internal/engine/model/tortarget.go b/internal/engine/model/tortarget.go new file mode 100644 index 0000000..d64460e --- /dev/null +++ b/internal/engine/model/tortarget.go @@ -0,0 +1,21 @@ +package model + +// TorTarget is a target for the tor experiment. +type TorTarget struct { + // Address is the address of the target. + Address string `json:"address"` + + // Name is the name of the target. + Name string `json:"name"` + + // Params contains optional params for, e.g., pluggable transports. + Params map[string][]string `json:"params"` + + // Protocol is the protocol to use with the target. + Protocol string `json:"protocol"` + + // Source is the source from which we fetched this specific + // target. Whenever the source is non-empty, we will treat + // this specific target as a private target. + Source string `json:"source"` +} diff --git a/internal/engine/model/urlinfo.go b/internal/engine/model/urlinfo.go new file mode 100644 index 0000000..79ec721 --- /dev/null +++ b/internal/engine/model/urlinfo.go @@ -0,0 +1,8 @@ +package model + +// URLInfo contains info on a test lists URL +type URLInfo struct { + CategoryCode string `json:"category_code"` + CountryCode string `json:"country_code"` + URL string `json:"url"` +} diff --git a/internal/engine/model/urllistconfig.go b/internal/engine/model/urllistconfig.go new file mode 100644 index 0000000..f85c018 --- /dev/null +++ b/internal/engine/model/urllistconfig.go @@ -0,0 +1,8 @@ +package model + +// URLListConfig contains configuration for fetching the URL list. +type URLListConfig struct { + Categories []string // Categories to query for (empty means all) + CountryCode string // CountryCode is the optional country code + Limit int64 // Max number of URLs (<= 0 means no limit) +} diff --git a/internal/engine/netx/README.md b/internal/engine/netx/README.md new file mode 100644 index 0000000..3272b92 --- /dev/null +++ b/internal/engine/netx/README.md @@ -0,0 +1,23 @@ +# Package github.com/ooni/probe-engine/netx + +OONI extensions to the `net` and `net/http` packages. This code is +used by `ooni/probe-engine` as a low level library to collect +network, DNS, and HTTP events occurring during OONI measurements. + +This library contains replacements for commonly used standard library +interfaces that facilitate seamless network measurements. By using +such replacements, as opposed to standard library interfaces, we can: + +* save the timing of HTTP events (e.g. received response headers) +* save the timing and result of every Connect, Read, Write, Close operation +* save the timing and result of the TLS handshake (including certificates) + +By default, this library uses the system resolver. In addition, it +is possible to configure alternative DNS transports and remote +servers. We support DNS over UDP, DNS over TCP, DNS over TLS (DoT), +and DNS over HTTPS (DoH). When using an alternative transport, we +are also able to intercept and save DNS messages, as well as any +other interaction with the remote server (e.g., the result of the +TLS handshake for DoT and DoH). + +This package is a fork of [github.com/ooni/netx](https://github.com/ooni/netx). diff --git a/internal/engine/netx/archival/archival.go b/internal/engine/netx/archival/archival.go new file mode 100644 index 0000000..ec1fdac --- /dev/null +++ b/internal/engine/netx/archival/archival.go @@ -0,0 +1,580 @@ +// Package archival contains data formats used for archival. +// +// See https://github.com/ooni/spec. +package archival + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "net" + "net/http" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/ooni/probe-cli/v3/internal/engine/geolocate" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// ExtSpec describes a data format extension +type ExtSpec struct { + Name string // extension name + V int64 // extension version +} + +// AddTo adds the current ExtSpec to the specified measurement +func (spec ExtSpec) AddTo(m *model.Measurement) { + if m.Extensions == nil { + m.Extensions = make(map[string]int64) + } + m.Extensions[spec.Name] = spec.V +} + +var ( + // ExtDNS is the version of df-002-dnst.md + ExtDNS = ExtSpec{Name: "dnst", V: 0} + + // ExtNetevents is the version of df-008-netevents.md + ExtNetevents = ExtSpec{Name: "netevents", V: 0} + + // ExtHTTP is the version of df-001-httpt.md + ExtHTTP = ExtSpec{Name: "httpt", V: 0} + + // ExtTCPConnect is the version of df-005-tcpconnect.md + ExtTCPConnect = ExtSpec{Name: "tcpconnect", V: 0} + + // ExtTLSHandshake is the version of df-006-tlshandshake.md + ExtTLSHandshake = ExtSpec{Name: "tlshandshake", V: 0} + + // ExtTunnel is the version of df-009-tunnel.md + ExtTunnel = ExtSpec{Name: "tunnel", V: 0} +) + +// TCPConnectStatus contains the TCP connect status. +// +// The Blocked field breaks the separation between measurement and analysis +// we have been enforcing for quite some time now. It is a legacy from the +// Web Connectivity experiment and it should be here because of that. +type TCPConnectStatus struct { + Blocked *bool `json:"blocked,omitempty"` // Web Connectivity only + Failure *string `json:"failure"` + Success bool `json:"success"` +} + +// TCPConnectEntry contains one of the entries that are part +// of the "tcp_connect" key of a OONI report. +type TCPConnectEntry struct { + ConnID int64 `json:"conn_id,omitempty"` + DialID int64 `json:"dial_id,omitempty"` + IP string `json:"ip"` + Port int `json:"port"` + Status TCPConnectStatus `json:"status"` + T float64 `json:"t"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// NewTCPConnectList creates a new TCPConnectList +func NewTCPConnectList(begin time.Time, events []trace.Event) []TCPConnectEntry { + var out []TCPConnectEntry + for _, event := range events { + if event.Name != errorx.ConnectOperation { + continue + } + if event.Proto != "tcp" { + continue + } + // We assume Go is passing us legit data structures + ip, sport, _ := net.SplitHostPort(event.Address) + iport, _ := strconv.Atoi(sport) + out = append(out, TCPConnectEntry{ + IP: ip, + Port: iport, + Status: TCPConnectStatus{ + Failure: NewFailure(event.Err), + Success: event.Err == nil, + }, + T: event.Time.Sub(begin).Seconds(), + }) + } + return out +} + +// NewFailure creates a failure nullable string from the given error +func NewFailure(err error) *string { + if err == nil { + return nil + } + // The following code guarantees that the error is always wrapped even + // when we could not actually hit our code that does the wrapping. A case + // in which this happen is with context deadline for HTTP. + err = errorx.SafeErrWrapperBuilder{ + Error: err, + Operation: errorx.TopLevelOperation, + }.MaybeBuild() + errWrapper := err.(*errorx.ErrWrapper) + s := errWrapper.Failure + if s == "" { + s = "unknown_failure: errWrapper.Failure is empty" + } + return &s +} + +// NewFailedOperation creates a failed operation string from the given error. +func NewFailedOperation(err error) *string { + if err == nil { + return nil + } + var ( + errWrapper *errorx.ErrWrapper + s = errorx.UnknownOperation + ) + if errors.As(err, &errWrapper) && errWrapper.Operation != "" { + s = errWrapper.Operation + } + return &s +} + +// HTTPTor contains Tor information +type HTTPTor struct { + ExitIP *string `json:"exit_ip"` + ExitName *string `json:"exit_name"` + IsTor bool `json:"is_tor"` +} + +// MaybeBinaryValue is a possibly binary string. We use this helper class +// to define a custom JSON encoder that allows us to choose the proper +// representation depending on whether the Value field is valid UTF-8 or not. +type MaybeBinaryValue struct { + Value string +} + +// MarshalJSON marshals a string-like to JSON following the OONI spec that +// says that UTF-8 content is represened as string and non-UTF-8 content is +// instead represented using `{"format":"base64","data":"..."}`. +func (hb MaybeBinaryValue) MarshalJSON() ([]byte, error) { + if utf8.ValidString(hb.Value) { + return json.Marshal(hb.Value) + } + er := make(map[string]string) + er["format"] = "base64" + er["data"] = base64.StdEncoding.EncodeToString([]byte(hb.Value)) + return json.Marshal(er) +} + +// UnmarshalJSON is the opposite of MarshalJSON. +func (hb *MaybeBinaryValue) UnmarshalJSON(d []byte) error { + if err := json.Unmarshal(d, &hb.Value); err == nil { + return nil + } + er := make(map[string]string) + if err := json.Unmarshal(d, &er); err != nil { + return err + } + if v, ok := er["format"]; !ok || v != "base64" { + return errors.New("missing or invalid format field") + } + if _, ok := er["data"]; !ok { + return errors.New("missing data field") + } + b64, err := base64.StdEncoding.DecodeString(er["data"]) + if err != nil { + return err + } + hb.Value = string(b64) + return nil +} + +// HTTPBody is an HTTP body. As an implementation note, this type must be +// an alias for the MaybeBinaryValue type, otherwise the specific serialisation +// mechanism implemented by MaybeBinaryValue is not working. +type HTTPBody = MaybeBinaryValue + +// HTTPHeader is a single HTTP header. +type HTTPHeader struct { + Key string + Value MaybeBinaryValue +} + +// MarshalJSON marshals a single HTTP header to a tuple where the first +// element is a string and the second element is maybe-binary data. +func (hh HTTPHeader) MarshalJSON() ([]byte, error) { + if utf8.ValidString(hh.Value.Value) { + return json.Marshal([]string{hh.Key, hh.Value.Value}) + } + value := make(map[string]string) + value["format"] = "base64" + value["data"] = base64.StdEncoding.EncodeToString([]byte(hh.Value.Value)) + return json.Marshal([]interface{}{hh.Key, value}) +} + +// UnmarshalJSON is the opposite of MarshalJSON. +func (hh *HTTPHeader) UnmarshalJSON(d []byte) error { + var pair []interface{} + if err := json.Unmarshal(d, &pair); err != nil { + return err + } + if len(pair) != 2 { + return errors.New("unexpected pair length") + } + key, ok := pair[0].(string) + if !ok { + return errors.New("the key is not a string") + } + value, ok := pair[1].(string) + if !ok { + mapvalue, ok := pair[1].(map[string]interface{}) + if !ok { + return errors.New("the value is neither a string nor a map[string]interface{}") + } + if _, ok := mapvalue["format"]; !ok { + return errors.New("missing format") + } + if v, ok := mapvalue["format"].(string); !ok || v != "base64" { + return errors.New("invalid format") + } + if _, ok := mapvalue["data"]; !ok { + return errors.New("missing data field") + } + v, ok := mapvalue["data"].(string) + if !ok { + return errors.New("the data field is not a string") + } + b64, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return err + } + value = string(b64) + } + hh.Key, hh.Value = key, MaybeBinaryValue{Value: value} + return nil +} + +// HTTPRequest contains an HTTP request. +// +// Headers are a map in Web Connectivity data format but +// we have added support for a list since January 2020. +type HTTPRequest struct { + Body HTTPBody `json:"body"` + BodyIsTruncated bool `json:"body_is_truncated"` + HeadersList []HTTPHeader `json:"headers_list"` + Headers map[string]MaybeBinaryValue `json:"headers"` + Method string `json:"method"` + Tor HTTPTor `json:"tor"` + Transport string `json:"x_transport"` + URL string `json:"url"` +} + +// HTTPResponse contains an HTTP response. +// +// Headers are a map in Web Connectivity data format but +// we have added support for a list since January 2020. +type HTTPResponse struct { + Body HTTPBody `json:"body"` + BodyIsTruncated bool `json:"body_is_truncated"` + Code int64 `json:"code"` + HeadersList []HTTPHeader `json:"headers_list"` + Headers map[string]MaybeBinaryValue `json:"headers"` + + // The following fields are not serialised but are useful to simplify + // analysing the measurements in telegram, whatsapp, etc. + Locations []string `json:"-"` +} + +// RequestEntry is one of the entries that are part of +// the "requests" key of a OONI report. +type RequestEntry struct { + Failure *string `json:"failure"` + Request HTTPRequest `json:"request"` + Response HTTPResponse `json:"response"` + T float64 `json:"t"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +func addheaders( + source http.Header, + destList *[]HTTPHeader, + destMap *map[string]MaybeBinaryValue, +) { + for key, values := range source { + for index, value := range values { + value := MaybeBinaryValue{Value: value} + // With the map representation we can only represent a single + // value for every key. Hence the list representation. + if index == 0 { + (*destMap)[key] = value + } + *destList = append(*destList, HTTPHeader{ + Key: key, + Value: value, + }) + } + } + sort.Slice(*destList, func(i, j int) bool { + return (*destList)[i].Key < (*destList)[j].Key + }) +} + +// NewRequestList returns the list for "requests" +func NewRequestList(begin time.Time, events []trace.Event) []RequestEntry { + // OONI wants the last request to appear first + var out []RequestEntry + tmp := newRequestList(begin, events) + for i := len(tmp) - 1; i >= 0; i-- { + out = append(out, tmp[i]) + } + return out +} + +func newRequestList(begin time.Time, events []trace.Event) []RequestEntry { + var ( + out []RequestEntry + entry RequestEntry + ) + for _, ev := range events { + switch ev.Name { + case "http_transaction_start": + entry = RequestEntry{} + entry.T = ev.Time.Sub(begin).Seconds() + case "http_request_body_snapshot": + entry.Request.Body.Value = string(ev.Data) + entry.Request.BodyIsTruncated = ev.DataIsTruncated + case "http_request_metadata": + entry.Request.Headers = make(map[string]MaybeBinaryValue) + addheaders( + ev.HTTPHeaders, &entry.Request.HeadersList, &entry.Request.Headers) + entry.Request.Method = ev.HTTPMethod + entry.Request.URL = ev.HTTPURL + entry.Request.Transport = ev.Transport + case "http_response_metadata": + entry.Response.Headers = make(map[string]MaybeBinaryValue) + addheaders( + ev.HTTPHeaders, &entry.Response.HeadersList, &entry.Response.Headers) + entry.Response.Code = int64(ev.HTTPStatusCode) + entry.Response.Locations = ev.HTTPHeaders.Values("Location") + case "http_response_body_snapshot": + entry.Response.Body.Value = string(ev.Data) + entry.Response.BodyIsTruncated = ev.DataIsTruncated + case "http_transaction_done": + entry.Failure = NewFailure(ev.Err) + out = append(out, entry) + } + } + return out +} + +// DNSAnswerEntry is the answer to a DNS query +type DNSAnswerEntry struct { + ASN int64 `json:"asn,omitempty"` + ASOrgName string `json:"as_org_name,omitempty"` + AnswerType string `json:"answer_type"` + Hostname string `json:"hostname,omitempty"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + TTL *uint32 `json:"ttl"` +} + +// DNSQueryEntry is a DNS query with possibly an answer +type DNSQueryEntry struct { + Answers []DNSAnswerEntry `json:"answers"` + DialID int64 `json:"dial_id,omitempty"` + Engine string `json:"engine"` + Failure *string `json:"failure"` + Hostname string `json:"hostname"` + QueryType string `json:"query_type"` + ResolverHostname *string `json:"resolver_hostname"` + ResolverPort *string `json:"resolver_port"` + ResolverAddress string `json:"resolver_address"` + T float64 `json:"t"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +type dnsQueryType string + +// NewDNSQueriesList returns a list of DNS queries. +func NewDNSQueriesList(begin time.Time, events []trace.Event, dbpath string) []DNSQueryEntry { + // TODO(bassosimone): add support for CNAME lookups. + var out []DNSQueryEntry + for _, ev := range events { + if ev.Name != "resolve_done" { + continue + } + for _, qtype := range []dnsQueryType{"A", "AAAA"} { + entry := qtype.makequeryentry(begin, ev) + for _, addr := range ev.Addresses { + if qtype.ipoftype(addr) { + entry.Answers = append( + entry.Answers, qtype.makeanswerentry(addr, dbpath)) + } + } + if len(entry.Answers) <= 0 && ev.Err == nil { + // This allows us to skip cases where the server does not have + // an IPv6 address but has an IPv4 address. Instead, when we + // receive an error, we want to track its existence. The main + // issue here is that we are cheating, because we are creating + // entries representing queries, but we don't know what the + // resolver actually did, especially the system resolver. So, + // this output is just our best guess. + continue + } + out = append(out, entry) + } + } + return out +} + +func (qtype dnsQueryType) ipoftype(addr string) bool { + switch qtype { + case "A": + return strings.Contains(addr, ":") == false + case "AAAA": + return strings.Contains(addr, ":") == true + } + return false +} + +func (qtype dnsQueryType) makeanswerentry(addr string, dbpath string) DNSAnswerEntry { + answer := DNSAnswerEntry{AnswerType: string(qtype)} + asn, org, _ := geolocate.LookupASN(dbpath, addr) + answer.ASN = int64(asn) + answer.ASOrgName = org + switch qtype { + case "A": + answer.IPv4 = addr + case "AAAA": + answer.IPv6 = addr + } + return answer +} + +func (qtype dnsQueryType) makequeryentry(begin time.Time, ev trace.Event) DNSQueryEntry { + return DNSQueryEntry{ + Engine: ev.Proto, + Failure: NewFailure(ev.Err), + Hostname: ev.Hostname, + QueryType: string(qtype), + ResolverAddress: ev.Address, + T: ev.Time.Sub(begin).Seconds(), + } +} + +// NetworkEvent is a network event. +type NetworkEvent struct { + Address string `json:"address,omitempty"` + ConnID int64 `json:"conn_id,omitempty"` + DialID int64 `json:"dial_id,omitempty"` + Failure *string `json:"failure"` + NumBytes int64 `json:"num_bytes,omitempty"` + Operation string `json:"operation"` + Proto string `json:"proto,omitempty"` + T float64 `json:"t"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// NewNetworkEventsList returns a list of DNS queries. +func NewNetworkEventsList(begin time.Time, events []trace.Event) []NetworkEvent { + var out []NetworkEvent + for _, ev := range events { + if ev.Name == errorx.ConnectOperation { + out = append(out, NetworkEvent{ + Address: ev.Address, + Failure: NewFailure(ev.Err), + Operation: ev.Name, + Proto: ev.Proto, + T: ev.Time.Sub(begin).Seconds(), + }) + continue + } + if ev.Name == errorx.ReadOperation { + out = append(out, NetworkEvent{ + Failure: NewFailure(ev.Err), + Operation: ev.Name, + NumBytes: int64(ev.NumBytes), + T: ev.Time.Sub(begin).Seconds(), + }) + continue + } + if ev.Name == errorx.WriteOperation { + out = append(out, NetworkEvent{ + Failure: NewFailure(ev.Err), + Operation: ev.Name, + NumBytes: int64(ev.NumBytes), + T: ev.Time.Sub(begin).Seconds(), + }) + continue + } + if ev.Name == errorx.ReadFromOperation { + out = append(out, NetworkEvent{ + Address: ev.Address, + Failure: NewFailure(ev.Err), + Operation: ev.Name, + NumBytes: int64(ev.NumBytes), + T: ev.Time.Sub(begin).Seconds(), + }) + continue + } + if ev.Name == errorx.WriteToOperation { + out = append(out, NetworkEvent{ + Address: ev.Address, + Failure: NewFailure(ev.Err), + Operation: ev.Name, + NumBytes: int64(ev.NumBytes), + T: ev.Time.Sub(begin).Seconds(), + }) + continue + } + out = append(out, NetworkEvent{ + Failure: NewFailure(ev.Err), + Operation: ev.Name, + T: ev.Time.Sub(begin).Seconds(), + }) + } + return out +} + +// TLSHandshake contains TLS handshake data +type TLSHandshake struct { + CipherSuite string `json:"cipher_suite"` + ConnID int64 `json:"conn_id,omitempty"` + Failure *string `json:"failure"` + NegotiatedProtocol string `json:"negotiated_protocol"` + NoTLSVerify bool `json:"no_tls_verify"` + PeerCertificates []MaybeBinaryValue `json:"peer_certificates"` + ServerName string `json:"server_name"` + T float64 `json:"t"` + TLSVersion string `json:"tls_version"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// NewTLSHandshakesList creates a new TLSHandshakesList +func NewTLSHandshakesList(begin time.Time, events []trace.Event) []TLSHandshake { + var out []TLSHandshake + for _, ev := range events { + if !strings.Contains(ev.Name, "_handshake_done") { + continue + } + out = append(out, TLSHandshake{ + CipherSuite: ev.TLSCipherSuite, + Failure: NewFailure(ev.Err), + NegotiatedProtocol: ev.TLSNegotiatedProto, + NoTLSVerify: ev.NoTLSVerify, + PeerCertificates: makePeerCerts(ev.TLSPeerCerts), + ServerName: ev.TLSServerName, + T: ev.Time.Sub(begin).Seconds(), + TLSVersion: ev.TLSVersion, + }) + } + return out +} + +func makePeerCerts(in []*x509.Certificate) (out []MaybeBinaryValue) { + for _, e := range in { + out = append(out, MaybeBinaryValue{Value: string(e.Raw)}) + } + return +} diff --git a/internal/engine/netx/archival/archival_test.go b/internal/engine/netx/archival/archival_test.go new file mode 100644 index 0000000..667ecff --- /dev/null +++ b/internal/engine/netx/archival/archival_test.go @@ -0,0 +1,1092 @@ +package archival_test + +import ( + "context" + "crypto/x509" + "errors" + "io" + "net/http" + "reflect" + "testing" + "time" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/gorilla/websocket" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx/archival" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" + "github.com/ooni/probe-cli/v3/internal/engine/resources" +) + +func TestNewTCPConnectList(t *testing.T) { + begin := time.Now() + type args struct { + begin time.Time + events []trace.Event + } + tests := []struct { + name string + args args + want []archival.TCPConnectEntry + }{{ + name: "empty run", + args: args{ + begin: begin, + events: nil, + }, + want: nil, + }, { + name: "realistic run", + args: args{ + begin: begin, + events: []trace.Event{{ + Addresses: []string{"8.8.8.8", "8.8.4.4"}, + Hostname: "dns.google.com", + Name: "resolve_done", + Time: begin.Add(100 * time.Millisecond), + }, { + Address: "8.8.8.8:853", + Duration: 30 * time.Millisecond, + Name: errorx.ConnectOperation, + Proto: "tcp", + Time: begin.Add(130 * time.Millisecond), + }, { + Address: "8.8.8.8:853", + Duration: 55 * time.Millisecond, + Name: errorx.ConnectOperation, + Proto: "udp", + Time: begin.Add(130 * time.Millisecond), + }, { + Address: "8.8.4.4:53", + Duration: 50 * time.Millisecond, + Err: io.EOF, + Name: errorx.ConnectOperation, + Proto: "tcp", + Time: begin.Add(180 * time.Millisecond), + }}, + }, + want: []archival.TCPConnectEntry{{ + IP: "8.8.8.8", + Port: 853, + Status: archival.TCPConnectStatus{ + Success: true, + }, + T: 0.13, + }, { + IP: "8.8.4.4", + Port: 53, + Status: archival.TCPConnectStatus{ + Failure: archival.NewFailure(io.EOF), + Success: false, + }, + T: 0.18, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := archival.NewTCPConnectList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) { + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestNewRequestList(t *testing.T) { + begin := time.Now() + type args struct { + begin time.Time + events []trace.Event + } + tests := []struct { + name string + args args + want []archival.RequestEntry + }{{ + name: "empty run", + args: args{ + begin: begin, + events: nil, + }, + want: nil, + }, { + name: "realistic run", + args: args{ + begin: begin, + events: []trace.Event{{ + Name: "http_transaction_start", + Time: begin.Add(10 * time.Millisecond), + }, { + Name: "http_request_body_snapshot", + Data: []byte("deadbeef"), + DataIsTruncated: false, + }, { + Name: "http_request_metadata", + HTTPHeaders: http.Header{ + "User-Agent": []string{"miniooni/0.1.0-dev"}, + }, + HTTPMethod: "POST", + HTTPURL: "https://www.example.com/submit", + }, { + Name: "http_response_metadata", + HTTPHeaders: http.Header{ + "Server": []string{"orchestra/0.1.0-dev"}, + }, + HTTPStatusCode: 200, + }, { + Name: "http_response_body_snapshot", + Data: []byte("{}"), + DataIsTruncated: false, + }, { + Name: "http_transaction_done", + }, { + Name: "http_transaction_start", + Time: begin.Add(20 * time.Millisecond), + }, { + Name: "http_request_metadata", + HTTPHeaders: http.Header{ + "User-Agent": []string{"miniooni/0.1.0-dev"}, + }, + HTTPMethod: "GET", + HTTPURL: "https://www.example.com/result", + }, { + Name: "http_transaction_done", + Err: io.EOF, + }}, + }, + want: []archival.RequestEntry{{ + Failure: archival.NewFailure(io.EOF), + Request: archival.HTTPRequest{ + HeadersList: []archival.HTTPHeader{{ + Key: "User-Agent", + Value: archival.MaybeBinaryValue{ + Value: "miniooni/0.1.0-dev", + }, + }}, + Headers: map[string]archival.MaybeBinaryValue{ + "User-Agent": {Value: "miniooni/0.1.0-dev"}, + }, + Method: "GET", + URL: "https://www.example.com/result", + }, + T: 0.02, + }, { + Request: archival.HTTPRequest{ + Body: archival.MaybeBinaryValue{ + Value: "deadbeef", + }, + HeadersList: []archival.HTTPHeader{{ + Key: "User-Agent", + Value: archival.MaybeBinaryValue{ + Value: "miniooni/0.1.0-dev", + }, + }}, + Headers: map[string]archival.MaybeBinaryValue{ + "User-Agent": {Value: "miniooni/0.1.0-dev"}, + }, + Method: "POST", + URL: "https://www.example.com/submit", + }, + Response: archival.HTTPResponse{ + Body: archival.MaybeBinaryValue{ + Value: "{}", + }, + Code: 200, + HeadersList: []archival.HTTPHeader{{ + Key: "Server", + Value: archival.MaybeBinaryValue{ + Value: "orchestra/0.1.0-dev", + }, + }}, + Headers: map[string]archival.MaybeBinaryValue{ + "Server": {Value: "orchestra/0.1.0-dev"}, + }, + Locations: nil, + }, + T: 0.01, + }}, + }, { + // for an example of why we need to sort headers, see + // https://github.com/ooni/probe-cli/v3/internal/engine/pull/751/checks?check_run_id=853562310 + name: "run with redirect and headers to sort", + args: args{ + begin: begin, + events: []trace.Event{{ + Name: "http_transaction_start", + Time: begin.Add(10 * time.Millisecond), + }, { + Name: "http_request_metadata", + HTTPHeaders: http.Header{ + "User-Agent": []string{"miniooni/0.1.0-dev"}, + }, + HTTPMethod: "GET", + HTTPURL: "https://www.example.com/", + }, { + Name: "http_response_metadata", + HTTPHeaders: http.Header{ + "Server": []string{"orchestra/0.1.0-dev"}, + "Location": []string{"https://x.example.com", "https://y.example.com"}, + }, + HTTPStatusCode: 302, + }, { + Name: "http_transaction_done", + }}, + }, + want: []archival.RequestEntry{{ + Request: archival.HTTPRequest{ + HeadersList: []archival.HTTPHeader{{ + Key: "User-Agent", + Value: archival.MaybeBinaryValue{ + Value: "miniooni/0.1.0-dev", + }, + }}, + Headers: map[string]archival.MaybeBinaryValue{ + "User-Agent": {Value: "miniooni/0.1.0-dev"}, + }, + Method: "GET", + URL: "https://www.example.com/", + }, + Response: archival.HTTPResponse{ + Code: 302, + HeadersList: []archival.HTTPHeader{{ + Key: "Location", + Value: archival.MaybeBinaryValue{ + Value: "https://x.example.com", + }, + }, { + Key: "Location", + Value: archival.MaybeBinaryValue{ + Value: "https://y.example.com", + }, + }, { + Key: "Server", + Value: archival.MaybeBinaryValue{ + Value: "orchestra/0.1.0-dev", + }, + }}, + Headers: map[string]archival.MaybeBinaryValue{ + "Server": {Value: "orchestra/0.1.0-dev"}, + "Location": {Value: "https://x.example.com"}, + }, + Locations: []string{ + "https://x.example.com", "https://y.example.com", + }, + }, + T: 0.01, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := archival.NewRequestList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) { + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestNewDNSQueriesList(t *testing.T) { + err := (&resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "miniooni/0.1.0-dev", + WorkDir: "../../testdata", + }).Ensure(context.Background()) + if err != nil { + t.Fatal(err) + } + begin := time.Now() + type args struct { + begin time.Time + events []trace.Event + dbpath string + } + tests := []struct { + name string + args args + want []archival.DNSQueryEntry + }{{ + name: "empty run", + args: args{ + begin: begin, + events: nil, + }, + want: nil, + }, { + name: "realistic run", + args: args{ + begin: begin, + events: []trace.Event{{ + Address: "1.1.1.1:853", + Addresses: []string{"8.8.8.8", "8.8.4.4"}, + Hostname: "dns.google.com", + Name: "resolve_done", + Proto: "dot", + Time: begin.Add(100 * time.Millisecond), + }, { + Address: "8.8.8.8:853", + Duration: 30 * time.Millisecond, + Name: errorx.ConnectOperation, + Proto: "tcp", + Time: begin.Add(130 * time.Millisecond), + }, { + Address: "8.8.4.4:53", + Duration: 50 * time.Millisecond, + Err: io.EOF, + Name: errorx.ConnectOperation, + Proto: "tcp", + Time: begin.Add(180 * time.Millisecond), + }}, + }, + want: []archival.DNSQueryEntry{{ + Answers: []archival.DNSAnswerEntry{{ + AnswerType: "A", + IPv4: "8.8.8.8", + }, { + AnswerType: "A", + IPv4: "8.8.4.4", + }}, + Engine: "dot", + Hostname: "dns.google.com", + QueryType: "A", + ResolverAddress: "1.1.1.1:853", + T: 0.1, + }}, + }, { + name: "run with IPv6 results", + args: args{ + begin: begin, + events: []trace.Event{{ + Addresses: []string{"2001:4860:4860::8888"}, + Hostname: "dns.google.com", + Name: "resolve_done", + Time: begin.Add(200 * time.Millisecond), + }}, + }, + want: []archival.DNSQueryEntry{{ + Answers: []archival.DNSAnswerEntry{{ + AnswerType: "AAAA", + IPv6: "2001:4860:4860::8888", + }}, + Hostname: "dns.google.com", + QueryType: "AAAA", + T: 0.2, + }}, + }, { + name: "run with ASN DB", + args: args{ + begin: begin, + events: []trace.Event{{ + Addresses: []string{"2001:4860:4860::8888"}, + Hostname: "dns.google.com", + Name: "resolve_done", + Time: begin.Add(200 * time.Millisecond), + }}, + dbpath: "../../testdata/asn.mmdb", + }, + want: []archival.DNSQueryEntry{{ + Answers: []archival.DNSAnswerEntry{{ + ASN: 15169, + ASOrgName: "Google LLC", + AnswerType: "AAAA", + IPv6: "2001:4860:4860::8888", + }}, + Hostname: "dns.google.com", + QueryType: "AAAA", + T: 0.2, + }}, + }, { + name: "run with errors", + args: args{ + begin: begin, + events: []trace.Event{{ + Err: &errorx.ErrWrapper{Failure: errorx.FailureDNSNXDOMAINError}, + Hostname: "dns.google.com", + Name: "resolve_done", + Time: begin.Add(200 * time.Millisecond), + }}, + dbpath: "../../testdata/asn.mmdb", + }, + want: []archival.DNSQueryEntry{{ + Answers: nil, + Failure: archival.NewFailure( + &errorx.ErrWrapper{Failure: errorx.FailureDNSNXDOMAINError}), + Hostname: "dns.google.com", + QueryType: "A", + T: 0.2, + }, { + Answers: nil, + Failure: archival.NewFailure( + &errorx.ErrWrapper{Failure: errorx.FailureDNSNXDOMAINError}), + Hostname: "dns.google.com", + QueryType: "AAAA", + T: 0.2, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := archival.NewDNSQueriesList( + tt.args.begin, tt.args.events, tt.args.dbpath); !reflect.DeepEqual(got, tt.want) { + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestNewNetworkEventsList(t *testing.T) { + begin := time.Now() + type args struct { + begin time.Time + events []trace.Event + } + tests := []struct { + name string + args args + want []archival.NetworkEvent + }{{ + name: "empty run", + args: args{ + begin: begin, + events: nil, + }, + want: nil, + }, { + name: "realistic run", + args: args{ + begin: begin, + events: []trace.Event{{ + Name: errorx.ConnectOperation, + Address: "8.8.8.8:853", + Err: io.EOF, + Proto: "tcp", + Time: begin.Add(7 * time.Millisecond), + }, { + Name: errorx.ReadOperation, + Err: context.Canceled, + NumBytes: 7117, + Time: begin.Add(11 * time.Millisecond), + }, { + Address: "8.8.8.8:853", + Name: errorx.ReadFromOperation, + Err: context.Canceled, + NumBytes: 7117, + Time: begin.Add(11 * time.Millisecond), + }, { + Name: errorx.WriteOperation, + Err: websocket.ErrBadHandshake, + NumBytes: 4114, + Time: begin.Add(14 * time.Millisecond), + }, { + Address: "8.8.8.8:853", + Name: errorx.WriteToOperation, + Err: websocket.ErrBadHandshake, + NumBytes: 4114, + Time: begin.Add(14 * time.Millisecond), + }, { + Name: errorx.CloseOperation, + Err: websocket.ErrReadLimit, + Time: begin.Add(17 * time.Millisecond), + }}, + }, + want: []archival.NetworkEvent{{ + Address: "8.8.8.8:853", + Failure: archival.NewFailure(io.EOF), + Operation: errorx.ConnectOperation, + Proto: "tcp", + T: 0.007, + }, { + Failure: archival.NewFailure(context.Canceled), + NumBytes: 7117, + Operation: errorx.ReadOperation, + T: 0.011, + }, { + Address: "8.8.8.8:853", + Failure: archival.NewFailure(context.Canceled), + NumBytes: 7117, + Operation: errorx.ReadFromOperation, + T: 0.011, + }, { + Failure: archival.NewFailure(websocket.ErrBadHandshake), + NumBytes: 4114, + Operation: errorx.WriteOperation, + T: 0.014, + }, { + Address: "8.8.8.8:853", + Failure: archival.NewFailure(websocket.ErrBadHandshake), + NumBytes: 4114, + Operation: errorx.WriteToOperation, + T: 0.014, + }, { + Failure: archival.NewFailure(websocket.ErrReadLimit), + Operation: errorx.CloseOperation, + T: 0.017, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := archival.NewNetworkEventsList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) { + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestNewTLSHandshakesList(t *testing.T) { + begin := time.Now() + type args struct { + begin time.Time + events []trace.Event + } + tests := []struct { + name string + args args + want []archival.TLSHandshake + }{{ + name: "empty run", + args: args{ + begin: begin, + events: nil, + }, + want: nil, + }, { + name: "realistic run", + args: args{ + begin: begin, + events: []trace.Event{{ + Name: errorx.CloseOperation, + Err: websocket.ErrReadLimit, + Time: begin.Add(17 * time.Millisecond), + }, { + Name: "tls_handshake_done", + Err: io.EOF, + NoTLSVerify: false, + TLSCipherSuite: "SUITE", + TLSNegotiatedProto: "h2", + TLSPeerCerts: []*x509.Certificate{{ + Raw: []byte("deadbeef"), + }, { + Raw: []byte("abad1dea"), + }}, + TLSServerName: "x.org", + TLSVersion: "TLSv1.3", + Time: begin.Add(55 * time.Millisecond), + }}, + }, + want: []archival.TLSHandshake{{ + CipherSuite: "SUITE", + Failure: archival.NewFailure(io.EOF), + NegotiatedProtocol: "h2", + NoTLSVerify: false, + PeerCertificates: []archival.MaybeBinaryValue{{ + Value: "deadbeef", + }, { + Value: "abad1dea", + }}, + ServerName: "x.org", + T: 0.055, + TLSVersion: "TLSv1.3", + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := archival.NewTLSHandshakesList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) { + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestExtSpec_AddTo(t *testing.T) { + m := new(model.Measurement) + archival.ExtDNS.AddTo(m) + expected := map[string]int64{"dnst": 0} + if d := cmp.Diff(m.Extensions, expected); d != "" { + t.Fatal(d) + } +} + +var binaryInput = []uint8{ + 0x57, 0xe5, 0x79, 0xfb, 0xa6, 0xbb, 0x0d, 0xbc, 0xce, 0xbd, 0xa7, 0xa0, + 0xba, 0xa4, 0x78, 0x78, 0x12, 0x59, 0xee, 0x68, 0x39, 0xa4, 0x07, 0x98, + 0xc5, 0x3e, 0xbc, 0x55, 0xcb, 0xfe, 0x34, 0x3c, 0x7e, 0x1b, 0x5a, 0xb3, + 0x22, 0x9d, 0xc1, 0x2d, 0x6e, 0xca, 0x5b, 0xf1, 0x10, 0x25, 0x47, 0x1e, + 0x44, 0xe2, 0x2d, 0x60, 0x08, 0xea, 0xb0, 0x0a, 0xcc, 0x05, 0x48, 0xa0, + 0xf5, 0x78, 0x38, 0xf0, 0xdb, 0x3f, 0x9d, 0x9f, 0x25, 0x6f, 0x89, 0x00, + 0x96, 0x93, 0xaf, 0x43, 0xac, 0x4d, 0xc9, 0xac, 0x13, 0xdb, 0x22, 0xbe, + 0x7a, 0x7d, 0xd9, 0x24, 0xa2, 0x52, 0x69, 0xd8, 0x89, 0xc1, 0xd1, 0x57, + 0xaa, 0x04, 0x2b, 0xa2, 0xd8, 0xb1, 0x19, 0xf6, 0xd5, 0x11, 0x39, 0xbb, + 0x80, 0xcf, 0x86, 0xf9, 0x5f, 0x9d, 0x8c, 0xab, 0xf5, 0xc5, 0x74, 0x24, + 0x3a, 0xa2, 0xd4, 0x40, 0x4e, 0xd7, 0x10, 0x1f, +} + +var encodedBinaryInput = []byte(`{"data":"V+V5+6a7DbzOvaeguqR4eBJZ7mg5pAeYxT68Vcv+NDx+G1qzIp3BLW7KW/EQJUceROItYAjqsArMBUig9Xg48Ns/nZ8lb4kAlpOvQ6xNyawT2yK+en3ZJKJSadiJwdFXqgQrotixGfbVETm7gM+G+V+djKv1xXQkOqLUQE7XEB8=","format":"base64"}`) + +func TestMaybeBinaryValue_MarshalJSON(t *testing.T) { + type fields struct { + Value string + } + tests := []struct { + name string + fields fields + want []byte + wantErr bool + }{{ + name: "with string input", + fields: fields{ + Value: "antani", + }, + want: []byte(`"antani"`), + wantErr: false, + }, { + name: "with binary input", + fields: fields{ + Value: string(binaryInput), + }, + want: encodedBinaryInput, + wantErr: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hb := archival.MaybeBinaryValue{ + Value: tt.fields.Value, + } + got, err := hb.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("MaybeBinaryValue.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestMaybeBinaryValue_UnmarshalJSON(t *testing.T) { + type fields struct { + WantValue string + } + type args struct { + d []byte + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{{ + name: "with string input", + fields: fields{ + WantValue: "xo", + }, + args: args{d: []byte(`"xo"`)}, + wantErr: false, + }, { + name: "with nil input", + fields: fields{ + WantValue: "", + }, + args: args{d: nil}, + wantErr: true, + }, { + name: "with missing/invalid format", + fields: fields{ + WantValue: "", + }, + args: args{d: []byte(`{"format": "foo"}`)}, + wantErr: true, + }, { + name: "with missing data", + fields: fields{ + WantValue: "", + }, + args: args{d: []byte(`{"format": "base64"}`)}, + wantErr: true, + }, { + name: "with invalid base64 data", + fields: fields{ + WantValue: "", + }, + args: args{d: []byte(`{"format": "base64", "data": "x"}`)}, + wantErr: true, + }, { + name: "with valid base64 data", + fields: fields{ + WantValue: string(binaryInput), + }, + args: args{d: encodedBinaryInput}, + wantErr: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hb := &archival.MaybeBinaryValue{} + if err := hb.UnmarshalJSON(tt.args.d); (err != nil) != tt.wantErr { + t.Errorf("MaybeBinaryValue.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + if d := cmp.Diff(tt.fields.WantValue, hb.Value); d != "" { + t.Error(d) + } + }) + } +} + +func TestHTTPHeader_MarshalJSON(t *testing.T) { + type fields struct { + Key string + Value archival.MaybeBinaryValue + } + tests := []struct { + name string + fields fields + want []byte + wantErr bool + }{{ + name: "with string value", + fields: fields{ + Key: "Content-Type", + Value: archival.MaybeBinaryValue{ + Value: "text/plain", + }, + }, + want: []byte(`["Content-Type","text/plain"]`), + wantErr: false, + }, { + name: "with binary value", + fields: fields{ + Key: "Content-Type", + Value: archival.MaybeBinaryValue{ + Value: string(binaryInput), + }, + }, + want: []byte(`["Content-Type",` + string(encodedBinaryInput) + `]`), + wantErr: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hh := archival.HTTPHeader{ + Key: tt.fields.Key, + Value: tt.fields.Value, + } + got, err := hh.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("HTTPHeader.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestHTTPHeader_UnmarshalJSON(t *testing.T) { + type fields struct { + WantKey string + WantValue archival.MaybeBinaryValue + } + type args struct { + d []byte + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{{ + name: "with invalid input", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`{}`), + }, + wantErr: true, + }, { + name: "with unexpected number of items", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`[]`), + }, + wantErr: true, + }, { + name: "with first item not being a string", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`[0,0]`), + }, + wantErr: true, + }, { + name: "with both items being a string", + fields: fields{ + WantKey: "x", + WantValue: archival.MaybeBinaryValue{ + Value: "y", + }, + }, + args: args{ + d: []byte(`["x","y"]`), + }, + wantErr: false, + }, { + name: "with second item not being a map[string]interface{}", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`["x",[]]`), + }, + wantErr: true, + }, { + name: "with missing format key in second item", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`["x",{}]`), + }, + wantErr: true, + }, { + name: "with format value not being base64", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`["x",{"format":1}]`), + }, + wantErr: true, + }, { + name: "with missing data field", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`["x",{"format":"base64"}]`), + }, + wantErr: true, + }, { + name: "with data not being a string", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`["x",{"format":"base64","data":1}]`), + }, + wantErr: true, + }, { + name: "with data not being base64", + fields: fields{ + WantKey: "", + WantValue: archival.MaybeBinaryValue{ + Value: "", + }, + }, + args: args{ + d: []byte(`["x",{"format":"base64","data":"xx"}]`), + }, + wantErr: true, + }, { + name: "with correctly encoded base64 data", + fields: fields{ + WantKey: "x", + WantValue: archival.MaybeBinaryValue{ + Value: string(binaryInput), + }, + }, + args: args{ + d: []byte(`["x",` + string(encodedBinaryInput) + `]`), + }, + wantErr: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hh := &archival.HTTPHeader{} + if err := hh.UnmarshalJSON(tt.args.d); (err != nil) != tt.wantErr { + t.Errorf("HTTPHeader.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + expect := &archival.HTTPHeader{ + Key: tt.fields.WantKey, + Value: tt.fields.WantValue, + } + if d := cmp.Diff(hh, expect); d != "" { + t.Error(d) + } + }) + } +} + +func TestNewFailure(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args args + want *string + }{{ + name: "when error is nil", + args: args{ + err: nil, + }, + want: nil, + }, { + name: "when error is wrapped and failure meaningful", + args: args{ + err: &errorx.ErrWrapper{ + Failure: errorx.FailureConnectionRefused, + }, + }, + want: func() *string { + s := errorx.FailureConnectionRefused + return &s + }(), + }, { + name: "when error is wrapped and failure is not meaningful", + args: args{ + err: &errorx.ErrWrapper{}, + }, + want: func() *string { + s := "unknown_failure: errWrapper.Failure is empty" + return &s + }(), + }, { + name: "when error is not wrapped but wrappable", + args: args{err: io.EOF}, + want: func() *string { + s := "eof_error" + return &s + }(), + }, { + name: "when the error is not wrapped and not wrappable", + args: args{ + err: errors.New("use of closed socket 127.0.0.1:8080->10.0.0.1:22"), + }, + want: func() *string { + s := "unknown_failure: use of closed socket [scrubbed]->[scrubbed]" + return &s + }(), + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := archival.NewFailure(tt.args.err) + if tt.want == nil && got == nil { + return + } + if tt.want == nil && got != nil { + t.Errorf("NewFailure: want %+v, got %s", tt.want, *got) + return + } + if tt.want != nil && got == nil { + t.Errorf("NewFailure: want %s, got %+v", *tt.want, got) + return + } + if *tt.want != *got { + t.Errorf("NewFailure: want %s, got %s", *tt.want, *got) + return + } + }) + } +} + +func TestDNSQueryTypeInvalidIPOfType(t *testing.T) { + qtype := archival.DNSQueryType("ANTANI") + if qtype.IPOfType("8.8.8.8") != false { + t.Fatal("unexpected return value") + } +} + +func TestNewFailedOperation(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args args + want *string + }{{ + name: "With no error", + args: args{ + err: nil, // explicit + }, + want: nil, // explicit + }, { + name: "With wrapped error and non-empty operation", + args: args{ + err: &errorx.ErrWrapper{ + Failure: errorx.FailureConnectionRefused, + Operation: errorx.ConnectOperation, + }, + }, + want: (func() *string { + s := errorx.ConnectOperation + return &s + })(), + }, { + name: "With wrapped error and empty operation", + args: args{ + err: &errorx.ErrWrapper{ + Failure: errorx.FailureConnectionRefused, + }, + }, + want: (func() *string { + s := errorx.UnknownOperation + return &s + })(), + }, { + name: "With non wrapped error", + args: args{ + err: io.EOF, + }, + want: (func() *string { + s := errorx.UnknownOperation + return &s + })(), + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := archival.NewFailedOperation(tt.args.err) + if got == nil && tt.want == nil { + return + } + if got == nil && tt.want != nil { + t.Errorf("NewFailedOperation() = %v, want %v", got, tt.want) + return + } + if got != nil && tt.want == nil { + t.Errorf("NewFailedOperation() = %v, want %v", got, tt.want) + return + } + if got != nil && tt.want != nil && *got != *tt.want { + t.Errorf("NewFailedOperation() = %v, want %v", got, tt.want) + return + } + }) + } +} diff --git a/internal/engine/netx/archival/archival_test_internal.go b/internal/engine/netx/archival/archival_test_internal.go new file mode 100644 index 0000000..976ba3f --- /dev/null +++ b/internal/engine/netx/archival/archival_test_internal.go @@ -0,0 +1,8 @@ +package archival + +// DNSQueryType allows to access dnsQueryType from unit tests +type DNSQueryType = dnsQueryType + +func (qtype dnsQueryType) IPOfType(addr string) bool { + return qtype.ipoftype(addr) +} diff --git a/internal/engine/netx/bytecounter/bytecounter.go b/internal/engine/netx/bytecounter/bytecounter.go new file mode 100644 index 0000000..426b874 --- /dev/null +++ b/internal/engine/netx/bytecounter/bytecounter.go @@ -0,0 +1,54 @@ +package bytecounter + +import "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + +// Counter counts bytes sent and received. +type Counter struct { + Received *atomicx.Int64 + Sent *atomicx.Int64 +} + +// New creates a new Counter. +func New() *Counter { + return &Counter{Received: atomicx.NewInt64(), Sent: atomicx.NewInt64()} +} + +// CountBytesSent adds count to the bytes sent counter. +func (c *Counter) CountBytesSent(count int) { + c.Sent.Add(int64(count)) +} + +// CountKibiBytesSent adds 1024*count to the bytes sent counter. +func (c *Counter) CountKibiBytesSent(count float64) { + c.Sent.Add(int64(1024 * count)) +} + +// BytesSent returns the bytes sent so far. +func (c *Counter) BytesSent() int64 { + return c.Sent.Load() +} + +// KibiBytesSent returns the KiB sent so far. +func (c *Counter) KibiBytesSent() float64 { + return float64(c.BytesSent()) / 1024 +} + +// CountBytesReceived adds count to the bytes received counter. +func (c *Counter) CountBytesReceived(count int) { + c.Received.Add(int64(count)) +} + +// CountKibiBytesReceived adds 1024*count to the bytes received counter. +func (c *Counter) CountKibiBytesReceived(count float64) { + c.Received.Add(int64(1024 * count)) +} + +// BytesReceived returns the bytes received so far. +func (c *Counter) BytesReceived() int64 { + return c.Received.Load() +} + +// KibiBytesReceived returns the KiB received so far. +func (c *Counter) KibiBytesReceived() float64 { + return float64(c.BytesReceived()) / 1024 +} diff --git a/internal/engine/netx/bytecounter/bytecounter_test.go b/internal/engine/netx/bytecounter/bytecounter_test.go new file mode 100644 index 0000000..908651a --- /dev/null +++ b/internal/engine/netx/bytecounter/bytecounter_test.go @@ -0,0 +1,27 @@ +package bytecounter_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" +) + +func TestGood(t *testing.T) { + counter := bytecounter.New() + counter.CountBytesReceived(16384) + counter.CountKibiBytesReceived(10) + counter.CountBytesSent(2048) + counter.CountKibiBytesSent(10) + if counter.BytesSent() != 12288 { + t.Fatal("invalid bytes sent") + } + if counter.BytesReceived() != 26624 { + t.Fatal("invalid bytes received") + } + if v := counter.KibiBytesSent(); v < 11.9 || v > 12.1 { + t.Fatal("invalid kibibytes sent") + } + if v := counter.KibiBytesReceived(); v < 25.9 || v > 26.1 { + t.Fatal("invalid kibibytes received") + } +} diff --git a/internal/engine/netx/dialer/bytecounter.go b/internal/engine/netx/dialer/bytecounter.go new file mode 100644 index 0000000..c818ba8 --- /dev/null +++ b/internal/engine/netx/dialer/bytecounter.go @@ -0,0 +1,93 @@ +package dialer + +import ( + "context" + "net" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" +) + +// ByteCounterDialer is a byte-counting-aware dialer. To perform byte counting, you +// should make sure that you insert this dialer in the dialing chain. +// +// Bug +// +// This implementation cannot properly account for the bytes that are sent by +// persistent connections, because they strick to the counters set when the +// connection was established. This typically means we miss the bytes sent and +// received when submitting a measurement. Such bytes are specifically not +// see by the experiment specific byte counter. +// +// For this reason, this implementation may be heavily changed/removed. +type ByteCounterDialer struct { + Dialer +} + +// DialContext implements Dialer.DialContext +func (d ByteCounterDialer) DialContext( + ctx context.Context, network, address string) (net.Conn, error) { + conn, err := d.Dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + exp := ContextExperimentByteCounter(ctx) + sess := ContextSessionByteCounter(ctx) + if exp == nil && sess == nil { + return conn, nil // no point in wrapping + } + return byteCounterConnWrapper{Conn: conn, exp: exp, sess: sess}, nil +} + +type byteCounterSessionKey struct{} + +// ContextSessionByteCounter retrieves the session byte counter from the context +func ContextSessionByteCounter(ctx context.Context) *bytecounter.Counter { + counter, _ := ctx.Value(byteCounterSessionKey{}).(*bytecounter.Counter) + return counter +} + +// WithSessionByteCounter assigns the session byte counter to the context +func WithSessionByteCounter(ctx context.Context, counter *bytecounter.Counter) context.Context { + return context.WithValue(ctx, byteCounterSessionKey{}, counter) +} + +type byteCounterExperimentKey struct{} + +// ContextExperimentByteCounter retrieves the experiment byte counter from the context +func ContextExperimentByteCounter(ctx context.Context) *bytecounter.Counter { + counter, _ := ctx.Value(byteCounterExperimentKey{}).(*bytecounter.Counter) + return counter +} + +// WithExperimentByteCounter assigns the experiment byte counter to the context +func WithExperimentByteCounter(ctx context.Context, counter *bytecounter.Counter) context.Context { + return context.WithValue(ctx, byteCounterExperimentKey{}, counter) +} + +type byteCounterConnWrapper struct { + net.Conn + exp *bytecounter.Counter + sess *bytecounter.Counter +} + +func (c byteCounterConnWrapper) Read(p []byte) (int, error) { + count, err := c.Conn.Read(p) + if c.exp != nil { + c.exp.CountBytesReceived(count) + } + if c.sess != nil { + c.sess.CountBytesReceived(count) + } + return count, err +} + +func (c byteCounterConnWrapper) Write(p []byte) (int, error) { + count, err := c.Conn.Write(p) + if c.exp != nil { + c.exp.CountBytesSent(count) + } + if c.sess != nil { + c.sess.CountBytesSent(count) + } + return count, err +} diff --git a/internal/engine/netx/dialer/bytecounter_test.go b/internal/engine/netx/dialer/bytecounter_test.go new file mode 100644 index 0000000..3376385 --- /dev/null +++ b/internal/engine/netx/dialer/bytecounter_test.go @@ -0,0 +1,81 @@ +package dialer_test + +import ( + "context" + "errors" + "io" + "io/ioutil" + "net" + "net/http" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +func dorequest(ctx context.Context, url string) error { + txp := http.DefaultTransport.(*http.Transport).Clone() + defer txp.CloseIdleConnections() + dialer := dialer.ByteCounterDialer{Dialer: new(net.Dialer)} + txp.DialContext = dialer.DialContext + client := &http.Client{Transport: txp} + req, err := http.NewRequestWithContext(ctx, "GET", "http://www.google.com", nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + if _, err := io.Copy(ioutil.Discard, resp.Body); err != nil { + return err + } + return resp.Body.Close() +} + +func TestByteCounterNormalUsage(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := bytecounter.New() + ctx := context.Background() + ctx = dialer.WithSessionByteCounter(ctx, sess) + if err := dorequest(ctx, "http://www.google.com"); err != nil { + t.Fatal(err) + } + exp := bytecounter.New() + ctx = dialer.WithExperimentByteCounter(ctx, exp) + if err := dorequest(ctx, "http://facebook.com"); err != nil { + t.Fatal(err) + } + if sess.Received.Load() <= exp.Received.Load() { + t.Fatal("session should have received more than experiment") + } + if sess.Sent.Load() <= exp.Sent.Load() { + t.Fatal("session should have sent more than experiment") + } +} + +func TestByteCounterNoHandlers(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + ctx := context.Background() + if err := dorequest(ctx, "http://www.google.com"); err != nil { + t.Fatal(err) + } + if err := dorequest(ctx, "http://facebook.com"); err != nil { + t.Fatal(err) + } +} + +func TestByteCounterConnectFailure(t *testing.T) { + dialer := dialer.ByteCounterDialer{Dialer: dialer.EOFDialer{}} + conn, err := dialer.DialContext(context.Background(), "tcp", "www.google.com:80") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} diff --git a/internal/engine/netx/dialer/dialer.go b/internal/engine/netx/dialer/dialer.go new file mode 100644 index 0000000..16a2ecc --- /dev/null +++ b/internal/engine/netx/dialer/dialer.go @@ -0,0 +1,29 @@ +package dialer + +import ( + "context" + "net" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/connid" +) + +// Dialer is the interface we expect from a dialer +type Dialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// Resolver is the interface we expect from a resolver +type Resolver interface { + LookupHost(ctx context.Context, hostname string) (addrs []string, err error) +} + +func safeLocalAddress(conn net.Conn) (s string) { + if conn != nil && conn.LocalAddr() != nil { + s = conn.LocalAddr().String() + } + return +} + +func safeConnID(network string, conn net.Conn) int64 { + return connid.Compute(network, safeLocalAddress(conn)) +} diff --git a/internal/engine/netx/dialer/dns.go b/internal/engine/netx/dialer/dns.go new file mode 100644 index 0000000..e6d30ce --- /dev/null +++ b/internal/engine/netx/dialer/dns.go @@ -0,0 +1,73 @@ +package dialer + +import ( + "context" + "errors" + "net" + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// DNSDialer is a dialer that uses the configured Resolver to resolver a +// domain name to IP addresses, and the configured Dialer to connect. +type DNSDialer struct { + Dialer + Resolver Resolver +} + +// DialContext implements Dialer.DialContext. +func (d DNSDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + onlyhost, onlyport, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ctx = dialid.WithDialID(ctx) // important to create before lookupHost + var addrs []string + addrs, err = d.LookupHost(ctx, onlyhost) + if err != nil { + return nil, err + } + var errorslist []error + for _, addr := range addrs { + target := net.JoinHostPort(addr, onlyport) + conn, err := d.Dialer.DialContext(ctx, network, target) + if err == nil { + return conn, nil + } + errorslist = append(errorslist, err) + } + return nil, ReduceErrors(errorslist) +} + +// ReduceErrors finds a known error in a list of errors since it's probably most relevant +func ReduceErrors(errorslist []error) error { + if len(errorslist) == 0 { + return nil + } + // If we have a known error, let's consider this the real error + // since it's probably most relevant. Otherwise let's return the + // first considering that (1) local resolvers likely will give + // us IPv4 first and (2) also our resolver does that. So, in case + // the user has no IPv6 connectivity, an IPv6 error is going to + // appear later in the list of errors. + for _, err := range errorslist { + var wrapper *errorx.ErrWrapper + if errors.As(err, &wrapper) && !strings.HasPrefix( + err.Error(), "unknown_failure", + ) { + return err + } + } + // TODO(bassosimone): handle this case in a better way + return errorslist[0] +} + +// LookupHost implements Resolver.LookupHost +func (d DNSDialer) LookupHost(ctx context.Context, hostname string) ([]string, error) { + if net.ParseIP(hostname) != nil { + return []string{hostname}, nil + } + return d.Resolver.LookupHost(ctx, hostname) +} diff --git a/internal/engine/netx/dialer/dns_test.go b/internal/engine/netx/dialer/dns_test.go new file mode 100644 index 0000000..ba8e406 --- /dev/null +++ b/internal/engine/netx/dialer/dns_test.go @@ -0,0 +1,171 @@ +package dialer_test + +import ( + "context" + "errors" + "io" + "net" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestDNSDialerNoPort(t *testing.T) { + dialer := dialer.DNSDialer{Dialer: new(net.Dialer), Resolver: new(net.Resolver)} + conn, err := dialer.DialContext(context.Background(), "tcp", "antani.ooni.nu") + if err == nil { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("expected a nil conn here") + } +} + +func TestDNSDialerLookupHostAddress(t *testing.T) { + dialer := dialer.DNSDialer{Dialer: new(net.Dialer), Resolver: MockableResolver{ + Err: errors.New("mocked error"), + }} + addrs, err := dialer.LookupHost(context.Background(), "1.1.1.1") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "1.1.1.1" { + t.Fatal("not the result we expected") + } +} + +func TestDNSDialerLookupHostFailure(t *testing.T) { + expected := errors.New("mocked error") + dialer := dialer.DNSDialer{Dialer: new(net.Dialer), Resolver: MockableResolver{ + Err: expected, + }} + conn, err := dialer.DialContext(context.Background(), "tcp", "dns.google.com:853") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn") + } +} + +type MockableResolver struct { + Addresses []string + Err error +} + +func (r MockableResolver) LookupHost(ctx context.Context, host string) ([]string, error) { + return r.Addresses, r.Err +} + +func TestDNSDialerDialForSingleIPFails(t *testing.T) { + dialer := dialer.DNSDialer{Dialer: dialer.EOFDialer{}, Resolver: new(net.Resolver)} + conn, err := dialer.DialContext(context.Background(), "tcp", "1.1.1.1:853") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn") + } +} + +func TestDNSDialerDialForManyIPFails(t *testing.T) { + dialer := dialer.DNSDialer{Dialer: dialer.EOFDialer{}, Resolver: MockableResolver{ + Addresses: []string{"1.1.1.1", "8.8.8.8"}, + }} + conn, err := dialer.DialContext(context.Background(), "tcp", "dot.dns:853") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn") + } +} + +func TestDNSDialerDialForManyIPSuccess(t *testing.T) { + dialer := dialer.DNSDialer{Dialer: dialer.EOFConnDialer{}, Resolver: MockableResolver{ + Addresses: []string{"1.1.1.1", "8.8.8.8"}, + }} + conn, err := dialer.DialContext(context.Background(), "tcp", "dot.dns:853") + if err != nil { + t.Fatal("expected nil error here") + } + if conn == nil { + t.Fatal("expected non-nil conn") + } + conn.Close() +} + +func TestDNSDialerDialSetsDialID(t *testing.T) { + saver := &handlers.SavingHandler{} + ctx := modelx.WithMeasurementRoot(context.Background(), &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: saver, + }) + dialer := dialer.DNSDialer{Dialer: dialer.EmitterDialer{ + Dialer: dialer.EOFConnDialer{}, + }, Resolver: MockableResolver{ + Addresses: []string{"1.1.1.1", "8.8.8.8"}, + }} + conn, err := dialer.DialContext(ctx, "tcp", "dot.dns:853") + if err != nil { + t.Fatal("expected nil error here") + } + if conn == nil { + t.Fatal("expected non-nil conn") + } + conn.Close() + events := saver.Read() + if len(events) != 2 { + t.Fatal("unexpected number of events") + } + for _, ev := range events { + if ev.Connect != nil && ev.Connect.DialID == 0 { + t.Fatal("unexpected DialID") + } + } +} + +func TestReduceErrors(t *testing.T) { + t.Run("no errors", func(t *testing.T) { + result := dialer.ReduceErrors(nil) + if result != nil { + t.Fatal("wrong result") + } + }) + + t.Run("single error", func(t *testing.T) { + err := errors.New("mocked error") + result := dialer.ReduceErrors([]error{err}) + if result != err { + t.Fatal("wrong result") + } + }) + + t.Run("multiple errors", func(t *testing.T) { + err1 := errors.New("mocked error #1") + err2 := errors.New("mocked error #2") + result := dialer.ReduceErrors([]error{err1, err2}) + if result.Error() != "mocked error #1" { + t.Fatal("wrong result") + } + }) + + t.Run("multiple errors with meaningful ones", func(t *testing.T) { + err1 := errors.New("mocked error #1") + err2 := &errorx.ErrWrapper{ + Failure: "unknown_failure: antani", + } + err3 := &errorx.ErrWrapper{ + Failure: errorx.FailureConnectionRefused, + } + err4 := errors.New("mocked error #3") + result := dialer.ReduceErrors([]error{err1, err2, err3, err4}) + if result.Error() != errorx.FailureConnectionRefused { + t.Fatal("wrong result") + } + }) +} diff --git a/internal/engine/netx/dialer/emitter.go b/internal/engine/netx/dialer/emitter.go new file mode 100644 index 0000000..c9dad70 --- /dev/null +++ b/internal/engine/netx/dialer/emitter.go @@ -0,0 +1,103 @@ +package dialer + +import ( + "context" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" +) + +// EmitterDialer is a Dialer that emits events +type EmitterDialer struct { + Dialer +} + +// DialContext implements Dialer.DialContext +func (d EmitterDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + start := time.Now() + conn, err := d.Dialer.DialContext(ctx, network, address) + stop := time.Now() + root := modelx.ContextMeasurementRootOrDefault(ctx) + root.Handler.OnMeasurement(modelx.Measurement{ + Connect: &modelx.ConnectEvent{ + ConnID: safeConnID(network, conn), + DialID: dialid.ContextDialID(ctx), + DurationSinceBeginning: stop.Sub(root.Beginning), + Error: err, + Network: network, + RemoteAddress: address, + SyscallDuration: stop.Sub(start), + TransactionID: transactionid.ContextTransactionID(ctx), + }, + }) + if err != nil { + return nil, err + } + return EmitterConn{ + Conn: conn, + Beginning: root.Beginning, + Handler: root.Handler, + ID: safeConnID(network, conn), + }, nil +} + +// EmitterConn is a net.Conn used to emit events +type EmitterConn struct { + net.Conn + Beginning time.Time + Handler modelx.Handler + ID int64 +} + +// Read implements net.Conn.Read +func (c EmitterConn) Read(b []byte) (n int, err error) { + start := time.Now() + n, err = c.Conn.Read(b) + stop := time.Now() + c.Handler.OnMeasurement(modelx.Measurement{ + Read: &modelx.ReadEvent{ + ConnID: c.ID, + DurationSinceBeginning: stop.Sub(c.Beginning), + Error: err, + NumBytes: int64(n), + SyscallDuration: stop.Sub(start), + }, + }) + return +} + +// Write implements net.Conn.Write +func (c EmitterConn) Write(b []byte) (n int, err error) { + start := time.Now() + n, err = c.Conn.Write(b) + stop := time.Now() + c.Handler.OnMeasurement(modelx.Measurement{ + Write: &modelx.WriteEvent{ + ConnID: c.ID, + DurationSinceBeginning: stop.Sub(c.Beginning), + Error: err, + NumBytes: int64(n), + SyscallDuration: stop.Sub(start), + }, + }) + return +} + +// Close implements net.Conn.Close +func (c EmitterConn) Close() (err error) { + start := time.Now() + err = c.Conn.Close() + stop := time.Now() + c.Handler.OnMeasurement(modelx.Measurement{ + Close: &modelx.CloseEvent{ + ConnID: c.ID, + DurationSinceBeginning: stop.Sub(c.Beginning), + Error: err, + SyscallDuration: stop.Sub(start), + }, + }) + return +} diff --git a/internal/engine/netx/dialer/emitter_test.go b/internal/engine/netx/dialer/emitter_test.go new file mode 100644 index 0000000..adf5ad5 --- /dev/null +++ b/internal/engine/netx/dialer/emitter_test.go @@ -0,0 +1,166 @@ +package dialer_test + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +func TestEmitterFailure(t *testing.T) { + ctx := dialid.WithDialID(context.Background()) + saver := &handlers.SavingHandler{} + ctx = modelx.WithMeasurementRoot(ctx, &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: saver, + }) + ctx = transactionid.WithTransactionID(ctx) + d := dialer.EmitterDialer{Dialer: dialer.EOFDialer{}} + conn, err := d.DialContext(ctx, "tcp", "www.google.com:443") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected a nil conn here") + } + events := saver.Read() + if len(events) != 1 { + t.Fatal("unexpected number of events saved") + } + if events[0].Connect == nil { + t.Fatal("expected non nil Connect") + } + conninfo := events[0].Connect + if conninfo.ConnID != 0 { + t.Fatal("unexpected ConnID value") + } + emitterCheckConnectEventCommon(t, conninfo, io.EOF) +} + +func emitterCheckConnectEventCommon( + t *testing.T, conninfo *modelx.ConnectEvent, err error) { + if conninfo.DialID == 0 { + t.Fatal("unexpected DialID value") + } + if conninfo.DurationSinceBeginning == 0 { + t.Fatal("unexpected DurationSinceBeginning value") + } + if !errors.Is(conninfo.Error, err) { + t.Fatal("unexpected Error value") + } + if conninfo.Network != "tcp" { + t.Fatal("unexpected Network value") + } + if conninfo.RemoteAddress != "www.google.com:443" { + t.Fatal("unexpected Network value") + } + if conninfo.SyscallDuration == 0 { + t.Fatal("unexpected SyscallDuration value") + } + if conninfo.TransactionID == 0 { + t.Fatal("unexpected TransactionID value") + } +} + +func TestEmitterSuccess(t *testing.T) { + ctx := dialid.WithDialID(context.Background()) + saver := &handlers.SavingHandler{} + ctx = modelx.WithMeasurementRoot(ctx, &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: saver, + }) + ctx = transactionid.WithTransactionID(ctx) + d := dialer.EmitterDialer{Dialer: dialer.EOFConnDialer{}} + conn, err := d.DialContext(ctx, "tcp", "www.google.com:443") + if err != nil { + t.Fatal("we expected no error") + } + if conn == nil { + t.Fatal("expected a non-nil conn here") + } + conn.Read(nil) + conn.Write(nil) + conn.Close() + events := saver.Read() + if len(events) != 4 { + t.Fatal("unexpected number of events saved") + } + if events[0].Connect == nil { + t.Fatal("expected non nil Connect") + } + conninfo := events[0].Connect + if conninfo.ConnID == 0 { + t.Fatal("unexpected ConnID value") + } + emitterCheckConnectEventCommon(t, conninfo, nil) + if events[1].Read == nil { + t.Fatal("expected non nil Read") + } + emitterCheckReadEvent(t, events[1].Read) + if events[2].Write == nil { + t.Fatal("expected non nil Write") + } + emitterCheckWriteEvent(t, events[2].Write) + if events[3].Close == nil { + t.Fatal("expected non nil Close") + } + emitterCheckCloseEvent(t, events[3].Close) +} + +func emitterCheckReadEvent(t *testing.T, ev *modelx.ReadEvent) { + if ev.ConnID == 0 { + t.Fatal("unexpected ConnID") + } + if ev.DurationSinceBeginning == 0 { + t.Fatal("unexpected DurationSinceBeginning") + } + if !errors.Is(ev.Error, io.EOF) { + t.Fatal("unexpected Error") + } + if ev.NumBytes != 0 { + t.Fatal("unexpected NumBytes") + } + if ev.SyscallDuration == 0 { + t.Fatal("unexpected SyscallDuration") + } +} + +func emitterCheckWriteEvent(t *testing.T, ev *modelx.WriteEvent) { + if ev.ConnID == 0 { + t.Fatal("unexpected ConnID") + } + if ev.DurationSinceBeginning == 0 { + t.Fatal("unexpected DurationSinceBeginning") + } + if !errors.Is(ev.Error, io.EOF) { + t.Fatal("unexpected Error") + } + if ev.NumBytes != 0 { + t.Fatal("unexpected NumBytes") + } + if ev.SyscallDuration == 0 { + t.Fatal("unexpected SyscallDuration") + } +} + +func emitterCheckCloseEvent(t *testing.T, ev *modelx.CloseEvent) { + if ev.ConnID == 0 { + t.Fatal("unexpected ConnID") + } + if ev.DurationSinceBeginning == 0 { + t.Fatal("unexpected DurationSinceBeginning") + } + if !errors.Is(ev.Error, io.EOF) { + t.Fatal("unexpected Error") + } + if ev.SyscallDuration == 0 { + t.Fatal("unexpected SyscallDuration") + } +} diff --git a/internal/engine/netx/dialer/eof_test.go b/internal/engine/netx/dialer/eof_test.go new file mode 100644 index 0000000..9016658 --- /dev/null +++ b/internal/engine/netx/dialer/eof_test.go @@ -0,0 +1,80 @@ +package dialer + +import ( + "context" + "crypto/tls" + "io" + "net" + "time" +) + +type EOFDialer struct{} + +func (EOFDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return nil, io.EOF +} + +type EOFConnDialer struct{} + +func (EOFConnDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return EOFConn{}, nil +} + +type EOFConn struct { + net.Conn +} + +func (EOFConn) Read(p []byte) (int, error) { + time.Sleep(10 * time.Microsecond) + return 0, io.EOF +} + +func (EOFConn) Write(p []byte) (int, error) { + time.Sleep(10 * time.Microsecond) + return 0, io.EOF +} + +func (EOFConn) Close() error { + time.Sleep(10 * time.Microsecond) + return io.EOF +} + +func (EOFConn) LocalAddr() net.Addr { + return EOFAddr{} +} + +func (EOFConn) RemoteAddr() net.Addr { + return EOFAddr{} +} + +func (EOFConn) SetDeadline(t time.Time) error { + return nil +} + +func (EOFConn) SetReadDeadline(t time.Time) error { + return nil +} + +func (EOFConn) SetWriteDeadline(t time.Time) error { + return nil +} + +type EOFAddr struct{} + +func (EOFAddr) Network() string { + return "tcp" +} + +func (EOFAddr) String() string { + return "127.0.0.1:1234" +} + +type EOFTLSHandshaker struct{} + +func (EOFTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + time.Sleep(10 * time.Microsecond) + return nil, tls.ConnectionState{}, io.EOF +} diff --git a/internal/engine/netx/dialer/errorwrapper.go b/internal/engine/netx/dialer/errorwrapper.go new file mode 100644 index 0000000..96a462e --- /dev/null +++ b/internal/engine/netx/dialer/errorwrapper.go @@ -0,0 +1,75 @@ +package dialer + +import ( + "context" + "net" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// ErrorWrapperDialer is a dialer that performs err wrapping +type ErrorWrapperDialer struct { + Dialer +} + +// DialContext implements Dialer.DialContext +func (d ErrorWrapperDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + dialID := dialid.ContextDialID(ctx) + conn, err := d.Dialer.DialContext(ctx, network, address) + err = errorx.SafeErrWrapperBuilder{ + // ConnID does not make any sense if we've failed and the error + // does not make any sense (and is nil) if we succeded. + DialID: dialID, + Error: err, + Operation: errorx.ConnectOperation, + }.MaybeBuild() + if err != nil { + return nil, err + } + return &ErrorWrapperConn{ + Conn: conn, ConnID: safeConnID(network, conn), DialID: dialID}, nil +} + +// ErrorWrapperConn is a net.Conn that performs error wrapping. +type ErrorWrapperConn struct { + net.Conn + ConnID int64 + DialID int64 +} + +// Read implements net.Conn.Read +func (c ErrorWrapperConn) Read(b []byte) (n int, err error) { + n, err = c.Conn.Read(b) + err = errorx.SafeErrWrapperBuilder{ + ConnID: c.ConnID, + DialID: c.DialID, + Error: err, + Operation: errorx.ReadOperation, + }.MaybeBuild() + return +} + +// Write implements net.Conn.Write +func (c ErrorWrapperConn) Write(b []byte) (n int, err error) { + n, err = c.Conn.Write(b) + err = errorx.SafeErrWrapperBuilder{ + ConnID: c.ConnID, + DialID: c.DialID, + Error: err, + Operation: errorx.WriteOperation, + }.MaybeBuild() + return +} + +// Close implements net.Conn.Close +func (c ErrorWrapperConn) Close() (err error) { + err = c.Conn.Close() + err = errorx.SafeErrWrapperBuilder{ + ConnID: c.ConnID, + DialID: c.DialID, + Error: err, + Operation: errorx.CloseOperation, + }.MaybeBuild() + return +} diff --git a/internal/engine/netx/dialer/errorwrapper_test.go b/internal/engine/netx/dialer/errorwrapper_test.go new file mode 100644 index 0000000..260519e --- /dev/null +++ b/internal/engine/netx/dialer/errorwrapper_test.go @@ -0,0 +1,66 @@ +package dialer_test + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestErrorWrapperFailure(t *testing.T) { + ctx := dialid.WithDialID(context.Background()) + d := dialer.ErrorWrapperDialer{Dialer: dialer.EOFDialer{}} + conn, err := d.DialContext(ctx, "tcp", "www.google.com:443") + if conn != nil { + t.Fatal("expected a nil conn here") + } + errorWrapperCheckErr(t, err, errorx.ConnectOperation) +} + +func errorWrapperCheckErr(t *testing.T, err error, op string) { + if !errors.Is(err, io.EOF) { + t.Fatal("expected another error here") + } + var errWrapper *errorx.ErrWrapper + if !errors.As(err, &errWrapper) { + t.Fatal("cannot cast to ErrWrapper") + } + if errWrapper.DialID == 0 { + t.Fatal("unexpected DialID") + } + if errWrapper.Operation != op { + t.Fatal("unexpected Operation") + } + if errWrapper.Failure != errorx.FailureEOFError { + t.Fatal("unexpected failure") + } +} + +func TestErrorWrapperSuccess(t *testing.T) { + ctx := dialid.WithDialID(context.Background()) + d := dialer.ErrorWrapperDialer{Dialer: dialer.EOFConnDialer{}} + conn, err := d.DialContext(ctx, "tcp", "www.google.com") + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("expected non-nil conn here") + } + count, err := conn.Read(nil) + errorWrapperCheckIOResult(t, count, err, errorx.ReadOperation) + count, err = conn.Write(nil) + errorWrapperCheckIOResult(t, count, err, errorx.WriteOperation) + err = conn.Close() + errorWrapperCheckErr(t, err, errorx.CloseOperation) +} + +func errorWrapperCheckIOResult(t *testing.T, count int, err error, op string) { + if count != 0 { + t.Fatal("expected nil count here") + } + errorWrapperCheckErr(t, err, op) +} diff --git a/internal/engine/netx/dialer/fake_test.go b/internal/engine/netx/dialer/fake_test.go new file mode 100644 index 0000000..9b66568 --- /dev/null +++ b/internal/engine/netx/dialer/fake_test.go @@ -0,0 +1,71 @@ +package dialer + +import ( + "context" + "io" + "net" + "time" +) + +type FakeDialer struct { + Conn net.Conn + Err error +} + +func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return d.Conn, d.Err +} + +type FakeConn struct { + ReadError error + ReadData []byte + SetDeadlineError error + SetReadDeadlineError error + SetWriteDeadlineError error + WriteError error +} + +func (c *FakeConn) Read(b []byte) (int, error) { + if len(c.ReadData) > 0 { + n := copy(b, c.ReadData) + c.ReadData = c.ReadData[n:] + return n, nil + } + if c.ReadError != nil { + return 0, c.ReadError + } + return 0, io.EOF +} + +func (c *FakeConn) Write(b []byte) (n int, err error) { + if c.WriteError != nil { + return 0, c.WriteError + } + n = len(b) + return +} + +func (*FakeConn) Close() (err error) { + return +} + +func (*FakeConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*FakeConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (c *FakeConn) SetDeadline(t time.Time) (err error) { + return c.SetDeadlineError +} + +func (c *FakeConn) SetReadDeadline(t time.Time) (err error) { + return c.SetReadDeadlineError +} + +func (c *FakeConn) SetWriteDeadline(t time.Time) (err error) { + return c.SetWriteDeadlineError +} diff --git a/internal/engine/netx/dialer/integration_test.go b/internal/engine/netx/dialer/integration_test.go new file mode 100644 index 0000000..2072a01 --- /dev/null +++ b/internal/engine/netx/dialer/integration_test.go @@ -0,0 +1,57 @@ +package dialer_test + +import ( + "context" + "net" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +func TestTLSDialerSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + log.SetLevel(log.DebugLevel) + dialer := dialer.TLSDialer{Dialer: new(net.Dialer), + TLSHandshaker: dialer.LoggingTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + Logger: log.Log, + }, + } + txp := &http.Transport{DialTLS: func(network, address string) (net.Conn, error) { + // AlpineLinux edge is still using Go 1.13. We cannot switch to + // using DialTLSContext here as we'd like to until either Alpine + // switches to Go 1.14 or we drop the MK dependency. + return dialer.DialTLSContext(context.Background(), network, address) + }} + client := &http.Client{Transport: txp} + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() +} + +func TestDNSDialerSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + log.SetLevel(log.DebugLevel) + dialer := dialer.DNSDialer{ + Dialer: dialer.LoggingDialer{ + Dialer: new(net.Dialer), + Logger: log.Log, + }, + Resolver: new(net.Resolver), + } + txp := &http.Transport{DialContext: dialer.DialContext} + client := &http.Client{Transport: txp} + resp, err := client.Get("http://www.google.com") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() +} diff --git a/internal/engine/netx/dialer/logging.go b/internal/engine/netx/dialer/logging.go new file mode 100644 index 0000000..c998122 --- /dev/null +++ b/internal/engine/netx/dialer/logging.go @@ -0,0 +1,56 @@ +package dialer + +import ( + "context" + "crypto/tls" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx" +) + +// Logger is the logger assumed by this package +type Logger interface { + Debugf(format string, v ...interface{}) + Debug(message string) +} + +// LoggingDialer is a Dialer with logging +type LoggingDialer struct { + Dialer + Logger Logger +} + +// DialContext implements Dialer.DialContext +func (d LoggingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + d.Logger.Debugf("dial %s/%s...", address, network) + start := time.Now() + conn, err := d.Dialer.DialContext(ctx, network, address) + stop := time.Now() + d.Logger.Debugf("dial %s/%s... %+v in %s", address, network, err, stop.Sub(start)) + return conn, err +} + +// LoggingTLSHandshaker is a TLSHandshaker with logging +type LoggingTLSHandshaker struct { + TLSHandshaker + Logger Logger +} + +// Handshake implements Handshaker.Handshake +func (h LoggingTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + h.Logger.Debugf("tls {sni=%s next=%+v}...", config.ServerName, config.NextProtos) + start := time.Now() + tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config) + stop := time.Now() + h.Logger.Debugf( + "tls {sni=%s next=%+v}... %+v in %s {next=%s cipher=%s v=%s}", config.ServerName, + config.NextProtos, err, stop.Sub(start), state.NegotiatedProtocol, + tlsx.CipherSuiteString(state.CipherSuite), tlsx.VersionString(state.Version)) + return tlsconn, state, err +} + +var _ Dialer = LoggingDialer{} +var _ TLSHandshaker = LoggingTLSHandshaker{} diff --git a/internal/engine/netx/dialer/logging_test.go b/internal/engine/netx/dialer/logging_test.go new file mode 100644 index 0000000..9f58836 --- /dev/null +++ b/internal/engine/netx/dialer/logging_test.go @@ -0,0 +1,42 @@ +package dialer_test + +import ( + "context" + "crypto/tls" + "errors" + "io" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +func TestLoggingDialerFailure(t *testing.T) { + d := dialer.LoggingDialer{ + Dialer: dialer.EOFDialer{}, + Logger: log.Log, + } + conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} + +func TestLoggingTLSHandshakerFailure(t *testing.T) { + h := dialer.LoggingTLSHandshaker{ + TLSHandshaker: dialer.EOFTLSHandshaker{}, + Logger: log.Log, + } + tlsconn, _, err := h.Handshake(context.Background(), dialer.EOFConn{}, &tls.Config{ + ServerName: "www.google.com", + }) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if tlsconn != nil { + t.Fatal("expected nil tlsconn here") + } +} diff --git a/internal/engine/netx/dialer/proxy.go b/internal/engine/netx/dialer/proxy.go new file mode 100644 index 0000000..fde442e --- /dev/null +++ b/internal/engine/netx/dialer/proxy.go @@ -0,0 +1,94 @@ +package dialer + +import ( + "context" + "errors" + "net" + "net/url" + + "golang.org/x/net/proxy" +) + +// ProxyDialer is a dialer that uses a proxy. If the ProxyURL is not configured, this +// dialer is a passthrough for the next Dialer in chain. Otherwise, it will internally +// create a SOCKS5 dialer that will connect to the proxy using the underlying Dialer. +// +// As a special case, you can force a proxy to be used only extemporarily. To this end, +// you can use the WithProxyURL function, to store the proxy URL in the context. This +// will take precedence over any otherwise configured proxy. The use case for this +// functionality is when you need a tunnel to contact OONI probe services. +type ProxyDialer struct { + Dialer + ProxyURL *url.URL +} + +type proxyKey struct{} + +// ContextProxyURL retrieves the proxy URL from the context. This is mainly used +// to force a tunnel when we fail contacting OONI probe services otherwise. +func ContextProxyURL(ctx context.Context) *url.URL { + url, _ := ctx.Value(proxyKey{}).(*url.URL) + return url +} + +// WithProxyURL assigns the proxy URL to the context +func WithProxyURL(ctx context.Context, url *url.URL) context.Context { + return context.WithValue(ctx, proxyKey{}, url) +} + +// DialContext implements Dialer.DialContext +func (d ProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + url := ContextProxyURL(ctx) // context URL takes precendence + if url == nil { + url = d.ProxyURL + } + if url == nil { + return d.Dialer.DialContext(ctx, network, address) + } + if url.Scheme != "socks5" { + return nil, errors.New("Scheme is not socks5") + } + // the code at proxy/socks5.go never fails; see https://git.io/JfJ4g + child, _ := proxy.SOCKS5( + network, url.Host, nil, proxyDialerWrapper{Dialer: d.Dialer}) + return d.dial(ctx, child, network, address) +} + +func (d ProxyDialer) dial( + ctx context.Context, child proxy.Dialer, network, address string) (net.Conn, error) { + connch := make(chan net.Conn) + errch := make(chan error, 1) + go func() { + conn, err := child.Dial(network, address) + if err != nil { + errch <- err + return + } + select { + case connch <- conn: + default: + conn.Close() + } + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case err := <-errch: + return nil, err + case conn := <-connch: + return conn, nil + } +} + +// proxyDialerWrapper is required because SOCKS5 expects a Dialer.Dial type but internally +// it checks whether DialContext is available and prefers that. So, we need to use this +// structure to cast our inner Dialer the way in which SOCKS5 likes it. +// +// See https://git.io/JfJ4g. +type proxyDialerWrapper struct { + Dialer +} + +func (d proxyDialerWrapper) Dial(network, address string) (net.Conn, error) { + return d.DialContext(context.Background(), network, address) +} diff --git a/internal/engine/netx/dialer/proxy_internal_test.go b/internal/engine/netx/dialer/proxy_internal_test.go new file mode 100644 index 0000000..063eef2 --- /dev/null +++ b/internal/engine/netx/dialer/proxy_internal_test.go @@ -0,0 +1,15 @@ +package dialer + +import ( + "context" + "net" + + "golang.org/x/net/proxy" +) + +type ProxyDialerWrapper = proxyDialerWrapper + +func (d ProxyDialer) DialContextWithDialer( + ctx context.Context, child proxy.Dialer, network, address string) (net.Conn, error) { + return d.dial(ctx, child, network, address) +} diff --git a/internal/engine/netx/dialer/proxy_test.go b/internal/engine/netx/dialer/proxy_test.go new file mode 100644 index 0000000..ebaf4ad --- /dev/null +++ b/internal/engine/netx/dialer/proxy_test.go @@ -0,0 +1,152 @@ +package dialer_test + +import ( + "context" + "errors" + "io" + "net/url" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +func TestProxyDialerDialContextNoProxyURL(t *testing.T) { + expected := errors.New("mocked error") + d := dialer.ProxyDialer{ + Dialer: dialer.FakeDialer{Err: expected}, + } + conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, expected) { + t.Fatal(err) + } + if conn != nil { + t.Fatal("conn is not nil") + } +} + +func TestProxyDialerContextTakesPrecedence(t *testing.T) { + expected := errors.New("mocked error") + d := dialer.ProxyDialer{ + Dialer: dialer.FakeDialer{Err: expected}, + ProxyURL: &url.URL{Scheme: "antani"}, + } + ctx := context.Background() + ctx = dialer.WithProxyURL(ctx, &url.URL{Scheme: "socks5", Host: "[::1]:443"}) + conn, err := d.DialContext(ctx, "tcp", "www.google.com:443") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("conn is not nil") + } +} + +func TestProxyDialerDialContextInvalidScheme(t *testing.T) { + d := dialer.ProxyDialer{ + Dialer: dialer.FakeDialer{}, + ProxyURL: &url.URL{Scheme: "antani"}, + } + conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443") + if err.Error() != "Scheme is not socks5" { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("conn is not nil") + } +} + +func TestProxyDialerDialContextWithEOF(t *testing.T) { + d := dialer.ProxyDialer{ + Dialer: dialer.FakeDialer{ + Err: io.EOF, + }, + ProxyURL: &url.URL{Scheme: "socks5"}, + } + conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("conn is not nil") + } +} + +func TestProxyDialerDialContextWithContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately fail + d := dialer.ProxyDialer{ + Dialer: dialer.FakeDialer{ + Err: io.EOF, + }, + ProxyURL: &url.URL{Scheme: "socks5"}, + } + conn, err := d.DialContext(ctx, "tcp", "www.google.com:443") + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("conn is not nil") + } +} + +func TestProxyDialerDialContextWithDialerSuccess(t *testing.T) { + d := dialer.ProxyDialer{ + Dialer: dialer.FakeDialer{ + Conn: &dialer.FakeConn{ + ReadError: io.EOF, + WriteError: io.EOF, + }, + }, + ProxyURL: &url.URL{Scheme: "socks5"}, + } + conn, err := d.DialContextWithDialer( + context.Background(), dialer.ProxyDialerWrapper{ + Dialer: d.Dialer, + }, "tcp", "www.google.com:443") + if err != nil { + t.Fatal(err) + } + conn.Close() +} + +func TestProxyDialerDialContextWithDialerCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + // Stop immediately. The FakeDialer sleeps for some microseconds so + // it is much more likely we immediately exit with done context. The + // arm where we receive the conn is much less likely. + cancel() + d := dialer.ProxyDialer{ + Dialer: dialer.FakeDialer{ + Conn: &dialer.FakeConn{ + ReadError: io.EOF, + WriteError: io.EOF, + }, + }, + ProxyURL: &url.URL{Scheme: "socks5"}, + } + conn, err := d.DialContextWithDialer( + ctx, dialer.ProxyDialerWrapper{ + Dialer: d.Dialer, + }, "tcp", "www.google.com:443") + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} + +func TestProxyDialerWrapper(t *testing.T) { + d := dialer.ProxyDialerWrapper{ + Dialer: dialer.FakeDialer{ + Err: io.EOF, + }, + } + conn, err := d.Dial("tcp", "www.google.com:443") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("conn is not nil") + } +} diff --git a/internal/engine/netx/dialer/saver.go b/internal/engine/netx/dialer/saver.go new file mode 100644 index 0000000..db18076 --- /dev/null +++ b/internal/engine/netx/dialer/saver.go @@ -0,0 +1,125 @@ +package dialer + +import ( + "context" + "crypto/tls" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// SaverDialer saves events occurring during the dial +type SaverDialer struct { + Dialer + Saver *trace.Saver +} + +// DialContext implements Dialer.DialContext +func (d SaverDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + start := time.Now() + conn, err := d.Dialer.DialContext(ctx, network, address) + stop := time.Now() + d.Saver.Write(trace.Event{ + Address: address, + Duration: stop.Sub(start), + Err: err, + Name: errorx.ConnectOperation, + Proto: network, + Time: stop, + }) + return conn, err +} + +// SaverTLSHandshaker saves events occurring during the handshake +type SaverTLSHandshaker struct { + TLSHandshaker + Saver *trace.Saver +} + +// Handshake implements TLSHandshaker.Handshake +func (h SaverTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + start := time.Now() + h.Saver.Write(trace.Event{ + Name: "tls_handshake_start", + NoTLSVerify: config.InsecureSkipVerify, + TLSNextProtos: config.NextProtos, + TLSServerName: config.ServerName, + Time: start, + }) + tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config) + stop := time.Now() + h.Saver.Write(trace.Event{ + Duration: stop.Sub(start), + Err: err, + Name: "tls_handshake_done", + NoTLSVerify: config.InsecureSkipVerify, + TLSCipherSuite: tlsx.CipherSuiteString(state.CipherSuite), + TLSNegotiatedProto: state.NegotiatedProtocol, + TLSNextProtos: config.NextProtos, + TLSPeerCerts: trace.PeerCerts(state, err), + TLSServerName: config.ServerName, + TLSVersion: tlsx.VersionString(state.Version), + Time: stop, + }) + return tlsconn, state, err +} + +// SaverConnDialer wraps the returned connection such that we +// collect all the read/write events that occur. +type SaverConnDialer struct { + Dialer + Saver *trace.Saver +} + +// DialContext implements Dialer.DialContext +func (d SaverConnDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := d.Dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return saverConn{saver: d.Saver, Conn: conn}, nil +} + +type saverConn struct { + net.Conn + saver *trace.Saver +} + +func (c saverConn) Read(p []byte) (int, error) { + start := time.Now() + count, err := c.Conn.Read(p) + stop := time.Now() + c.saver.Write(trace.Event{ + Data: p[:count], + Duration: stop.Sub(start), + Err: err, + NumBytes: count, + Name: errorx.ReadOperation, + Time: stop, + }) + return count, err +} + +func (c saverConn) Write(p []byte) (int, error) { + start := time.Now() + count, err := c.Conn.Write(p) + stop := time.Now() + c.saver.Write(trace.Event{ + Data: p[:count], + Duration: stop.Sub(start), + Err: err, + NumBytes: count, + Name: errorx.WriteOperation, + Time: stop, + }) + return count, err +} + +var _ Dialer = SaverDialer{} +var _ TLSHandshaker = SaverTLSHandshaker{} +var _ net.Conn = saverConn{} diff --git a/internal/engine/netx/dialer/saver_test.go b/internal/engine/netx/dialer/saver_test.go new file mode 100644 index 0000000..8d30323 --- /dev/null +++ b/internal/engine/netx/dialer/saver_test.go @@ -0,0 +1,371 @@ +package dialer_test + +import ( + "context" + "crypto/tls" + "errors" + "net" + "reflect" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestSaverDialerFailure(t *testing.T) { + expected := errors.New("mocked error") + saver := &trace.Saver{} + dlr := dialer.SaverDialer{ + Dialer: dialer.FakeDialer{ + Err: expected, + }, + Saver: saver, + } + conn, err := dlr.DialContext(context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, expected) { + t.Fatal("expected another error here") + } + if conn != nil { + t.Fatal("expected nil conn here") + } + ev := saver.Read() + if len(ev) != 1 { + t.Fatal("expected a single event here") + } + if ev[0].Address != "www.google.com:443" { + t.Fatal("unexpected Address") + } + if ev[0].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if !errors.Is(ev[0].Err, expected) { + t.Fatal("unexpected Err") + } + if ev[0].Name != errorx.ConnectOperation { + t.Fatal("unexpected Name") + } + if ev[0].Proto != "tcp" { + t.Fatal("unexpected Proto") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("unexpected Time") + } +} + +func TestSaverConnDialerFailure(t *testing.T) { + expected := errors.New("mocked error") + saver := &trace.Saver{} + dlr := dialer.SaverConnDialer{ + Dialer: dialer.FakeDialer{ + Err: expected, + }, + Saver: saver, + } + conn, err := dlr.DialContext(context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} + +func TestSaverTLSHandshakerSuccessWithReadWrite(t *testing.T) { + // This is the most common use case for collecting reads, writes + if testing.Short() { + t.Skip("skip test in short mode") + } + nextprotos := []string{"h2"} + saver := &trace.Saver{} + tlsdlr := dialer.TLSDialer{ + Config: &tls.Config{NextProtos: nextprotos}, + Dialer: dialer.SaverConnDialer{ + Dialer: new(net.Dialer), + Saver: saver, + }, + TLSHandshaker: dialer.SaverTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + Saver: saver, + }, + } + // Implementation note: we don't close the connection here because it is + // very handy to have the last event being the end of the handshake + _, err := tlsdlr.DialTLSContext(context.Background(), "tcp", "www.google.com:443") + if err != nil { + t.Fatal(err) + } + ev := saver.Read() + if len(ev) < 4 { + // it's a bit tricky to be sure about the right number of + // events because network conditions may influence that + t.Fatal("unexpected number of events") + } + if ev[0].Name != "tls_handshake_start" { + t.Fatal("unexpected Name") + } + if ev[0].TLSServerName != "www.google.com" { + t.Fatal("unexpected TLSServerName") + } + if !reflect.DeepEqual(ev[0].TLSNextProtos, nextprotos) { + t.Fatal("unexpected TLSNextProtos") + } + if ev[0].Time.After(time.Now()) { + t.Fatal("unexpected Time") + } + last := len(ev) - 1 + for idx := 1; idx < last; idx++ { + if ev[idx].Data == nil { + t.Fatal("unexpected Data") + } + if ev[idx].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if ev[idx].Err != nil { + t.Fatal("unexpected Err") + } + if ev[idx].NumBytes <= 0 { + t.Fatal("unexpected NumBytes") + } + switch ev[idx].Name { + case errorx.ReadOperation, errorx.WriteOperation: + default: + t.Fatal("unexpected Name") + } + if ev[idx].Time.Before(ev[idx-1].Time) { + t.Fatal("unexpected Time") + } + } + if ev[last].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if ev[last].Err != nil { + t.Fatal("unexpected Err") + } + if ev[last].Name != "tls_handshake_done" { + t.Fatal("unexpected Name") + } + if ev[last].TLSCipherSuite == "" { + t.Fatal("unexpected TLSCipherSuite") + } + if ev[last].TLSNegotiatedProto != "h2" { + t.Fatal("unexpected TLSNegotiatedProto") + } + if !reflect.DeepEqual(ev[last].TLSNextProtos, nextprotos) { + t.Fatal("unexpected TLSNextProtos") + } + if ev[last].TLSPeerCerts == nil { + t.Fatal("unexpected TLSPeerCerts") + } + if ev[last].TLSServerName != "www.google.com" { + t.Fatal("unexpected TLSServerName") + } + if ev[last].TLSVersion == "" { + t.Fatal("unexpected TLSVersion") + } + if ev[last].Time.Before(ev[last-1].Time) { + t.Fatal("unexpected Time") + } +} + +func TestSaverTLSHandshakerSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + nextprotos := []string{"h2"} + saver := &trace.Saver{} + tlsdlr := dialer.TLSDialer{ + Config: &tls.Config{NextProtos: nextprotos}, + Dialer: new(net.Dialer), + TLSHandshaker: dialer.SaverTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + Saver: saver, + }, + } + conn, err := tlsdlr.DialTLSContext(context.Background(), "tcp", "www.google.com:443") + if err != nil { + t.Fatal(err) + } + conn.Close() + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("unexpected number of events") + } + if ev[0].Name != "tls_handshake_start" { + t.Fatal("unexpected Name") + } + if ev[0].TLSServerName != "www.google.com" { + t.Fatal("unexpected TLSServerName") + } + if !reflect.DeepEqual(ev[0].TLSNextProtos, nextprotos) { + t.Fatal("unexpected TLSNextProtos") + } + if ev[0].Time.After(time.Now()) { + t.Fatal("unexpected Time") + } + if ev[1].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if ev[1].Err != nil { + t.Fatal("unexpected Err") + } + if ev[1].Name != "tls_handshake_done" { + t.Fatal("unexpected Name") + } + if ev[1].TLSCipherSuite == "" { + t.Fatal("unexpected TLSCipherSuite") + } + if ev[1].TLSNegotiatedProto != "h2" { + t.Fatal("unexpected TLSNegotiatedProto") + } + if !reflect.DeepEqual(ev[1].TLSNextProtos, nextprotos) { + t.Fatal("unexpected TLSNextProtos") + } + if ev[1].TLSPeerCerts == nil { + t.Fatal("unexpected TLSPeerCerts") + } + if ev[1].TLSServerName != "www.google.com" { + t.Fatal("unexpected TLSServerName") + } + if ev[1].TLSVersion == "" { + t.Fatal("unexpected TLSVersion") + } + if ev[1].Time.Before(ev[0].Time) { + t.Fatal("unexpected Time") + } +} + +func TestSaverTLSHandshakerHostnameError(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := &trace.Saver{} + tlsdlr := dialer.TLSDialer{ + Dialer: new(net.Dialer), + TLSHandshaker: dialer.SaverTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + Saver: saver, + }, + } + conn, err := tlsdlr.DialTLSContext( + context.Background(), "tcp", "wrong.host.badssl.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("expected nil conn here") + } + for _, ev := range saver.Read() { + if ev.Name != "tls_handshake_done" { + continue + } + if ev.NoTLSVerify == true { + t.Fatal("expected NoTLSVerify to be false") + } + if len(ev.TLSPeerCerts) < 1 { + t.Fatal("expected at least a certificate here") + } + } +} + +func TestSaverTLSHandshakerInvalidCertError(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := &trace.Saver{} + tlsdlr := dialer.TLSDialer{ + Dialer: new(net.Dialer), + TLSHandshaker: dialer.SaverTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + Saver: saver, + }, + } + conn, err := tlsdlr.DialTLSContext( + context.Background(), "tcp", "expired.badssl.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("expected nil conn here") + } + for _, ev := range saver.Read() { + if ev.Name != "tls_handshake_done" { + continue + } + if ev.NoTLSVerify == true { + t.Fatal("expected NoTLSVerify to be false") + } + if len(ev.TLSPeerCerts) < 1 { + t.Fatal("expected at least a certificate here") + } + } +} + +func TestSaverTLSHandshakerAuthorityError(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := &trace.Saver{} + tlsdlr := dialer.TLSDialer{ + Dialer: new(net.Dialer), + TLSHandshaker: dialer.SaverTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + Saver: saver, + }, + } + conn, err := tlsdlr.DialTLSContext( + context.Background(), "tcp", "self-signed.badssl.com:443") + if err == nil { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("expected nil conn here") + } + for _, ev := range saver.Read() { + if ev.Name != "tls_handshake_done" { + continue + } + if ev.NoTLSVerify == true { + t.Fatal("expected NoTLSVerify to be false") + } + if len(ev.TLSPeerCerts) < 1 { + t.Fatal("expected at least a certificate here") + } + } +} + +func TestSaverTLSHandshakerNoTLSVerify(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := &trace.Saver{} + tlsdlr := dialer.TLSDialer{ + Config: &tls.Config{InsecureSkipVerify: true}, + Dialer: new(net.Dialer), + TLSHandshaker: dialer.SaverTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + Saver: saver, + }, + } + conn, err := tlsdlr.DialTLSContext( + context.Background(), "tcp", "self-signed.badssl.com:443") + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("expected non-nil conn here") + } + conn.Close() + for _, ev := range saver.Read() { + if ev.Name != "tls_handshake_done" { + continue + } + if ev.NoTLSVerify != true { + t.Fatal("expected NoTLSVerify to be true") + } + if len(ev.TLSPeerCerts) < 1 { + t.Fatal("expected at least a certificate here") + } + } +} diff --git a/internal/engine/netx/dialer/shaping_disabled.go b/internal/engine/netx/dialer/shaping_disabled.go new file mode 100644 index 0000000..0d07bf6 --- /dev/null +++ b/internal/engine/netx/dialer/shaping_disabled.go @@ -0,0 +1,21 @@ +// +build !shaping + +package dialer + +import ( + "context" + "net" +) + +// ShapingDialer ensures we don't use too much bandwidth +// when using integration tests at GitHub. To select +// the implementation with shaping use `-tags shaping`. +type ShapingDialer struct { + Dialer +} + +// DialContext implements Dialer.DialContext +func (d ShapingDialer) DialContext( + ctx context.Context, network, address string) (net.Conn, error) { + return d.Dialer.DialContext(ctx, network, address) +} diff --git a/internal/engine/netx/dialer/shaping_enabled.go b/internal/engine/netx/dialer/shaping_enabled.go new file mode 100644 index 0000000..00b27ca --- /dev/null +++ b/internal/engine/netx/dialer/shaping_enabled.go @@ -0,0 +1,40 @@ +// +build shaping + +package dialer + +import ( + "context" + "net" + "time" +) + +// ShapingDialer ensures we don't use too much bandwidth +// when using integration tests at GitHub. To select +// the implementation with shaping use `-tags shaping`. +type ShapingDialer struct { + Dialer +} + +// DialContext implements Dialer.DialContext +func (d ShapingDialer) DialContext( + ctx context.Context, network, address string) (net.Conn, error) { + conn, err := d.Dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return &shapingConn{Conn: conn}, nil +} + +type shapingConn struct { + net.Conn +} + +func (c shapingConn) Read(p []byte) (int, error) { + time.Sleep(100 * time.Millisecond) + return c.Conn.Read(p) +} + +func (c shapingConn) Write(p []byte) (int, error) { + time.Sleep(100 * time.Millisecond) + return c.Conn.Write(p) +} diff --git a/internal/engine/netx/dialer/shaping_test.go b/internal/engine/netx/dialer/shaping_test.go new file mode 100644 index 0000000..e910c04 --- /dev/null +++ b/internal/engine/netx/dialer/shaping_test.go @@ -0,0 +1,27 @@ +package dialer_test + +import ( + "net" + "net/http" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +func TestGood(t *testing.T) { + txp := netx.NewHTTPTransport(netx.Config{ + Dialer: dialer.ShapingDialer{ + Dialer: new(net.Dialer), + }, + }) + client := &http.Client{Transport: txp} + resp, err := client.Get("https://www.google.com/") + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected nil response here") + } + resp.Body.Close() +} diff --git a/internal/engine/netx/dialer/timeout.go b/internal/engine/netx/dialer/timeout.go new file mode 100644 index 0000000..36b24a2 --- /dev/null +++ b/internal/engine/netx/dialer/timeout.go @@ -0,0 +1,24 @@ +package dialer + +import ( + "context" + "net" + "time" +) + +// TimeoutDialer is a Dialer that enforces a timeout +type TimeoutDialer struct { + Dialer + ConnectTimeout time.Duration // default: 30 seconds +} + +// DialContext implements Dialer.DialContext +func (d TimeoutDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + timeout := 30 * time.Second + if d.ConnectTimeout != 0 { + timeout = d.ConnectTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return d.Dialer.DialContext(ctx, network, address) +} diff --git a/internal/engine/netx/dialer/timeout_test.go b/internal/engine/netx/dialer/timeout_test.go new file mode 100644 index 0000000..62bb6bb --- /dev/null +++ b/internal/engine/netx/dialer/timeout_test.go @@ -0,0 +1,34 @@ +package dialer_test + +import ( + "context" + "errors" + "io" + "net" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +type SlowDialer struct{} + +func (SlowDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(30 * time.Second): + return nil, io.EOF + } +} + +func TestTimeoutDialer(t *testing.T) { + d := dialer.TimeoutDialer{Dialer: SlowDialer{}, ConnectTimeout: time.Second} + conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} diff --git a/internal/engine/netx/dialer/tls.go b/internal/engine/netx/dialer/tls.go new file mode 100644 index 0000000..cf958d8 --- /dev/null +++ b/internal/engine/netx/dialer/tls.go @@ -0,0 +1,141 @@ +package dialer + +import ( + "context" + "crypto/tls" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/connid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// TLSHandshaker is the generic TLS handshaker +type TLSHandshaker interface { + Handshake(ctx context.Context, conn net.Conn, config *tls.Config) ( + net.Conn, tls.ConnectionState, error) +} + +// SystemTLSHandshaker is the system TLS handshaker. +type SystemTLSHandshaker struct{} + +// Handshake implements Handshaker.Handshake +func (h SystemTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + tlsconn := tls.Client(conn, config) + if err := tlsconn.Handshake(); err != nil { + return nil, tls.ConnectionState{}, err + } + return tlsconn, tlsconn.ConnectionState(), nil +} + +// TimeoutTLSHandshaker is a TLSHandshaker with timeout +type TimeoutTLSHandshaker struct { + TLSHandshaker + HandshakeTimeout time.Duration // default: 10 second +} + +// Handshake implements Handshaker.Handshake +func (h TimeoutTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + timeout := 10 * time.Second + if h.HandshakeTimeout != 0 { + timeout = h.HandshakeTimeout + } + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return nil, tls.ConnectionState{}, err + } + tlsconn, connstate, err := h.TLSHandshaker.Handshake(ctx, conn, config) + conn.SetDeadline(time.Time{}) + return tlsconn, connstate, err +} + +// ErrorWrapperTLSHandshaker wraps the returned error to be an OONI error +type ErrorWrapperTLSHandshaker struct { + TLSHandshaker +} + +// Handshake implements Handshaker.Handshake +func (h ErrorWrapperTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + connID := connid.Compute(conn.RemoteAddr().Network(), conn.RemoteAddr().String()) + tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config) + err = errorx.SafeErrWrapperBuilder{ + ConnID: connID, + Error: err, + Operation: errorx.TLSHandshakeOperation, + }.MaybeBuild() + return tlsconn, state, err +} + +// EmitterTLSHandshaker emits events using the MeasurementRoot +type EmitterTLSHandshaker struct { + TLSHandshaker +} + +// Handshake implements Handshaker.Handshake +func (h EmitterTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + connID := connid.Compute(conn.RemoteAddr().Network(), conn.RemoteAddr().String()) + root := modelx.ContextMeasurementRootOrDefault(ctx) + root.Handler.OnMeasurement(modelx.Measurement{ + TLSHandshakeStart: &modelx.TLSHandshakeStartEvent{ + ConnID: connID, + DurationSinceBeginning: time.Now().Sub(root.Beginning), + SNI: config.ServerName, + }, + }) + tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config) + root.Handler.OnMeasurement(modelx.Measurement{ + TLSHandshakeDone: &modelx.TLSHandshakeDoneEvent{ + ConnID: connID, + ConnectionState: modelx.NewTLSConnectionState(state), + Error: err, + DurationSinceBeginning: time.Now().Sub(root.Beginning), + }, + }) + return tlsconn, state, err +} + +// TLSDialer is the TLS dialer +type TLSDialer struct { + Config *tls.Config + Dialer Dialer + TLSHandshaker TLSHandshaker +} + +// DialTLSContext is like tls.DialTLS but with the signature of net.Dialer.DialContext +func (d TLSDialer) DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) { + // Implementation note: when DialTLS is not set, the code in + // net/http will perform the handshake. Otherwise, if DialTLS + // is set, we will end up here. This code is still used when + // performing non-HTTP TLS-enabled dial operations. + host, _, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + conn, err := d.Dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + config := d.Config + if config == nil { + config = new(tls.Config) + } else { + config = config.Clone() + } + if config.ServerName == "" { + config.ServerName = host + } + tlsconn, _, err := d.TLSHandshaker.Handshake(ctx, conn, config) + if err != nil { + conn.Close() + return nil, err + } + return tlsconn, nil +} diff --git a/internal/engine/netx/dialer/tls_test.go b/internal/engine/netx/dialer/tls_test.go new file mode 100644 index 0000000..d7b16ac --- /dev/null +++ b/internal/engine/netx/dialer/tls_test.go @@ -0,0 +1,277 @@ +package dialer_test + +import ( + "context" + "crypto/tls" + "errors" + "io" + "net" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +func TestSystemTLSHandshakerEOFError(t *testing.T) { + h := dialer.SystemTLSHandshaker{} + conn, _, err := h.Handshake(context.Background(), dialer.EOFConn{}, &tls.Config{ + ServerName: "x.org", + }) + if err != io.EOF { + t.Fatal("not the error that we expected") + } + if conn != nil { + t.Fatal("expected nil con here") + } +} + +func TestTimeoutTLSHandshakerSetDeadlineError(t *testing.T) { + h := dialer.TimeoutTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + HandshakeTimeout: 200 * time.Millisecond, + } + expected := errors.New("mocked error") + conn, _, err := h.Handshake( + context.Background(), &dialer.FakeConn{SetDeadlineError: expected}, + new(tls.Config)) + if !errors.Is(err, expected) { + t.Fatal("not the error that we expected") + } + if conn != nil { + t.Fatal("expected nil con here") + } +} + +func TestTimeoutTLSHandshakerEOFError(t *testing.T) { + h := dialer.TimeoutTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + HandshakeTimeout: 200 * time.Millisecond, + } + conn, _, err := h.Handshake( + context.Background(), dialer.EOFConn{}, &tls.Config{ServerName: "x.org"}) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error that we expected") + } + if conn != nil { + t.Fatal("expected nil con here") + } +} + +func TestTimeoutTLSHandshakerCallsSetDeadline(t *testing.T) { + h := dialer.TimeoutTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + HandshakeTimeout: 200 * time.Millisecond, + } + underlying := &SetDeadlineConn{} + conn, _, err := h.Handshake( + context.Background(), underlying, &tls.Config{ServerName: "x.org"}) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error that we expected") + } + if conn != nil { + t.Fatal("expected nil con here") + } + if len(underlying.deadlines) != 2 { + t.Fatal("SetDeadline not called twice") + } + if underlying.deadlines[0].Before(time.Now()) { + t.Fatal("the first SetDeadline call was incorrect") + } + if !underlying.deadlines[1].IsZero() { + t.Fatal("the second SetDeadline call was incorrect") + } +} + +type SetDeadlineConn struct { + dialer.EOFConn + deadlines []time.Time +} + +func (c *SetDeadlineConn) SetDeadline(t time.Time) error { + c.deadlines = append(c.deadlines, t) + return nil +} + +func TestErrorWrapperTLSHandshakerFailure(t *testing.T) { + h := dialer.ErrorWrapperTLSHandshaker{TLSHandshaker: dialer.EOFTLSHandshaker{}} + conn, _, err := h.Handshake( + context.Background(), dialer.EOFConn{}, new(tls.Config)) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error that we expected") + } + if conn != nil { + t.Fatal("expected nil con here") + } + var errWrapper *errorx.ErrWrapper + if !errors.As(err, &errWrapper) { + t.Fatal("cannot cast to ErrWrapper") + } + if errWrapper.ConnID == 0 { + t.Fatal("unexpected ConnID") + } + if errWrapper.Failure != errorx.FailureEOFError { + t.Fatal("unexpected Failure") + } + if errWrapper.Operation != errorx.TLSHandshakeOperation { + t.Fatal("unexpected Operation") + } +} + +func TestEmitterTLSHandshakerFailure(t *testing.T) { + saver := &handlers.SavingHandler{} + ctx := modelx.WithMeasurementRoot(context.Background(), &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: saver, + }) + h := dialer.EmitterTLSHandshaker{TLSHandshaker: dialer.EOFTLSHandshaker{}} + conn, _, err := h.Handshake(ctx, dialer.EOFConn{}, &tls.Config{ + ServerName: "www.kernel.org", + }) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error that we expected") + } + if conn != nil { + t.Fatal("expected nil con here") + } + events := saver.Read() + if len(events) != 2 { + t.Fatal("Wrong number of events") + } + if events[0].TLSHandshakeStart == nil { + t.Fatal("missing TLSHandshakeStart event") + } + if events[0].TLSHandshakeStart.ConnID == 0 { + t.Fatal("expected nonzero ConnID") + } + if events[0].TLSHandshakeStart.DurationSinceBeginning == 0 { + t.Fatal("expected nonzero DurationSinceBeginning") + } + if events[0].TLSHandshakeStart.SNI != "www.kernel.org" { + t.Fatal("expected nonzero SNI") + } + if events[1].TLSHandshakeDone == nil { + t.Fatal("missing TLSHandshakeDone event") + } + if events[1].TLSHandshakeDone.ConnID == 0 { + t.Fatal("expected nonzero ConnID") + } + if events[1].TLSHandshakeDone.DurationSinceBeginning == 0 { + t.Fatal("expected nonzero DurationSinceBeginning") + } +} + +func TestTLSDialerFailureSplitHostPort(t *testing.T) { + dialer := dialer.TLSDialer{} + conn, err := dialer.DialTLSContext( + context.Background(), "tcp", "www.google.com") // missing port + if err == nil { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("connection is not nil") + } +} + +func TestTLSDialerFailureDialing(t *testing.T) { + dialer := dialer.TLSDialer{Dialer: dialer.EOFDialer{}} + conn, err := dialer.DialTLSContext( + context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, io.EOF) { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("connection is not nil") + } +} + +func TestTLSDialerFailureHandshaking(t *testing.T) { + rec := &RecorderTLSHandshaker{TLSHandshaker: dialer.SystemTLSHandshaker{}} + dialer := dialer.TLSDialer{ + Dialer: dialer.EOFConnDialer{}, + TLSHandshaker: rec, + } + conn, err := dialer.DialTLSContext( + context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, io.EOF) { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("connection is not nil") + } + if rec.SNI != "www.google.com" { + t.Fatal("unexpected SNI value") + } +} + +func TestTLSDialerFailureHandshakingOverrideSNI(t *testing.T) { + rec := &RecorderTLSHandshaker{TLSHandshaker: dialer.SystemTLSHandshaker{}} + dialer := dialer.TLSDialer{ + Config: &tls.Config{ + ServerName: "x.org", + }, + Dialer: dialer.EOFConnDialer{}, + TLSHandshaker: rec, + } + conn, err := dialer.DialTLSContext( + context.Background(), "tcp", "www.google.com:443") + if !errors.Is(err, io.EOF) { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("connection is not nil") + } + if rec.SNI != "x.org" { + t.Fatal("unexpected SNI value") + } +} + +type RecorderTLSHandshaker struct { + dialer.TLSHandshaker + SNI string +} + +func (h *RecorderTLSHandshaker) Handshake( + ctx context.Context, conn net.Conn, config *tls.Config, +) (net.Conn, tls.ConnectionState, error) { + h.SNI = config.ServerName + return h.TLSHandshaker.Handshake(ctx, conn, config) +} + +func TestDialTLSContextGood(t *testing.T) { + dialer := dialer.TLSDialer{ + Config: &tls.Config{ServerName: "google.com"}, + Dialer: new(net.Dialer), + TLSHandshaker: dialer.SystemTLSHandshaker{}, + } + conn, err := dialer.DialTLSContext(context.Background(), "tcp", "google.com:443") + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("connection is nil") + } + conn.Close() +} + +func TestDialTLSContextTimeout(t *testing.T) { + dialer := dialer.TLSDialer{ + Config: &tls.Config{ServerName: "google.com"}, + Dialer: new(net.Dialer), + TLSHandshaker: dialer.ErrorWrapperTLSHandshaker{ + TLSHandshaker: dialer.TimeoutTLSHandshaker{ + TLSHandshaker: dialer.SystemTLSHandshaker{}, + HandshakeTimeout: 10 * time.Microsecond, + }, + }, + } + conn, err := dialer.DialTLSContext(context.Background(), "tcp", "google.com:443") + if err.Error() != errorx.FailureGenericTimeoutError { + t.Fatal("not the error that we expected") + } + if conn != nil { + t.Fatal("connection is not nil") + } +} diff --git a/internal/engine/netx/errorx/errorx.go b/internal/engine/netx/errorx/errorx.go new file mode 100644 index 0000000..fc72510 --- /dev/null +++ b/internal/engine/netx/errorx/errorx.go @@ -0,0 +1,322 @@ +// Package errorx contains error extensions +package errorx + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "regexp" + "strings" +) + +const ( + // FailureConnectionRefused means ECONNREFUSED. + FailureConnectionRefused = "connection_refused" + + // FailureConnectionReset means ECONNRESET. + FailureConnectionReset = "connection_reset" + + // FailureDNSBogonError means we detected bogon in DNS reply. + FailureDNSBogonError = "dns_bogon_error" + + // FailureDNSNXDOMAINError means we got NXDOMAIN in DNS reply. + FailureDNSNXDOMAINError = "dns_nxdomain_error" + + // FailureEOFError means we got unexpected EOF on connection. + FailureEOFError = "eof_error" + + // FailureGenericTimeoutError means we got some timer has expired. + FailureGenericTimeoutError = "generic_timeout_error" + + // FailureInterrupted means that the user interrupted us. + FailureInterrupted = "interrupted" + + // FailureNoCompatibleQUICVersion means that the server does not support the proposed QUIC version + FailureNoCompatibleQUICVersion = "quic_incompatible_version" + + // FailureSSLInvalidHostname means we got certificate is not valid for SNI. + FailureSSLInvalidHostname = "ssl_invalid_hostname" + + // FailureSSLUnknownAuthority means we cannot find CA validating certificate. + FailureSSLUnknownAuthority = "ssl_unknown_authority" + + // FailureSSLInvalidCertificate means certificate experired or other + // sort of errors causing it to be invalid. + FailureSSLInvalidCertificate = "ssl_invalid_certificate" + + // FailureJSONParseError indicates that we couldn't parse a JSON + FailureJSONParseError = "json_parse_error" +) + +const ( + // ResolveOperation is the operation where we resolve a domain name + ResolveOperation = "resolve" + + // ConnectOperation is the operation where we do a TCP connect + ConnectOperation = "connect" + + // TLSHandshakeOperation is the TLS handshake + TLSHandshakeOperation = "tls_handshake" + + // QUICHandshakeOperation is the handshake to setup a QUIC connection + QUICHandshakeOperation = "quic_handshake" + + // HTTPRoundTripOperation is the HTTP round trip + HTTPRoundTripOperation = "http_round_trip" + + // CloseOperation is when we close a socket + CloseOperation = "close" + + // ReadOperation is when we read from a socket + ReadOperation = "read" + + // WriteOperation is when we write to a socket + WriteOperation = "write" + + // ReadFromOperation is when we read from an UDP socket + ReadFromOperation = "read_from" + + // WriteToOperation is when we write to an UDP socket + WriteToOperation = "write_to" + + // UnknownOperation is when we cannot determine the operation + UnknownOperation = "unknown" + + // TopLevelOperation is used when the failure happens at top level. This + // happens for example with urlgetter with a cancelled context. + TopLevelOperation = "top_level" +) + +// ErrDNSBogon indicates that we found a bogon address. This is the +// correct value with which to initialize MeasurementRoot.ErrDNSBogon +// to tell this library to return an error when a bogon is found. +var ErrDNSBogon = errors.New("dns: detected bogon address") + +// ErrWrapper is our error wrapper for Go errors. The key objective of +// this structure is to properly set Failure, which is also returned by +// the Error() method, so be one of the OONI defined strings. +type ErrWrapper struct { + // ConnID is the connection ID, or zero if not known. + ConnID int64 + + // DialID is the dial ID, or zero if not known. + DialID int64 + + // Failure is the OONI failure string. The failure strings are + // loosely backward compatible with Measurement Kit. + // + // This is either one of the FailureXXX strings or any other + // string like `unknown_failure ...`. The latter represents an + // error that we have not yet mapped to a failure. + Failure string + + // Operation is the operation that failed. If possible, it + // SHOULD be a _major_ operation. Major operations are: + // + // - ResolveOperation: resolving a domain name failed + // - ConnectOperation: connecting to an IP failed + // - TLSHandshakeOperation: TLS handshaking failed + // - HTTPRoundTripOperation: other errors during round trip + // + // Because a network connection doesn't necessarily know + // what is the current major operation we also have the + // following _minor_ operations: + // + // - CloseOperation: CLOSE failed + // - ReadOperation: READ failed + // - WriteOperation: WRITE failed + // + // If an ErrWrapper referring to a major operation is wrapping + // another ErrWrapper and such ErrWrapper already refers to + // a major operation, then the new ErrWrapper should use the + // child ErrWrapper major operation. Otherwise, it should use + // its own major operation. This way, the topmost wrapper is + // supposed to refer to the major operation that failed. + Operation string + + // TransactionID is the transaction ID, or zero if not known. + TransactionID int64 + + // WrappedErr is the error that we're wrapping. + WrappedErr error +} + +// Error returns a description of the error that occurred. +func (e *ErrWrapper) Error() string { + return e.Failure +} + +// Unwrap allows to access the underlying error +func (e *ErrWrapper) Unwrap() error { + return e.WrappedErr +} + +// SafeErrWrapperBuilder contains a builder for ErrWrapper that +// is safe, i.e., behaves correctly when the error is nil. +type SafeErrWrapperBuilder struct { + // ConnID is the connection ID, if any + ConnID int64 + + // DialID is the dial ID, if any + DialID int64 + + // Error is the error, if any + Error error + + // Operation is the operation that failed + Operation string + + // TransactionID is the transaction ID, if any + TransactionID int64 +} + +// MaybeBuild builds a new ErrWrapper, if b.Error is not nil, and returns +// a nil error value, instead, if b.Error is nil. +func (b SafeErrWrapperBuilder) MaybeBuild() (err error) { + if b.Error != nil { + err = &ErrWrapper{ + ConnID: b.ConnID, + DialID: b.DialID, + Failure: toFailureString(b.Error), + Operation: toOperationString(b.Error, b.Operation), + TransactionID: b.TransactionID, + WrappedErr: b.Error, + } + } + return +} + +func toFailureString(err error) string { + // The list returned here matches the values used by MK unless + // explicitly noted otherwise with a comment. + + var errwrapper *ErrWrapper + if errors.As(err, &errwrapper) { + return errwrapper.Error() // we've already wrapped it + } + + if errors.Is(err, ErrDNSBogon) { + return FailureDNSBogonError // not in MK + } + if errors.Is(err, context.Canceled) { + return FailureInterrupted + } + var x509HostnameError x509.HostnameError + if errors.As(err, &x509HostnameError) { + // Test case: https://wrong.host.badssl.com/ + return FailureSSLInvalidHostname + } + var x509UnknownAuthorityError x509.UnknownAuthorityError + if errors.As(err, &x509UnknownAuthorityError) { + // Test case: https://self-signed.badssl.com/. This error has + // never been among the ones returned by MK. + return FailureSSLUnknownAuthority + } + var x509CertificateInvalidError x509.CertificateInvalidError + if errors.As(err, &x509CertificateInvalidError) { + // Test case: https://expired.badssl.com/ + return FailureSSLInvalidCertificate + } + + s := err.Error() + if strings.HasSuffix(s, "operation was canceled") { + return FailureInterrupted + } + if strings.HasSuffix(s, "EOF") { + return FailureEOFError + } + if strings.HasSuffix(s, "connection refused") { + return FailureConnectionRefused + } + if strings.HasSuffix(s, "connection reset by peer") { + return FailureConnectionReset + } + if strings.HasSuffix(s, "context deadline exceeded") { + return FailureGenericTimeoutError + } + if strings.HasSuffix(s, "transaction is timed out") { + return FailureGenericTimeoutError + } + if strings.HasSuffix(s, "i/o timeout") { + return FailureGenericTimeoutError + } + if strings.HasSuffix(s, "TLS handshake timeout") { + return FailureGenericTimeoutError + } + if strings.HasSuffix(s, "no such host") { + // This is dns_lookup_error in MK but such error is used as a + // generic "hey, the lookup failed" error. Instead, this error + // that we return here is significantly more specific. + return FailureDNSNXDOMAINError + } + + // TODO(kelmenhorst): see whether it is possible to match errors + // from qtls rather than strings for TLS errors below. + // + // TODO(kelmenhorst): make sure we have tests for all errors. Also, + // how to ensure we are robust to changes in other libs? + // + // special QUIC errors + matched, err := regexp.MatchString(`.*x509: certificate is valid for.*not.*`, s) + if matched { + return FailureSSLInvalidHostname + } + if strings.HasSuffix(s, "x509: certificate signed by unknown authority") { + return FailureSSLUnknownAuthority + } + certInvalidErrors := []string{"x509: certificate is not authorized to sign other certificates", "x509: certificate has expired or is not yet valid:", "x509: a root or intermediate certificate is not authorized to sign for this name:", "x509: a root or intermediate certificate is not authorized for an extended key usage:", "x509: too many intermediates for path length constraint", "x509: certificate specifies an incompatible key usage", "x509: issuer name does not match subject from issuing certificate", "x509: issuer has name constraints but leaf doesn't have a SAN extension", "x509: issuer has name constraints but leaf contains unknown or unconstrained name:"} + for _, errstr := range certInvalidErrors { + if strings.Contains(s, errstr) { + return FailureSSLInvalidCertificate + } + } + if strings.HasPrefix(s, "No compatible QUIC version found") { + return FailureNoCompatibleQUICVersion + } + if strings.HasSuffix(s, "Handshake did not complete in time") { + return FailureGenericTimeoutError + } + if strings.HasSuffix(s, "connection_refused") { + return FailureConnectionRefused + } + if strings.Contains(s, "stateless_reset") { + return FailureConnectionReset + } + if strings.Contains(s, "deadline exceeded") { + return FailureGenericTimeoutError + } + formatted := fmt.Sprintf("unknown_failure: %s", s) + return Scrub(formatted) // scrub IP addresses in the error +} + +func toOperationString(err error, operation string) string { + var errwrapper *ErrWrapper + if errors.As(err, &errwrapper) { + // Basically, as explained in ErrWrapper docs, let's + // keep the child major operation, if any. + if errwrapper.Operation == ConnectOperation { + return errwrapper.Operation + } + if errwrapper.Operation == HTTPRoundTripOperation { + return errwrapper.Operation + } + if errwrapper.Operation == ResolveOperation { + return errwrapper.Operation + } + if errwrapper.Operation == TLSHandshakeOperation { + return errwrapper.Operation + } + if errwrapper.Operation == QUICHandshakeOperation { + return errwrapper.Operation + } + if errwrapper.Operation == "quic_handshake_start" { + return QUICHandshakeOperation + } + if errwrapper.Operation == "quic_handshake_done" { + return QUICHandshakeOperation + } + // FALLTHROUGH + } + return operation +} diff --git a/internal/engine/netx/errorx/errorx_test.go b/internal/engine/netx/errorx/errorx_test.go new file mode 100644 index 0000000..9b69d2a --- /dev/null +++ b/internal/engine/netx/errorx/errorx_test.go @@ -0,0 +1,247 @@ +package errorx + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "syscall" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/lucas-clemente/quic-go" + "github.com/pion/stun" +) + +func TestMaybeBuildFactory(t *testing.T) { + err := SafeErrWrapperBuilder{ + ConnID: 1, + DialID: 10, + Error: errors.New("mocked error"), + TransactionID: 100, + }.MaybeBuild() + var target *ErrWrapper + if errors.As(err, &target) == false { + t.Fatal("not the expected error type") + } + if target.ConnID != 1 { + t.Fatal("wrong ConnID") + } + if target.DialID != 10 { + t.Fatal("wrong DialID") + } + if target.Failure != "unknown_failure: mocked error" { + t.Fatal("the failure string is wrong") + } + if target.TransactionID != 100 { + t.Fatal("the transactionID is wrong") + } + if target.WrappedErr.Error() != "mocked error" { + t.Fatal("the wrapped error is wrong") + } +} + +func TestToFailureString(t *testing.T) { + t.Run("for already wrapped error", func(t *testing.T) { + err := SafeErrWrapperBuilder{Error: io.EOF}.MaybeBuild() + if toFailureString(err) != FailureEOFError { + t.Fatal("unexpected result") + } + }) + t.Run("for ErrDNSBogon", func(t *testing.T) { + if toFailureString(ErrDNSBogon) != FailureDNSBogonError { + t.Fatal("unexpected result") + } + }) + t.Run("for context.Canceled", func(t *testing.T) { + if toFailureString(context.Canceled) != FailureInterrupted { + t.Fatal("unexpected result") + } + }) + t.Run("for x509.HostnameError", func(t *testing.T) { + var err x509.HostnameError + if toFailureString(err) != FailureSSLInvalidHostname { + t.Fatal("unexpected result") + } + }) + t.Run("for x509.UnknownAuthorityError", func(t *testing.T) { + var err x509.UnknownAuthorityError + if toFailureString(err) != FailureSSLUnknownAuthority { + t.Fatal("unexpected result") + } + }) + t.Run("for x509.CertificateInvalidError", func(t *testing.T) { + var err x509.CertificateInvalidError + if toFailureString(err) != FailureSSLInvalidCertificate { + t.Fatal("unexpected result") + } + }) + t.Run("for operation was canceled error", func(t *testing.T) { + if toFailureString(errors.New("operation was canceled")) != FailureInterrupted { + t.Fatal("unexpected result") + } + }) + t.Run("for EOF", func(t *testing.T) { + if toFailureString(io.EOF) != FailureEOFError { + t.Fatal("unexpected results") + } + }) + t.Run("for connection_refused", func(t *testing.T) { + if toFailureString(syscall.ECONNREFUSED) != FailureConnectionRefused { + t.Fatal("unexpected results") + } + }) + t.Run("for connection_reset", func(t *testing.T) { + if toFailureString(syscall.ECONNRESET) != FailureConnectionReset { + t.Fatal("unexpected results") + } + }) + t.Run("for context deadline exceeded", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1) + defer cancel() + <-ctx.Done() + if toFailureString(ctx.Err()) != FailureGenericTimeoutError { + t.Fatal("unexpected results") + } + }) + t.Run("for stun's transaction is timed out", func(t *testing.T) { + if toFailureString(stun.ErrTransactionTimeOut) != FailureGenericTimeoutError { + t.Fatal("unexpected results") + } + }) + t.Run("for i/o error", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1) + defer cancel() // fail immediately + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", "www.google.com:80") + if err == nil { + t.Fatal("expected an error here") + } + if conn != nil { + t.Fatal("expected nil connection here") + } + if toFailureString(err) != FailureGenericTimeoutError { + t.Fatal("unexpected results") + } + }) + t.Run("for TLS handshake timeout error", func(t *testing.T) { + err := errors.New("net/http: TLS handshake timeout") + if toFailureString(err) != FailureGenericTimeoutError { + t.Fatal("unexpected results") + } + }) + t.Run("for no such host", func(t *testing.T) { + if toFailureString(&net.DNSError{ + Err: "no such host", + }) != FailureDNSNXDOMAINError { + t.Fatal("unexpected results") + } + }) + t.Run("for errors including IPv4 address", func(t *testing.T) { + input := errors.New("read tcp 10.0.2.15:56948->93.184.216.34:443: use of closed network connection") + expected := "unknown_failure: read tcp [scrubbed]->[scrubbed]: use of closed network connection" + out := toFailureString(input) + if out != expected { + t.Fatal(cmp.Diff(expected, out)) + } + }) + t.Run("for errors including IPv6 address", func(t *testing.T) { + input := errors.New("read tcp [::1]:56948->[::1]:443: use of closed network connection") + expected := "unknown_failure: read tcp [scrubbed]->[scrubbed]: use of closed network connection" + out := toFailureString(input) + if out != expected { + t.Fatal(cmp.Diff(expected, out)) + } + }) + // QUIC failures + t.Run("for connection_refused", func(t *testing.T) { + if toFailureString(errors.New("connection_refused")) != FailureConnectionRefused { + t.Fatal("unexpected results") + } + }) + t.Run("for connection_reset", func(t *testing.T) { + if toFailureString(errors.New("stateless_reset")) != FailureConnectionReset { + t.Fatal("unexpected results") + } + }) + t.Run("for incompatible quic version", func(t *testing.T) { + if toFailureString(errors.New("No compatible QUIC version found")) != FailureNoCompatibleQUICVersion { + t.Fatal("unexpected results") + } + }) + t.Run("for i/o error", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1) + defer cancel() // fail immediately + udpAddr := &net.UDPAddr{IP: net.ParseIP("216.58.212.164"), Port: 80, Zone: ""} + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + sess, err := quic.DialEarlyContext(ctx, udpConn, udpAddr, "google.com:80", &tls.Config{}, &quic.Config{}) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected nil session here") + } + if toFailureString(err) != FailureGenericTimeoutError { + t.Fatal("unexpected results") + } + }) + t.Run("for QUIC handshake timeout error", func(t *testing.T) { + err := errors.New("Handshake did not complete in time") + if toFailureString(err) != FailureGenericTimeoutError { + t.Fatal("unexpected results") + } + }) +} + +func TestToOperationString(t *testing.T) { + t.Run("for connect", func(t *testing.T) { + // You're doing HTTP and connect fails. You want to know + // that connect failed not that HTTP failed. + err := &ErrWrapper{Operation: ConnectOperation} + if toOperationString(err, HTTPRoundTripOperation) != ConnectOperation { + t.Fatal("unexpected result") + } + }) + t.Run("for http_round_trip", func(t *testing.T) { + // You're doing DoH and something fails inside HTTP. You want + // to know about the internal HTTP error, not resolve. + err := &ErrWrapper{Operation: HTTPRoundTripOperation} + if toOperationString(err, ResolveOperation) != HTTPRoundTripOperation { + t.Fatal("unexpected result") + } + }) + t.Run("for resolve", func(t *testing.T) { + // You're doing HTTP and the DNS fails. You want to + // know that resolve failed. + err := &ErrWrapper{Operation: ResolveOperation} + if toOperationString(err, HTTPRoundTripOperation) != ResolveOperation { + t.Fatal("unexpected result") + } + }) + t.Run("for tls_handshake", func(t *testing.T) { + // You're doing HTTP and the TLS handshake fails. You want + // to know about a TLS handshake error. + err := &ErrWrapper{Operation: TLSHandshakeOperation} + if toOperationString(err, HTTPRoundTripOperation) != TLSHandshakeOperation { + t.Fatal("unexpected result") + } + }) + t.Run("for minor operation", func(t *testing.T) { + // You just noticed that TLS handshake failed and you + // have a child error telling you that read failed. Here + // you want to know about a TLS handshake error. + err := &ErrWrapper{Operation: ReadOperation} + if toOperationString(err, TLSHandshakeOperation) != TLSHandshakeOperation { + t.Fatal("unexpected result") + } + }) + t.Run("for quic_handshake", func(t *testing.T) { + // You're doing HTTP and the TLS handshake fails. You want + // to know about a TLS handshake error. + err := &ErrWrapper{Operation: QUICHandshakeOperation} + if toOperationString(err, HTTPRoundTripOperation) != QUICHandshakeOperation { + t.Fatal("unexpected result") + } + }) +} diff --git a/internal/engine/netx/errorx/sanitizer.go b/internal/engine/netx/errorx/sanitizer.go new file mode 100644 index 0000000..d181f31 --- /dev/null +++ b/internal/engine/netx/errorx/sanitizer.go @@ -0,0 +1,70 @@ +package errorx + +import "regexp" + +// The code in this file is adapted from github.com/keroserene/snowflake's +// common/safelog/safelog.go implementation . +// +// ================================================================================ +// Copyright (c) 2016, Serene Han, Arlo Breault +// Copyright (c) 2019-2020, The Tor Project, Inc +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * 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. +// +// * Neither the names of the copyright owners 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. +// ================================================================================ + +const ipv4Address = `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` +const ipv6Address = `([0-9a-fA-F]{0,4}:){5,7}([0-9a-fA-F]{0,4})?` +const ipv6Compressed = `([0-9a-fA-F]{0,4}:){0,5}([0-9a-fA-F]{0,4})?(::)([0-9a-fA-F]{0,4}:){0,5}([0-9a-fA-F]{0,4})?` +const ipv6Full = `(` + ipv6Address + `(` + ipv4Address + `))` + + `|(` + ipv6Compressed + `(` + ipv4Address + `))` + + `|(` + ipv6Address + `)` + `|(` + ipv6Compressed + `)` +const optionalPort = `(:\d{1,5})?` +const addressPattern = `((` + ipv4Address + `)|(\[(` + ipv6Full + `)\])|(` + ipv6Full + `))` + optionalPort +const fullAddrPattern = `(^|\s|[^\w:])` + addressPattern + `(\s|(:\s)|[^\w:]|$)` + +var scrubberPatterns = []*regexp.Regexp{ + regexp.MustCompile(fullAddrPattern), +} + +var addressRegexp = regexp.MustCompile(addressPattern) + +func scrub(b []byte) []byte { + scrubbedBytes := b + for _, pattern := range scrubberPatterns { + // this is a workaround since go does not yet support look ahead or look + // behind for regular expressions. + scrubbedBytes = pattern.ReplaceAllFunc(scrubbedBytes, func(b []byte) []byte { + return addressRegexp.ReplaceAll(b, []byte("[scrubbed]")) + }) + } + return scrubbedBytes +} + +// Scrub sanitizes a string containing an error such that +// any occurrence of IP endpoints is scrubbed +func Scrub(s string) string { + return string(scrub([]byte(s))) +} diff --git a/internal/engine/netx/errorx/sanitizer_test.go b/internal/engine/netx/errorx/sanitizer_test.go new file mode 100644 index 0000000..0b7692f --- /dev/null +++ b/internal/engine/netx/errorx/sanitizer_test.go @@ -0,0 +1,129 @@ +package errorx + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// The code in this file is adapted from github.com/keroserene/snowflake's +// common/safelog/safelog.go implementation . +// +// ================================================================================ +// Copyright (c) 2016, Serene Han, Arlo Breault +// Copyright (c) 2019-2020, The Tor Project, Inc +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * 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. +// +// * Neither the names of the copyright owners 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. +// ================================================================================ + +//Test the log scrubber on known problematic log messages +func TestLogScrubberMessages(t *testing.T) { + for _, test := range []struct { + input, expected string + }{ + { + "http: TLS handshake error from 129.97.208.23:38310: ", + "http: TLS handshake error from [scrubbed]: ", + }, + { + "http2: panic serving [2620:101:f000:780:9097:75b1:519f:dbb8]:58344: interface conversion: *http2.responseWriter is not http.Hijacker: missing method Hijack", + "http2: panic serving [scrubbed]: interface conversion: *http2.responseWriter is not http.Hijacker: missing method Hijack", + }, + { + //Make sure it doesn't scrub fingerprint + "a=fingerprint:sha-256 33:B6:FA:F6:94:CA:74:61:45:4A:D2:1F:2C:2F:75:8A:D9:EB:23:34:B2:30:E9:1B:2A:A6:A9:E0:44:72:CC:74", + "a=fingerprint:sha-256 33:B6:FA:F6:94:CA:74:61:45:4A:D2:1F:2C:2F:75:8A:D9:EB:23:34:B2:30:E9:1B:2A:A6:A9:E0:44:72:CC:74", + }, + { + //try with enclosing parens + "(1:2:3:4:c:d:e:f) {1:2:3:4:c:d:e:f}", + "([scrubbed]) {[scrubbed]}", + }, + { + //Make sure it doesn't scrub timestamps + "2019/05/08 15:37:31 starting", + "2019/05/08 15:37:31 starting", + }, + } { + if Scrub(test.input) != test.expected { + t.Error(cmp.Diff(test.input, test.expected)) + } + } +} + +func TestLogScrubberGoodFormats(t *testing.T) { + for _, addr := range []string{ + // IPv4 + "1.2.3.4", + "255.255.255.255", + // IPv4 with port + "1.2.3.4:55", + "255.255.255.255:65535", + // IPv6 + "1:2:3:4:c:d:e:f", + "1111:2222:3333:4444:CCCC:DDDD:EEEE:FFFF", + // IPv6 with brackets + "[1:2:3:4:c:d:e:f]", + "[1111:2222:3333:4444:CCCC:DDDD:EEEE:FFFF]", + // IPv6 with brackets and port + "[1:2:3:4:c:d:e:f]:55", + "[1111:2222:3333:4444:CCCC:DDDD:EEEE:FFFF]:65535", + // compressed IPv6 + "::f", + "::d:e:f", + "1:2:3::", + "1:2:3::d:e:f", + "1:2:3:d:e:f::", + "::1:2:3:d:e:f", + "1111:2222:3333::DDDD:EEEE:FFFF", + // compressed IPv6 with brackets + "[::d:e:f]", + "[1:2:3::]", + "[1:2:3::d:e:f]", + "[1111:2222:3333::DDDD:EEEE:FFFF]", + "[1:2:3:4:5:6::8]", + "[1::7:8]", + // compressed IPv6 with brackets and port + "[1::]:58344", + "[::d:e:f]:55", + "[1:2:3::]:55", + "[1:2:3::d:e:f]:55", + "[1111:2222:3333::DDDD:EEEE:FFFF]:65535", + // IPv4-compatible and IPv4-mapped + "::255.255.255.255", + "::ffff:255.255.255.255", + "[::255.255.255.255]", + "[::ffff:255.255.255.255]", + "[::255.255.255.255]:65535", + "[::ffff:255.255.255.255]:65535", + "[::ffff:0:255.255.255.255]", + "[2001:db8:3:4::192.0.2.33]", + } { + if Scrub(addr) != "[scrubbed]" { + t.Error(cmp.Diff(addr, "[scrubbed]")) + } + } +} diff --git a/internal/engine/netx/fake_test.go b/internal/engine/netx/fake_test.go new file mode 100644 index 0000000..2c3b464 --- /dev/null +++ b/internal/engine/netx/fake_test.go @@ -0,0 +1,56 @@ +package netx + +import ( + "context" + "io/ioutil" + "net" + "net/http" + "time" +) + +type FakeDialer struct { + Conn net.Conn + Err error +} + +func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return d.Conn, d.Err +} + +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() {} + +type FakeBody struct { + Err error +} + +func (fb FakeBody) Read(p []byte) (int, error) { + time.Sleep(10 * time.Microsecond) + return 0, fb.Err +} + +func (fb FakeBody) Close() error { + return nil +} diff --git a/internal/engine/netx/gocertifi/certifi.go b/internal/engine/netx/gocertifi/certifi.go new file mode 100644 index 0000000..7de7804 --- /dev/null +++ b/internal/engine/netx/gocertifi/certifi.go @@ -0,0 +1,3250 @@ +// Code generated by go generate; DO NOT EDIT. +// 2021-01-29 09:54:51.941105652 +0100 CET m=+1.231498959 +// https://curl.haxx.se/ca/cacert.pem + +package gocertifi + +//go:generate go run generate.go "https://curl.haxx.se/ca/cacert.pem" + +import "crypto/x509" + +const pemcerts string = ` +## +## Bundle of CA Root Certificates +## +## Certificate data from Mozilla as of: Tue Jan 19 04:12:04 2021 GMT +## +## This is a bundle of X.509 certificates of public Certificate Authorities +## (CA). These were automatically extracted from Mozilla's root certificates +## file (certdata.txt). This file can be found in the mozilla source tree: +## https://hg.mozilla.org/releases/mozilla-release/raw-file/default/security/nss/lib/ckfw/builtins/certdata.txt +## +## It contains the certificates in PEM format and therefore +## can be directly used with curl / libcurl / php_curl, or with +## an Apache+mod_ssl webserver for SSL client authentication. +## Just configure this file as the SSLCACertificateFile. +## +## Conversion done with mk-ca-bundle.pl version 1.28. +## SHA256: 3bdc63d1de27058fec943a999a2a8a01fcc6806a611b19221a7727d3d9bbbdfd +## + + +GlobalSign Root CA +================== +-----BEGIN CERTIFICATE----- +MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkGA1UEBhMCQkUx +GTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jvb3QgQ0ExGzAZBgNVBAMTEkds +b2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAwMDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNV +BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYD +VQQDExJHbG9iYWxTaWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa +DuaZjc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavpxy0Sy6sc +THAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp1Wrjsok6Vjk4bwY8iGlb +Kk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdGsnUOhugZitVtbNV4FpWi6cgKOOvyJBNP +c1STE4U6G7weNLWLBYy5d4ux2x8gkasJU26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrX +gzT/LCrBbBlDSgeF59N89iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0BAQUF +AAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOzyj1hTdNGCbM+w6Dj +Y1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE38NflNUVyRRBnMRddWQVDf9VMOyG +j/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymPAbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhH +hm4qxFYxldBniYUr+WymXUadDKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveC +X4XSQRjbgbMEHMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== +-----END CERTIFICATE----- + +GlobalSign Root CA - R2 +======================= +-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4GA1UECxMXR2xv +YmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh +bFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT +aWduIFJvb3QgQ0EgLSBSMjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln +bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6 +ErPLv4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8eoLrvozp +s6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklqtTleiDTsvHgMCJiEbKjN +S7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzdC9XZzPnqJworc5HGnRusyMvo4KD0L5CL +TfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pazq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6C +ygPCm48CAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQUm+IHV2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5nbG9i +YWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG3lm0mi3f3BmGLjAN +BgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4GsJ0/WwbgcQ3izDJr86iw8bmEbTUsp +9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu +01yiPqFbQfXf5WRDLenVOavSot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG7 +9G+dwfCMNYxdAfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE----- + +Entrust.net Premium 2048 Secure Server CA +========================================= +-----BEGIN CERTIFICATE----- +MIIEKjCCAxKgAwIBAgIEOGPe+DANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChMLRW50cnVzdC5u +ZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBpbmNvcnAuIGJ5IHJlZi4gKGxp +bWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNV +BAMTKkVudHJ1c3QubmV0IENlcnRpZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQx +NzUwNTFaFw0yOTA3MjQxNDE1MTJaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3 +d3d3LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxpYWIuKTEl +MCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEGA1UEAxMqRW50cnVzdC5u +ZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEArU1LqRKGsuqjIAcVFmQqK0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOL +Gp18EzoOH1u3Hs/lJBQesYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSr +hRSGlVuXMlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVTXTzW +nLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/HoZdenoVve8AjhUi +VBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH4QIDAQABo0IwQDAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVeSB0RGAvtiJuQijMfmhJAkWuXAwDQYJ +KoZIhvcNAQEFBQADggEBADubj1abMOdTmXx6eadNl9cZlZD7Bh/KM3xGY4+WZiT6QBshJ8rmcnPy +T/4xmf3IDExoU8aAghOY+rat2l098c5u9hURlIIM7j+VrxGrD9cv3h8Dj1csHsm7mhpElesYT6Yf +zX1XEC+bBAlahLVu2B064dae0Wx5XnkcFMXj0EyTO2U87d89vqbllRrDtRnDvV5bu/8j72gZyxKT +J1wDLW8w0B62GqzeWvfRqqgnpv55gcR5mTNXuhKwqeBCbJPKVt7+bYQLCIt+jerXmCHG8+c8eS9e +nNFMFY3h7CI3zJpDC5fcgJCNs2ebb0gIFVbPv/ErfF6adulZkMV8gzURZVE= +-----END CERTIFICATE----- + +Baltimore CyberTrust Root +========================= +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJRTESMBAGA1UE +ChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3li +ZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoXDTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMC +SUUxEjAQBgNVBAoTCUJhbHRpbW9yZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFs +dGltb3JlIEN5YmVyVHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKME +uyKrmD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjrIZ3AQSsB +UnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeKmpYcqWe4PwzV9/lSEy/C +G9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSuXmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9 +XbIGevOF6uvUA65ehD5f/xXtabz5OTZydc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjpr +l3RjM71oGDHweI12v/yejl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoI +VDaGezq1BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEB +BQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT929hkTI7gQCvlYpNRh +cL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3WgxjkzSswF07r51XgdIGn9w/xZchMB5 +hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsa +Y71k5h+3zvDyny67G7fyUIhzksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9H +RCwBXbsdtTLSR9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp +-----END CERTIFICATE----- + +Entrust Root Certification Authority +==================================== +-----BEGIN CERTIFICATE----- +MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMCVVMxFjAUBgNV +BAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0Lm5ldC9DUFMgaXMgaW5jb3Jw +b3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMWKGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsG +A1UEAxMkRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0 +MloXDTI2MTEyNzIwNTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMu +MTkwNwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSByZWZlcmVu +Y2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNVBAMTJEVudHJ1c3QgUm9v +dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALaVtkNC+sZtKm9I35RMOVcF7sN5EUFoNu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYsz +A9u3g3s+IIRe7bJWKKf44LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOww +Cj0Yzfv9KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGIrb68 +j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi94DkZfs0Nw4pgHBN +rziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOBsDCBrTAOBgNVHQ8BAf8EBAMCAQYw +DwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAigA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1 +MzQyWjAfBgNVHSMEGDAWgBRokORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DH +hmak8fdLQ/uEvW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA +A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9tO1KzKtvn1ISM +Y/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6ZuaAGAT/3B+XxFNSRuzFVJ7yVTa +v52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTS +W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0 +tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8 +-----END CERTIFICATE----- + +Comodo AAA Services root +======================== +-----BEGIN CERTIFICATE----- +MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEbMBkGA1UECAwS +R3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0Eg +TGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAw +MFoXDTI4MTIzMTIzNTk1OVowezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hl +c3RlcjEQMA4GA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV +BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQuaBtDFcCLNSS1UY8y2bmhG +C1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe3M/vg4aijJRPn2jymJBGhCfHdr/jzDUs +i14HZGWCwEiwqJH5YZ92IFCokcdmtet4YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszW +Y19zjNoFmag4qMsXeDZRrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjH +Ypy+g8cmez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQUoBEK +Iz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wewYDVR0f +BHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNl +cy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29tb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2Vz +LmNybDANBgkqhkiG9w0BAQUFAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm +7l3sAg9g1o1QGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz +Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2G9w84FoVxp7Z +8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsil2D4kF501KKaU73yqWjgom7C +12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg== +-----END CERTIFICATE----- + +QuoVadis Root CA +================ +-----BEGIN CERTIFICATE----- +MIIF0DCCBLigAwIBAgIEOrZQizANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJCTTEZMBcGA1UE +ChMQUXVvVmFkaXMgTGltaXRlZDElMCMGA1UECxMcUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0 +eTEuMCwGA1UEAxMlUXVvVmFkaXMgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wMTAz +MTkxODMzMzNaFw0yMTAzMTcxODMzMzNaMH8xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRp +cyBMaW1pdGVkMSUwIwYDVQQLExxSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MS4wLAYDVQQD +EyVRdW9WYWRpcyBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAv2G1lVO6V/z68mcLOhrfEYBklbTRvM16z/Ypli4kVEAkOPcahdxYTMuk +J0KX0J+DisPkBgNbAKVRHnAEdOLB1Dqr1607BxgFjv2DrOpm2RgbaIr1VxqYuvXtdj182d6UajtL +F8HVj71lODqV0D1VNk7feVcxKh7YWWVJWCCYfqtffp/p1k3sg3Spx2zY7ilKhSoGFPlU5tPaZQeL +YzcS19Dsw3sgQUSj7cugF+FxZc4dZjH3dgEZyH0DWLaVSR2mEiboxgx24ONmy+pdpibu5cxfvWen +AScOospUxbF6lR1xHkopigPcakXBpBlebzbNw6Kwt/5cOOJSvPhEQ+aQuwIDAQABo4ICUjCCAk4w +PQYIKwYBBQUHAQEEMTAvMC0GCCsGAQUFBzABhiFodHRwczovL29jc3AucXVvdmFkaXNvZmZzaG9y +ZS5jb20wDwYDVR0TAQH/BAUwAwEB/zCCARoGA1UdIASCAREwggENMIIBCQYJKwYBBAG+WAABMIH7 +MIHUBggrBgEFBQcCAjCBxxqBxFJlbGlhbmNlIG9uIHRoZSBRdW9WYWRpcyBSb290IENlcnRpZmlj +YXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJs +ZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRpb24gcHJh +Y3RpY2VzLCBhbmQgdGhlIFF1b1ZhZGlzIENlcnRpZmljYXRlIFBvbGljeS4wIgYIKwYBBQUHAgEW +Fmh0dHA6Ly93d3cucXVvdmFkaXMuYm0wHQYDVR0OBBYEFItLbe3TKbkGGew5Oanwl4Rqy+/fMIGu +BgNVHSMEgaYwgaOAFItLbe3TKbkGGew5Oanwl4Rqy+/foYGEpIGBMH8xCzAJBgNVBAYTAkJNMRkw +FwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMSUwIwYDVQQLExxSb290IENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MS4wLAYDVQQDEyVRdW9WYWRpcyBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggQ6 +tlCLMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQUFAAOCAQEAitQUtf70mpKnGdSkfnIYj9lo +fFIk3WdvOXrEql494liwTXCYhGHoG+NpGA7O+0dQoE7/8CQfvbLO9Sf87C9TqnN7Az10buYWnuul +LsS/VidQK2K6vkscPFVcQR0kvoIgR13VRH56FmjffU1RcHhXHTMe/QKZnAzNCgVPx7uOpHX6Sm2x +gI4JVrmcGmD+XcHXetwReNDWXcG31a0ymQM6isxUJTkxgXsTIlG6Rmyhu576BGxJJnSP0nPrzDCi +5upZIof4l/UO/erMkqQWxFIY6iHOsfHmhIHluqmGKPJDWl0Snawe2ajlCmqnf6CHKc/yiU3U7MXi +5nrQNiOKSnQ2+Q== +-----END CERTIFICATE----- + +QuoVadis Root CA 2 +================== +-----BEGIN CERTIFICATE----- +MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT +EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMjAeFw0wNjExMjQx +ODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCaGMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6 +XJxgFyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55JWpzmM+Yk +lvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bBrrcCaoF6qUWD4gXmuVbB +lDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp+ARz8un+XJiM9XOva7R+zdRcAitMOeGy +lZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt +66/3FsvbzSUr5R/7mp/iUcw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1Jdxn +wQ5hYIizPtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og/zOh +D7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UHoycR7hYQe7xFSkyy +BNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuIyV77zGHcizN300QyNQliBJIWENie +J0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1Ud +DgQWBBQahGK8SEwzJQTU7tD2A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGU +a6FJpEcwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT +ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2fBluornFdLwUv +Z+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzng/iN/Ae42l9NLmeyhP3ZRPx3 +UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2BlfF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodm +VjB3pjd4M1IQWK4/YY7yarHvGH5KWWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK ++JDSV6IZUaUtl0HaB0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrW +IozchLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPRTUIZ3Ph1 +WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWDmbA4CD/pXvk1B+TJYm5X +f6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0ZohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II +4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8 +VCLAAVBpQ570su9t+Oza8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u +-----END CERTIFICATE----- + +QuoVadis Root CA 3 +================== +-----BEGIN CERTIFICATE----- +MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT +EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMzAeFw0wNjExMjQx +OTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDMV0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNgg +DhoB4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUrH556VOij +KTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd8lyyBTNvijbO0BNO/79K +DDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9CabwvvWhDFlaJKjdhkf2mrk7AyxRllDdLkgbv +BNDInIjbC3uBr7E9KsRlOni27tyAsdLTmZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwp +p5ijJUMv7/FfJuGITfhebtfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8 +nT8KKdjcT5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDtWAEX +MJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZc6tsgLjoC2SToJyM +Gf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A4iLItLRkT9a6fUg+qGkM17uGcclz +uD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYDVR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHT +BgkrBgEEAb5YAAMwgcUwgZMGCCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmlj +YXRlIGNvbnN0aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0 +aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVudC4wLQYIKwYB +BQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2NwczALBgNVHQ8EBAMCAQYwHQYD +VR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4GA1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4 +ywLQoUmkRzBFMQswCQYDVQQGEwJCTTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UE +AxMSUXVvVmFkaXMgUm9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZV +qyM07ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSemd1o417+s +hvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd+LJ2w/w4E6oM3kJpK27z +POuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2 +Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadNt54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp +8kokUvd0/bpO5qgdAm6xDYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBC +bjPsMZ57k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6szHXu +g/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0jWy10QJLZYxkNc91p +vGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeTmJlglFwjz1onl14LBQaTNx47aTbr +qZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK4SVhM7JZG+Ju1zdXtg2pEto= +-----END CERTIFICATE----- + +Security Communication Root CA +============================== +-----BEGIN CERTIFICATE----- +MIIDWjCCAkKgAwIBAgIBADANBgkqhkiG9w0BAQUFADBQMQswCQYDVQQGEwJKUDEYMBYGA1UEChMP +U0VDT00gVHJ1c3QubmV0MScwJQYDVQQLEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTEw +HhcNMDMwOTMwMDQyMDQ5WhcNMjMwOTMwMDQyMDQ5WjBQMQswCQYDVQQGEwJKUDEYMBYGA1UEChMP +U0VDT00gVHJ1c3QubmV0MScwJQYDVQQLEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTEw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzs/5/022x7xZ8V6UMbXaKL0u/ZPtM7orw +8yl89f/uKuDp6bpbZCKamm8sOiZpUQWZJtzVHGpxxpp9Hp3dfGzGjGdnSj74cbAZJ6kJDKaVv0uM +DPpVmDvY6CKhS3E4eayXkmmziX7qIWgGmBSWh9JhNrxtJ1aeV+7AwFb9Ms+k2Y7CI9eNqPPYJayX +5HA49LY6tJ07lyZDo6G8SVlyTCMwhwFY9k6+HGhWZq/NQV3Is00qVUarH9oe4kA92819uZKAnDfd +DJZkndwi92SL32HeFZRSFaB9UslLqCHJxrHty8OVYNEP8Ktw+N/LTX7s1vqr2b1/VPKl6Xn62dZ2 +JChzAgMBAAGjPzA9MB0GA1UdDgQWBBSgc0mZaNyFW2XjmygvV5+9M7wHSDALBgNVHQ8EBAMCAQYw +DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAaECpqLvkT115swW1F7NgE+vGkl3g +0dNq/vu+m22/xwVtWSDEHPC32oRYAmP6SBbvT6UL90qY8j+eG61Ha2POCEfrUj94nK9NrvjVT8+a +mCoQQTlSxN3Zmw7vkwGusi7KaEIkQmywszo+zenaSMQVy+n5Bw+SUEmK3TGXX8npN6o7WWWXlDLJ +s58+OmJYxUmtYg5xpTKqL8aJdkNAExNnPaJUJRDL8Try2frbSVa7pv6nQTXD4IhhyYjH3zYQIphZ +6rBK+1YWc26sTfcioU+tHXotRSflMMFe8toTyyVCUZVHA4xsIcx0Qu1T/zOLjw9XARYvz6buyXAi +FL39vmwLAw== +-----END CERTIFICATE----- + +Sonera Class 2 Root CA +====================== +-----BEGIN CERTIFICATE----- +MIIDIDCCAgigAwIBAgIBHTANBgkqhkiG9w0BAQUFADA5MQswCQYDVQQGEwJGSTEPMA0GA1UEChMG +U29uZXJhMRkwFwYDVQQDExBTb25lcmEgQ2xhc3MyIENBMB4XDTAxMDQwNjA3Mjk0MFoXDTIxMDQw +NjA3Mjk0MFowOTELMAkGA1UEBhMCRkkxDzANBgNVBAoTBlNvbmVyYTEZMBcGA1UEAxMQU29uZXJh +IENsYXNzMiBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJAXSjWdyvANlsdE+hY3 +/Ei9vX+ALTU74W+oZ6m/AxxNjG8yR9VBaKQTBME1DJqEQ/xcHf+Js+gXGM2RX/uJ4+q/Tl18GybT +dXnt5oTjV+WtKcT0OijnpXuENmmz/V52vaMtmdOQTiMofRhj8VQ7Jp12W5dCsv+u8E7s3TmVToMG +f+dJQMjFAbJUWmYdPfz56TwKnoG4cPABi+QjVHzIrviQHgCWctRUz2EjvOr7nQKV0ba5cTppCD8P +tOFCx4j1P5iop7oc4HFx71hXgVB6XGt0Rg6DA5jDjqhu8nYybieDwnPz3BjotJPqdURrBGAgcVeH +nfO+oJAjPYok4doh28MCAwEAAaMzMDEwDwYDVR0TAQH/BAUwAwEB/zARBgNVHQ4ECgQISqCqWITT +XjwwCwYDVR0PBAQDAgEGMA0GCSqGSIb3DQEBBQUAA4IBAQBazof5FnIVV0sd2ZvnoiYw7JNn39Yt +0jSv9zilzqsWuasvfDXLrNAPtEwr/IDva4yRXzZ299uzGxnq9LIR/WFxRL8oszodv7ND6J+/3DEI +cbCdjdY0RzKQxmUk96BKfARzjzlvF4xytb1LyHr4e4PDKE6cCepnP7JnBBvDFNr450kkkdAdavph +Oe9r5yF1BgfYErQhIHBCcYHaPJo2vqZbDWpsmh+Re/n570K6Tk6ezAyNlNzZRZxe7EJQY670XcSx +EtzKO6gunRRaBXW37Ndj4ro1tgQIkejanZz2ZrUYrAqmVCY0M9IbwdR/GjqOC6oybtv8TyWf2TLH +llpwrN9M +-----END CERTIFICATE----- + +XRamp Global CA Root +==================== +-----BEGIN CERTIFICATE----- +MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCBgjELMAkGA1UE +BhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2Vj +dXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwHhcNMDQxMTAxMTcxNDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMx +HjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkg +U2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3Jp +dHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS638eMpSe2OAtp87ZOqCwu +IR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCPKZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMx +foArtYzAQDsRhtDLooY2YKTVMIJt2W7QDxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FE +zG+gSqmUsE3a56k0enI4qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqs +AxcZZPRaJSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNViPvry +xS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASsjVy16bYbMDYGA1UdHwQvMC0wK6Ap +oCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMC +AQEwDQYJKoZIhvcNAQEFBQADggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc +/Kh4ZzXxHfARvbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt +qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLaIR9NmXmd4c8n +nxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSyi6mx5O+aGtA9aZnuqCij4Tyz +8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQO+7ETPTsJ3xCwnR8gooJybQDJbw= +-----END CERTIFICATE----- + +Go Daddy Class 2 CA +=================== +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMY +VGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRp +ZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkG +A1UEBhMCVVMxITAfBgNVBAoTGFRoZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28g +RGFkZHkgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQAD +ggENADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCAPVYYYwhv +2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6wwdhFJ2+qN1j3hybX2C32 +qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXiEqITLdiOr18SPaAIBQi2XKVlOARFmR6j +YGB0xUGlcmIbYsUfb18aQr4CUWWoriMYavx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmY +vLEHZ6IVDd2gWMZEewo+YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0O +BBYEFNLEsNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h/t2o +atTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMu +MTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwG +A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wim +PQoZ+YeAEW5p5JYXMP80kWNyOO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKt +I3lpjbi2Tc7PTMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ +HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mERdEr/VxqHD3VI +Ls9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5CufReYNnyicsbkqWletNw+vHX/b +vZ8= +-----END CERTIFICATE----- + +Starfield Class 2 CA +==================== +-----BEGIN CERTIFICATE----- +MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzElMCMGA1UEChMc +U3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZpZWxkIENsYXNzIDIg +Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQwNjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBo +MQswCQYDVQQGEwJVUzElMCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAG +A1UECxMpU3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqG +SIb3DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf8MOh2tTY +bitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN+lq2cwQlZut3f+dZxkqZ +JRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVm +epsZGD3/cVE8MC5fvj13c7JdBmzDI1aaK4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSN +F4Azbl5KXZnJHoe0nRrA1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HF +MIHCMB0GA1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fRzt0f +hvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNo +bm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBDbGFzcyAyIENlcnRpZmljYXRpb24g +QXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGs +afPzWdqbAYcaT1epoXkJKtv3L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLM +PUxA2IGvd56Deruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl +xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynpVSJYACPq4xJD +KVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEYWQPJIrSPnNVeKtelttQKbfi3 +QBFGmh95DmK/D5fs4C8fF5Q= +-----END CERTIFICATE----- + +DigiCert Assured ID Root CA +=========================== +-----BEGIN CERTIFICATE----- +MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw +IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzEx +MTEwMDAwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL +ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0Ew +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7cJpSIqvTO +9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYPmDI2dsze3Tyoou9q+yHy +UmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW +/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpy +oeb6pNnVFzF1roV9Iq4/AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whf +GHdPAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRF +66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzANBgkq +hkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRCdWKuh+vy1dneVrOfzM4UKLkNl2Bc +EkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTffwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38Fn +SbNd67IJKusm7Xi+fT8r87cmNW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i +8b5QZ7dsvfPxH2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe ++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== +-----END CERTIFICATE----- + +DigiCert Global Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw +HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAw +MDAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 +dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsBCSDMAZOn +TjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97nh6Vfe63SKMI2tavegw5 +BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt43C/dxC//AH2hdmoRBBYMql1GNXRor5H +4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7PT19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y +7vrTC0LUq7dBMtoM1O/4gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQAB +o2MwYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbRTLtm +8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEF +BQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/EsrhMAtudXH/vTBH1jLuG2cenTnmCmr +EbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIt +tep3Sp+dWOIrWcBAI+0tKIJFPnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886 +UAb3LujEV0lsYSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE----- + +DigiCert High Assurance EV Root CA +================================== +-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSsw +KQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAw +MFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ +MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFu +Y2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0t +Mqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMS +OO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3 +MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQ +NAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUe +h10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSY +JhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQ +V8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFp +myPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkK +mNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe +vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K +-----END CERTIFICATE----- + +DST Root CA X3 +============== +-----BEGIN CERTIFICATE----- +MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/MSQwIgYDVQQK +ExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMTDkRTVCBSb290IENBIFgzMB4X +DTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVowPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1 +cmUgVHJ1c3QgQ28uMRcwFQYDVQQDEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmT +rE4Orz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEqOLl5CjH9 +UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9bxiqKqy69cK3FCxolkHRy +xXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40d +utolucbY38EVAjqr2m7xPi71XAicPNaDaeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQ +MA0GCSqGSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69ikug +dB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXrAvHRAosZy5Q6XkjE +GB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZzR8srzJmwN0jP41ZL9c8PDHIyh8bw +RLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubS +fZGL+T0yjWW06XyxV3bqxbYoOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ +-----END CERTIFICATE----- + +SwissSign Gold CA - G2 +====================== +-----BEGIN CERTIFICATE----- +MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNIMRUw +EwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2lnbiBHb2xkIENBIC0gRzIwHhcN +MDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBFMQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dp +c3NTaWduIEFHMR8wHQYDVQQDExZTd2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUq +t2/876LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+bbqBHH5C +jCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c6bM8K8vzARO/Ws/BtQpg +vd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqEemA8atufK+ze3gE/bk3lUIbLtK/tREDF +ylqM2tIrfKjuvqblCqoOpd8FUrdVxyJdMmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvR +AiTysybUa9oEVeXBCsdtMDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuend +jIj3o02yMszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69yFGkO +peUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPiaG59je883WX0XaxR +7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxMgI93e2CaHt+28kgeDrpOVG2Y4OGi +GqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw +AwEB/zAdBgNVHQ4EFgQUWyV7lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64 +OfPAeGZe6Drn8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov +L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe645R88a7A3hfm +5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczOUYrHUDFu4Up+GC9pWbY9ZIEr +44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOf +Mke6UiI0HTJ6CVanfCU2qT1L2sCCbwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6m +Gu6uLftIdxf+u+yvGPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxp +mo/a77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCChdiDyyJk +vC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid392qgQmwLOM7XdVAyksLf +KzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEppLd6leNcG2mqeSz53OiATIgHQv2ieY2Br +NU0LbbqhPcCT4H8js1WtciVORvnSFu+wZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6Lqj +viOvrv1vA+ACOzB2+httQc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ +-----END CERTIFICATE----- + +SwissSign Silver CA - G2 +======================== +-----BEGIN CERTIFICATE----- +MIIFvTCCA6WgAwIBAgIITxvUL1S7L0swDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UEBhMCQ0gxFTAT +BgNVBAoTDFN3aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMB4X +DTA2MTAyNTA4MzI0NloXDTM2MTAyNTA4MzI0NlowRzELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3 +aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAxPGHf9N4Mfc4yfjDmUO8x/e8N+dOcbpLj6VzHVxumK4DV644 +N0MvFz0fyM5oEMF4rhkDKxD6LHmD9ui5aLlV8gREpzn5/ASLHvGiTSf5YXu6t+WiE7brYT7QbNHm ++/pe7R20nqA1W6GSy/BJkv6FCgU+5tkL4k+73JU3/JHpMjUi0R86TieFnbAVlDLaYQ1HTWBCrpJH +6INaUFjpiou5XaHc3ZlKHzZnu0jkg7Y360g6rw9njxcH6ATK72oxh9TAtvmUcXtnZLi2kUpCe2Uu +MGoM9ZDulebyzYLs2aFK7PayS+VFheZteJMELpyCbTapxDFkH4aDCyr0NQp4yVXPQbBH6TCfmb5h +qAaEuSh6XzjZG6k4sIN/c8HDO0gqgg8hm7jMqDXDhBuDsz6+pJVpATqJAHgE2cn0mRmrVn5bi4Y5 +FZGkECwJMoBgs5PAKrYYC51+jUnyEEp/+dVGLxmSo5mnJqy7jDzmDrxHB9xzUfFwZC8I+bRHHTBs +ROopN4WSaGa8gzj+ezku01DwH/teYLappvonQfGbGHLy9YR0SslnxFSuSGTfjNFusB3hB48IHpmc +celM2KX3RxIfdNFRnobzwqIjQAtz20um53MGjMGg6cFZrEb65i/4z3GcRm25xBWNOHkDRUjvxF3X +CO6HOSKGsg0PWEP3calILv3q1h8CAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUF6DNweRBtjpbO8tFnb0cwpj6hlgwHwYDVR0jBBgwFoAUF6DNweRB +tjpbO8tFnb0cwpj6hlgwRgYDVR0gBD8wPTA7BglghXQBWQEDAQEwLjAsBggrBgEFBQcCARYgaHR0 +cDovL3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBAHPGgeAn0i0P +4JUw4ppBf1AsX19iYamGamkYDHRJ1l2E6kFSGG9YrVBWIGrGvShpWJHckRE1qTodvBqlYJ7YH39F +kWnZfrt4csEGDyrOj4VwYaygzQu4OSlWhDJOhrs9xCrZ1x9y7v5RoSJBsXECYxqCsGKrXlcSH9/L +3XWgwF15kIwb4FDm3jH+mHtwX6WQ2K34ArZv02DdQEsixT2tOnqfGhpHkXkzuoLcMmkDlm4fS/Bx +/uNncqCxv1yL5PqZIseEuRuNI5c/7SXgz2W79WEE790eslpBIlqhn10s6FvJbakMDHiqYMZWjwFa +DGi8aRl5xB9+lwW/xekkUV7U1UtT7dkjWjYDZaPBA61BMPNGG4WQr2W11bHkFlt4dR2Xem1ZqSqP +e97Dh4kQmUlzeMg9vVE1dCrV8X5pGyq7O70luJpaPXJhkGaH7gzWTdQRdAtq/gsD/KNVV4n+Ssuu +WxcFyPKNIzFTONItaj+CuY0IavdeQXRuwxF+B6wpYJE/OMpXEA29MC/HpeZBoNquBYeaoKRlbEwJ +DIm6uNO5wJOKMPqN5ZprFQFOZ6raYlY+hAhm0sQ2fac+EPyI4NSA5QC9qvNOBqN6avlicuMJT+ub +DgEj8Z+7fNzcbBGXJbLytGMU0gYqZ4yD9c7qB9iaah7s5Aq7KkzrCWA5zspi2C5u +-----END CERTIFICATE----- + +SecureTrust CA +============== +-----BEGIN CERTIFICATE----- +MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBIMQswCQYDVQQG +EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xFzAVBgNVBAMTDlNlY3VyZVRy +dXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIzMTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAe +BgNVBAoTF1NlY3VyZVRydXN0IENvcnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQX +OZEzZum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO0gMdA+9t +DWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIaowW8xQmxSPmjL8xk037uH +GFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b +01k/unK8RCSc43Oz969XL0Imnal0ugBS8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmH +ursCAwEAAaOBnTCBmjATBgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCegJYYj +aHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ +KoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt36Z3q059c4EVlew3KW+JwULKUBRSu +SceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHf +mbx8IVQr5Fiiu1cprp6poxkmD5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZ +nMUFdAvnZyPSCPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR +3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= +-----END CERTIFICATE----- + +Secure Global CA +================ +-----BEGIN CERTIFICATE----- +MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQG +EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBH +bG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkxMjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEg +MB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwg +Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jx +YDiJiQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa/FHtaMbQ +bqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJjnIFHovdRIWCQtBJwB1g +8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnIHmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYV +HDGA76oYa8J719rO+TMg1fW9ajMtgQT7sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi +0XPnj3pDAgMBAAGjgZ0wgZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCswKaAn +oCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsGAQQBgjcVAQQDAgEA +MA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0LURYD7xh8yOOvaliTFGCRsoTciE6+ +OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXOH0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cn +CDpOGR86p1hcF895P4vkp9MmI50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/5 +3CYNv6ZHdAbYiNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc +f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW +-----END CERTIFICATE----- + +COMODO Certification Authority +============================== +-----BEGIN CERTIFICATE----- +MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCBgTELMAkGA1UE +BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG +A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNVBAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1 +dGhvcml0eTAeFw0wNjEyMDEwMDAwMDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEb +MBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFD +T01PRE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3UcEbVASY06m/weaKXTuH ++7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI2GqGd0S7WWaXUF601CxwRM/aN5VCaTww +xHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV +4EajcNxo2f8ESIl33rXp+2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA +1KGzqSX+DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5OnKVI +rLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW/zAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6gPKA6hjhodHRwOi8vY3JsLmNvbW9k +b2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOC +AQEAPpiem/Yb6dc5t3iuHXIYSdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CP +OGEIqB6BCsAvIC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/ +RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4zJVSk/BwJVmc +IGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5ddBA6+C4OmF4O5MBKgxTMVBbkN ++8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IBZQ== +-----END CERTIFICATE----- + +Network Solutions Certificate Authority +======================================= +-----BEGIN CERTIFICATE----- +MIID5jCCAs6gAwIBAgIQV8szb8JcFuZHFhfjkDFo4DANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQG +EwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMuMTAwLgYDVQQDEydOZXR3b3Jr +IFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMDYxMjAxMDAwMDAwWhcNMjkxMjMx +MjM1OTU5WjBiMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMu +MTAwLgYDVQQDEydOZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkvH6SMG3G2I4rC7xGzuAnlt7e+foS0zwzc7MEL7xx +jOWftiJgPl9dzgn/ggwbmlFQGiaJ3dVhXRncEg8tCqJDXRfQNJIg6nPPOCwGJgl6cvf6UDL4wpPT +aaIjzkGxzOTVHzbRijr4jGPiFFlp7Q3Tf2vouAPlT2rlmGNpSAW+Lv8ztumXWWn4Zxmuk2GWRBXT +crA/vGp97Eh/jcOrqnErU2lBUzS1sLnFBgrEsEX1QV1uiUV7PTsmjHTC5dLRfbIR1PtYMiKagMnc +/Qzpf14Dl847ABSHJ3A4qY5usyd2mFHgBeMhqxrVhSI8KbWaFsWAqPS7azCPL0YCorEMIuDTAgMB +AAGjgZcwgZQwHQYDVR0OBBYEFCEwyfsA106Y2oeqKtCnLrFAMadMMA4GA1UdDwEB/wQEAwIBBjAP +BgNVHRMBAf8EBTADAQH/MFIGA1UdHwRLMEkwR6BFoEOGQWh0dHA6Ly9jcmwubmV0c29sc3NsLmNv +bS9OZXR3b3JrU29sdXRpb25zQ2VydGlmaWNhdGVBdXRob3JpdHkuY3JsMA0GCSqGSIb3DQEBBQUA +A4IBAQC7rkvnt1frf6ott3NHhWrB5KUd5Oc86fRZZXe1eltajSU24HqXLjjAV2CDmAaDn7l2em5Q +4LqILPxFzBiwmZVRDuwduIj/h1AcgsLj4DKAv6ALR8jDMe+ZZzKATxcheQxpXN5eNK4CtSbqUN9/ +GGUsyfJj4akH/nxxH2szJGoeBfcFaMBqEssuXmHLrijTfsK0ZpEmXzwuJF/LWA/rKOyvEZbz3Htv +wKeI8lN3s2Berq4o2jUsbzRF0ybh3uxbTydrFny9RAQYgrOJeRcQcT16ohZO9QHNpGxlaKFJdlxD +ydi8NmdspZS11My5vWo1ViHe2MPr+8ukYEywVaCge1ey +-----END CERTIFICATE----- + +COMODO ECC Certification Authority +================================== +-----BEGIN CERTIFICATE----- +MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTELMAkGA1UEBhMC +R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE +ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwHhcNMDgwMzA2MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0Ix +GzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR +Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRo +b3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSRFtSrYpn1PlILBs5BAH+X +4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0JcfRK9ChQtP6IHG4/bC8vCVlbpVsLM5ni +wz2J+Wos77LTBumjQjBAMB0GA1UdDgQWBBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VG +FAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeA +U/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= +-----END CERTIFICATE----- + +Certigna +======== +-----BEGIN CERTIFICATE----- +MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNVBAYTAkZSMRIw +EAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4XDTA3MDYyOTE1MTMwNVoXDTI3 +MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwI +Q2VydGlnbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7q +XOEm7RFHYeGifBZ4QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyH +GxnygQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbwzBfsV1/p +ogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q130yGLMLLGq/jj8UEYkg +DncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKf +Irjxwo1p3Po6WAbfAgMBAAGjgbwwgbkwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQ +tCRZvgHyUtVF9lo53BEwZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJ +BgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzjAQ/J +SP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG9w0BAQUFAAOCAQEA +hQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8hbV6lUmPOEvjvKtpv6zf+EwLHyzs+ +ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFncfca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1klu +PBS1xp81HlDQwY9qcEQCYsuuHWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY +1gkIl2PlwS6wt0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw +WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg== +-----END CERTIFICATE----- + +Cybertrust Global Root +====================== +-----BEGIN CERTIFICATE----- +MIIDoTCCAomgAwIBAgILBAAAAAABD4WqLUgwDQYJKoZIhvcNAQEFBQAwOzEYMBYGA1UEChMPQ3li +ZXJ0cnVzdCwgSW5jMR8wHQYDVQQDExZDeWJlcnRydXN0IEdsb2JhbCBSb290MB4XDTA2MTIxNTA4 +MDAwMFoXDTIxMTIxNTA4MDAwMFowOzEYMBYGA1UEChMPQ3liZXJ0cnVzdCwgSW5jMR8wHQYDVQQD +ExZDeWJlcnRydXN0IEdsb2JhbCBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA ++Mi8vRRQZhP/8NN57CPytxrHjoXxEnOmGaoQ25yiZXRadz5RfVb23CO21O1fWLE3TdVJDm71aofW +0ozSJ8bi/zafmGWgE07GKmSb1ZASzxQG9Dvj1Ci+6A74q05IlG2OlTEQXO2iLb3VOm2yHLtgwEZL +AfVJrn5GitB0jaEMAs7u/OePuGtm839EAL9mJRQr3RAwHQeWP032a7iPt3sMpTjr3kfb1V05/Iin +89cqdPHoWqI7n1C6poxFNcJQZZXcY4Lv3b93TZxiyWNzFtApD0mpSPCzqrdsxacwOUBdrsTiXSZT +8M4cIwhhqJQZugRiQOwfOHB3EgZxpzAYXSUnpQIDAQABo4GlMIGiMA4GA1UdDwEB/wQEAwIBBjAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS2CHsNesysIEyGVjJez6tuhS1wVzA/BgNVHR8EODA2 +MDSgMqAwhi5odHRwOi8vd3d3Mi5wdWJsaWMtdHJ1c3QuY29tL2NybC9jdC9jdHJvb3QuY3JsMB8G +A1UdIwQYMBaAFLYIew16zKwgTIZWMl7Pq26FLXBXMA0GCSqGSIb3DQEBBQUAA4IBAQBW7wojoFRO +lZfJ+InaRcHUowAl9B8Tq7ejhVhpwjCt2BWKLePJzYFa+HMjWqd8BfP9IjsO0QbE2zZMcwSO5bAi +5MXzLqXZI+O4Tkogp24CJJ8iYGd7ix1yCcUxXOl5n4BHPa2hCwcUPUf/A2kaDAtE52Mlp3+yybh2 +hO0j9n0Hq0V+09+zv+mKts2oomcrUtW3ZfA5TGOgkXmTUg9U3YO7n9GPp1Nzw8v/MOx8BLjYRB+T +X3EJIrduPuocA06dGiBh+4E37F78CkWr1+cXVdCg6mCbpvbjjFspwgZgFJ0tl0ypkxWdYcQBX0jW +WL1WMRJOEcgh4LMRkWXbtKaIOM5V +-----END CERTIFICATE----- + +ePKI Root Certification Authority +================================= +-----BEGIN CERTIFICATE----- +MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBeMQswCQYDVQQG +EwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0ZC4xKjAoBgNVBAsMIWVQS0kg +Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMx +MjdaMF4xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEq +MCgGA1UECwwhZVBLSSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAHSyZbCUNs +IZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAhijHyl3SJCRImHJ7K2RKi +lTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3XDZoTM1PRYfl61dd4s5oz9wCGzh1NlDiv +qOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX +12ruOzjjK9SXDrkb5wdJfzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0O +WQqraffAsgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uUWH1+ +ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLSnT0IFaUQAS2zMnao +lQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pHdmX2Os+PYhcZewoozRrSgx4hxyy/ +vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJipNiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXi +Zo1jDiVN1Rmy5nk3pyKdVDECAwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/Qkqi +MAwGA1UdEwQFMAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH +ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGBuvl2ICO1J2B0 +1GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6YlPwZpVnPDimZI+ymBV3QGypzq +KOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkPJXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdV +xrsStZf0X4OFunHB2WyBEXYKCrC/gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEP +NXubrjlpC2JgQCA2j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+r +GNm65ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUBo2M3IUxE +xJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS/jQ6fbjpKdx2qcgw+BRx +gMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2zGp1iro2C6pSe3VkQw63d4k3jMdXH7Ojy +sP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTEW9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmOD +BCEIZ43ygknQW/2xzQ+DhNQ+IIX3Sj0rnP0qCglN6oH4EZw= +-----END CERTIFICATE----- + +certSIGN ROOT CA +================ +-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYTAlJPMREwDwYD +VQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTAeFw0wNjA3MDQxNzIwMDRa +Fw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UE +CxMQY2VydFNJR04gUk9PVCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7I +JUqOtdu0KBuqV5Do0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHH +rfAQUySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5dRdY4zTW2 +ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQOA7+j0xbm0bqQfWwCHTD +0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwvJoIQ4uNllAoEwF73XVv4EOLQunpL+943 +AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B +Af8EBAMCAcYwHQYDVR0OBBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IB +AQA+0hyJLjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecYMnQ8 +SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ44gx+FkagQnIl6Z0 +x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6IJd1hJyMctTEHBDa0GpC9oHRxUIlt +vBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNwi/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7Nz +TogVZ96edhBiIL5VaZVDADlN9u6wWk5JRFRYX0KD +-----END CERTIFICATE----- + +GeoTrust Primary Certification Authority - G2 +============================================= +-----BEGIN CERTIFICATE----- +MIICrjCCAjWgAwIBAgIQPLL0SAoA4v7rJDteYD7DazAKBggqhkjOPQQDAzCBmDELMAkGA1UEBhMC +VVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsTMChjKSAyMDA3IEdlb1RydXN0IElu +Yy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTE2MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFyeSBD +ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMB4XDTA3MTEwNTAwMDAwMFoXDTM4MDExODIzNTk1 +OVowgZgxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAoYykg +MjAwNyBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0BgNVBAMTLUdl +b1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBHMjB2MBAGByqGSM49AgEG +BSuBBAAiA2IABBWx6P0DFUPlrOuHNxFi79KDNlJ9RVcLSo17VDs6bl8VAsBQps8lL33KSLjHUGMc +KiEIfJo22Av+0SbFWDEwKCXzXV2juLaltJLtbCyf691DiaI8S0iRHVDsJt/WYC69IaNCMEAwDwYD +VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBVfNVdRVfslsq0DafwBo/q+ +EVXVMAoGCCqGSM49BAMDA2cAMGQCMGSWWaboCd6LuvpaiIjwH5HTRqjySkwCY/tsXzjbLkGTqQ7m +ndwxHLKgpxgceeHHNgIwOlavmnRs9vuD4DPTCF+hnMJbn0bWtsuRBmOiBuczrD6ogRLQy7rQkgu2 +npaqBA+K +-----END CERTIFICATE----- + +VeriSign Universal Root Certification Authority +=============================================== +-----BEGIN CERTIFICATE----- +MIIEuTCCA6GgAwIBAgIQQBrEZCGzEyEDDrvkEhrFHTANBgkqhkiG9w0BAQsFADCBvTELMAkGA1UE +BhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBO +ZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwOCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVk +IHVzZSBvbmx5MTgwNgYDVQQDEy9WZXJpU2lnbiBVbml2ZXJzYWwgUm9vdCBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTAeFw0wODA0MDIwMDAwMDBaFw0zNzEyMDEyMzU5NTlaMIG9MQswCQYDVQQGEwJV +UzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlTaWduIFRydXN0IE5ldHdv +cmsxOjA4BgNVBAsTMShjKSAyMDA4IFZlcmlTaWduLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNl +IG9ubHkxODA2BgNVBAMTL1ZlcmlTaWduIFVuaXZlcnNhbCBSb290IENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx2E3XrEBNNti1xWb/1hajCMj +1mCOkdeQmIN65lgZOIzF9uVkhbSicfvtvbnazU0AtMgtc6XHaXGVHzk8skQHnOgO+k1KxCHfKWGP +MiJhgsWHH26MfF8WIFFE0XBPV+rjHOPMee5Y2A7Cs0WTwCznmhcrewA3ekEzeOEz4vMQGn+HLL72 +9fdC4uW/h2KJXwBL38Xd5HVEMkE6HnFuacsLdUYI0crSK5XQz/u5QGtkjFdN/BMReYTtXlT2NJ8I +AfMQJQYXStrxHXpma5hgZqTZ79IugvHw7wnqRMkVauIDbjPTrJ9VAMf2CGqUuV/c4DPxhGD5WycR +tPwW8rtWaoAljQIDAQABo4GyMIGvMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMG0G +CCsGAQUFBwEMBGEwX6FdoFswWTBXMFUWCWltYWdlL2dpZjAhMB8wBwYFKw4DAhoEFI/l0xqGrI2O +a8PPgGrUSBgsexkuMCUWI2h0dHA6Ly9sb2dvLnZlcmlzaWduLmNvbS92c2xvZ28uZ2lmMB0GA1Ud +DgQWBBS2d/ppSEefUxLVwuoHMnYH0ZcHGTANBgkqhkiG9w0BAQsFAAOCAQEASvj4sAPmLGd75JR3 +Y8xuTPl9Dg3cyLk1uXBPY/ok+myDjEedO2Pzmvl2MpWRsXe8rJq+seQxIcaBlVZaDrHC1LGmWazx +Y8u4TB1ZkErvkBYoH1quEPuBUDgMbMzxPcP1Y+Oz4yHJJDnp/RVmRvQbEdBNc6N9Rvk97ahfYtTx +P/jgdFcrGJ2BtMQo2pSXpXDrrB2+BxHw1dvd5Yzw1TKwg+ZX4o+/vqGqvz0dtdQ46tewXDpPaj+P +wGZsY6rp2aQW9IHRlRQOfc2VNNnSj3BzgXucfr2YYdhFh5iQxeuGMMY1v/D/w1WIg0vvBZIGcfK4 +mJO37M2CYfE45k+XmCpajQ== +-----END CERTIFICATE----- + +NetLock Arany (Class Gold) Főtanúsítvány +======================================== +-----BEGIN CERTIFICATE----- +MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQGEwJIVTERMA8G +A1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3MDUGA1UECwwuVGFuw7pzw610 +dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBB +cmFueSAoQ2xhc3MgR29sZCkgRsWRdGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgx +MjA2MTUwODIxWjCBpzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxO +ZXRMb2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlmaWNhdGlv +biBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNzIEdvbGQpIEbFkXRhbsO6 +c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCRec75LbRTDofTjl5Bu +0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrTlF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw +/HpYzY6b7cNGbIRwXdrzAZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAk +H3B5r9s5VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRGILdw +fzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2BJtr+UBdADTHLpl1 +neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2MU9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwW +qZw8UQCgwBEIBaeZ5m8BiFRhbvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTta +YtOUZcTh5m2C+C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC +bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2FuLjbvrW5Kfna +NwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2XjG4Kvte9nHfRCaexOYNkbQu +dZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= +-----END CERTIFICATE----- + +Hongkong Post Root CA 1 +======================= +-----BEGIN CERTIFICATE----- +MIIDMDCCAhigAwIBAgICA+gwDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UEBhMCSEsxFjAUBgNVBAoT +DUhvbmdrb25nIFBvc3QxIDAeBgNVBAMTF0hvbmdrb25nIFBvc3QgUm9vdCBDQSAxMB4XDTAzMDUx +NTA1MTMxNFoXDTIzMDUxNTA0NTIyOVowRzELMAkGA1UEBhMCSEsxFjAUBgNVBAoTDUhvbmdrb25n +IFBvc3QxIDAeBgNVBAMTF0hvbmdrb25nIFBvc3QgUm9vdCBDQSAxMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEArP84tulmAknjorThkPlAj3n54r15/gK97iSSHSL22oVyaf7XPwnU3ZG1 +ApzQjVrhVcNQhrkpJsLj2aDxaQMoIIBFIi1WpztUlVYiWR8o3x8gPW2iNr4joLFutbEnPzlTCeqr +auh0ssJlXI6/fMN4hM2eFvz1Lk8gKgifd/PFHsSaUmYeSF7jEAaPIpjhZY4bXSNmO7ilMlHIhqqh +qZ5/dpTCpmy3QfDVyAY45tQM4vM7TG1QjMSDJ8EThFk9nnV0ttgCXjqQesBCNnLsak3c78QA3xMY +V18meMjWCnl3v/evt3a5pQuEF10Q6m/hq5URX208o1xNg1vysxmKgIsLhwIDAQABoyYwJDASBgNV +HRMBAf8ECDAGAQH/AgEDMA4GA1UdDwEB/wQEAwIBxjANBgkqhkiG9w0BAQUFAAOCAQEADkbVPK7i +h9legYsCmEEIjEy82tvuJxuC52pF7BaLT4Wg87JwvVqWuspube5Gi27nKi6Wsxkz67SfqLI37pio +l7Yutmcn1KZJ/RyTZXaeQi/cImyaT/JaFTmxcdcrUehtHJjA2Sr0oYJ71clBoiMBdDhViw+5Lmei +IAQ32pwL0xch4I+XeTRvhEgCIDMb5jREn5Fw9IBehEPCKdJsEhTkYY2sEJCehFC78JZvRZ+K88ps +T/oROhUVRsPNH4NbLUES7VBnQRM9IauUiqpOfMGx+6fWtScvl6tu4B3i0RwsH0Ti/L6RoZz71ilT +c4afU9hDDl3WY4JxHYB0yvbiAmvZWg== +-----END CERTIFICATE----- + +SecureSign RootCA11 +=================== +-----BEGIN CERTIFICATE----- +MIIDbTCCAlWgAwIBAgIBATANBgkqhkiG9w0BAQUFADBYMQswCQYDVQQGEwJKUDErMCkGA1UEChMi +SmFwYW4gQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcywgSW5jLjEcMBoGA1UEAxMTU2VjdXJlU2lnbiBS +b290Q0ExMTAeFw0wOTA0MDgwNDU2NDdaFw0yOTA0MDgwNDU2NDdaMFgxCzAJBgNVBAYTAkpQMSsw +KQYDVQQKEyJKYXBhbiBDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzLCBJbmMuMRwwGgYDVQQDExNTZWN1 +cmVTaWduIFJvb3RDQTExMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA/XeqpRyQBTvL +TJszi1oURaTnkBbR31fSIRCkF/3frNYfp+TbfPfs37gD2pRY/V1yfIw/XwFndBWW4wI8h9uuywGO +wvNmxoVF9ALGOrVisq/6nL+k5tSAMJjzDbaTj6nU2DbysPyKyiyhFTOVMdrAG/LuYpmGYz+/3ZMq +g6h2uRMft85OQoWPIucuGvKVCbIFtUROd6EgvanyTgp9UK31BQ1FT0Zx/Sg+U/sE2C3XZR1KG/rP +O7AxmjVuyIsG0wCR8pQIZUyxNAYAeoni8McDWc/V1uinMrPmmECGxc0nEovMe863ETxiYAcjPitA +bpSACW22s293bzUIUPsCh8U+iQIDAQABo0IwQDAdBgNVHQ4EFgQUW/hNT7KlhtQ60vFjmqC+CfZX +t94wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAKCh +OBZmLqdWHyGcBvod7bkixTgm2E5P7KN/ed5GIaGHd48HCJqypMWvDzKYC3xmKbabfSVSSUOrTC4r +bnpwrxYO4wJs+0LmGJ1F2FXI6Dvd5+H0LgscNFxsWEr7jIhQX5Ucv+2rIrVls4W6ng+4reV6G4pQ +Oh29Dbx7VFALuUKvVaAYga1lme++5Jy/xIWrQbJUb9wlze144o4MjQlJ3WN7WmmWAiGovVJZ6X01 +y8hSyn+B/tlr0/cR7SXf+Of5pPpyl4RTDaXQMhhRdlkUbA/r7F+AjHVDg8OFmP9Mni0N5HeDk061 +lgeLKBObjBmNQSdJQO7e5iNEOdyhIta6A/I= +-----END CERTIFICATE----- + +Microsec e-Szigno Root CA 2009 +============================== +-----BEGIN CERTIFICATE----- +MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYDVQQGEwJIVTER +MA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jv +c2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o +dTAeFw0wOTA2MTYxMTMwMThaFw0yOTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UE +BwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUt +U3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvPkd6mJviZpWNwrZuuyjNA +fW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tccbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG +0IMZfcChEhyVbUr02MelTTMuhTlAdX4UfIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKA +pxn1ntxVUwOXewdI/5n7N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm +1HxdrtbCxkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1+rUC +AwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTLD8bf +QkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAbBgNVHREE +FDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqGSIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0o +lZMEyL/azXm4Q5DwpL7v8u8hmLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfX +I/OMn74dseGkddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775 +tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c2Pm2G2JwCz02 +yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5tHMN1Rq41Bab2XD0h7lbwyYIi +LXpUq3DDfSJlgnCW +-----END CERTIFICATE----- + +GlobalSign Root CA - R3 +======================= +-----BEGIN CERTIFICATE----- +MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xv +YmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh +bFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT +aWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln +bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWt +iHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ +0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3 +rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjl +OCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2 +xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7 +lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8 +EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1E +bddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18 +YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7r +kpeDMdmztcpHWD9f +-----END CERTIFICATE----- + +Autoridad de Certificacion Firmaprofesional CIF A62634068 +========================================================= +-----BEGIN CERTIFICATE----- +MIIGFDCCA/ygAwIBAgIIU+w77vuySF8wDQYJKoZIhvcNAQEFBQAwUTELMAkGA1UEBhMCRVMxQjBA +BgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1hcHJvZmVzaW9uYWwgQ0lGIEE2 +MjYzNDA2ODAeFw0wOTA1MjAwODM4MTVaFw0zMDEyMzEwODM4MTVaMFExCzAJBgNVBAYTAkVTMUIw +QAYDVQQDDDlBdXRvcmlkYWQgZGUgQ2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBB +NjI2MzQwNjgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDD +Utd9thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQMcas9UX4P +B99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefGL9ItWY16Ck6WaVICqjaY +7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15iNA9wBj4gGFrO93IbJWyTdBSTo3OxDqqH +ECNZXyAFGUftaI6SEspd/NYrspI8IM/hX68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyI +plD9amML9ZMWGxmPsu2bm8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctX +MbScyJCyZ/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirjaEbsX +LZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/TKI8xWVvTyQKmtFLK +bpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF6NkBiDkal4ZkQdU7hwxu+g/GvUgU +vzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVhOSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMBIGA1Ud +EwEB/wQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRlzeurNR4APn7VdMActHNH +DhpkLzCBpgYDVR0gBIGeMIGbMIGYBgRVHSAAMIGPMC8GCCsGAQUFBwIBFiNodHRwOi8vd3d3LmZp +cm1hcHJvZmVzaW9uYWwuY29tL2NwczBcBggrBgEFBQcCAjBQHk4AUABhAHMAZQBvACAAZABlACAA +bABhACAAQgBvAG4AYQBuAG8AdgBhACAANAA3ACAAQgBhAHIAYwBlAGwAbwBuAGEAIAAwADgAMAAx +ADcwDQYJKoZIhvcNAQEFBQADggIBABd9oPm03cXF661LJLWhAqvdpYhKsg9VSytXjDvlMd3+xDLx +51tkljYyGOylMnfX40S2wBEqgLk9am58m9Ot/MPWo+ZkKXzR4Tgegiv/J2Wv+xYVxC5xhOW1//qk +R71kMrv2JYSiJ0L1ILDCExARzRAVukKQKtJE4ZYm6zFIEv0q2skGz3QeqUvVhyj5eTSSPi5E6PaP +T481PyWzOdxjKpBrIF/EUhJOlywqrJ2X3kjyo2bbwtKDlaZmp54lD+kLM5FlClrD2VQS3a/DTg4f +Jl4N3LON7NWBcN7STyQF82xO9UxJZo3R/9ILJUFI/lGExkKvgATP0H5kSeTy36LssUzAKh3ntLFl +osS88Zj0qnAHY7S42jtM+kAiMFsRpvAFDsYCA0irhpuF3dvd6qJ2gHN99ZwExEWN57kci57q13XR +crHedUTnQn3iV2t93Jm8PYMo6oCTjcVMZcFwgbg4/EMxsvYDNEeyrPsiBsse3RdHHF9mudMaotoR +saS8I8nkvof/uZS2+F0gStRf571oe2XyFR7SOqkt6dhrJKyXWERHrVkY8SFlcN7ONGCoQPHzPKTD +KCOM/iczQ0CgFzzr6juwcqajuUpLXhZI9LK8yIySxZ2frHI2vDSANGupi5LAuBft7HZT9SQBjLMi +6Et8Vcad+qMUu2WFbm5PEn4KPJ2V +-----END CERTIFICATE----- + +Izenpe.com +========== +-----BEGIN CERTIFICATE----- +MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4MQswCQYDVQQG +EwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wHhcNMDcxMjEz +MTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMu +QS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ +03rKDx6sp4boFmVqscIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAK +ClaOxdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6HLmYRY2xU ++zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFXuaOKmMPsOzTFlUFpfnXC +PCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQDyCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxT +OTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbK +F7jJeodWLBoBHmy+E60QrLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK +0GqfvEyNBjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8Lhij+ +0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIBQFqNeb+Lz0vPqhbB +leStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+HMh3/1uaD7euBUbl8agW7EekFwID +AQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2luZm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+ +SVpFTlBFIFMuQS4gLSBDSUYgQTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBG +NjIgUzgxQzBBBgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx +MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O +BBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUAA4ICAQB4pgwWSp9MiDrAyw6l +Fn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWblaQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbga +kEyrkgPH7UIBzg/YsfqikuFgba56awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8q +hT/AQKM6WfxZSzwoJNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Cs +g1lwLDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCTVyvehQP5 +aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGkLhObNA5me0mrZJfQRsN5 +nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJbUjWumDqtujWTI6cfSN01RpiyEGjkpTHC +ClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZo +Q0iy2+tzJOeRf1SktoA+naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1Z +WrOZyGlsQyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw== +-----END CERTIFICATE----- + +Chambers of Commerce Root - 2008 +================================ +-----BEGIN CERTIFICATE----- +MIIHTzCCBTegAwIBAgIJAKPaQn6ksa7aMA0GCSqGSIb3DQEBBQUAMIGuMQswCQYDVQQGEwJFVTFD +MEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNv +bS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMu +QS4xKTAnBgNVBAMTIENoYW1iZXJzIG9mIENvbW1lcmNlIFJvb3QgLSAyMDA4MB4XDTA4MDgwMTEy +Mjk1MFoXDTM4MDczMTEyMjk1MFowga4xCzAJBgNVBAYTAkVVMUMwQQYDVQQHEzpNYWRyaWQgKHNl +ZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVyZmlybWEuY29tL2FkZHJlc3MpMRIwEAYDVQQF +EwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENhbWVyZmlybWEgUy5BLjEpMCcGA1UEAxMgQ2hhbWJl +cnMgb2YgQ29tbWVyY2UgUm9vdCAtIDIwMDgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC +AQCvAMtwNyuAWko6bHiUfaN/Gh/2NdW928sNRHI+JrKQUrpjOyhYb6WzbZSm891kDFX29ufyIiKA +XuFixrYp4YFs8r/lfTJqVKAyGVn+H4vXPWCGhSRv4xGzdz4gljUha7MI2XAuZPeEklPWDrCQiorj +h40G072QDuKZoRuGDtqaCrsLYVAGUvGef3bsyw/QHg3PmTA9HMRFEFis1tPo1+XqxQEHd9ZR5gN/ +ikilTWh1uem8nk4ZcfUyS5xtYBkL+8ydddy/Js2Pk3g5eXNeJQ7KXOt3EgfLZEFHcpOrUMPrCXZk +NNI5t3YRCQ12RcSprj1qr7V9ZS+UWBDsXHyvfuK2GNnQm05aSd+pZgvMPMZ4fKecHePOjlO+Bd5g +D2vlGts/4+EhySnB8esHnFIbAURRPHsl18TlUlRdJQfKFiC4reRB7noI/plvg6aRArBsNlVq5331 +lubKgdaX8ZSD6e2wsWsSaR6s+12pxZjptFtYer49okQ6Y1nUCyXeG0+95QGezdIp1Z8XGQpvvwyQ +0wlf2eOKNcx5Wk0ZN5K3xMGtr/R5JJqyAQuxr1yW84Ay+1w9mPGgP0revq+ULtlVmhduYJ1jbLhj +ya6BXBg14JC7vjxPNyK5fuvPnnchpj04gftI2jE9K+OJ9dC1vX7gUMQSibMjmhAxhduub+84Mxh2 +EQIDAQABo4IBbDCCAWgwEgYDVR0TAQH/BAgwBgEB/wIBDDAdBgNVHQ4EFgQU+SSsD7K1+HnA+mCI +G8TZTQKeFxkwgeMGA1UdIwSB2zCB2IAU+SSsD7K1+HnA+mCIG8TZTQKeFxmhgbSkgbEwga4xCzAJ +BgNVBAYTAkVVMUMwQQYDVQQHEzpNYWRyaWQgKHNlZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNh +bWVyZmlybWEuY29tL2FkZHJlc3MpMRIwEAYDVQQFEwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENh +bWVyZmlybWEgUy5BLjEpMCcGA1UEAxMgQ2hhbWJlcnMgb2YgQ29tbWVyY2UgUm9vdCAtIDIwMDiC +CQCj2kJ+pLGu2jAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUH +AgEWHGh0dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20wDQYJKoZIhvcNAQEFBQADggIBAJASryI1 +wqM58C7e6bXpeHxIvj99RZJe6dqxGfwWPJ+0W2aeaufDuV2I6A+tzyMP3iU6XsxPpcG1Lawk0lgH +3qLPaYRgM+gQDROpI9CF5Y57pp49chNyM/WqfcZjHwj0/gF/JM8rLFQJ3uIrbZLGOU8W6jx+ekbU +RWpGqOt1glanq6B8aBMz9p0w8G8nOSQjKpD9kCk18pPfNKXG9/jvjA9iSnyu0/VU+I22mlaHFoI6 +M6taIgj3grrqLuBHmrS1RaMFO9ncLkVAO+rcf+g769HsJtg1pDDFOqxXnrN2pSB7+R5KBWIBpih1 +YJeSDW4+TTdDDZIVnBgizVGZoCkaPF+KMjNbMMeJL0eYD6MDxvbxrN8y8NmBGuScvfaAFPDRLLmF +9dijscilIeUcE5fuDr3fKanvNFNb0+RqE4QGtjICxFKuItLcsiFCGtpA8CnJ7AoMXOLQusxI0zcK +zBIKinmwPQN/aUv0NCB9szTqjktk9T79syNnFQ0EuPAtwQlRPLJsFfClI9eDdOTlLsn+mCdCxqvG +nrDQWzilm1DefhiYtUU79nm06PcaewaD+9CL2rvHvRirCG88gGtAPxkZumWK5r7VXNM21+9AUiRg +OGcEMeyP84LG3rlV8zsxkVrctQgVrXYlCg17LofiDKYGvCYQbTed7N14jHyAxfDZd0jQ +-----END CERTIFICATE----- + +Global Chambersign Root - 2008 +============================== +-----BEGIN CERTIFICATE----- +MIIHSTCCBTGgAwIBAgIJAMnN0+nVfSPOMA0GCSqGSIb3DQEBBQUAMIGsMQswCQYDVQQGEwJFVTFD +MEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNv +bS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMu +QS4xJzAlBgNVBAMTHkdsb2JhbCBDaGFtYmVyc2lnbiBSb290IC0gMjAwODAeFw0wODA4MDExMjMx +NDBaFw0zODA3MzExMjMxNDBaMIGsMQswCQYDVQQGEwJFVTFDMEEGA1UEBxM6TWFkcmlkIChzZWUg +Y3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNvbS9hZGRyZXNzKTESMBAGA1UEBRMJ +QTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMuQS4xJzAlBgNVBAMTHkdsb2JhbCBD +aGFtYmVyc2lnbiBSb290IC0gMjAwODCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMDf +VtPkOpt2RbQT2//BthmLN0EYlVJH6xedKYiONWwGMi5HYvNJBL99RDaxccy9Wglz1dmFRP+RVyXf +XjaOcNFccUMd2drvXNL7G706tcuto8xEpw2uIRU/uXpbknXYpBI4iRmKt4DS4jJvVpyR1ogQC7N0 +ZJJ0YPP2zxhPYLIj0Mc7zmFLmY/CDNBAspjcDahOo7kKrmCgrUVSY7pmvWjg+b4aqIG7HkF4ddPB +/gBVsIdU6CeQNR1MM62X/JcumIS/LMmjv9GYERTtY/jKmIhYF5ntRQOXfjyGHoiMvvKRhI9lNNgA +TH23MRdaKXoKGCQwoze1eqkBfSbW+Q6OWfH9GzO1KTsXO0G2Id3UwD2ln58fQ1DJu7xsepeY7s2M +H/ucUa6LcL0nn3HAa6x9kGbo1106DbDVwo3VyJ2dwW3Q0L9R5OP4wzg2rtandeavhENdk5IMagfe +Ox2YItaswTXbo6Al/3K1dh3ebeksZixShNBFks4c5eUzHdwHU1SjqoI7mjcv3N2gZOnm3b2u/GSF +HTynyQbehP9r6GsaPMWis0L7iwk+XwhSx2LE1AVxv8Rk5Pihg+g+EpuoHtQ2TS9x9o0o9oOpE9Jh +wZG7SMA0j0GMS0zbaRL/UJScIINZc+18ofLx/d33SdNDWKBWY8o9PeU1VlnpDsogzCtLkykPAgMB +AAGjggFqMIIBZjASBgNVHRMBAf8ECDAGAQH/AgEMMB0GA1UdDgQWBBS5CcqcHtvTbDprru1U8VuT +BjUuXjCB4QYDVR0jBIHZMIHWgBS5CcqcHtvTbDprru1U8VuTBjUuXqGBsqSBrzCBrDELMAkGA1UE +BhMCRVUxQzBBBgNVBAcTOk1hZHJpZCAoc2VlIGN1cnJlbnQgYWRkcmVzcyBhdCB3d3cuY2FtZXJm +aXJtYS5jb20vYWRkcmVzcykxEjAQBgNVBAUTCUE4Mjc0MzI4NzEbMBkGA1UEChMSQUMgQ2FtZXJm +aXJtYSBTLkEuMScwJQYDVQQDEx5HbG9iYWwgQ2hhbWJlcnNpZ24gUm9vdCAtIDIwMDiCCQDJzdPp +1X0jzjAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0 +dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20wDQYJKoZIhvcNAQEFBQADggIBAICIf3DekijZBZRG +/5BXqfEv3xoNa/p8DhxJJHkn2EaqbylZUohwEurdPfWbU1Rv4WCiqAm57OtZfMY18dwY6fFn5a+6 +ReAJ3spED8IXDneRRXozX1+WLGiLwUePmJs9wOzL9dWCkoQ10b42OFZyMVtHLaoXpGNR6woBrX/s +dZ7LoR/xfxKxueRkf2fWIyr0uDldmOghp+G9PUIadJpwr2hsUF1Jz//7Dl3mLEfXgTpZALVza2Mg +9jFFCDkO9HB+QHBaP9BrQql0PSgvAm11cpUJjUhjxsYjV5KTXjXBjfkK9yydYhz2rXzdpjEetrHH +foUm+qRqtdpjMNHvkzeyZi99Bffnt0uYlDXA2TopwZ2yUDMdSqlapskD7+3056huirRXhOukP9Du +qqqHW2Pok+JrqNS4cnhrG+055F3Lm6qH1U9OAP7Zap88MQ8oAgF9mOinsKJknnn4SPIVqczmyETr +P3iZ8ntxPjzxmKfFGBI/5rsoM0LpRQp8bfKGeS/Fghl9CYl8slR2iK7ewfPM4W7bMdaTrpmg7yVq +c5iJWzouE4gev8CSlDQb4ye3ix5vQv/n6TebUB0tovkC7stYWDpxvGjjqsGvHCgfotwjZT+B6q6Z +09gwzxMNTxXJhLynSC34MCN32EZLeW32jO06f2ARePTpm67VVMB0gNELQp/B +-----END CERTIFICATE----- + +Go Daddy Root Certificate Authority - G2 +======================================== +-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMu +MTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 +MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 +b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8G +A1UEAxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq +9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD ++qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutd +fMh8+7ArU6SSYmlRJQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMl +NAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNC +MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqahQcQZyi27/a9 +BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096ac +vNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r +5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYV +N8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO +LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1 +-----END CERTIFICATE----- + +Starfield Root Certificate Authority - G2 +========================================= +-----BEGIN CERTIFICATE----- +MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s +b2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0 +eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAw +DgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQg +VGVjaG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBB +dXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3twQP89o/8ArFv +W59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdhl+72WoTs +bhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNk +N3mSwOxGXn/hbVNMYq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7Nf +ZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbU +JtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfol +TwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx +4mcujJUDJi5DnUox9g61DLu34jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUw +F5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K +pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZ +c2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 +-----END CERTIFICATE----- + +Starfield Services Root Certificate Authority - G2 +================================================== +-----BEGIN CERTIFICATE----- +MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s +b2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRl +IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNV +BAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxT +dGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2VydmljZXMg +Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20pOsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2 +h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm28xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4Pa +hHQUw2eeBGg6345AWh1KTs9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLP +LJGmpufehRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk6mFB +rMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMA0GCSqG +SIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMIbw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPP +E95Dz+I0swSdHynVv/heyNXBve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTy +xQGjhdByPq1zqwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd +iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jza +YyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6 +-----END CERTIFICATE----- + +AffirmTrust Commercial +====================== +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UEBhMCVVMxFDAS +BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMB4XDTEw +MDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly +bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6Eqdb +DuKPHx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yrba0F8PrV +C8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPALMeIrJmqbTFeurCA+ukV6 +BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1yHp52UKqK39c/s4mT6NmgTWvRLpUHhww +MmWd5jyTXlBOeuM61G7MGvv50jeuJCqrVwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNV +HQ4EFgQUnZPGU4teyq8/nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYGXUPG +hi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNjvbz4YYCanrHOQnDi +qX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivtZ8SOyUOyXGsViQK8YvxO8rUzqrJv +0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9gN53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0kh +sUlHRUe072o0EclNmsxZt9YCnlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8= +-----END CERTIFICATE----- + +AffirmTrust Networking +====================== +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UEBhMCVVMxFDAS +BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMB4XDTEw +MDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly +bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SE +Hi3yYJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbuakCNrmreI +dIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRLQESxG9fhwoXA3hA/Pe24 +/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gb +h+0t+nvujArjqWaJGctB+d1ENmHP4ndGyH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNV +HQ4EFgQUBx/S55zawm6iQLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfOtDIu +UFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzuQY0x2+c06lkh1QF6 +12S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZLgo/bNjR9eUJtGxUAArgFU2HdW23 +WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4uolu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9 +/ZFvgrG+CJPbFEfxojfHRZ48x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s= +-----END CERTIFICATE----- + +AffirmTrust Premium +=================== +-----BEGIN CERTIFICATE----- +MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UEBhMCVVMxFDAS +BgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMB4XDTEwMDEy +OTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRy +dXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A +MIICCgKCAgEAxBLfqV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtn +BKAQJG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ+jjeRFcV +5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrSs8PhaJyJ+HoAVt70VZVs ++7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmd +GPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d770O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5R +p9EixAqnOEhss/n/fauGV+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NI +S+LI+H+SqHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S5u04 +6uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4IaC1nEWTJ3s7xgaVY5 +/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TXOwF0lkLgAOIua+rF7nKsu7/+6qqo ++Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYEFJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB +/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByv +MiPIs0laUZx2KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg +Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B8OWycvpEgjNC +6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQMKSOyARiqcTtNd56l+0OOF6S +L5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK ++4w1IX2COPKpVJEZNZOUbWo6xbLQu4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmV +BtWVyuEklut89pMFu+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFg +IxpHYoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8GKa1qF60 +g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaORtGdFNrHF+QFlozEJLUb +zxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6eKeC2uAloGRwYQw== +-----END CERTIFICATE----- + +AffirmTrust Premium ECC +======================= +-----BEGIN CERTIFICATE----- +MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMCVVMxFDASBgNV +BAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQcmVtaXVtIEVDQzAeFw0xMDAx +MjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1U +cnVzdDEgMB4GA1UEAwwXQWZmaXJtVHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAQNMF4bFZ0D0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQ +N8O9ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0GA1UdDgQW +BBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAK +BggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/VsaobgxCd05DhT1wV/GzTjxi+zygk8N53X +57hG8f2h4nECMEJZh0PUUd+60wkyWs6Iflc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKM +eQ== +-----END CERTIFICATE----- + +Certum Trusted Network CA +========================= +-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK +ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIy +MTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBU +ZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 +MSIwIAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rHUV+rpDKmYYe2bg+G0jAC +l/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jUI4ZZJ88J +J7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4 +fOQtf/WsX+sWn7Et0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0 +cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8BAf8EBAMCAQYw +DQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCj +jSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1 +mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5aj +Zt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI +03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw= +-----END CERTIFICATE----- + +TWCA Root Certification Authority +================================= +-----BEGIN CERTIFICATE----- +MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJ +VEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMzWhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQG +EwJUVzESMBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NB +IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFEAcK0HMMx +QhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HHK3XLfJ+utdGdIzdjp9xC +oi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeXRfwZVzsrb+RH9JlF/h3x+JejiB03HFyP +4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/zrX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1r +y+UPizgN7gr8/g+YnzAx3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIB +BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkqhkiG +9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeCMErJk/9q56YAf4lC +mtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdlsXebQ79NqZp4VKIV66IIArB6nCWlW +QtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62Dlhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVY +T0bf+215WfKEIlKuD8z7fDvnaspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocny +Yh0igzyXxfkZYiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw== +-----END CERTIFICATE----- + +Security Communication RootCA2 +============================== +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDElMCMGA1UEChMc +U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMeU2VjdXJpdHkgQ29tbXVuaWNh +dGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoXDTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMC +SlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3Vy +aXR5IENvbW11bmljYXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ANAVOVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGrzbl+dp++ ++T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVMVAX3NuRFg3sUZdbcDE3R +3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQhNBqyjoGADdH5H5XTz+L62e4iKrFvlNV +spHEfbmwhRkGeC7bYRr6hfVKkaHnFtWOojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1K +EOtOghY6rCcMU/Gt1SSwawNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8 +QIH4D5csOPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB +CwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpFcoJxDjrSzG+ntKEj +u/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXcokgfGT+Ok+vx+hfuzU7jBBJV1uXk +3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6q +tnRGEmyR7jTV7JqR50S+kDFy1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29 +mvVXIwAHIRc/SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03 +-----END CERTIFICATE----- + +EC-ACC +====== +-----BEGIN CERTIFICATE----- +MIIFVjCCBD6gAwIBAgIQ7is969Qh3hSoYqwE893EATANBgkqhkiG9w0BAQUFADCB8zELMAkGA1UE +BhMCRVMxOzA5BgNVBAoTMkFnZW5jaWEgQ2F0YWxhbmEgZGUgQ2VydGlmaWNhY2lvIChOSUYgUS0w +ODAxMTc2LUkpMSgwJgYDVQQLEx9TZXJ2ZWlzIFB1YmxpY3MgZGUgQ2VydGlmaWNhY2lvMTUwMwYD +VQQLEyxWZWdldSBodHRwczovL3d3dy5jYXRjZXJ0Lm5ldC92ZXJhcnJlbCAoYykwMzE1MDMGA1UE +CxMsSmVyYXJxdWlhIEVudGl0YXRzIGRlIENlcnRpZmljYWNpbyBDYXRhbGFuZXMxDzANBgNVBAMT +BkVDLUFDQzAeFw0wMzAxMDcyMzAwMDBaFw0zMTAxMDcyMjU5NTlaMIHzMQswCQYDVQQGEwJFUzE7 +MDkGA1UEChMyQWdlbmNpYSBDYXRhbGFuYSBkZSBDZXJ0aWZpY2FjaW8gKE5JRiBRLTA4MDExNzYt +SSkxKDAmBgNVBAsTH1NlcnZlaXMgUHVibGljcyBkZSBDZXJ0aWZpY2FjaW8xNTAzBgNVBAsTLFZl +Z2V1IGh0dHBzOi8vd3d3LmNhdGNlcnQubmV0L3ZlcmFycmVsIChjKTAzMTUwMwYDVQQLEyxKZXJh +cnF1aWEgRW50aXRhdHMgZGUgQ2VydGlmaWNhY2lvIENhdGFsYW5lczEPMA0GA1UEAxMGRUMtQUND +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsyLHT+KXQpWIR4NA9h0X84NzJB5R85iK +w5K4/0CQBXCHYMkAqbWUZRkiFRfCQ2xmRJoNBD45b6VLeqpjt4pEndljkYRm4CgPukLjbo73FCeT +ae6RDqNfDrHrZqJyTxIThmV6PttPB/SnCWDaOkKZx7J/sxaVHMf5NLWUhdWZXqBIoH7nF2W4onW4 +HvPlQn2v7fOKSGRdghST2MDk/7NQcvJ29rNdQlB50JQ+awwAvthrDk4q7D7SzIKiGGUzE3eeml0a +E9jD2z3Il3rucO2n5nzbcc8tlGLfbdb1OL4/pYUKGbio2Al1QnDE6u/LDsg0qBIimAy4E5S2S+zw +0JDnJwIDAQABo4HjMIHgMB0GA1UdEQQWMBSBEmVjX2FjY0BjYXRjZXJ0Lm5ldDAPBgNVHRMBAf8E +BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUoMOLRKo3pUW/l4Ba0fF4opvpXY0wfwYD +VR0gBHgwdjB0BgsrBgEEAfV4AQMBCjBlMCwGCCsGAQUFBwIBFiBodHRwczovL3d3dy5jYXRjZXJ0 +Lm5ldC92ZXJhcnJlbDA1BggrBgEFBQcCAjApGidWZWdldSBodHRwczovL3d3dy5jYXRjZXJ0Lm5l +dC92ZXJhcnJlbCAwDQYJKoZIhvcNAQEFBQADggEBAKBIW4IB9k1IuDlVNZyAelOZ1Vr/sXE7zDkJ +lF7W2u++AVtd0x7Y/X1PzaBB4DSTv8vihpw3kpBWHNzrKQXlxJ7HNd+KDM3FIUPpqojlNcAZQmNa +Al6kSBg6hW/cnbw/nZzBh7h6YQjpdwt/cKt63dmXLGQehb+8dJahw3oS7AwaboMMPOhyRp/7SNVe +l+axofjk70YllJyJ22k4vuxcDlbHZVHlUIiIv0LVKz3l+bqeLrPK9HOSAgu+TGbrIP65y7WZf+a2 +E/rKS03Z7lNGBjvGTq2TWoF+bCpLagVFjPIhpDGQh2xlnJ2lYJU6Un/10asIbvPuW/mIPX64b24D +5EI= +-----END CERTIFICATE----- + +Hellenic Academic and Research Institutions RootCA 2011 +======================================================= +-----BEGIN CERTIFICATE----- +MIIEMTCCAxmgAwIBAgIBADANBgkqhkiG9w0BAQUFADCBlTELMAkGA1UEBhMCR1IxRDBCBgNVBAoT +O0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9y +aXR5MUAwPgYDVQQDEzdIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25z +IFJvb3RDQSAyMDExMB4XDTExMTIwNjEzNDk1MloXDTMxMTIwMTEzNDk1MlowgZUxCzAJBgNVBAYT +AkdSMUQwQgYDVQQKEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25z +IENlcnQuIEF1dGhvcml0eTFAMD4GA1UEAxM3SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNo +IEluc3RpdHV0aW9ucyBSb290Q0EgMjAxMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AKlTAOMupvaO+mDYLZU++CwqVE7NuYRhlFhPjz2L5EPzdYmNUeTDN9KKiE15HrcS3UN4SoqS5tdI +1Q+kOilENbgH9mgdVc04UfCMJDGFr4PJfel3r+0ae50X+bOdOFAPplp5kYCvN66m0zH7tSYJnTxa +71HFK9+WXesyHgLacEnsbgzImjeN9/E2YEsmLIKe0HjzDQ9jpFEw4fkrJxIH2Oq9GGKYsFk3fb7u +8yBRQlqD75O6aRXxYp2fmTmCobd0LovUxQt7L/DICto9eQqakxylKHJzkUOap9FNhYS5qXSPFEDH +3N6sQWRstBmbAmNtJGSPRLIl6s5ddAxjMlyNh+UCAwEAAaOBiTCBhjAPBgNVHRMBAf8EBTADAQH/ +MAsGA1UdDwQEAwIBBjAdBgNVHQ4EFgQUppFC/RNhSiOeCKQp5dgTBCPuQSUwRwYDVR0eBEAwPqA8 +MAWCAy5ncjAFggMuZXUwBoIELmVkdTAGggQub3JnMAWBAy5ncjAFgQMuZXUwBoEELmVkdTAGgQQu +b3JnMA0GCSqGSIb3DQEBBQUAA4IBAQAf73lB4XtuP7KMhjdCSk4cNx6NZrokgclPEg8hwAOXhiVt +XdMiKahsog2p6z0GW5k6x8zDmjR/qw7IThzh+uTczQ2+vyT+bOdrwg3IBp5OjWEopmr95fZi6hg8 +TqBTnbI6nOulnJEWtk2C4AwFSKls9cz4y51JtPACpf1wA+2KIaWuE4ZJwzNzvoc7dIsXRSZMFpGD +/md9zU1jZ/rzAxKWeAaNsWftjj++n08C9bMJL/NMh98qy5V8AcysNnq/onN694/BtZqhFLKPM58N +7yLcZnuEvUUXBj08yrl3NI/K6s8/MT7jiOOASSXIl7WdmplNsDz4SgCbZN2fOUvRJ9e4 +-----END CERTIFICATE----- + +Actalis Authentication Root CA +============================== +-----BEGIN CERTIFICATE----- +MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCSVQxDjAM +BgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UE +AwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDky +MjExMjIwMlowazELMAkGA1UEBhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlz +IFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290 +IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNvUTufClrJ +wkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX4ay8IMKx4INRimlNAJZa +by/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9KK3giq0itFZljoZUj5NDKd45RnijMCO6 +zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1f +YVEiVRvjRuPjPdA1YprbrxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2 +oxgkg4YQ51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2Fbe8l +EfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxeKF+w6D9Fz8+vm2/7 +hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4Fv6MGn8i1zeQf1xcGDXqVdFUNaBr8 +EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbnfpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5 +jF66CyCU3nuDuP/jVo23Eek7jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLY +iDrIn3hm7YnzezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt +ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQALe3KHwGCmSUyI +WOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70jsNjLiNmsGe+b7bAEzlgqqI0 +JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDzWochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKx +K3JCaKygvU5a2hi/a5iB0P2avl4VSM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+ +Xlff1ANATIGk0k9jpwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC +4yyXX04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+OkfcvHlXHo +2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7RK4X9p2jIugErsWx0Hbhz +lefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btUZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXem +OR/qnuOf0GZvBeyqdn6/axag67XH/JJULysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9 +vwGYT7JZVEc+NHt4bVaTLnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg== +-----END CERTIFICATE----- + +Trustis FPS Root CA +=================== +-----BEGIN CERTIFICATE----- +MIIDZzCCAk+gAwIBAgIQGx+ttiD5JNM2a/fH8YygWTANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQG +EwJHQjEYMBYGA1UEChMPVHJ1c3RpcyBMaW1pdGVkMRwwGgYDVQQLExNUcnVzdGlzIEZQUyBSb290 +IENBMB4XDTAzMTIyMzEyMTQwNloXDTI0MDEyMTExMzY1NFowRTELMAkGA1UEBhMCR0IxGDAWBgNV +BAoTD1RydXN0aXMgTGltaXRlZDEcMBoGA1UECxMTVHJ1c3RpcyBGUFMgUm9vdCBDQTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMVQe547NdDfxIzNjpvto8A2mfRC6qc+gIMPpqdZh8mQ +RUN+AOqGeSoDvT03mYlmt+WKVoaTnGhLaASMk5MCPjDSNzoiYYkchU59j9WvezX2fihHiTHcDnlk +H5nSW7r+f2C/revnPDgpai/lkQtV/+xvWNUtyd5MZnGPDNcE2gfmHhjjvSkCqPoc4Vu5g6hBSLwa +cY3nYuUtsuvffM/bq1rKMfFMIvMFE/eC+XN5DL7XSxzA0RU8k0Fk0ea+IxciAIleH2ulrG6nS4zt +o3Lmr2NNL4XSFDWaLk6M6jKYKIahkQlBOrTh4/L68MkKokHdqeMDx4gVOxzUGpTXn2RZEm0CAwEA +AaNTMFEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS6+nEleYtXQSUhhgtx67JkDoshZzAd +BgNVHQ4EFgQUuvpxJXmLV0ElIYYLceuyZA6LIWcwDQYJKoZIhvcNAQEFBQADggEBAH5Y//01GX2c +GE+esCu8jowU/yyg2kdbw++BLa8F6nRIW/M+TgfHbcWzk88iNVy2P3UnXwmWzaD+vkAMXBJV+JOC +yinpXj9WV4s4NvdFGkwozZ5BuO1WTISkQMi4sKUraXAEasP41BIy+Q7DsdwyhEQsb8tGD+pmQQ9P +8Vilpg0ND2HepZ5dfWWhPBfnqFVO76DH7cZEf1T1o+CP8HxVIo8ptoGj4W1OLBuAZ+ytIJ8MYmHV +l/9D7S3B2l0pKoU/rGXuhg8FjZBf3+6f9L/uHfuY5H+QK4R4EA5sSVPvFVtlRkpdr7r7OnIdzfYl +iB6XzCGcKQENZetX2fNXlrtIzYE= +-----END CERTIFICATE----- + +Buypass Class 2 Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU +QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4X +DTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 +eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1 +g1Lr6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn +9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC911K2GScuVr1QGbNgGE41b +/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFOBz3cFIqU +CqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeff +awrbD02TTqigzXsu8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgI +zRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhn +Bkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/TizPJhk9H9Z2vX +Uq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHs +M+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF +AAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s +A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EI +osHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48S +aq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYd +DnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWD +LfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0 +oyLQI+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGC +wWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koS +CTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv4x3kqdbQCtCev9eBCfHJxyYN +rJgWVqA= +-----END CERTIFICATE----- + +Buypass Class 3 Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU +QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMyBSb290IENBMB4X +DTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFowTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 +eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRH +sJ8YZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3EN3coTRiR +5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9tznDDgFHmV0ST9tD+leh +7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX0DJq1l1sDPGzbjniazEuOQAnFN44wOwZ +ZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c/3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH +2xc519woe2v1n/MuwU8XKhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV +/afmiSTYzIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvSO1UQ +RwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D34xFMFbG02SrZvPA +Xpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgPK9Dx2hzLabjKSWJtyNBjYt1gD1iq +j6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFEe4zf/lb+74suwvTg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF +AAOCAgEAACAjQTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV +cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXSIGrs/CIBKM+G +uIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2HJLw5QY33KbmkJs4j1xrG0aG +Q0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsaO5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8 +ZORK15FTAaggiG6cX0S5y2CBNOxv033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2 +KSb12tjE8nVhz36udmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz +6MkEkbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg413OEMXbug +UZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvDu79leNKGef9JOxqDDPDe +eOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq4/g7u9xN12TyUb7mqqta6THuBrxzvxNi +Cp/HuZc= +-----END CERTIFICATE----- + +T-TeleSec GlobalRoot Class 3 +============================ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM +IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU +cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgx +MDAxMTAyOTU2WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz +dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD +ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN8ELg63iIVl6bmlQdTQyK +9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/RLyTPWGrTs0NvvAgJ1gORH8EGoel15YU +NpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZF +iP0Zf3WHHx+xGwpzJFu5ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W +0eDrXltMEnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGjQjBA +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1A/d2O2GCahKqGFPr +AyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOyWL6ukK2YJ5f+AbGwUgC4TeQbIXQb +fsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzT +ucpH9sry9uetuUg/vBa3wW306gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7h +P0HHRwA11fXT91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml +e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4pTpPDpFQUWw== +-----END CERTIFICATE----- + +D-TRUST Root Class 3 CA 2 2009 +============================== +-----BEGIN CERTIFICATE----- +MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTAe +Fw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NThaME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxE +LVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOAD +ER03UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42tSHKXzlA +BF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9RySPocq60vFYJfxLLHLGv +KZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsMlFqVlNpQmvH/pStmMaTJOKDfHR+4CS7z +p+hnUquVH+BGPtikw8paxTGA6Eian5Rp/hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUC +AwEAAaOCARowggEWMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ +4PGEMA4GA1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVjdG9y +eS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUyMENBJTIwMiUyMDIw +MDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRlcmV2b2NhdGlvbmxpc3QwQ6BBoD+G +PWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAw +OS5jcmwwDQYJKoZIhvcNAQELBQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm +2H6NMLVwMeniacfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0 +o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4KzCUqNQT4YJEV +dT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8PIWmawomDeCTmGCufsYkl4ph +X5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3YJohw1+qRzT65ysCQblrGXnRl11z+o+I= +-----END CERTIFICATE----- + +D-TRUST Root Class 3 CA 2 EV 2009 +================================= +-----BEGIN CERTIFICATE----- +MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw +OTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUwNDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw +OTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfS +egpnljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM03TP1YtHh +zRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6ZqQTMFexgaDbtCHu39b+T +7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lRp75mpoo6Kr3HGrHhFPC+Oh25z1uxav60 +sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure35 +11H3a6UCAwEAAaOCASQwggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyv +cop9NteaHNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFwOi8v +ZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xhc3MlMjAzJTIwQ0El +MjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1ERT9jZXJ0aWZpY2F0ZXJldm9jYXRp +b25saXN0MEagRKBChkBodHRwOi8vd3d3LmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xh +c3NfM19jYV8yX2V2XzIwMDkuY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+ +PPoeUSbrh/Yp3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05 +nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNFCSuGdXzfX2lX +ANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7naxpeG0ILD5EJt/rDiZE4OJudA +NCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqXKVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVv +w9y4AyHqnxbxLFS1 +-----END CERTIFICATE----- + +CA Disig Root R2 +================ +-----BEGIN CERTIFICATE----- +MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNVBAYTAlNLMRMw +EQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMuMRkwFwYDVQQDExBDQSBEaXNp +ZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQyMDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sx +EzARBgNVBAcTCkJyYXRpc2xhdmExEzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERp +c2lnIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbC +w3OeNcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNHPWSb6Wia +xswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3Ix2ymrdMxp7zo5eFm1tL7 +A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbeQTg06ov80egEFGEtQX6sx3dOy1FU+16S +GBsEWmjGycT6txOgmLcRK7fWV8x8nhfRyyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqV +g8NTEQxzHQuyRpDRQjrOQG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa +5Beny912H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJQfYE +koopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUDi/ZnWejBBhG93c+A +Ak9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORsnLMOPReisjQS1n6yqEm70XooQL6i +Fh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNV +HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5u +Qu0wDQYJKoZIhvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM +tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqfGopTpti72TVV +sRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkblvdhuDvEK7Z4bLQjb/D907Je +dR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka+elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W8 +1k/BfDxujRNt+3vrMNDcTa/F1balTFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjx +mHHEt38OFdAlab0inSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01 +utI3gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18DrG5gPcFw0 +sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3OszMOl6W8KjptlwlCFtaOg +UxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8xL4ysEr3vQCj8KWefshNPZiTEUxnpHikV +7+ZtsH8tZ/3zbBt1RqPlShfppNcL +-----END CERTIFICATE----- + +ACCVRAIZ1 +========= +-----BEGIN CERTIFICATE----- +MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UEAwwJQUNDVlJB +SVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQswCQYDVQQGEwJFUzAeFw0xMTA1 +MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQBgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwH +UEtJQUNDVjENMAsGA1UECgwEQUNDVjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCbqau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gM +jmoYHtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWoG2ioPej0 +RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpAlHPrzg5XPAOBOp0KoVdD +aaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhrIA8wKFSVf+DuzgpmndFALW4ir50awQUZ +0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDG +WuzndN9wrqODJerWx5eHk6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs7 +8yM2x/474KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMOm3WR +5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpacXpkatcnYGMN285J +9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPluUsXQA+xtrn13k/c4LOsOxFwYIRK +Q26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYIKwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRw +Oi8vd3d3LmFjY3YuZXMvZmlsZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEu +Y3J0MB8GCCsGAQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2 +VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeTVfZW6oHlNsyM +Hj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIGCCsGAQUFBwICMIIBFB6CARAA +QQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUAcgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBh +AO0AegAgAGQAZQAgAGwAYQAgAEEAQwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUA +YwBuAG8AbABvAGcA7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBj +AHQAcgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAAQwBQAFMA +IABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUAczAwBggrBgEFBQcCARYk +aHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2MuaHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0 +dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRtaW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2 +MV9kZXIuY3JsMA4GA1UdDwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZI +hvcNAQEFBQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdpD70E +R9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gUJyCpZET/LtZ1qmxN +YEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+mAM/EKXMRNt6GGT6d7hmKG9Ww7Y49 +nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepDvV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJ +TS+xJlsndQAJxGJ3KQhfnlmstn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3 +sCPdK6jT2iWH7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h +I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szAh1xA2syVP1Xg +Nce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xFd3+YJ5oyXSrjhO7FmGYvliAd +3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2HpPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3p +EfbRD0tVNEYqi4Y7 +-----END CERTIFICATE----- + +TWCA Global Root CA +=================== +-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcxEjAQBgNVBAoT +CVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMTVFdDQSBHbG9iYWwgUm9vdCBD +QTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQK +EwlUQUlXQU4tQ0ExEDAOBgNVBAsTB1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3Qg +Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2C +nJfF10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz0ALfUPZV +r2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfChMBwqoJimFb3u/Rk28OKR +Q4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbHzIh1HrtsBv+baz4X7GGqcXzGHaL3SekV +tTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1W +KKD+u4ZqyPpcC1jcxkt2yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99 +sy2sbZCilaLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYPoA/p +yJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQABDzfuBSO6N+pjWxn +kjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcEqYSjMq+u7msXi7Kx/mzhkIyIqJdI +zshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6g +cFGn90xHNcgL1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn +LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WFH6vPNOw/KP4M +8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNoRI2T9GRwoD2dKAXDOXC4Ynsg +/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlg +lPx4mI88k1HtQJAH32RjJMtOcQWh15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryP +A9gK8kxkRr05YuWW6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3m +i4TWnsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5jwa19hAM8 +EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWzaGHQRiapIVJpLesux+t3 +zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmyKwbQBM0= +-----END CERTIFICATE----- + +TeliaSonera Root CA v1 +====================== +-----BEGIN CERTIFICATE----- +MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAwNzEUMBIGA1UE +CgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJvb3QgQ0EgdjEwHhcNMDcxMDE4 +MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYDVQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwW +VGVsaWFTb25lcmEgUm9vdCBDQSB2MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+ +6yfwIaPzaSZVfp3FVRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA +3GV17CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+XZ75Ljo1k +B1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+/jXh7VB7qTCNGdMJjmhn +Xb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxH +oLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkmdtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3 +F0fUTPHSiXk+TT2YqGHeOh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJ +oWjiUIMusDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4pgd7 +gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fsslESl1MpWtTwEhDc +TwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQarMCpgKIv7NHfirZ1fpoeDVNAgMB +AAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qW +DNXr+nuqF+gTEjANBgkqhkiG9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNm +zqjMDfz1mgbldxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx +0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1TjTQpgcmLNkQfW +pb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBedY2gea+zDTYa4EzAvXUYNR0PV +G6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpc +c41teyWRyu5FrgZLAMzTsVlQ2jqIOylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOT +JsjrDNYmiLbAJM+7vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2 +qReWt88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcnHL/EVlP6 +Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVxSK236thZiNSQvxaz2ems +WWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY= +-----END CERTIFICATE----- + +E-Tugra Certification Authority +=============================== +-----BEGIN CERTIFICATE----- +MIIGSzCCBDOgAwIBAgIIamg+nFGby1MwDQYJKoZIhvcNAQELBQAwgbIxCzAJBgNVBAYTAlRSMQ8w +DQYDVQQHDAZBbmthcmExQDA+BgNVBAoMN0UtVHXEn3JhIEVCRyBCaWxpxZ9pbSBUZWtub2xvamls +ZXJpIHZlIEhpem1ldGxlcmkgQS7Fni4xJjAkBgNVBAsMHUUtVHVncmEgU2VydGlmaWthc3lvbiBN +ZXJrZXppMSgwJgYDVQQDDB9FLVR1Z3JhIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTEzMDMw +NTEyMDk0OFoXDTIzMDMwMzEyMDk0OFowgbIxCzAJBgNVBAYTAlRSMQ8wDQYDVQQHDAZBbmthcmEx +QDA+BgNVBAoMN0UtVHXEn3JhIEVCRyBCaWxpxZ9pbSBUZWtub2xvamlsZXJpIHZlIEhpem1ldGxl +cmkgQS7Fni4xJjAkBgNVBAsMHUUtVHVncmEgU2VydGlmaWthc3lvbiBNZXJrZXppMSgwJgYDVQQD +DB9FLVR1Z3JhIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A +MIICCgKCAgEA4vU/kwVRHoViVF56C/UYB4Oufq9899SKa6VjQzm5S/fDxmSJPZQuVIBSOTkHS0vd +hQd2h8y/L5VMzH2nPbxHD5hw+IyFHnSOkm0bQNGZDbt1bsipa5rAhDGvykPL6ys06I+XawGb1Q5K +CKpbknSFQ9OArqGIW66z6l7LFpp3RMih9lRozt6Plyu6W0ACDGQXwLWTzeHxE2bODHnv0ZEoq1+g +ElIwcxmOj+GMB6LDu0rw6h8VqO4lzKRG+Bsi77MOQ7osJLjFLFzUHPhdZL3Dk14opz8n8Y4e0ypQ +BaNV2cvnOVPAmJ6MVGKLJrD3fY185MaeZkJVgkfnsliNZvcHfC425lAcP9tDJMW/hkd5s3kc91r0 +E+xs+D/iWR+V7kI+ua2oMoVJl0b+SzGPWsutdEcf6ZG33ygEIqDUD13ieU/qbIWGvaimzuT6w+Gz +rt48Ue7LE3wBf4QOXVGUnhMMti6lTPk5cDZvlsouDERVxcr6XQKj39ZkjFqzAQqptQpHF//vkUAq +jqFGOjGY5RH8zLtJVor8udBhmm9lbObDyz51Sf6Pp+KJxWfXnUYTTjF2OySznhFlhqt/7x3U+Lzn +rFpct1pHXFXOVbQicVtbC/DP3KBhZOqp12gKY6fgDT+gr9Oq0n7vUaDmUStVkhUXU8u3Zg5mTPj5 +dUyQ5xJwx0UCAwEAAaNjMGEwHQYDVR0OBBYEFC7j27JJ0JxUeVz6Jyr+zE7S6E5UMA8GA1UdEwEB +/wQFMAMBAf8wHwYDVR0jBBgwFoAULuPbsknQnFR5XPonKv7MTtLoTlQwDgYDVR0PAQH/BAQDAgEG +MA0GCSqGSIb3DQEBCwUAA4ICAQAFNzr0TbdF4kV1JI+2d1LoHNgQk2Xz8lkGpD4eKexd0dCrfOAK +kEh47U6YA5n+KGCRHTAduGN8qOY1tfrTYXbm1gdLymmasoR6d5NFFxWfJNCYExL/u6Au/U5Mh/jO +XKqYGwXgAEZKgoClM4so3O0409/lPun++1ndYYRP0lSWE2ETPo+Aab6TR7U1Q9Jauz1c77NCR807 +VRMGsAnb/WP2OogKmW9+4c4bU2pEZiNRCHu8W1Ki/QY3OEBhj0qWuJA3+GbHeJAAFS6LrVE1Uweo +a2iu+U48BybNCAVwzDk/dr2l02cmAYamU9JgO3xDf1WKvJUawSg5TB9D0pH0clmKuVb8P7Sd2nCc +dlqMQ1DujjByTd//SffGqWfZbawCEeI6FiWnWAjLb1NBnEg4R2gz0dfHj9R0IdTDBZB6/86WiLEV +KV0jq9BgoRJP3vQXzTLlyb/IQ639Lo7xr+L0mPoSHyDYwKcMhcWQ9DstliaxLL5Mq+ux0orJ23gT +Dx4JnW2PAJ8C2sH6H3p6CcRK5ogql5+Ji/03X186zjhZhkuvcQu02PJwT58yE+Owp1fl2tpDy4Q0 +8ijE6m30Ku/Ba3ba+367hTzSU8JNvnHhRdH9I2cNE3X7z2VnIp2usAnRCf8dNL/+I5c30jn6PQ0G +C7TbO6Orb1wdtn7os4I07QZcJA== +-----END CERTIFICATE----- + +T-TeleSec GlobalRoot Class 2 +============================ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM +IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU +cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgx +MDAxMTA0MDE0WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz +dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD +ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUdAqSzm1nzHoqvNK38DcLZ +SBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiCFoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/F +vudocP05l03Sx5iRUKrERLMjfTlH6VJi1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx970 +2cu+fjOlbpSD8DT6IavqjnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGV +WOHAD3bZwI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGjQjBA +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/WSA2AHmgoCJrjNXy +YdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhyNsZt+U2e+iKo4YFWz827n+qrkRk4 +r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPACuvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNf +vNoBYimipidx5joifsFvHZVwIEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR +3p1m0IvVVGb6g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN +9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlPBSeOE6Fuwg== +-----END CERTIFICATE----- + +Atos TrustedRoot 2011 +===================== +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UEAwwVQXRvcyBU +cnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0xMTA3MDcxNDU4 +MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMMFUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsG +A1UECgwEQXRvczELMAkGA1UEBhMCREUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV +hTuXbyo7LjvPpvMpNb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr +54rMVD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+SZFhyBH+ +DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ4J7sVaE3IqKHBAUsR320 +HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0Lcp2AMBYHlT8oDv3FdU9T1nSatCQujgKR +z3bFmx5VdJx4IbHwLfELn8LVlhgf8FQieowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7R +l+lwrrw7GWzbITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZ +bNshMBgGA1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8jvZfza1zv7v1Apt+h +k6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kPDpFrdRbhIfzYJsdHt6bPWHJxfrrh +TZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pcmaHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a9 +61qn8FYiqTxlVMYVqL2Gns2Dlmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G +3mB/ufNPRJLvKrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed +-----END CERTIFICATE----- + +QuoVadis Root CA 1 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIUeFhfLq0sGUvjNwc1NBMotZbUZZMwDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMSBHMzAeFw0xMjAxMTIxNzI3NDRaFw00MjAxMTIxNzI3NDRaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDEg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCgvlAQjunybEC0BJyFuTHK3C3kEakE +PBtVwedYMB0ktMPvhd6MLOHBPd+C5k+tR4ds7FtJwUrVu4/sh6x/gpqG7D0DmVIB0jWerNrwU8lm +PNSsAgHaJNM7qAJGr6Qc4/hzWHa39g6QDbXwz8z6+cZM5cOGMAqNF34168Xfuw6cwI2H44g4hWf6 +Pser4BOcBRiYz5P1sZK0/CPTz9XEJ0ngnjybCKOLXSoh4Pw5qlPafX7PGglTvF0FBM+hSo+LdoIN +ofjSxxR3W5A2B4GbPgb6Ul5jxaYA/qXpUhtStZI5cgMJYr2wYBZupt0lwgNm3fME0UDiTouG9G/l +g6AnhF4EwfWQvTA9xO+oabw4m6SkltFi2mnAAZauy8RRNOoMqv8hjlmPSlzkYZqn0ukqeI1RPToV +7qJZjqlc3sX5kCLliEVx3ZGZbHqfPT2YfF72vhZooF6uCyP8Wg+qInYtyaEQHeTTRCOQiJ/GKubX +9ZqzWB4vMIkIG1SitZgj7Ah3HJVdYdHLiZxfokqRmu8hqkkWCKi9YSgxyXSthfbZxbGL0eUQMk1f +iyA6PEkfM4VZDdvLCXVDaXP7a3F98N/ETH3Goy7IlXnLc6KOTk0k+17kBL5yG6YnLUlamXrXXAkg +t3+UuU/xDRxeiEIbEbfnkduebPRq34wGmAOtzCjvpUfzUwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUo5fW816iEOGrRZ88F2Q87gFwnMwwDQYJKoZI +hvcNAQELBQADggIBABj6W3X8PnrHX3fHyt/PX8MSxEBd1DKquGrX1RUVRpgjpeaQWxiZTOOtQqOC +MTaIzen7xASWSIsBx40Bz1szBpZGZnQdT+3Btrm0DWHMY37XLneMlhwqI2hrhVd2cDMT/uFPpiN3 +GPoajOi9ZcnPP/TJF9zrx7zABC4tRi9pZsMbj/7sPtPKlL92CiUNqXsCHKnQO18LwIE6PWThv6ct +Tr1NxNgpxiIY0MWscgKCP6o6ojoilzHdCGPDdRS5YCgtW2jgFqlmgiNR9etT2DGbe+m3nUvriBbP ++V04ikkwj+3x6xn0dxoxGE1nVGwvb2X52z3sIexe9PSLymBlVNFxZPT5pqOBMzYzcfCkeF9OrYMh +3jRJjehZrJ3ydlo28hP0r+AJx2EqbPfgna67hkooby7utHnNkDPDs3b69fBsnQGQ+p6Q9pxyz0fa +wx/kNSBT8lTR32GDpgLiJTjehTItXnOQUl1CxM49S+H5GYQd1aJQzEH7QRTDvdbJWqNjZgKAvQU6 +O0ec7AAmTPWIUb+oI38YB7AL7YsmoWTTYUrrXJ/es69nA7Mf3W1daWhpq1467HxpvMc7hU6eFbm0 +FU/DlXpY18ls6Wy58yljXrQs8C097Vpl4KlbQMJImYFtnh8GKjwStIsPm6Ik8KaN1nrgS7ZklmOV +hMJKzRwuJIczYOXD +-----END CERTIFICATE----- + +QuoVadis Root CA 2 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFh +ZiFfqq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMWn4rjyduY +NM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ymc5GQYaYDFCDy54ejiK2t +oIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+o +MiwMzAkd056OXbxMmO7FGmh77FOm6RQ1o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+l +V0POKa2Mq1W/xPtbAd0jIaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZo +L1NesNKqIcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz8eQQ +sSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43ehvNURG3YBZwjgQQvD +6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l7ZizlWNof/k19N+IxWA1ksB8aRxh +lRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALGcC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZI +hvcNAQELBQADggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66 +AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RCroijQ1h5fq7K +pVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0GaW/ZZGYjeVYg3UQt4XAoeo0L9 +x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4nlv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgz +dWqTHBLmYF5vHX/JHyPLhGGfHoJE+V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6X +U/IyAgkwo1jwDQHVcsaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+Nw +mNtddbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNgKCLjsZWD +zYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeMHVOyToV7BjjHLPj4sHKN +JeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4WSr2Rz0ZiC3oheGe7IUIarFsNMkd7Egr +O3jtZsSOeWmD3n+M +-----END CERTIFICATE----- + +QuoVadis Root CA 3 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286 +IxSR/xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNuFoM7pmRL +Mon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXRU7Ox7sWTaYI+FrUoRqHe +6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+cra1AdHkrAj80//ogaX3T7mH1urPnMNA3 +I4ZyYUUpSFlob3emLoG+B01vr87ERRORFHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3U +VDmrJqMz6nWB2i3ND0/kA9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f7 +5li59wzweyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634RylsSqi +Md5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBpVzgeAVuNVejH38DM +dyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0QA4XN8f+MFrXBsj6IbGB/kE+V9/Yt +rQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZI +hvcNAQELBQADggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px +KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnIFUBhynLWcKzS +t/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5WvvoxXqA/4Ti2Tk08HS6IT7SdEQ +TXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFgu/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9Du +DcpmvJRPpq3t/O5jrFc/ZSXPsoaP0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGib +Ih6BJpsQBJFxwAYf3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmD +hPbl8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+DhcI00iX +0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HNPlopNLk9hM6xZdRZkZFW +dSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ywaZWWDYWGWVjUTR939+J399roD1B0y2 +PpxxVJkES/1Y+Zj0 +-----END CERTIFICATE----- + +DigiCert Assured ID Root G2 +=========================== +-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw +IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgw +MTE1MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL +ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSAn61UQbVH +35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4HteccbiJVMWWXvdMX0h5i89vq +bFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9HpEgjAALAcKxHad3A2m67OeYfcgnDmCXRw +VWmvo2ifv922ebPynXApVfSr/5Vh88lAbx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OP +YLfykqGxvYmJHzDNw6YuYjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+Rn +lTGNAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTO +w0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPIQW5pJ6d1Ee88hjZv +0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I0jJmwYrA8y8678Dj1JGG0VDjA9tz +d29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4GnilmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAW +hsI6yLETcDbYz+70CjTVW0z9B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0M +jomZmWzwPDCvON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo +IhNzbM8m9Yop5w== +-----END CERTIFICATE----- + +DigiCert Assured ID Root G3 +=========================== +-----BEGIN CERTIFICATE----- +MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYD +VQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 +MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQ +BgcqhkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJfZn4f5dwb +RXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17QRSAPWXYQ1qAk8C3eNvJs +KTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgF +UaFNN6KDec6NHSrkhDAKBggqhkjOPQQDAwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5Fy +YZ5eEJJZVrmDxxDnOOlYJjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy +1vUhZscv6pZjamVFkpUBtA== +-----END CERTIFICATE----- + +DigiCert Global Root G2 +======================= +-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw +HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUx +MjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 +dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJ +kTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO +3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hdG82MHauV +BJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyM +UNGPHgm+F6HmIcr9g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQAB +o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu +5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9PMWLSn/pvtsr +F9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0U +WTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBH +QRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/ +iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl +MrY= +-----END CERTIFICATE----- + +DigiCert Global Root G3 +======================= +-----BEGIN CERTIFICATE----- +MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYD +VQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAw +MDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5k +aWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FGfp4tn+6O +YwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPOZ9wj/wMco+I+o0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNp +Yim8S8YwCgYIKoZIzj0EAwMDaAAwZQIxAK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y +3maTD/HMsQmP3Wyr+mt/oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34 +VOKa5Vt8sycX +-----END CERTIFICATE----- + +DigiCert Trusted Root G4 +======================== +-----BEGIN CERTIFICATE----- +MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEw +HwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 +MTIwMDAwWjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0G +CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEp +pz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9o +k3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7Fsa +vOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY +QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6 +MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtm +mnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7 +f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFH +dL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8 +oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD +ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2SV1EY+CtnJYY +ZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd+SeuMIW59mdNOj6PWTkiU0Tr +yF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWcfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy +7zBZLq7gcfJW5GqXb5JQbZaNaHqasjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iah +ixTXTBmyUEFxPT9NcCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN +5r5N0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie4u1Ki7wb +/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mIr/OSmbaz5mEP0oUA51Aa +5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tK +G48BtieVU+i2iW1bvGjUI+iLUaJW+fCmgKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP +82Z+ +-----END CERTIFICATE----- + +COMODO RSA Certification Authority +================================== +-----BEGIN CERTIFICATE----- +MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCBhTELMAkGA1UE +BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG +A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwHhcNMTAwMTE5MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMC +R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE +ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR6FSS0gpWsawNJN3Fz0Rn +dJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8Xpz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZ +FGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+ +5eNu/Nio5JIk2kNrYrhV/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pG +x8cgoLEfZd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z+pUX +2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7wqP/0uK3pN/u6uPQL +OvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZahSL0896+1DSJMwBGB7FY79tOi4lu3 +sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVICu9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+C +GCe01a60y1Dma/RMhnEw6abfFobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5 +WdYgGq/yapiqcrxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E +FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvlwFTPoCWOAvn9sKIN9SCYPBMt +rFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+ +nq6PK7o9mfjYcwlYRm6mnPTXJ9OV2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSg +tZx8jb8uk2IntznaFxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwW +sRqZCuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiKboHGhfKp +pC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmckejkk9u+UJueBPSZI9FoJA +zMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yLS0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHq +ZJx64SIDqZxubw5lT2yHh17zbqD5daWbQOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk52 +7RH89elWsn2/x20Kk4yl0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7I +LaZRfyHBNVOFBkpdn627G190 +-----END CERTIFICATE----- + +USERTrust RSA Certification Authority +===================================== +-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK +ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK +ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCAEmUXNg7D2wiz +0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2j +Y0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFn +RghRy4YUVD+8M/5+bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O ++T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq +/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Plfg6lZrEpfDKE +Y1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJM +lXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8 +yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+ +eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd +BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeW +FPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ +7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQ +Eg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM +8WcRiQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGi +FSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdi +yA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9c +J2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw +sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gx +Q+6IHdfGjjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE----- + +USERTrust ECC Certification Authority +===================================== +-----BEGIN CERTIFICATE----- +MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDELMAkGA1UEBhMC +VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMC +VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqfloI+d61SRvU8Za2EurxtW2 +0eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinngo4N+LZfQYcTxmdwlkWOrfzCjtHDix6Ez +nPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNV +HQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBB +HU6+4WMBzzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbWRNZu +9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg= +-----END CERTIFICATE----- + +GlobalSign ECC Root CA - R4 +=========================== +-----BEGIN CERTIFICATE----- +MIIB4TCCAYegAwIBAgIRKjikHJYKBN5CsiilC+g0mAIwCgYIKoZIzj0EAwIwUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI0MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI0MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuMZ5049sJQ6fLjkZHAOkrprl +OQcJFspjsbmG+IpXwVfOQvpzofdlQv8ewQCybnMO/8ch5RikqtlxP6jUuc6MHaNCMEAwDgYDVR0P +AQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFFSwe61FuOJAf/sKbvu+M8k8o4TV +MAoGCCqGSM49BAMCA0gAMEUCIQDckqGgE6bPA7DmxCGXkPoUVy0D7O48027KqGx2vKLeuwIgJ6iF +JzWbVsaj8kfSt24bAgAXqmemFZHe+pTsewv4n4Q= +-----END CERTIFICATE----- + +GlobalSign ECC Root CA - R5 +=========================== +-----BEGIN CERTIFICATE----- +MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6 +SFkc8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8kehOvRnkmS +h5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYIKoZIzj0EAwMDaAAwZQIxAOVpEslu28Yx +uglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7 +yFz9SO8NdCKoCOJuxUnOxwy8p2Fp8fc74SrL+SvzZpA3 +-----END CERTIFICATE----- + +Staat der Nederlanden Root CA - G3 +================================== +-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIEAJiiOTANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJOTDEeMBwGA1UE +CgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSswKQYDVQQDDCJTdGFhdCBkZXIgTmVkZXJsYW5kZW4g +Um9vdCBDQSAtIEczMB4XDTEzMTExNDExMjg0MloXDTI4MTExMzIzMDAwMFowWjELMAkGA1UEBhMC +TkwxHjAcBgNVBAoMFVN0YWF0IGRlciBOZWRlcmxhbmRlbjErMCkGA1UEAwwiU3RhYXQgZGVyIE5l +ZGVybGFuZGVuIFJvb3QgQ0EgLSBHMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL4y +olQPcPssXFnrbMSkUeiFKrPMSjTysF/zDsccPVMeiAho2G89rcKezIJnByeHaHE6n3WWIkYFsO2t +x1ueKt6c/DrGlaf1F2cY5y9JCAxcz+bMNO14+1Cx3Gsy8KL+tjzk7FqXxz8ecAgwoNzFs21v0IJy +EavSgWhZghe3eJJg+szeP4TrjTgzkApyI/o1zCZxMdFyKJLZWyNtZrVtB0LrpjPOktvA9mxjeM3K +Tj215VKb8b475lRgsGYeCasH/lSJEULR9yS6YHgamPfJEf0WwTUaVHXvQ9Plrk7O53vDxk5hUUur +mkVLoR9BvUhTFXFkC4az5S6+zqQbwSmEorXLCCN2QyIkHxcE1G6cxvx/K2Ya7Irl1s9N9WMJtxU5 +1nus6+N86U78dULI7ViVDAZCopz35HCz33JvWjdAidiFpNfxC95DGdRKWCyMijmev4SH8RY7Ngzp +07TKbBlBUgmhHbBqv4LvcFEhMtwFdozL92TkA1CvjJFnq8Xy7ljY3r735zHPbMk7ccHViLVlvMDo +FxcHErVc0qsgk7TmgoNwNsXNo42ti+yjwUOH5kPiNL6VizXtBznaqB16nzaeErAMZRKQFWDZJkBE +41ZgpRDUajz9QdwOWke275dhdU/Z/seyHdTtXUmzqWrLZoQT1Vyg3N9udwbRcXXIV2+vD3dbAgMB +AAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRUrfrHkleu +yjWcLhL75LpdINyUVzANBgkqhkiG9w0BAQsFAAOCAgEAMJmdBTLIXg47mAE6iqTnB/d6+Oea31BD +U5cqPco8R5gu4RV78ZLzYdqQJRZlwJ9UXQ4DO1t3ApyEtg2YXzTdO2PCwyiBwpwpLiniyMMB8jPq +KqrMCQj3ZWfGzd/TtiunvczRDnBfuCPRy5FOCvTIeuXZYzbB1N/8Ipf3YF3qKS9Ysr1YvY2WTxB1 +v0h7PVGHoTx0IsL8B3+A3MSs/mrBcDCw6Y5p4ixpgZQJut3+TcCDjJRYwEYgr5wfAvg1VUkvRtTA +8KCWAg8zxXHzniN9lLf9OtMJgwYh/WA9rjLA0u6NpvDntIJ8CsxwyXmA+P5M9zWEGYox+wrZ13+b +8KKaa8MFSu1BYBQw0aoRQm7TIwIEC8Zl3d1Sd9qBa7Ko+gE4uZbqKmxnl4mUnrzhVNXkanjvSr0r +mj1AfsbAddJu+2gw7OyLnflJNZoaLNmzlTnVHpL3prllL+U9bTpITAjc5CgSKL59NVzq4BZ+Extq +1z7XnvwtdbLBFNUjA9tbbws+eC8N3jONFrdI54OagQ97wUNNVQQXOEpR1VmiiXTTn74eS9fGbbeI +JG9gkaSChVtWQbzQRKtqE77RLFi3EjNYsjdj3BP1lB0/QFH1T/U67cjF68IeHRaVesd+QnGTbksV +tzDfqu1XhUisHWrdOWnk4Xl4vs4Fv6EM94B7IWcnMFk= +-----END CERTIFICATE----- + +Staat der Nederlanden EV Root CA +================================ +-----BEGIN CERTIFICATE----- +MIIFcDCCA1igAwIBAgIEAJiWjTANBgkqhkiG9w0BAQsFADBYMQswCQYDVQQGEwJOTDEeMBwGA1UE +CgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSkwJwYDVQQDDCBTdGFhdCBkZXIgTmVkZXJsYW5kZW4g +RVYgUm9vdCBDQTAeFw0xMDEyMDgxMTE5MjlaFw0yMjEyMDgxMTEwMjhaMFgxCzAJBgNVBAYTAk5M +MR4wHAYDVQQKDBVTdGFhdCBkZXIgTmVkZXJsYW5kZW4xKTAnBgNVBAMMIFN0YWF0IGRlciBOZWRl +cmxhbmRlbiBFViBSb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA48d+ifkk +SzrSM4M1LGns3Amk41GoJSt5uAg94JG6hIXGhaTK5skuU6TJJB79VWZxXSzFYGgEt9nCUiY4iKTW +O0Cmws0/zZiTs1QUWJZV1VD+hq2kY39ch/aO5ieSZxeSAgMs3NZmdO3dZ//BYY1jTw+bbRcwJu+r +0h8QoPnFfxZpgQNH7R5ojXKhTbImxrpsX23Wr9GxE46prfNeaXUmGD5BKyF/7otdBwadQ8QpCiv8 +Kj6GyzyDOvnJDdrFmeK8eEEzduG/L13lpJhQDBXd4Pqcfzho0LKmeqfRMb1+ilgnQ7O6M5HTp5gV +XJrm0w912fxBmJc+qiXbj5IusHsMX/FjqTf5m3VpTCgmJdrV8hJwRVXj33NeN/UhbJCONVrJ0yPr +08C+eKxCKFhmpUZtcALXEPlLVPxdhkqHz3/KRawRWrUgUY0viEeXOcDPusBCAUCZSCELa6fS/ZbV +0b5GnUngC6agIk440ME8MLxwjyx1zNDFjFE7PZQIZCZhfbnDZY8UnCHQqv0XcgOPvZuM5l5Tnrmd +74K74bzickFbIZTTRTeU0d8JOV3nI6qaHcptqAqGhYqCvkIH1vI4gnPah1vlPNOePqc7nvQDs/nx +fRN0Av+7oeX6AHkcpmZBiFxgV6YuCcS6/ZrPpx9Aw7vMWgpVSzs4dlG4Y4uElBbmVvMCAwEAAaNC +MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFP6rAJCYniT8qcwa +ivsnuL8wbqg7MA0GCSqGSIb3DQEBCwUAA4ICAQDPdyxuVr5Os7aEAJSrR8kN0nbHhp8dB9O2tLsI +eK9p0gtJ3jPFrK3CiAJ9Brc1AsFgyb/E6JTe1NOpEyVa/m6irn0F3H3zbPB+po3u2dfOWBfoqSmu +c0iH55vKbimhZF8ZE/euBhD/UcabTVUlT5OZEAFTdfETzsemQUHSv4ilf0X8rLiltTMMgsT7B/Zq +5SWEXwbKwYY5EdtYzXc7LMJMD16a4/CrPmEbUCTCwPTxGfARKbalGAKb12NMcIxHowNDXLldRqAN +b/9Zjr7dn3LDWyvfjFvO5QxGbJKyCqNMVEIYFRIYvdr8unRu/8G2oGTYqV9Vrp9canaW2HNnh/tN +f1zuacpzEPuKqf2evTY4SUmH9A4U8OmHuD+nT3pajnnUk+S7aFKErGzp85hwVXIy+TSrK0m1zSBi +5Dp6Z2Orltxtrpfs/J92VoguZs9btsmksNcFuuEnL5O7Jiqik7Ab846+HUCjuTaPPoIaGl6I6lD4 +WeKDRikL40Rc4ZW2aZCaFG+XroHPaO+Zmr615+F/+PoTRxZMzG0IQOeLeG9QgkRQP2YGiqtDhFZK +DyAthg710tvSeopLzaXoTvFeJiUBWSOgftL2fiFX1ye8FVdMpEbB4IMeDExNH08GGeL5qPQ6gqGy +eUN51q1veieQA6TqJIc/2b3Z6fJfUEkc7uzXLg== +-----END CERTIFICATE----- + +IdenTrust Commercial Root CA 1 +============================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBKMQswCQYDVQQG +EwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBS +b290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQwMTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzES +MBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENB +IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ld +hNlT3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU+ehcCuz/ +mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gpS0l4PJNgiCL8mdo2yMKi +1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1bVoE/c40yiTcdCMbXTMTEl3EASX2MN0C +XZ/g1Ue9tOsbobtJSdifWwLziuQkkORiT0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl +3ZBWzvurpWCdxJ35UrCLvYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzy +NeVJSQjKVsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZKdHzV +WYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHTc+XvvqDtMwt0viAg +xGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hvl7yTmvmcEpB4eoCHFddydJxVdHix +uuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5NiGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZI +hvcNAQELBQADggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH +6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwtLRvM7Kqas6pg +ghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93nAbowacYXVKV7cndJZ5t+qnt +ozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3+wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmV +YjzlVYA211QC//G5Xc7UI2/YRYRKW2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUX +feu+h1sXIFRRk0pTAwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/ro +kTLql1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG4iZZRHUe +2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZmUlO+KWA2yUPHGNiiskz +Z2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7R +cGzM7vRX+Bi6hG6H +-----END CERTIFICATE----- + +IdenTrust Public Sector Root CA 1 +================================= +-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCgFCgAAAAUUjz0Z8AAAAAjANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQG +EwJVUzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3Rv +ciBSb290IENBIDEwHhcNMTQwMTE2MTc1MzMyWhcNMzQwMTE2MTc1MzMyWjBNMQswCQYDVQQGEwJV +UzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3RvciBS +b290IENBIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2IpT8pEiv6EdrCvsnduTy +P4o7ekosMSqMjbCpwzFrqHd2hCa2rIFCDQjrVVi7evi8ZX3yoG2LqEfpYnYeEe4IFNGyRBb06tD6 +Hi9e28tzQa68ALBKK0CyrOE7S8ItneShm+waOh7wCLPQ5CQ1B5+ctMlSbdsHyo+1W/CD80/HLaXI +rcuVIKQxKFdYWuSNG5qrng0M8gozOSI5Cpcu81N3uURF/YTLNiCBWS2ab21ISGHKTN9T0a9SvESf +qy9rg3LvdYDaBjMbXcjaY8ZNzaxmMc3R3j6HEDbhuaR672BQssvKplbgN6+rNBM5Jeg5ZuSYeqoS +mJxZZoY+rfGwyj4GD3vwEUs3oERte8uojHH01bWRNszwFcYr3lEXsZdMUD2xlVl8BX0tIdUAvwFn +ol57plzy9yLxkA2T26pEUWbMfXYD62qoKjgZl3YNa4ph+bz27nb9cCvdKTz4Ch5bQhyLVi9VGxyh +LrXHFub4qjySjmm2AcG1hp2JDws4lFTo6tyePSW8Uybt1as5qsVATFSrsrTZ2fjXctscvG29ZV/v +iDUqZi/u9rNl8DONfJhBaUYPQxxp+pu10GFqzcpL2UyQRqsVWaFHVCkugyhfHMKiq3IXAAaOReyL +4jM9f9oZRORicsPfIsbyVtTdX5Vy7W1f90gDW/3FKqD2cyOEEBsB5wIDAQABo0IwQDAOBgNVHQ8B +Af8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU43HgntinQtnbcZFrlJPrw6PRFKMw +DQYJKoZIhvcNAQELBQADggIBAEf63QqwEZE4rU1d9+UOl1QZgkiHVIyqZJnYWv6IAcVYpZmxI1Qj +t2odIFflAWJBF9MJ23XLblSQdf4an4EKwt3X9wnQW3IV5B4Jaj0z8yGa5hV+rVHVDRDtfULAj+7A +mgjVQdZcDiFpboBhDhXAuM/FSRJSzL46zNQuOAXeNf0fb7iAaJg9TaDKQGXSc3z1i9kKlT/YPyNt +GtEqJBnZhbMX73huqVjRI9PHE+1yJX9dsXNw0H8GlwmEKYBhHfpe/3OsoOOJuBxxFcbeMX8S3OFt +m6/n6J91eEyrRjuazr8FGF1NFTwWmhlQBJqymm9li1JfPFgEKCXAZmExfrngdbkaqIHWchezxQMx +NRF4eKLg6TCMf4DfWN88uieW4oA0beOY02QnrEh+KHdcxiVhJfiFDGX6xDIvpZgF5PgLZxYWxoK4 +Mhn5+bl53B/N66+rDt0b20XkeucC4pVd/GnwU2lhlXV5C15V5jgclKlZM57IcXR5f1GJtshquDDI +ajjDbp7hNxbqBWJMWxJH7ae0s1hWx0nzfxJoCTFx8G34Tkf71oXuxVhAGaQdp/lLQzfcaFpPz+vC +ZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ +3Wl9af0AVqW3rLatt8o+Ae+c +-----END CERTIFICATE----- + +Entrust Root Certification Authority - G2 +========================================= +-----BEGIN CERTIFICATE----- +MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMCVVMxFjAUBgNV +BAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVy +bXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ug +b25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIw +HhcNMDkwNzA3MTcyNTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoT +DUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMx +OTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25s +eTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP +/vaCeb9zYQYKpSfYs1/TRU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXz +HHfV1IWNcCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hWwcKU +s/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1U1+cPvQXLOZprE4y +TGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0jaWvYkxN4FisZDQSA/i2jZRjJKRx +AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ6 +0B7vfec7aVHUbI2fkBJmqzANBgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5Z +iXMRrEPR9RP/jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ +Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v1fN2D807iDgi +nWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4RnAuknZoh8/CbCzB428Hch0P+ +vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmHVHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xO +e4pIb4tF9g== +-----END CERTIFICATE----- + +Entrust Root Certification Authority - EC1 +========================================== +-----BEGIN CERTIFICATE----- +MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkGA1UEBhMCVVMx +FjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVn +YWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXpl +ZCB1c2Ugb25seTEzMDEGA1UEAxMqRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 +IC0gRUMxMB4XDTEyMTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYw +FAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2Fs +LXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQg +dXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAt +IEVDMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHy +AsWfoPZb1YsGGYZPUxBtByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef +9eNi1KlHBz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVCR98crlOZF7ZvHH3h +vxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nXhTcGtXsI/esni0qU+eH6p44mCOh8 +kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G +-----END CERTIFICATE----- + +CFCA EV ROOT +============ +-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIEGErM1jANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJDTjEwMC4GA1UE +CgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQDDAxDRkNB +IEVWIFJPT1QwHhcNMTIwODA4MDMwNzAxWhcNMjkxMjMxMDMwNzAxWjBWMQswCQYDVQQGEwJDTjEw +MC4GA1UECgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQD +DAxDRkNBIEVWIFJPT1QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDXXWvNED8fBVnV +BU03sQ7smCuOFR36k0sXgiFxEFLXUWRwFsJVaU2OFW2fvwwbwuCjZ9YMrM8irq93VCpLTIpTUnrD +7i7es3ElweldPe6hL6P3KjzJIx1qqx2hp/Hz7KDVRM8Vz3IvHWOX6Jn5/ZOkVIBMUtRSqy5J35DN +uF++P96hyk0g1CXohClTt7GIH//62pCfCqktQT+x8Rgp7hZZLDRJGqgG16iI0gNyejLi6mhNbiyW +ZXvKWfry4t3uMCz7zEasxGPrb382KzRzEpR/38wmnvFyXVBlWY9ps4deMm/DGIq1lY+wejfeWkU7 +xzbh72fROdOXW3NiGUgthxwG+3SYIElz8AXSG7Ggo7cbcNOIabla1jj0Ytwli3i/+Oh+uFzJlU9f +py25IGvPa931DfSCt/SyZi4QKPaXWnuWFo8BGS1sbn85WAZkgwGDg8NNkt0yxoekN+kWzqotaK8K +gWU6cMGbrU1tVMoqLUuFG7OA5nBFDWteNfB/O7ic5ARwiRIlk9oKmSJgamNgTnYGmE69g60dWIol +hdLHZR4tjsbftsbhf4oEIRUpdPA+nJCdDC7xij5aqgwJHsfVPKPtl8MeNPo4+QgO48BdK4PRVmrJ +tqhUUy54Mmc9gn900PvhtgVguXDbjgv5E1hvcWAQUhC5wUEJ73IfZzF4/5YFjQIDAQABo2MwYTAf +BgNVHSMEGDAWgBTj/i39KNALtbq2osS/BqoFjJP7LzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAdBgNVHQ4EFgQU4/4t/SjQC7W6tqLEvwaqBYyT+y8wDQYJKoZIhvcNAQELBQADggIB +ACXGumvrh8vegjmWPfBEp2uEcwPenStPuiB/vHiyz5ewG5zz13ku9Ui20vsXiObTej/tUxPQ4i9q +ecsAIyjmHjdXNYmEwnZPNDatZ8POQQaIxffu2Bq41gt/UP+TqhdLjOztUmCypAbqTuv0axn96/Ua +4CUqmtzHQTb3yHQFhDmVOdYLO6Qn+gjYXB74BGBSESgoA//vU2YApUo0FmZ8/Qmkrp5nGm9BC2sG +E5uPhnEFtC+NiWYzKXZUmhH4J/qyP5Hgzg0b8zAarb8iXRvTvyUFTeGSGn+ZnzxEk8rUQElsgIfX +BDrDMlI1Dlb4pd19xIsNER9Tyx6yF7Zod1rg1MvIB671Oi6ON7fQAUtDKXeMOZePglr4UeWJoBjn +aH9dCi77o0cOPaYjesYBx4/IXr9tgFa+iiS6M+qf4TIRnvHST4D2G0CvOJ4RUHlzEhLN5mydLIhy +PDCBBpEi6lmt2hkuIsKNuYyH4Ga8cyNfIWRjgEj1oDwYPZTISEEdQLpe/v5WOaHIz16eGWRGENoX +kbcFgKyLmZJ956LYBws2J+dIeWCKw9cTXPhyQN9Ky8+ZAAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3C +ekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su +-----END CERTIFICATE----- + +OISTE WISeKey Global Root GB CA +=============================== +-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBtMQswCQYDVQQG +EwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNl +ZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAw +MzJaFw0zOTEyMDExNTEwMzFaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYD +VQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEds +b2JhbCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3HEokKtaX +scriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGxWuR51jIjK+FTzJlFXHtP +rby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk +9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNku7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4o +Qnc/nSMbsrY9gBQHTC5P99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvg +GUpuuy9rM2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZI +hvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrghcViXfa43FK8+5/ea4n32cZiZBKpD +dHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0 +VQreUGdNZtGn//3ZwLWoo4rOZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEui +HZeeevJuQHHfaPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic +Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM= +-----END CERTIFICATE----- + +SZAFIR ROOT CA2 +=============== +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQELBQAwUTELMAkG +A1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6ZW5pb3dhIFMuQS4xGDAWBgNV +BAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkwNzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJ +BgNVBAYTAlBMMSgwJgYDVQQKDB9LcmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYD +VQQDDA9TWkFGSVIgUk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5Q +qEvNQLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT3PSQ1hNK +DJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw3gAeqDRHu5rr/gsUvTaE +2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr63fE9biCloBK0TXC5ztdyO4mTp4CEHCdJ +ckm1/zuVnsHMyAHs6A6KCpbns6aH5db5BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwi +ieDhZNRnvDF5YTy7ykHNXGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsFAAOC +AQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw8PRBEew/R40/cof5 +O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOGnXkZ7/e7DDWQw4rtTw/1zBLZpD67 +oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCPoky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul +4+vJhaAlIDf7js4MNIThPIGyd05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6 ++/NNIxuZMzSgLvWpCz/UXeHPhJ/iGcJfitYgHuNztw== +-----END CERTIFICATE----- + +Certum Trusted Network CA 2 +=========================== +-----BEGIN CERTIFICATE----- +MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCBgDELMAkGA1UE +BhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAlBgNVBAsTHkNlcnR1 +bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29y +ayBDQSAyMCIYDzIwMTExMDA2MDgzOTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQ +TDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl +cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENB +IDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWADGSdhhuWZGc/IjoedQF9 +7/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+o +CgCXhVqqndwpyeI1B+twTUrWwbNWuKFBOJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40b +Rr5HMNUuctHFY9rnY3lEfktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2p +uTRZCr+ESv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1mo130 +GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02isx7QBlrd9pPPV3WZ +9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOWOZV7bIBaTxNyxtd9KXpEulKkKtVB +Rgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgezTv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pye +hizKV/Ma5ciSixqClnrDvFASadgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vM +BhBgu4M1t15n3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZI +hvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQF/xlhMcQSZDe28cmk4gmb3DW +Al45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTfCVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuA +L55MYIR4PSFk1vtBHxgP58l1cb29XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMo +clm2q8KMZiYcdywmdjWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tM +pkT/WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jbAoJnwTnb +w3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksqP/ujmv5zMnHCnsZy4Ypo +J/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Kob7a6bINDd82Kkhehnlt4Fj1F4jNy3eFm +ypnTycUm/Q1oBEauttmbjL4ZvrHG8hnjXALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLX +is7VmFxWlgPF7ncGNf/P5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7 +zAYspsbiDrW5viSP +-----END CERTIFICATE----- + +Hellenic Academic and Research Institutions RootCA 2015 +======================================================= +-----BEGIN CERTIFICATE----- +MIIGCzCCA/OgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcT +BkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0 +aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNVBAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl +YXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIwMTUwHhcNMTUwNzA3MTAxMTIxWhcNNDAwNjMwMTAx +MTIxWjCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMg +QWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNV +BAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIw +MTUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDC+Kk/G4n8PDwEXT2QNrCROnk8Zlrv +bTkBSRq0t89/TSNTt5AA4xMqKKYx8ZEA4yjsriFBzh/a/X0SWwGDD7mwX5nh8hKDgE0GPt+sr+eh +iGsxr/CL0BgzuNtFajT0AoAkKAoCFZVedioNmToUW/bLy1O8E00BiDeUJRtCvCLYjqOWXjrZMts+ +6PAQZe104S+nfK8nNLspfZu2zwnI5dMK/IhlZXQK3HMcXM1AsRzUtoSMTFDPaI6oWa7CJ06CojXd +FPQf/7J31Ycvqm59JCfnxssm5uX+Zwdj2EUN3TpZZTlYepKZcj2chF6IIbjV9Cz82XBST3i4vTwr +i5WY9bPRaM8gFH5MXF/ni+X1NYEZN9cRCLdmvtNKzoNXADrDgfgXy5I2XdGj2HUb4Ysn6npIQf1F +GQatJ5lOwXBH3bWfgVMS5bGMSF0xQxfjjMZ6Y5ZLKTBOhE5iGV48zpeQpX8B653g+IuJ3SWYPZK2 +fu/Z8VFRfS0myGlZYeCsargqNhEEelC9MoS+L9xy1dcdFkfkR2YgP/SWxa+OAXqlD3pk9Q0Yh9mu +iNX6hME6wGkoLfINaFGq46V3xqSQDqE3izEjR8EJCOtu93ib14L8hCCZSRm2Ekax+0VVFqmjZayc +Bw/qa9wfLgZy7IaIEuQt218FL+TwA9MmM+eAws1CoRc0CwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUcRVnyMjJvXVdctA4GGqd83EkVAswDQYJKoZI +hvcNAQELBQADggIBAHW7bVRLqhBYRjTyYtcWNl0IXtVsyIe9tC5G8jH4fOpCtZMWVdyhDBKg2mF+ +D1hYc2Ryx+hFjtyp8iY/xnmMsVMIM4GwVhO+5lFc2JsKT0ucVlMC6U/2DWDqTUJV6HwbISHTGzrM +d/K4kPFox/la/vot9L/J9UUbzjgQKjeKeaO04wlshYaT/4mWJ3iBj2fjRnRUjtkNaeJK9E10A/+y +d+2VZ5fkscWrv2oj6NSU4kQoYsRL4vDY4ilrGnB+JGGTe08DMiUNRSQrlrRGar9KC/eaj8GsGsVn +82800vpzY4zvFrCopEYq+OsS7HK07/grfoxSwIuEVPkvPuNVqNxmsdnhX9izjFk0WaSrT2y7Hxjb +davYy5LNlDhhDgcGH0tGEPEVvo2FXDtKK4F5D7Rpn0lQl033DlZdwJVqwjbDG2jJ9SrcR5q+ss7F +Jej6A7na+RZukYT1HCjI/CbM1xyQVqdfbzoEvM14iQuODy+jqk+iGxI9FghAD/FGTNeqewjBCvVt +J94Cj8rDtSvK6evIIVM4pcw72Hc3MKJP2W/R8kCtQXoXxdZKNYm3QdV8hn9VTYNKpXMgwDqvkPGa +JI7ZjnHKe7iG2rKPmT4dEw0SEe7Uq/DpFXYC5ODfqiAeW2GFZECpkJcNrVPSWh2HagCXZWK0vm9q +p/UsQu0yrbYhnr68 +-----END CERTIFICATE----- + +Hellenic Academic and Research Institutions ECC RootCA 2015 +=========================================================== +-----BEGIN CERTIFICATE----- +MIICwzCCAkqgAwIBAgIBADAKBggqhkjOPQQDAjCBqjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0 +aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u +cyBDZXJ0LiBBdXRob3JpdHkxRDBCBgNVBAMTO0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJj +aCBJbnN0aXR1dGlvbnMgRUNDIFJvb3RDQSAyMDE1MB4XDTE1MDcwNzEwMzcxMloXDTQwMDYzMDEw +MzcxMlowgaoxCzAJBgNVBAYTAkdSMQ8wDQYDVQQHEwZBdGhlbnMxRDBCBgNVBAoTO0hlbGxlbmlj +IEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9yaXR5MUQwQgYD +VQQDEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25zIEVDQyBSb290 +Q0EgMjAxNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJKgQehLgoRc4vgxEZmGZE4JJS+dQS8KrjVP +dJWyUWRrjWvmP3CV8AVER6ZyOFB2lQJajq4onvktTpnvLEhvTCUp6NFxW98dwXU3tNf6e3pCnGoK +Vlp8aQuqgAkkbH7BRqNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O +BBYEFLQiC4KZJAEOnLvkDv2/+5cgk5kqMAoGCCqGSM49BAMCA2cAMGQCMGfOFmI4oqxiRaeplSTA +GiecMjvAwNW6qef4BENThe5SId6d9SWDPp5YSy/XZxMOIQIwBeF1Ad5o7SofTUwJCA3sS61kFyjn +dc5FZXIhF8siQQ6ME5g4mlRtm8rifOoCWCKR +-----END CERTIFICATE----- + +ISRG Root X1 +============ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UE +BhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQD +EwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQG +EwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMT +DElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54r +Vygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj1 +3Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8K +b4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCN +Aymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ +4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf +1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFu +hjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQH +usEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/r +OPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4G +A1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY +9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV +0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwt +hDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJw +TdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nx +e5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZA +JzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahD +YVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9n +JEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJ +m+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- + +AC RAIZ FNMT-RCM +================ +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsxCzAJBgNVBAYT +AkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTAeFw0wODEw +MjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJD +TTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBALpxgHpMhm5/yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcf +qQgfBBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAzWHFctPVr +btQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxFtBDXaEAUwED653cXeuYL +j2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z374jNUUeAlz+taibmSXaXvMiwzn15Cou +08YfxGyqxRxqAQVKL9LFwag0Jl1mpdICIfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mw +WsXmo8RZZUc1g16p6DULmbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnT +tOmlcYF7wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peSMKGJ +47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2ZSysV4999AeU14EC +ll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMetUqIJ5G+GR4of6ygnXYMgrwTJbFaa +i0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FPd9xf3E6Jobd2Sn9R2gzL+HYJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1o +dHRwOi8vd3d3LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD +nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1RXxlDPiyN8+s +D8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYMLVN0V2Ue1bLdI4E7pWYjJ2cJ +j+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrT +Qfv6MooqtyuGC2mDOL7Nii4LcK2NJpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW ++YJF1DngoABd15jmfZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7 +Ixjp6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp1txyM/1d +8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B9kiABdcPUXmsEKvU7ANm +5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wokRqEIr9baRRmW1FMdW4R58MD3R++Lj8UG +rp1MYp3/RgT408m2ECVAdf4WqslKYIYvuu8wd+RU4riEmViAqhOLUTpPSPaLtrM= +-----END CERTIFICATE----- + +Amazon Root CA 1 +================ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1 +MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv +bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgH +FzZM9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQ +gLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6VOujw5H5SNz/0egwLX0t +dHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7LWIbxcce +VOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3 +DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PM +CCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gDS63pYaACbvXy +8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa +2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2 +xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE----- + +Amazon Root CA 2 +================ +-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwFADA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAyMB4XDTE1 +MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv +bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAK2Wny2cSkxKgXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4 +kHbZW0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg1dKmSYXp +N+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K8nu+NQWpEjTj82R0Yiw9 +AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvd +fLC6HM783k81ds8P+HgfajZRRidhW+mez/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAEx +kv8LV/SasrlX6avvDXbR8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSS +btqDT6ZjmUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz7Mt0 +Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6+XUyo05f7O0oYtlN +c/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI0u1ufm8/0i2BWSlmy5A5lREedCf+ +3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSw +DPBMMPQFWAJI/TPlUq9LhONmUjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oA +A7CXDpO8Wqj2LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY ++gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kSk5Nrp+gvU5LE +YFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl7uxMMne0nxrpS10gxdr9HIcW +xkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygmbtmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQ +gj9sAq+uEjonljYE1x2igGOpm/HlurR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbW +aQbLU8uz/mtBzUF+fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoV +Yh63n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE76KlXIx3 +KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H9jVlpNMKVv/1F2Rs76gi +JUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT4PsJYGw= +-----END CERTIFICATE----- + +Amazon Root CA 3 +================ +-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5MQswCQYDVQQG +EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAzMB4XDTE1MDUy +NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ +MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZB +f8ANm+gBG1bG8lKlui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjr +Zt6jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSrttvXBp43 +rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkrBqWTrBqYaGFy+uGh0Psc +eGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteMYyRIHN8wfdVoOw== +-----END CERTIFICATE----- + +Amazon Root CA 4 +================ +-----BEGIN CERTIFICATE----- +MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5MQswCQYDVQQG +EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSA0MB4XDTE1MDUy +NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ +MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN +/sGKe0uoe0ZLY7Bi9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri +83BkM6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WBMAoGCCqGSM49BAMDA2gA +MGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlwCkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1 +AE47xDqUEpHJWEadIRNyp4iciuRMStuW1KyLa2tJElMzrdfkviT8tQp21KW8EA== +-----END CERTIFICATE----- + +TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1 +============================================= +-----BEGIN CERTIFICATE----- +MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIxGDAWBgNVBAcT +D0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxpbXNlbCB2ZSBUZWtub2xvamlr +IEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0wKwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24g +TWVya2V6aSAtIEthbXUgU00xNjA0BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRp +ZmlrYXNpIC0gU3VydW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYD +VQQGEwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXllIEJpbGlt +c2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklUQUsxLTArBgNVBAsTJEth +bXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBTTTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11 +IFNNIFNTTCBLb2sgU2VydGlmaWthc2kgLSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAr3UwM6q7a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y8 +6Ij5iySrLqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INrN3wc +wv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2XYacQuFWQfw4tJzh0 +3+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/iSIzL+aFCr2lqBs23tPcLG07xxO9 +WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4fAJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQU +ZT/HiobGPN08VFw1+DrtUgxHV8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJ +KoZIhvcNAQELBQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh +AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPfIPP54+M638yc +lNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4lzwDGrpDxpa5RXI4s6ehlj2R +e37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0j +q5Rm+K37DwhuJi1/FwcJsoz7UMCflo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM= +-----END CERTIFICATE----- + +GDCA TrustAUTH R5 ROOT +====================== +-----BEGIN CERTIFICATE----- +MIIFiDCCA3CgAwIBAgIIfQmX/vBH6nowDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCQ04xMjAw +BgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZIENPLixMVEQuMR8wHQYDVQQD +DBZHRENBIFRydXN0QVVUSCBSNSBST09UMB4XDTE0MTEyNjA1MTMxNVoXDTQwMTIzMTE1NTk1OVow +YjELMAkGA1UEBhMCQ04xMjAwBgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZ +IENPLixMVEQuMR8wHQYDVQQDDBZHRENBIFRydXN0QVVUSCBSNSBST09UMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEA2aMW8Mh0dHeb7zMNOwZ+Vfy1YI92hhJCfVZmPoiC7XJjDp6L3TQs +AlFRwxn9WVSEyfFrs0yw6ehGXTjGoqcuEVe6ghWinI9tsJlKCvLriXBjTnnEt1u9ol2x8kECK62p +OqPseQrsXzrj/e+APK00mxqriCZ7VqKChh/rNYmDf1+uKU49tm7srsHwJ5uu4/Ts765/94Y9cnrr +pftZTqfrlYwiOXnhLQiPzLyRuEH3FMEjqcOtmkVEs7LXLM3GKeJQEK5cy4KOFxg2fZfmiJqwTTQJ +9Cy5WmYqsBebnh52nUpmMUHfP/vFBu8btn4aRjb3ZGM74zkYI+dndRTVdVeSN72+ahsmUPI2JgaQ +xXABZG12ZuGR224HwGGALrIuL4xwp9E7PLOR5G62xDtw8mySlwnNR30YwPO7ng/Wi64HtloPzgsM +R6flPri9fcebNaBhlzpBdRfMK5Z3KpIhHtmVdiBnaM8Nvd/WHwlqmuLMc3GkL30SgLdTMEZeS1SZ +D2fJpcjyIMGC7J0R38IC+xo70e0gmu9lZJIQDSri3nDxGGeCjGHeuLzRL5z7D9Ar7Rt2ueQ5Vfj4 +oR24qoAATILnsn8JuLwwoC8N9VKejveSswoAHQBUlwbgsQfZxw9cZX08bVlX5O2ljelAU58VS6Bx +9hoh49pwBiFYFIeFd3mqgnkCAwEAAaNCMEAwHQYDVR0OBBYEFOLJQJ9NzuiaoXzPDj9lxSmIahlR +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQDRSVfg +p8xoWLoBDysZzY2wYUWsEe1jUGn4H3++Fo/9nesLqjJHdtJnJO29fDMylyrHBYZmDRd9FBUb1Ov9 +H5r2XpdptxolpAqzkT9fNqyL7FeoPueBihhXOYV0GkLH6VsTX4/5COmSdI31R9KrO9b7eGZONn35 +6ZLpBN79SWP8bfsUcZNnL0dKt7n/HipzcEYwv1ryL3ml4Y0M2fmyYzeMN2WFcGpcWwlyua1jPLHd ++PwyvzeG5LuOmCd+uh8W4XAR8gPfJWIyJyYYMoSf/wA6E7qaTfRPuBRwIrHKK5DOKcFw9C+df/KQ +HtZa37dG/OaG+svgIHZ6uqbL9XzeYqWxi+7egmaKTjowHz+Ay60nugxe19CxVsp3cbK1daFQqUBD +F8Io2c9Si1vIY9RCPqAzekYu9wogRlR+ak8x8YF+QnQ4ZXMn7sZ8uI7XpTrXmKGcjBBV09tL7ECQ +8s1uV9JiDnxXk7Gnbc2dg7sq5+W2O3FYrf3RRbxake5TFW/TRQl1brqQXR4EzzffHqhmsYzmIGrv +/EhOdJhCrylvLmrH+33RZjEizIYAfmaDDEL0vTSSwxrqT8p+ck0LcIymSLumoRT2+1hEmRSuqguT +aaApJUqlyyvdimYHFngVV3Eb7PVHhPOeMTd61X8kreS8/f3MboPoDKi3QWwH3b08hpcv0g== +-----END CERTIFICATE----- + +TrustCor RootCert CA-1 +====================== +-----BEGIN CERTIFICATE----- +MIIEMDCCAxigAwIBAgIJANqb7HHzA7AZMA0GCSqGSIb3DQEBCwUAMIGkMQswCQYDVQQGEwJQQTEP +MA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEkMCIGA1UECgwbVHJ1c3RDb3Ig +U3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5UcnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3Jp +dHkxHzAdBgNVBAMMFlRydXN0Q29yIFJvb3RDZXJ0IENBLTEwHhcNMTYwMjA0MTIzMjE2WhcNMjkx +MjMxMTcyMzE2WjCBpDELMAkGA1UEBhMCUEExDzANBgNVBAgMBlBhbmFtYTEUMBIGA1UEBwwLUGFu +YW1hIENpdHkxJDAiBgNVBAoMG1RydXN0Q29yIFN5c3RlbXMgUy4gZGUgUi5MLjEnMCUGA1UECwwe +VHJ1c3RDb3IgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MR8wHQYDVQQDDBZUcnVzdENvciBSb290Q2Vy +dCBDQS0xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv463leLCJhJrMxnHQFgKq1mq +jQCj/IDHUHuO1CAmujIS2CNUSSUQIpidRtLByZ5OGy4sDjjzGiVoHKZaBeYei0i/mJZ0PmnK6bV4 +pQa81QBeCQryJ3pS/C3Vseq0iWEk8xoT26nPUu0MJLq5nux+AHT6k61sKZKuUbS701e/s/OojZz0 +JEsq1pme9J7+wH5COucLlVPat2gOkEz7cD+PSiyU8ybdY2mplNgQTsVHCJCZGxdNuWxu72CVEY4h +gLW9oHPY0LJ3xEXqWib7ZnZ2+AYfYW0PVcWDtxBWcgYHpfOxGgMFZA6dWorWhnAbJN7+KIor0Gqw +/Hqi3LJ5DotlDwIDAQABo2MwYTAdBgNVHQ4EFgQU7mtJPHo/DeOxCbeKyKsZn3MzUOcwHwYDVR0j +BBgwFoAU7mtJPHo/DeOxCbeKyKsZn3MzUOcwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AYYwDQYJKoZIhvcNAQELBQADggEBACUY1JGPE+6PHh0RU9otRCkZoB5rMZ5NDp6tPVxBb5UrJKF5 +mDo4Nvu7Zp5I/5CQ7z3UuJu0h3U/IJvOcs+hVcFNZKIZBqEHMwwLKeXx6quj7LUKdJDHfXLy11yf +ke+Ri7fc7Waiz45mO7yfOgLgJ90WmMCV1Aqk5IGadZQ1nJBfiDcGrVmVCrDRZ9MZyonnMlo2HD6C +qFqTvsbQZJG2z9m2GM/bftJlo6bEjhcxwft+dtvTheNYsnd6djtsL1Ac59v2Z3kf9YKVmgenFK+P +3CghZwnS1k1aHBkcjndcw5QkPTJrS37UeJSDvjdNzl/HHk484IkzlQsPpTLWPFp5LBk= +-----END CERTIFICATE----- + +TrustCor RootCert CA-2 +====================== +-----BEGIN CERTIFICATE----- +MIIGLzCCBBegAwIBAgIIJaHfyjPLWQIwDQYJKoZIhvcNAQELBQAwgaQxCzAJBgNVBAYTAlBBMQ8w +DQYDVQQIDAZQYW5hbWExFDASBgNVBAcMC1BhbmFtYSBDaXR5MSQwIgYDVQQKDBtUcnVzdENvciBT +eXN0ZW1zIFMuIGRlIFIuTC4xJzAlBgNVBAsMHlRydXN0Q29yIENlcnRpZmljYXRlIEF1dGhvcml0 +eTEfMB0GA1UEAwwWVHJ1c3RDb3IgUm9vdENlcnQgQ0EtMjAeFw0xNjAyMDQxMjMyMjNaFw0zNDEy +MzExNzI2MzlaMIGkMQswCQYDVQQGEwJQQTEPMA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5h +bWEgQ2l0eTEkMCIGA1UECgwbVHJ1c3RDb3IgU3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5U +cnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxHzAdBgNVBAMMFlRydXN0Q29yIFJvb3RDZXJ0 +IENBLTIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnIG7CKqJiJJWQdsg4foDSq8Gb +ZQWU9MEKENUCrO2fk8eHyLAnK0IMPQo+QVqedd2NyuCb7GgypGmSaIwLgQ5WoD4a3SwlFIIvl9Nk +RvRUqdw6VC0xK5mC8tkq1+9xALgxpL56JAfDQiDyitSSBBtlVkxs1Pu2YVpHI7TYabS3OtB0PAx1 +oYxOdqHp2yqlO/rOsP9+aij9JxzIsekp8VduZLTQwRVtDr4uDkbIXvRR/u8OYzo7cbrPb1nKDOOb +XUm4TOJXsZiKQlecdu/vvdFoqNL0Cbt3Nb4lggjEFixEIFapRBF37120Hapeaz6LMvYHL1cEksr1 +/p3C6eizjkxLAjHZ5DxIgif3GIJ2SDpxsROhOdUuxTTCHWKF3wP+TfSvPd9cW436cOGlfifHhi5q +jxLGhF5DUVCcGZt45vz27Ud+ez1m7xMTiF88oWP7+ayHNZ/zgp6kPwqcMWmLmaSISo5uZk3vFsQP +eSghYA2FFn3XVDjxklb9tTNMg9zXEJ9L/cb4Qr26fHMC4P99zVvh1Kxhe1fVSntb1IVYJ12/+Ctg +rKAmrhQhJ8Z3mjOAPF5GP/fDsaOGM8boXg25NSyqRsGFAnWAoOsk+xWq5Gd/bnc/9ASKL3x74xdh +8N0JqSDIvgmk0H5Ew7IwSjiqqewYmgeCK9u4nBit2uBGF6zPXQIDAQABo2MwYTAdBgNVHQ4EFgQU +2f4hQG6UnrybPZx9mCAZ5YwwYrIwHwYDVR0jBBgwFoAU2f4hQG6UnrybPZx9mCAZ5YwwYrIwDwYD +VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggIBAJ5Fngw7tu/h +Osh80QA9z+LqBrWyOrsGS2h60COXdKcs8AjYeVrXWoSK2BKaG9l9XE1wxaX5q+WjiYndAfrs3fnp +kpfbsEZC89NiqpX+MWcUaViQCqoL7jcjx1BRtPV+nuN79+TMQjItSQzL/0kMmx40/W5ulop5A7Zv +2wnL/V9lFDfhOPXzYRZY5LVtDQsEGz9QLX+zx3oaFoBg+Iof6Rsqxvm6ARppv9JYx1RXCI/hOWB3 +S6xZhBqI8d3LT3jX5+EzLfzuQfogsL7L9ziUwOHQhQ+77Sxzq+3+knYaZH9bDTMJBzN7Bj8RpFxw +PIXAz+OQqIN3+tvmxYxoZxBnpVIt8MSZj3+/0WvitUfW2dCFmU2Umw9Lje4AWkcdEQOsQRivh7dv +DDqPys/cA8GiCcjl/YBeyGBCARsaU1q7N6a3vLqE6R5sGtRk2tRD/pOLS/IseRYQ1JMLiI+h2IYU +RpFHmygk71dSTlxCnKr3Sewn6EAes6aJInKc9Q0ztFijMDvd1GpUk74aTfOTlPf8hAs/hCBcNANE +xdqtvArBAs8e5ZTZ845b2EzwnexhF7sUMlQMAimTHpKG9n/v55IFDlndmQguLvqcAFLTxWYp5KeX +RKQOKIETNcX2b2TmQcTVL8w0RSXPQQCWPUouwpaYT05KnJe32x+SMsj/D1Fu1uwJ +-----END CERTIFICATE----- + +TrustCor ECA-1 +============== +-----BEGIN CERTIFICATE----- +MIIEIDCCAwigAwIBAgIJAISCLF8cYtBAMA0GCSqGSIb3DQEBCwUAMIGcMQswCQYDVQQGEwJQQTEP +MA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEkMCIGA1UECgwbVHJ1c3RDb3Ig +U3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5UcnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3Jp +dHkxFzAVBgNVBAMMDlRydXN0Q29yIEVDQS0xMB4XDTE2MDIwNDEyMzIzM1oXDTI5MTIzMTE3Mjgw +N1owgZwxCzAJBgNVBAYTAlBBMQ8wDQYDVQQIDAZQYW5hbWExFDASBgNVBAcMC1BhbmFtYSBDaXR5 +MSQwIgYDVQQKDBtUcnVzdENvciBTeXN0ZW1zIFMuIGRlIFIuTC4xJzAlBgNVBAsMHlRydXN0Q29y +IENlcnRpZmljYXRlIEF1dGhvcml0eTEXMBUGA1UEAwwOVHJ1c3RDb3IgRUNBLTEwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDPj+ARtZ+odnbb3w9U73NjKYKtR8aja+3+XzP4Q1HpGjOR +MRegdMTUpwHmspI+ap3tDvl0mEDTPwOABoJA6LHip1GnHYMma6ve+heRK9jGrB6xnhkB1Zem6g23 +xFUfJ3zSCNV2HykVh0A53ThFEXXQmqc04L/NyFIduUd+Dbi7xgz2c1cWWn5DkR9VOsZtRASqnKmc +p0yJF4OuowReUoCLHhIlERnXDH19MURB6tuvsBzvgdAsxZohmz3tQjtQJvLsznFhBmIhVE5/wZ0+ +fyCMgMsq2JdiyIMzkX2woloPV+g7zPIlstR8L+xNxqE6FXrntl019fZISjZFZtS6mFjBAgMBAAGj +YzBhMB0GA1UdDgQWBBREnkj1zG1I1KBLf/5ZJC+Dl5mahjAfBgNVHSMEGDAWgBREnkj1zG1I1KBL +f/5ZJC+Dl5mahjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF +AAOCAQEABT41XBVwm8nHc2FvcivUwo/yQ10CzsSUuZQRg2dd4mdsdXa/uwyqNsatR5Nj3B5+1t4u +/ukZMjgDfxT2AHMsWbEhBuH7rBiVDKP/mZb3Kyeb1STMHd3BOuCYRLDE5D53sXOpZCz2HAF8P11F +hcCF5yWPldwX8zyfGm6wyuMdKulMY/okYWLW2n62HGz1Ah3UKt1VkOsqEUc8Ll50soIipX1TH0Xs +J5F95yIW6MBoNtjG8U+ARDL54dHRHareqKucBK+tIA5kmE2la8BIWJZpTdwHjFGTot+fDz2LYLSC +jaoITmJF4PkL0uDgPFveXHEnJcLmA4GLEFPjx1WitJ/X5g== +-----END CERTIFICATE----- + +SSL.com Root Certification Authority RSA +======================================== +-----BEGIN CERTIFICATE----- +MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxDjAM +BgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24x +MTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYw +MjEyMTczOTM5WhcNNDEwMjEyMTczOTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NM +LmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcNAQEBBQAD +ggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2RxFdHaxh3a3by/ZPkPQ/C +Fp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aXqhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8 +P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcCC52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/ge +oeOy3ZExqysdBP+lSgQ36YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkp +k8zruFvh/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrFYD3Z +fBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93EJNyAKoFBbZQ+yODJ +gUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVcUS4cK38acijnALXRdMbX5J+tB5O2 +UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi8 +1xtZPCvM8hnIk2snYxnP/Okm+Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4s +bE6x/c+cCbqiM+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV +HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGVcpNxJK1ok1iOMq8bs3AD/CUr +dIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBcHadm47GUBwwyOabqG7B52B2ccETjit3E+ZUf +ijhDPwGFpUenPUayvOUiaPd7nNgsPgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAsl +u1OJD7OAUN5F7kR/q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjq +erQ0cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jra6x+3uxj +MxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90IH37hVZkLId6Tngr75qNJ +vTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/YK9f1JmzJBjSWFupwWRoyeXkLtoh/D1JI +Pb9s2KJELtFOt3JY04kTlf5Eq/jXixtunLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406y +wKBjYZC6VWg3dGq2ktufoYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NI +WuuA8ShYIc2wBlX7Jz9TkHCpBB5XJ7k= +-----END CERTIFICATE----- + +SSL.com Root Certification Authority ECC +======================================== +-----BEGIN CERTIFICATE----- +MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMCVVMxDjAMBgNV +BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xMTAv +BgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEy +MTgxNDAzWhcNNDEwMjEyMTgxNDAzWjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAO +BgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv +bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuBBAAiA2IA +BEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI7Z4INcgn64mMU1jrYor+ +8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPgCemB+vNH06NjMGEwHQYDVR0OBBYEFILR +hXMw5zUE044CkvvlpNHEIejNMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTT +jgKS++Wk0cQh6M0wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCW +e+0F+S8Tkdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+gA0z +5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl +-----END CERTIFICATE----- + +SSL.com EV Root Certification Authority RSA R2 +============================================== +-----BEGIN CERTIFICATE----- +MIIF6zCCA9OgAwIBAgIIVrYpzTS8ePYwDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNVBAYTAlVTMQ4w +DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9u +MTcwNQYDVQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIy +MB4XDTE3MDUzMTE4MTQzN1oXDTQyMDUzMDE4MTQzN1owgYIxCzAJBgNVBAYTAlVTMQ4wDAYDVQQI +DAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMTcwNQYD +VQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIyMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjzZlQOHWTcDXtOlG2mvqM0fNTPl9fb69LT3w23jh +hqXZuglXaO1XPqDQCEGD5yhBJB/jchXQARr7XnAjssufOePPxU7Gkm0mxnu7s9onnQqG6YE3Bf7w +cXHswxzpY6IXFJ3vG2fThVUCAtZJycxa4bH3bzKfydQ7iEGonL3Lq9ttewkfokxykNorCPzPPFTO +Zw+oz12WGQvE43LrrdF9HSfvkusQv1vrO6/PgN3B0pYEW3p+pKk8OHakYo6gOV7qd89dAFmPZiw+ +B6KjBSYRaZfqhbcPlgtLyEDhULouisv3D5oi53+aNxPN8k0TayHRwMwi8qFG9kRpnMphNQcAb9Zh +CBHqurj26bNg5U257J8UZslXWNvNh2n4ioYSA0e/ZhN2rHd9NCSFg83XqpyQGp8hLH94t2S42Oim +9HizVcuE0jLEeK6jj2HdzghTreyI/BXkmg3mnxp3zkyPuBQVPWKchjgGAGYS5Fl2WlPAApiiECto +RHuOec4zSnaqW4EWG7WK2NAAe15itAnWhmMOpgWVSbooi4iTsjQc2KRVbrcc0N6ZVTsj9CLg+Slm +JuwgUHfbSguPvuUCYHBBXtSuUDkiFCbLsjtzdFVHB3mBOagwE0TlBIqulhMlQg+5U8Sb/M3kHN48 ++qvWBkofZ6aYMBzdLNvcGJVXZsb/XItW9XcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNV +HSMEGDAWgBT5YLvU49U09rj1BoAlp3PbRmmonjAdBgNVHQ4EFgQU+WC71OPVNPa49QaAJadz20Zp +qJ4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBWs47LCp1Jjr+kxJG7ZhcFUZh1 +++VQLHqe8RT6q9OKPv+RKY9ji9i0qVQBDb6Thi/5Sm3HXvVX+cpVHBK+Rw82xd9qt9t1wkclf7nx +Y/hoLVUE0fKNsKTPvDxeH3jnpaAgcLAExbf3cqfeIg29MyVGjGSSJuM+LmOW2puMPfgYCdcDzH2G +guDKBAdRUNf/ktUM79qGn5nX67evaOI5JpS6aLe/g9Pqemc9YmeuJeVy6OLk7K4S9ksrPJ/psEDz +OFSz/bdoyNrGj1E8svuR3Bznm53htw1yj+KkxKl4+esUrMZDBcJlOSgYAsOCsp0FvmXtll9ldDz7 +CTUue5wT/RsPXcdtgTpWD8w74a8CLyKsRspGPKAcTNZEtF4uXBVmCeEmKf7GUmG6sXP/wwyc5Wxq +lD8UykAWlYTzWamsX0xhk23RO8yilQwipmdnRC652dKKQbNmC1r7fSOl8hqw/96bg5Qu0T/fkreR +rwU7ZcegbLHNYhLDkBvjJc40vG93drEQw/cFGsDWr3RiSBd3kmmQYRzelYB0VI8YHMPzA9C/pEN1 +hlMYegouCRw2n5H9gooiS9EOUCXdywMMF8mDAAhONU2Ki+3wApRmLER/y5UnlhetCTCstnEXbosX +9hwJ1C07mKVx01QT2WDz9UtmT/rx7iASjbSsV7FFY6GsdqnC+w== +-----END CERTIFICATE----- + +SSL.com EV Root Certification Authority ECC +=========================================== +-----BEGIN CERTIFICATE----- +MIIClDCCAhqgAwIBAgIILCmcWxbtBZUwCgYIKoZIzj0EAwIwfzELMAkGA1UEBhMCVVMxDjAMBgNV +BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xNDAy +BgNVBAMMK1NTTC5jb20gRVYgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYw +MjEyMTgxNTIzWhcNNDEwMjEyMTgxNTIzWjB/MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjE0MDIGA1UEAwwrU1NM +LmNvbSBFViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB +BAAiA2IABKoSR5CYG/vvw0AHgyBO8TCCogbR8pKGYfL2IWjKAMTH6kMAVIbc/R/fALhBYlzccBYy +3h+Z1MzFB8gIH2EWB1E9fVwHU+M1OIzfzZ/ZLg1KthkuWnBaBu2+8KGwytAJKaNjMGEwHQYDVR0O +BBYEFFvKXuXe0oGqzagtZFG22XKbl+ZPMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUW8pe +5d7SgarNqC1kUbbZcpuX5k8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2gAMGUCMQCK5kCJ +N+vp1RPZytRrJPOwPYdGWBrssd9v+1a6cGvHOMzosYxPD/fxZ3YOg9AeUY8CMD32IygmTMZgh5Mm +m7I1HrrW9zzRHM76JTymGoEVW/MSD2zuZYrJh6j5B+BimoxcSg== +-----END CERTIFICATE----- + +GlobalSign Root CA - R6 +======================= +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEgMB4GA1UECxMX +R2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds +b2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQxMjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9i +YWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFs +U2lnbjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQss +grRIxutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1kZguSgMpE +3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxDaNc9PIrFsmbVkJq3MQbF +vuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJwLnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqM +PKq0pPbzlUoSB239jLKJz9CgYXfIWHSw1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+ +azayOeSsJDa38O+2HBNXk7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05O +WgtH8wY2SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/hbguy +CLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4nWUx2OVvq+aWh2IMP +0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpYrZxCRXluDocZXFSxZba/jJvcE+kN +b7gu3GduyYsRtYQUigAZcIN5kZeR1BonvzceMgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQE +AwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNV +HSMEGDAWgBSubAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN +nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGtIxg93eFyRJa0 +lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr6155wsTLxDKZmOMNOsIeDjHfrY +BzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLjvUYAGm0CuiVdjaExUd1URhxN25mW7xocBFym +Fe944Hn+Xds+qkxV/ZoVqW/hpvvfcDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr +3TsTjxKM4kEaSHpzoHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB1 +0jZpnOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfspA9MRf/T +uTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+vJJUEeKgDu+6B5dpffItK +oZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+t +JDfLRVpOoERIyNiwmcUVhAn21klJwGW45hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA= +-----END CERTIFICATE----- + +OISTE WISeKey Global Root GC CA +=============================== +-----BEGIN CERTIFICATE----- +MIICaTCCAe+gAwIBAgIQISpWDK7aDKtARb8roi066jAKBggqhkjOPQQDAzBtMQswCQYDVQQGEwJD +SDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNlZDEo +MCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQyBDQTAeFw0xNzA1MDkwOTQ4MzRa +Fw00MjA1MDkwOTU4MzNaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQL +ExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2Jh +bCBSb290IEdDIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAETOlQwMYPchi82PG6s4nieUqjFqdr +VCTbUf/q9Akkwwsin8tqJ4KBDdLArzHkdIJuyiXZjHWd8dvQmqJLIX4Wp2OQ0jnUsYd4XxiWD1Ab +NTcPasbc2RNNpI6QN+a9WzGRo1QwUjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUSIcUrOPDnpBgOtfKie7TrYy0UGYwEAYJKwYBBAGCNxUBBAMCAQAwCgYIKoZIzj0E +AwMDaAAwZQIwJsdpW9zV57LnyAyMjMPdeYwbY9XJUpROTYJKcx6ygISpJcBMWm1JKWB4E+J+SOtk +AjEA2zQgMgj/mkkCtojeFK9dbJlxjRo/i9fgojaGHAeCOnZT/cKi7e97sIBPWA9LUzm9 +-----END CERTIFICATE----- + +GTS Root R1 +=========== +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIQbkepxUtHDA3sM9CJuRz04TANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQG +EwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv +b3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAG +A1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx +9vaMf/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7r +aKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjwTcLCeoiKu7rPWRnW +r4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy4HqM +LnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly +4cpk9+aCEI3oncKKiPo4Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr +06zqkUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92 +wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPvOmbsB4om +3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNu +JLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEM +BQADggIBADiWCu49tJYeX++dnAsznyvgyv3SjgofQXSlfKqE1OXyHuY3UjKcC9FhHb8owbZEKTV1 +d5iyfNm9dKyKaOOpMQkpAWBz40d8U6iQSifvS9efk+eCNs6aaAyC58/UEBZvXw6ZXPYfcX3v73sv +fuo21pdwCxXu11xWajOl40k4DLh9+42FpLFZXvRq4d2h9mREruZRgyFmxhE+885H7pwoHyXa/6xm +ld01D1zvICxi/ZG6qcz8WpyTgYMpl0p8WnK0OdC3d8t5/Wk6kjftbjhlRn7pYL15iJdfOBL07q9b +gsiG1eGZbYwE8na6SfZu6W0eX6DvJ4J2QPim01hcDyxC2kLGe4g0x8HYRZvBPsVhHdljUEn2NIVq +4BjFbkerQUIpm/ZgDdIx02OYI5NaAIFItO/Nis3Jz5nu2Z6qNuFoS3FJFDYoOj0dzpqPJeaAcWEr +tXvM+SUWgeExX6GjfhaknBZqlxi9dnKlC54dNuYvoS++cJEPqOba+MSSQGwlfnuzCdyyF62ARPBo +pY+Udf90WuioAnwMCeKpSwughQtiue+hMZL77/ZRBIls6Kl0obsXs7X9SQ98POyDGCBDTtWTurQ0 +sR8WNh8M5mQ5Fkzc4P4dyKliPUDqysU0ArSuiYgzNdwsE3PYJ/HQcu51OyLemGhmW/HGY0dVHLql +CFF1pkgl +-----END CERTIFICATE----- + +GTS Root R2 +=========== +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIQbkepxlqz5yDFMJo/aFLybzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQG +EwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv +b3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAG +A1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTuk +k3LvCvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY6Dlo +7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAuMC6C/Pq8tBcKSOWI +m8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7kRXuJVfeKH2JShBKzwkCX44ofR5Gm +dFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWgf9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbu +ak7MkogwTZq9TwtImoS1mKPV+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscsz +cTJGr61K8YzodDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW +Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKaG73Vululycsl +aVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCqgc7dGtxRcw1PcOnlthYhGXmy +5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEM +BQADggIBALZp8KZ3/p7uC4Gt4cCpx/k1HUCCq+YEtN/L9x0Pg/B+E02NjO7jMyLDOfxA325BS0JT +vhaI8dI4XsRomRyYUpOM52jtG2pzegVATX9lO9ZY8c6DR2Dj/5epnGB3GFW1fgiTz9D2PGcDFWEJ ++YF59exTpJ/JjwGLc8R3dtyDovUMSRqodt6Sm2T4syzFJ9MHwAiApJiS4wGWAqoC7o87xdFtCjMw +c3i5T1QWvwsHoaRc5svJXISPD+AVdyx+Jn7axEvbpxZ3B7DNdehyQtaVhJ2Gg/LkkM0JR9SLA3Da +WsYDQvTtN6LwG1BUSw7YhN4ZKJmBR64JGz9I0cNv4rBgF/XuIwKl2gBbbZCr7qLpGzvpx0QnRY5r +n/WkhLx3+WuXrD5RRaIRpsyF7gpo8j5QOHokYh4XIDdtak23CZvJ/KRY9bb7nE4Yu5UC56Gtmwfu +Nmsk0jmGwZODUNKBRqhfYlcsu2xkiAhu7xNUX90txGdj08+JN7+dIPT7eoOboB6BAFDC5AwiWVIQ +7UNWhwD4FFKnHYuTjKJNRn8nxnGbJN7k2oaLDX5rIMHAnuFl2GqjpuiFizoHCBy69Y9Vmhh1fuXs +gWbRIXOhNUQLgD1bnF5vKheW0YMjiGZt5obicDIvUiLnyOd/xCxgXS/Dr55FBcOEArf9LAhST4Ld +o/DUhgkC +-----END CERTIFICATE----- + +GTS Root R3 +=========== +-----BEGIN CERTIFICATE----- +MIICDDCCAZGgAwIBAgIQbkepx2ypcyRAiQ8DVd2NHTAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg +UjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE +ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUU +Rout736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL24Cej +QjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTB8Sa6oC2uhYHP +0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEAgFukfCPAlaUs3L6JbyO5o91lAFJekazInXJ0 +glMLfalAvWhgxeG4VDvBNhcl2MG9AjEAnjWSdIUlUfUk7GRSJFClH9voy8l27OyCbvWFGFPouOOa +KaqW04MjyaR7YbPMAuhd +-----END CERTIFICATE----- + +GTS Root R4 +=========== +-----BEGIN CERTIFICATE----- +MIICCjCCAZGgAwIBAgIQbkepyIuUtui7OyrYorLBmTAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg +UjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE +ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa +6zzuhXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvRHYqj +QjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSATNbrdP9JNqPV +2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNnADBkAjBqUFJ0CMRw3J5QdCHojXohw0+WbhXRIjVhLfoI +N+4Zba3bssx9BzT1YBkstTTZbyACMANxsbqjYAuG7ZoIapVon+Kz4ZNkfF6Tpt95LY2F45TPI11x +zPKwTdb+mciUqXWi4w== +-----END CERTIFICATE----- + +UCA Global G2 Root +================== +-----BEGIN CERTIFICATE----- +MIIFRjCCAy6gAwIBAgIQXd+x2lqj7V2+WmUgZQOQ7zANBgkqhkiG9w0BAQsFADA9MQswCQYDVQQG +EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxGzAZBgNVBAMMElVDQSBHbG9iYWwgRzIgUm9vdDAeFw0x +NjAzMTEwMDAwMDBaFw00MDEyMzEwMDAwMDBaMD0xCzAJBgNVBAYTAkNOMREwDwYDVQQKDAhVbmlU +cnVzdDEbMBkGA1UEAwwSVUNBIEdsb2JhbCBHMiBSb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A +MIICCgKCAgEAxeYrb3zvJgUno4Ek2m/LAfmZmqkywiKHYUGRO8vDaBsGxUypK8FnFyIdK+35KYmT +oni9kmugow2ifsqTs6bRjDXVdfkX9s9FxeV67HeToI8jrg4aA3++1NDtLnurRiNb/yzmVHqUwCoV +8MmNsHo7JOHXaOIxPAYzRrZUEaalLyJUKlgNAQLx+hVRZ2zA+te2G3/RVogvGjqNO7uCEeBHANBS +h6v7hn4PJGtAnTRnvI3HLYZveT6OqTwXS3+wmeOwcWDcC/Vkw85DvG1xudLeJ1uK6NjGruFZfc8o +LTW4lVYa8bJYS7cSN8h8s+1LgOGN+jIjtm+3SJUIsUROhYw6AlQgL9+/V087OpAh18EmNVQg7Mc/ +R+zvWr9LesGtOxdQXGLYD0tK3Cv6brxzks3sx1DoQZbXqX5t2Okdj4q1uViSukqSKwxW/YDrCPBe +KW4bHAyvj5OJrdu9o54hyokZ7N+1wxrrFv54NkzWbtA+FxyQF2smuvt6L78RHBgOLXMDj6DlNaBa +4kx1HXHhOThTeEDMg5PXCp6dW4+K5OXgSORIskfNTip1KnvyIvbJvgmRlld6iIis7nCs+dwp4wwc +OxJORNanTrAmyPPZGpeRaOrvjUYG0lZFWJo8DA+DuAUlwznPO6Q0ibd5Ei9Hxeepl2n8pndntd97 +8XplFeRhVmUCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFIHEjMz15DD/pQwIX4wVZyF0Ad/fMA0GCSqGSIb3DQEBCwUAA4ICAQATZSL1jiutROTL/7lo +5sOASD0Ee/ojL3rtNtqyzm325p7lX1iPyzcyochltq44PTUbPrw7tgTQvPlJ9Zv3hcU2tsu8+Mg5 +1eRfB70VVJd0ysrtT7q6ZHafgbiERUlMjW+i67HM0cOU2kTC5uLqGOiiHycFutfl1qnN3e92mI0A +Ds0b+gO3joBYDic/UvuUospeZcnWhNq5NXHzJsBPd+aBJ9J3O5oUb3n09tDh05S60FdRvScFDcH9 +yBIw7m+NESsIndTUv4BFFJqIRNow6rSn4+7vW4LVPtateJLbXDzz2K36uGt/xDYotgIVilQsnLAX +c47QN6MUPJiVAAwpBVueSUmxX8fjy88nZY41F7dXyDDZQVu5FLbowg+UMaeUmMxq67XhJ/UQqAHo +jhJi6IjMtX9Gl8CbEGY4GjZGXyJoPd/JxhMnq1MGrKI8hgZlb7F+sSlEmqO6SWkoaY/X5V+tBIZk +bxqgDMUIYs6Ao9Dz7GjevjPHF1t/gMRMTLGmhIrDO7gJzRSBuhjjVFc2/tsvfEehOjPI+Vg7RE+x +ygKJBJYoaMVLuCaJu9YzL1DV/pqJuhgyklTGW+Cd+V7lDSKb9triyCGyYiGqhkCyLmTTX8jjfhFn +RR8F/uOi77Oos/N9j/gMHyIfLXC0uAE0djAA5SN4p1bXUB+K+wb1whnw0A== +-----END CERTIFICATE----- + +UCA Extended Validation Root +============================ +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIQT9Irj/VkyDOeTzRYZiNwYDANBgkqhkiG9w0BAQsFADBHMQswCQYDVQQG +EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9u +IFJvb3QwHhcNMTUwMzEzMDAwMDAwWhcNMzgxMjMxMDAwMDAwWjBHMQswCQYDVQQGEwJDTjERMA8G +A1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9uIFJvb3QwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpCQcoEwKwmeBkqh5DFnpzsZGgdT6o+uM4AHrs +iWogD4vFsJszA1qGxliG1cGFu0/GnEBNyr7uaZa4rYEwmnySBesFK5pI0Lh2PpbIILvSsPGP2KxF +Rv+qZ2C0d35qHzwaUnoEPQc8hQ2E0B92CvdqFN9y4zR8V05WAT558aopO2z6+I9tTcg1367r3CTu +eUWnhbYFiN6IXSV8l2RnCdm/WhUFhvMJHuxYMjMR83dksHYf5BA1FxvyDrFspCqjc/wJHx4yGVMR +59mzLC52LqGj3n5qiAno8geK+LLNEOfic0CTuwjRP+H8C5SzJe98ptfRr5//lpr1kXuYC3fUfugH +0mK1lTnj8/FtDw5lhIpjVMWAtuCeS31HJqcBCF3RiJ7XwzJE+oJKCmhUfzhTA8ykADNkUVkLo4KR +el7sFsLzKuZi2irbWWIQJUoqgQtHB0MGcIfS+pMRKXpITeuUx3BNr2fVUbGAIAEBtHoIppB/TuDv +B0GHr2qlXov7z1CymlSvw4m6WC31MJixNnI5fkkE/SmnTHnkBVfblLkWU41Gsx2VYVdWf6/wFlth +WG82UBEL2KwrlRYaDh8IzTY0ZRBiZtWAXxQgXy0MoHgKaNYs1+lvK9JKBZP8nm9rZ/+I8U6laUpS +NwXqxhaN0sSZ0YIrO7o1dfdRUVjzyAfd5LQDfwIDAQABo0IwQDAdBgNVHQ4EFgQU2XQ65DA9DfcS +3H5aBZ8eNJr34RQwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQEL +BQADggIBADaNl8xCFWQpN5smLNb7rhVpLGsaGvdftvkHTFnq88nIua7Mui563MD1sC3AO6+fcAUR +ap8lTwEpcOPlDOHqWnzcSbvBHiqB9RZLcpHIojG5qtr8nR/zXUACE/xOHAbKsxSQVBcZEhrxH9cM +aVr2cXj0lH2RC47skFSOvG+hTKv8dGT9cZr4QQehzZHkPJrgmzI5c6sq1WnIeJEmMX3ixzDx/BR4 +dxIOE/TdFpS/S2d7cFOFyrC78zhNLJA5wA3CXWvp4uXViI3WLL+rG761KIcSF3Ru/H38j9CHJrAb ++7lsq+KePRXBOy5nAliRn+/4Qh8st2j1da3Ptfb/EX3C8CSlrdP6oDyp+l3cpaDvRKS+1ujl5BOW +F3sGPjLtx7dCvHaj2GU4Kzg1USEODm8uNBNA4StnDG1KQTAYI1oyVZnJF+A83vbsea0rWBmirSwi +GpWOvpaQXUJXxPkUAzUrHC1RVwinOt4/5Mi0A3PCwSaAuwtCH60NryZy2sy+s6ODWA2CxR9GUeOc +GMyNm43sSet1UNWMKFnKdDTajAshqx7qG+XH/RU+wBeq+yNuJkbL+vmxcmtpzyKEC2IPrNkZAJSi +djzULZrtBJ4tBmIQN1IchXIbJ+XMxjHsN+xjWZsLHXbMfjKaiJUINlK73nZfdklJrX+9ZSCyycEr +dhh2n1ax +-----END CERTIFICATE----- + +Certigna Root CA +================ +-----BEGIN CERTIFICATE----- +MIIGWzCCBEOgAwIBAgIRAMrpG4nxVQMNo+ZBbcTjpuEwDQYJKoZIhvcNAQELBQAwWjELMAkGA1UE +BhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczEcMBoGA1UECwwTMDAwMiA0ODE0NjMwODEwMDAzNjEZ +MBcGA1UEAwwQQ2VydGlnbmEgUm9vdCBDQTAeFw0xMzEwMDEwODMyMjdaFw0zMzEwMDEwODMyMjda +MFoxCzAJBgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxHDAaBgNVBAsMEzAwMDIgNDgxNDYz +MDgxMDAwMzYxGTAXBgNVBAMMEENlcnRpZ25hIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDNGDllGlmx6mQWDoyUJJV8g9PFOSbcDO8WV43X2KyjQn+Cyu3NW9sOty3tRQgX +stmzy9YXUnIo245Onoq2C/mehJpNdt4iKVzSs9IGPjA5qXSjklYcoW9MCiBtnyN6tMbaLOQdLNyz +KNAT8kxOAkmhVECe5uUFoC2EyP+YbNDrihqECB63aCPuI9Vwzm1RaRDuoXrC0SIxwoKF0vJVdlB8 +JXrJhFwLrN1CTivngqIkicuQstDuI7pmTLtipPlTWmR7fJj6o0ieD5Wupxj0auwuA0Wv8HT4Ks16 +XdG+RCYyKfHx9WzMfgIhC59vpD++nVPiz32pLHxYGpfhPTc3GGYo0kDFUYqMwy3OU4gkWGQwFsWq +4NYKpkDfePb1BHxpE4S80dGnBs8B92jAqFe7OmGtBIyT46388NtEbVncSVmurJqZNjBBe3YzIoej +wpKGbvlw7q6Hh5UbxHq9MfPU0uWZ/75I7HX1eBYdpnDBfzwboZL7z8g81sWTCo/1VTp2lc5ZmIoJ +lXcymoO6LAQ6l73UL77XbJuiyn1tJslV1c/DeVIICZkHJC1kJWumIWmbat10TWuXekG9qxf5kBdI +jzb5LdXF2+6qhUVB+s06RbFo5jZMm5BX7CO5hwjCxAnxl4YqKE3idMDaxIzb3+KhF1nOJFl0Mdp/ +/TBt2dzhauH8XwIDAQABo4IBGjCCARYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw +HQYDVR0OBBYEFBiHVuBud+4kNTxOc5of1uHieX4rMB8GA1UdIwQYMBaAFBiHVuBud+4kNTxOc5of +1uHieX4rMEQGA1UdIAQ9MDswOQYEVR0gADAxMC8GCCsGAQUFBwIBFiNodHRwczovL3d3d3cuY2Vy +dGlnbmEuZnIvYXV0b3JpdGVzLzBtBgNVHR8EZjBkMC+gLaArhilodHRwOi8vY3JsLmNlcnRpZ25h +LmZyL2NlcnRpZ25hcm9vdGNhLmNybDAxoC+gLYYraHR0cDovL2NybC5kaGlteW90aXMuY29tL2Nl +cnRpZ25hcm9vdGNhLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAlLieT/DjlQgi581oQfccVdV8AOIt +OoldaDgvUSILSo3L6btdPrtcPbEo/uRTVRPPoZAbAh1fZkYJMyjhDSSXcNMQH+pkV5a7XdrnxIxP +TGRGHVyH41neQtGbqH6mid2PHMkwgu07nM3A6RngatgCdTer9zQoKJHyBApPNeNgJgH60BGM+RFq +7q89w1DTj18zeTyGqHNFkIwgtnJzFyO+B2XleJINugHA64wcZr+shncBlA2c5uk5jR+mUYyZDDl3 +4bSb+hxnV29qao6pK0xXeXpXIs/NX2NGjVxZOob4Mkdio2cNGJHc+6Zr9UhhcyNZjgKnvETq9Emd +8VRY+WCv2hikLyhF3HqgiIZd8zvn/yk1gPxkQ5Tm4xxvvq0OKmOZK8l+hfZx6AYDlf7ej0gcWtSS +6Cvu5zHbugRqh5jnxV/vfaci9wHYTfmJ0A6aBVmknpjZbyvKcL5kwlWj9Omvw5Ip3IgWJJk8jSaY +tlu3zM63Nwf9JtmYhST/WSMDmu2dnajkXjjO11INb9I/bbEFa0nOipFGc/T2L/Coc3cOZayhjWZS +aX5LaAzHHjcng6WMxwLkFM1JAbBzs/3GkDpv0mztO+7skb6iQ12LAEpmJURw3kAP+HwV96LOPNde +E4yBFxgX0b3xdxA61GU5wSesVywlVP+i2k+KYTlerj1KjL0= +-----END CERTIFICATE----- + +emSign Root CA - G1 +=================== +-----BEGIN CERTIFICATE----- +MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYDVQQGEwJJTjET +MBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl +ZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBHMTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgx +ODMwMDBaMGcxCzAJBgNVBAYTAklOMRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVk +aHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQzf2N4aLTN +LnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO8oG0x5ZOrRkVUkr+PHB1 +cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aqd7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHW +DV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhMtTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ +6DqS0hdW5TUaQBw+jSztOd9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrH +hQIDAQABo0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQDAgEG +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31xPaOfG1vR2vjTnGs2 +vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjMwiI/aTvFthUvozXGaCocV685743Q +NcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6dGNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q ++Mri/Tm3R7nrft8EI6/6nAYH6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeih +U80Bv2noWgbyRQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx +iN66zB+Afko= +-----END CERTIFICATE----- + +emSign ECC Root CA - G3 +======================= +-----BEGIN CERTIFICATE----- +MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQGEwJJTjETMBEG +A1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg +MB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4 +MTgzMDAwWjBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11 +ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g +RzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0WXTsuwYc +58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xySfvalY8L1X44uT6EYGQIr +MgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuBzhccLikenEhjQjAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+D +CBeQyh+KTOgNG3qxrdWBCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7 +jHvrZQnD+JbNR6iC8hZVdyR+EhCVBCyj +-----END CERTIFICATE----- + +emSign Root CA - C1 +=================== +-----BEGIN CERTIFICATE----- +MIIDczCCAlugAwIBAgILAK7PALrEzzL4Q7IwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMx +EzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNp +Z24gUm9vdCBDQSAtIEMxMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowVjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQD +ExNlbVNpZ24gUm9vdCBDQSAtIEMxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+up +ufGZBczYKCFK83M0UYRWEPWgTywS4/oTmifQz/l5GnRfHXk5/Fv4cI7gklL35CX5VIPZHdPIWoU/ +Xse2B+4+wM6ar6xWQio5JXDWv7V7Nq2s9nPczdcdioOl+yuQFTdrHCZH3DspVpNqs8FqOp099cGX +OFgFixwR4+S0uF2FHYP+eF8LRWgYSKVGczQ7/g/IdrvHGPMF0Ybzhe3nudkyrVWIzqa2kbBPrH4V +I5b2P/AgNBbeCsbEBEV5f6f9vtKppa+cxSMq9zwhbL2vj07FOrLzNBL834AaSaTUqZX3noleooms +lMuoaJuvimUnzYnu3Yy1aylwQ6BpC+S5DwIDAQABo0IwQDAdBgNVHQ4EFgQU/qHgcB4qAzlSWkK+ +XJGFehiqTbUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQAD +ggEBAMJKVvoVIXsoounlHfv4LcQ5lkFMOycsxGwYFYDGrK9HWS8mC+M2sO87/kOXSTKZEhVb3xEp +/6tT+LvBeA+snFOvV71ojD1pM/CjoCNjO2RnIkSt1XHLVip4kqNPEjE2NuLe/gDEo2APJ62gsIq1 +NnpSob0n9CAnYuhNlCQT5AoE6TyrLshDCUrGYQTlSTR+08TI9Q/Aqum6VF7zYytPT1DU/rl7mYw9 +wC68AivTxEDkigcxHpvOJpkT+xHqmiIMERnHXhuBUDDIlhJu58tBf5E7oke3VIAb3ADMmpDqw8NQ +BmIMMMAVSKeoWXzhriKi4gp6D/piq1JM4fHfyr6DDUI= +-----END CERTIFICATE----- + +emSign ECC Root CA - C3 +======================= +-----BEGIN CERTIFICATE----- +MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQGEwJVUzETMBEG +A1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxIDAeBgNVBAMTF2VtU2lnbiBF +Q0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQD +ExdlbVNpZ24gRUNDIFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd +6bciMK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4OjavtisIGJAnB9 +SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0OBBYEFPtaSNCAIEDyqOkA +B2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gA +MGUCMQC02C8Cif22TGK6Q04ThHK1rt0c3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwU +ZOR8loMRnLDRWmFLpg9J0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ== +-----END CERTIFICATE----- + +Hongkong Post Root CA 3 +======================= +-----BEGIN CERTIFICATE----- +MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQELBQAwbzELMAkG +A1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJSG9uZyBLb25nMRYwFAYDVQQK +Ew1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25na29uZyBQb3N0IFJvb3QgQ0EgMzAeFw0xNzA2 +MDMwMjI5NDZaFw00MjA2MDMwMjI5NDZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtv +bmcxEjAQBgNVBAcTCUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMX +SG9uZ2tvbmcgUG9zdCBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz +iNfqzg8gTr7m1gNt7ln8wlffKWihgw4+aMdoWJwcYEuJQwy51BWy7sFOdem1p+/l6TWZ5Mwc50tf +jTMwIDNT2aa71T4Tjukfh0mtUC1Qyhi+AViiE3CWu4mIVoBc+L0sPOFMV4i707mV78vH9toxdCim +5lSJ9UExyuUmGs2C4HDaOym71QP1mbpV9WTRYA6ziUm4ii8F0oRFKHyPaFASePwLtVPLwpgchKOe +sL4jpNrcyCse2m5FHomY2vkALgbpDDtw1VAliJnLzXNg99X/NWfFobxeq81KuEXryGgeDQ0URhLj +0mRiikKYvLTGCAj4/ahMZJx2Ab0vqWwzD9g/KLg8aQFChn5pwckGyuV6RmXpwtZQQS4/t+TtbNe/ +JgERohYpSms0BpDsE9K2+2p20jzt8NYt3eEV7KObLyzJPivkaTv/ciWxNoZbx39ri1UbSsUgYT2u +y1DhCDq+sI9jQVMwCFk8mB13umOResoQUGC/8Ne8lYePl8X+l2oBlKN8W4UdKjk60FSh0Tlxnf0h ++bV78OLgAo9uliQlLKAeLKjEiafv7ZkGL7YKTE/bosw3Gq9HhS2KX8Q0NEwA/RiTZxPRN+ZItIsG +xVd7GYYKecsAyVKvQv83j+GjHno9UKtjBucVtT+2RTeUN7F+8kjDf8V1/peNRY8apxpyKBpADwID +AQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQXnc0e +i9Y5K3DTXNSguB+wAPzFYTAdBgNVHQ4EFgQUF53NHovWOStw01zUoLgfsAD8xWEwDQYJKoZIhvcN +AQELBQADggIBAFbVe27mIgHSQpsY1Q7XZiNc4/6gx5LS6ZStS6LG7BJ8dNVI0lkUmcDrudHr9Egw +W62nV3OZqdPlt9EuWSRY3GguLmLYauRwCy0gUCCkMpXRAJi70/33MvJJrsZ64Ee+bs7Lo3I6LWld +y8joRTnU+kLBEUx3XZL7av9YROXrgZ6voJmtvqkBZss4HTzfQx/0TW60uhdG/H39h4F5ag0zD/ov ++BS5gLNdTaqX4fnkGMX41TiMJjz98iji7lpJiCzfeT2OnpA8vUFKOt1b9pq0zj8lMH8yfaIDlNDc +eqFS3m6TjRgm/VWsvY+b0s+v54Ysyx8Jb6NvqYTUc79NoXQbTiNg8swOqn+knEwlqLJmOzj/2ZQw +9nKEvmhVEA/GcywWaZMH/rFF7buiVWqw2rVKAiUnhde3t4ZEFolsgCs+l6mc1X5VTMbeRRAc6uk7 +nwNT7u56AQIWeNTowr5GdogTPyK7SBIdUgC0An4hGh6cJfTzPV4e0hz5sy229zdcxsshTrD3mUcY +hcErulWuBurQB7Lcq9CClnXO0lD+mefPL5/ndtFhKvshuzHQqp9HpLIiyhY6UFfEW0NnxWViA0kB +60PZ2Pierc+xYw5F9KBaLJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fq +dBb9HxEGmpv0 +-----END CERTIFICATE----- + +Entrust Root Certification Authority - G4 +========================================= +-----BEGIN CERTIFICATE----- +MIIGSzCCBDOgAwIBAgIRANm1Q3+vqTkPAAAAAFVlrVgwDQYJKoZIhvcNAQELBQAwgb4xCzAJBgNV +BAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3Qu +bmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNSBFbnRydXN0LCBJbmMuIC0gZm9yIGF1 +dGhvcml6ZWQgdXNlIG9ubHkxMjAwBgNVBAMTKUVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1 +dGhvcml0eSAtIEc0MB4XDTE1MDUyNzExMTExNloXDTM3MTIyNzExNDExNlowgb4xCzAJBgNVBAYT +AlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0 +L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNSBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhv +cml6ZWQgdXNlIG9ubHkxMjAwBgNVBAMTKUVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhv +cml0eSAtIEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsewsQu7i0TD/pZJH4i3D +umSXbcr3DbVZwbPLqGgZ2K+EbTBwXX7zLtJTmeH+H17ZSK9dE43b/2MzTdMAArzE+NEGCJR5WIoV +3imz/f3ET+iq4qA7ec2/a0My3dl0ELn39GjUu9CH1apLiipvKgS1sqbHoHrmSKvS0VnM1n4j5pds +8ELl3FFLFUHtSUrJ3hCX1nbB76W1NhSXNdh4IjVS70O92yfbYVaCNNzLiGAMC1rlLAHGVK/XqsEQ +e9IFWrhAnoanw5CGAlZSCXqc0ieCU0plUmr1POeo8pyvi73TDtTUXm6Hnmo9RR3RXRv06QqsYJn7 +ibT/mCzPfB3pAqoEmh643IhuJbNsZvc8kPNXwbMv9W3y+8qh+CmdRouzavbmZwe+LGcKKh9asj5X +xNMhIWNlUpEbsZmOeX7m640A2Vqq6nPopIICR5b+W45UYaPrL0swsIsjdXJ8ITzI9vF01Bx7owVV +7rtNOzK+mndmnqxpkCIHH2E6lr7lmk/MBTwoWdPBDFSoWWG9yHJM6Nyfh3+9nEg2XpWjDrk4JFX8 +dWbrAuMINClKxuMrLzOg2qOGpRKX/YAr2hRC45K9PvJdXmd0LhyIRyk0X+IyqJwlN4y6mACXi0mW +Hv0liqzc2thddG5msP9E36EYxr5ILzeUePiVSj9/E15dWf10hkNjc0kCAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJ84xFYjwznooHFs6FRM5Og6sb9n +MA0GCSqGSIb3DQEBCwUAA4ICAQAS5UKme4sPDORGpbZgQIeMJX6tuGguW8ZAdjwD+MlZ9POrYs4Q +jbRaZIxowLByQzTSGwv2LFPSypBLhmb8qoMi9IsabyZIrHZ3CL/FmFz0Jomee8O5ZDIBf9PD3Vht +7LGrhFV0d4QEJ1JrhkzO3bll/9bGXp+aEJlLdWr+aumXIOTkdnrG0CSqkM0gkLpHZPt/B7NTeLUK +YvJzQ85BK4FqLoUWlFPUa19yIqtRLULVAJyZv967lDtX/Zr1hstWO1uIAeV8KEsD+UmDfLJ/fOPt +jqF/YFOOVZ1QNBIPt5d7bIdKROf1beyAN/BYGW5KaHbwH5Lk6rWS02FREAutp9lfx1/cH6NcjKF+ +m7ee01ZvZl4HliDtC3T7Zk6LERXpgUl+b7DUUH8i119lAg2m9IUe2K4GS0qn0jFmwvjO5QimpAKW +RGhXxNUzzxkvFMSUHHuk2fCfDrGA4tGeEWSpiBE6doLlYsKA2KSD7ZPvfC+QsDJMlhVoSFLUmQjA +JOgc47OlIQ6SwJAfzyBfyjs4x7dtOvPmRLgOMWuIjnDrnBdSqEGULoe256YSxXXfW8AKbnuk5F6G ++TaU33fD6Q3AOfF5u0aOq0NZJ7cguyPpVkAh7DE9ZapD8j3fcEThuk0mEDuYn/PIjhs4ViFqUZPT +kcpG2om3PVODLAgfi49T3f+sHw== +-----END CERTIFICATE----- + +Microsoft ECC Root Certificate Authority 2017 +============================================= +-----BEGIN CERTIFICATE----- +MIICWTCCAd+gAwIBAgIQZvI9r4fei7FK6gxXMQHC7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV +UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgRUND +IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjMwNjQ1WhcNNDIwNzE4 +MjMxNjA0WjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw +NAYDVQQDEy1NaWNyb3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwdjAQ +BgcqhkjOPQIBBgUrgQQAIgNiAATUvD0CQnVBEyPNgASGAlEvaqiBYgtlzPbKnR5vSmZRogPZnZH6 +thaxjG7efM3beaYvzrvOcS/lpaso7GMEZpn4+vKTEAXhgShC48Zo9OYbhGBKia/teQ87zvH2RPUB +eMCjVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTIy5lycFIM ++Oa+sgRXKSrPQhDtNTAQBgkrBgEEAYI3FQEEAwIBADAKBggqhkjOPQQDAwNoADBlAjBY8k3qDPlf +Xu5gKcs68tvWMoQZP3zVL8KxzJOuULsJMsbG7X7JNpQS5GiFBqIb0C8CMQCZ6Ra0DvpWSNSkMBaR +eNtUjGUBiudQZsIxtzm6uBoiB078a1QWIP8rtedMDE2mT3M= +-----END CERTIFICATE----- + +Microsoft RSA Root Certificate Authority 2017 +============================================= +-----BEGIN CERTIFICATE----- +MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQG +EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQg +UlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIw +NzE4MjMwMDIzWjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u +MTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw +ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZNt9GkMml +7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0ZdDMbRnMlfl7rEqUrQ7e +S0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw7 +1VdyvD/IybLeS2v4I2wDwAW9lcfNcztmgGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+ +dkC0zVJhUXAoP8XFWvLJjEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49F +yGcohJUcaDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaGYaRS +MLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6W6IYZVcSn2i51BVr +lMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4KUGsTuqwPN1q3ErWQgR5WrlcihtnJ +0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH+FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJ +ClTUFLkqqNfs+avNJVgyeY+QW5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZCLgLNFgVZJ8og +6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OCgMNPOsduET/m4xaRhPtthH80 +dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk ++ONVFT24bcMKpBLBaYVu32TxU5nhSnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex +/2kskZGT4d9Mozd2TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDy +AmH3pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGRxpl/j8nW +ZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiAppGWSZI1b7rCoucL5mxAyE +7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKT +c0QWbej09+CVgI+WXTik9KveCjCHk9hNAHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D +5KbvtwEwXlGjefVwaaZBRA+GsCyRxj3qrg+E +-----END CERTIFICATE----- + +e-Szigno Root CA 2017 +===================== +-----BEGIN CERTIFICATE----- +MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNVBAYTAkhVMREw +DwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUt +MjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJvb3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZa +Fw00MjA4MjIxMjA3MDZaMHExCzAJBgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UE +CgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3pp +Z25vIFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtvxie+RJCx +s1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+HWyx7xf58etqjYzBhMA8G +A1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSHERUI0arBeAyxr87GyZDv +vzAEwDAfBgNVHSMEGDAWgBSHERUI0arBeAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEA +tVfd14pVCzbhhkT61NlojbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxO +svxyqltZ+efcMQ== +-----END CERTIFICATE----- + +certSIGN Root CA G2 +=================== +-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIJEQA0tk7GNi02MA0GCSqGSIb3DQEBCwUAMEExCzAJBgNVBAYTAlJPMRQw +EgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjAeFw0xNzAy +MDYwOTI3MzVaFw00MjAyMDYwOTI3MzVaMEExCzAJBgNVBAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lH +TiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBAMDFdRmRfUR0dIf+DjuW3NgBFszuY5HnC2/OOwppGnzC46+CjobXXo9X69MhWf05 +N0IwvlDqtg+piNguLWkh59E3GE59kdUWX2tbAMI5Qw02hVK5U2UPHULlj88F0+7cDBrZuIt4Imfk +abBoxTzkbFpG583H+u/E7Eu9aqSs/cwoUe+StCmrqzWaTOTECMYmzPhpn+Sc8CnTXPnGFiWeI8Mg +wT0PPzhAsP6CRDiqWhqKa2NYOLQV07YRaXseVO6MGiKscpc/I1mbySKEwQdPzH/iV8oScLumZfNp +dWO9lfsbl83kqK/20U6o2YpxJM02PbyWxPFsqa7lzw1uKA2wDrXKUXt4FMMgL3/7FFXhEZn91Qqh +ngLjYl/rNUssuHLoPj1PrCy7Lobio3aP5ZMqz6WryFyNSwb/EkaseMsUBzXgqd+L6a8VTxaJW732 +jcZZroiFDsGJ6x9nxUWO/203Nit4ZoORUSs9/1F3dmKh7Gc+PoGD4FapUB8fepmrY7+EF3fxDTvf +95xhszWYijqy7DwaNz9+j5LP2RIUZNoQAhVB/0/E6xyjyfqZ90bp4RjZsbgyLcsUDFDYg2WD7rlc +z8sFWkz6GZdr1l0T08JcVLwyc6B49fFtHsufpaafItzRUZ6CeWRgKRM+o/1Pcmqr4tTluCRVLERL +iohEnMqE0yo7AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1Ud +DgQWBBSCIS1mxteg4BXrzkwJd8RgnlRuAzANBgkqhkiG9w0BAQsFAAOCAgEAYN4auOfyYILVAzOB +ywaK8SJJ6ejqkX/GM15oGQOGO0MBzwdw5AgeZYWR5hEit/UCI46uuR59H35s5r0l1ZUa8gWmr4UC +b6741jH/JclKyMeKqdmfS0mbEVeZkkMR3rYzpMzXjWR91M08KCy0mpbqTfXERMQlqiCA2ClV9+BB +/AYm/7k29UMUA2Z44RGx2iBfRgB4ACGlHgAoYXhvqAEBj500mv/0OJD7uNGzcgbJceaBxXntC6Z5 +8hMLnPddDnskk7RI24Zf3lCGeOdA5jGokHZwYa+cNywRtYK3qq4kNFtyDGkNzVmf9nGvnAvRCjj5 +BiKDUyUM/FHE5r7iOZULJK2v0ZXkltd0ZGtxTgI8qoXzIKNDOXZbbFD+mpwUHmUUihW9o4JFWklW +atKcsWMy5WHgUyIOpwpJ6st+H6jiYoD2EEVSmAYY3qXNL3+q1Ok+CHLsIwMCPKaq2LxndD0UF/tU +Sxfj03k9bWtJySgOLnRQvwzZRjoQhsmnP+mg7H/rpXdYaXHmgwo38oZJar55CJD2AhZkPuXaTH4M +NMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE1LlSVHJ7liXMvGnjSG4N +0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MXQRBdJ3NghVdJIgc= +-----END CERTIFICATE----- + +Trustwave Global Certification Authority +======================================== +-----BEGIN CERTIFICATE----- +MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJV +UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2 +ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTAeFw0xNzA4MjMxOTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJV +UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2 +ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALldUShLPDeS0YLOvR29 +zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0XznswuvCAAJWX/NKSqIk4cXGIDtiLK0thAf +LdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4Bq +stTnoApTAbqOl5F2brz81Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9o +WN0EACyW80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotPJqX+ +OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1lRtzuzWniTY+HKE40 +Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfwhI0Vcnyh78zyiGG69Gm7DIwLdVcE +uE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm ++9jaJXLE9gCxInm943xZYkqcBW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqj +ifLJS3tBEW1ntwiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1UdDwEB/wQEAwIB +BjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W0OhUKDtkLSGm+J1WE2pIPU/H +PinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfeuyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0H +ZJDmHvUqoai7PF35owgLEQzxPy0QlG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla +4gt5kNdXElE1GYhBaCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5R +vbbEsLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPTMaCm/zjd +zyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qequ5AvzSxnI9O4fKSTx+O +856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxhVicGaeVyQYHTtgGJoC86cnn+OjC/QezH +Yj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu +3R3y4G5OBVixwJAWKqQ9EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP +29FpHOTKyeC2nOnOcXHebD8WpHk= +-----END CERTIFICATE----- + +Trustwave Global ECC P256 Certification Authority +================================================= +-----BEGIN CERTIFICATE----- +MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYDVQQGEwJVUzER +MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI +b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy +dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1 +NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABH77bOYj +43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoNFWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqm +P62jQzBBMA8GA1UdEwEB/wQFMAMBAf8wDwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt +0UrrdaVKEJmzsaGLSvcwCgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjz +RM4q3wghDDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7 +-----END CERTIFICATE----- + +Trustwave Global ECC P384 Certification Authority +================================================= +-----BEGIN CERTIFICATE----- +MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzER +MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI +b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy +dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4 +NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuBBAAiA2IABGvaDXU1CDFH +Ba5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJj9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr +/TklZvFe/oyujUF5nQlgziip04pt89ZF1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNV +HQ8BAf8EBQMDBwYAMB0GA1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNn +ADBkAjA3AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsCMGcl +CrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVuSw== +-----END CERTIFICATE----- + +NAVER Global Root Certification Authority +========================================= +-----BEGIN CERTIFICATE----- +MIIFojCCA4qgAwIBAgIUAZQwHqIL3fXFMyqxQ0Rx+NZQTQ0wDQYJKoZIhvcNAQEMBQAwaTELMAkG +A1UEBhMCS1IxJjAkBgNVBAoMHU5BVkVSIEJVU0lORVNTIFBMQVRGT1JNIENvcnAuMTIwMAYDVQQD +DClOQVZFUiBHbG9iYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MTgwODU4 +NDJaFw0zNzA4MTgyMzU5NTlaMGkxCzAJBgNVBAYTAktSMSYwJAYDVQQKDB1OQVZFUiBCVVNJTkVT +UyBQTEFURk9STSBDb3JwLjEyMDAGA1UEAwwpTkFWRVIgR2xvYmFsIFJvb3QgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC21PGTXLVAiQqrDZBb +UGOukJR0F0Vy1ntlWilLp1agS7gvQnXp2XskWjFlqxcX0TM62RHcQDaH38dq6SZeWYp34+hInDEW ++j6RscrJo+KfziFTowI2MMtSAuXaMl3Dxeb57hHHi8lEHoSTGEq0n+USZGnQJoViAbbJAh2+g1G7 +XNr4rRVqmfeSVPc0W+m/6imBEtRTkZazkVrd/pBzKPswRrXKCAfHcXLJZtM0l/aM9BhK4dA9WkW2 +aacp+yPOiNgSnABIqKYPszuSjXEOdMWLyEz59JuOuDxp7W87UC9Y7cSw0BwbagzivESq2M0UXZR4 +Yb8ObtoqvC8MC3GmsxY/nOb5zJ9TNeIDoKAYv7vxvvTWjIcNQvcGufFt7QSUqP620wbGQGHfnZ3z +VHbOUzoBppJB7ASjjw2i1QnK1sua8e9DXcCrpUHPXFNwcMmIpi3Ua2FzUCaGYQ5fG8Ir4ozVu53B +A0K6lNpfqbDKzE0K70dpAy8i+/Eozr9dUGWokG2zdLAIx6yo0es+nPxdGoMuK8u180SdOqcXYZai +cdNwlhVNt0xz7hlcxVs+Qf6sdWA7G2POAN3aCJBitOUt7kinaxeZVL6HSuOpXgRM6xBtVNbv8ejy +YhbLgGvtPe31HzClrkvJE+2KAQHJuFFYwGY6sWZLxNUxAmLpdIQM201GLQIDAQABo0IwQDAdBgNV +HQ4EFgQU0p+I36HNLL3s9TsBAZMzJ7LrYEswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB +Af8wDQYJKoZIhvcNAQEMBQADggIBADLKgLOdPVQG3dLSLvCkASELZ0jKbY7gyKoNqo0hV4/GPnrK +21HUUrPUloSlWGB/5QuOH/XcChWB5Tu2tyIvCZwTFrFsDDUIbatjcu3cvuzHV+YwIHHW1xDBE1UB +jCpD5EHxzzp6U5LOogMFDTjfArsQLtk70pt6wKGm+LUx5vR1yblTmXVHIloUFcd4G7ad6Qz4G3bx +hYTeodoS76TiEJd6eN4MUZeoIUCLhr0N8F5OSza7OyAfikJW4Qsav3vQIkMsRIz75Sq0bBwcupTg +E34h5prCy8VCZLQelHsIJchxzIdFV4XTnyliIoNRlwAYl3dqmJLJfGBs32x9SuRwTMKeuB330DTH +D8z7p/8Dvq1wkNoL3chtl1+afwkyQf3NosxabUzyqkn+Zvjp2DXrDige7kgvOtB5CTh8piKCk5XQ +A76+AqAF3SAi428diDRgxuYKuQl1C/AH6GmWNcf7I4GOODm4RStDeKLRLBT/DShycpWbXgnbiUSY +qqFJu3FS8r/2/yehNq+4tneI3TqkbZs0kNwUXTC/t+sX5Ie3cdCh13cV1ELX8vMxmV2b3RZtP+oG +I/hGoiLtk/bdmuYqh7GYVPEi92tF4+KOdh2ajcQGjTa3FPOdVGm3jjzVpG2Tgbet9r1ke8LJaDmg +kpzNNIaRkPpkUZ3+/uul9XXeifdy +-----END CERTIFICATE----- + +` + +// CACerts builds an X.509 certificate pool containing the +// certificate bundle from https://curl.haxx.se/ca/cacert.pem fetch on 2021-01-29 09:54:51.941105652 +0100 CET m=+1.231498959. +// Returns nil on error along with an appropriate error code. +func CACerts() (*x509.CertPool, error) { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM([]byte(pemcerts)) + return pool, nil +} diff --git a/internal/engine/netx/gocertifi/generate.go b/internal/engine/netx/gocertifi/generate.go new file mode 100644 index 0000000..927b08b --- /dev/null +++ b/internal/engine/netx/gocertifi/generate.go @@ -0,0 +1,95 @@ +// +build ignore + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Forked from github.com/certifi/gocertifi . +// +// This script should not be invoked directly, rather it should be +// executed by running go generate ./... from toplevel dir. + +package main + +import ( + "crypto/x509" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "text/template" + "time" +) + +var tmpl = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. +// {{ .Timestamp }} +// {{ .URL }} + +package gocertifi + +//go:generate go run generate.go "{{ .URL }}" + +import "crypto/x509" + +const pemcerts string = ` + "`" + ` +{{ .Bundle }} +` + "`" + ` + +// CACerts builds an X.509 certificate pool containing the +// certificate bundle from {{ .URL }} fetch on {{ .Timestamp }}. +// Returns nil on error along with an appropriate error code. +func CACerts() (*x509.CertPool, error) { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM([]byte(pemcerts)) + return pool, nil +} +`)) + +func main() { + if len(os.Args) != 2 || !strings.HasPrefix(os.Args[1], "https://") { + log.Fatal("usage: go run generate.go ") + } + url := os.Args[1] + + resp, err := http.Get(url) + if err != nil { + log.Fatal(err) + } + if resp.StatusCode != 200 { + log.Fatal("expected 200, got", resp.StatusCode) + } + defer resp.Body.Close() + + bundle, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(bundle) { + log.Fatalf("can't parse certificates from %s", url) + } + + fp, err := os.Create("certifi.go") + if err != nil { + log.Fatal(err) + } + + err = tmpl.Execute(fp, struct { + Timestamp time.Time + URL string + Bundle string + }{ + Timestamp: time.Now(), + URL: url, + Bundle: string(bundle), + }) + if err != nil { + log.Fatal(err) + } + + if err := fp.Close(); err != nil { + log.Fatal(err) + } +} diff --git a/internal/engine/netx/httptransport/bytecounter.go b/internal/engine/netx/httptransport/bytecounter.go new file mode 100644 index 0000000..cc61559 --- /dev/null +++ b/internal/engine/netx/httptransport/bytecounter.go @@ -0,0 +1,73 @@ +package httptransport + +import ( + "io" + "net/http" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" +) + +// ByteCountingTransport is a RoundTripper that counts bytes. +type ByteCountingTransport struct { + RoundTripper + Counter *bytecounter.Counter +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp ByteCountingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Body != nil { + req.Body = byteCountingBody{ + ReadCloser: req.Body, Account: txp.Counter.CountBytesSent} + } + txp.estimateRequestMetadata(req) + resp, err := txp.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + txp.estimateResponseMetadata(resp) + resp.Body = byteCountingBody{ + ReadCloser: resp.Body, Account: txp.Counter.CountBytesReceived} + return resp, nil +} + +func (txp ByteCountingTransport) estimateRequestMetadata(req *http.Request) { + txp.Counter.CountBytesSent(len(req.Method)) + txp.Counter.CountBytesSent(len(req.URL.String())) + for key, values := range req.Header { + for _, value := range values { + txp.Counter.CountBytesSent(len(key)) + txp.Counter.CountBytesSent(len(": ")) + txp.Counter.CountBytesSent(len(value)) + txp.Counter.CountBytesSent(len("\r\n")) + } + } + txp.Counter.CountBytesSent(len("\r\n")) +} + +func (txp ByteCountingTransport) estimateResponseMetadata(resp *http.Response) { + txp.Counter.CountBytesReceived(len(resp.Status)) + for key, values := range resp.Header { + for _, value := range values { + txp.Counter.CountBytesReceived(len(key)) + txp.Counter.CountBytesReceived(len(": ")) + txp.Counter.CountBytesReceived(len(value)) + txp.Counter.CountBytesReceived(len("\r\n")) + } + } + txp.Counter.CountBytesReceived(len("\r\n")) +} + +type byteCountingBody struct { + io.ReadCloser + Account func(int) +} + +func (r byteCountingBody) Read(p []byte) (int, error) { + count, err := r.ReadCloser.Read(p) + if count > 0 { + r.Account(count) + } + return count, err +} + +var _ RoundTripper = ByteCountingTransport{} diff --git a/internal/engine/netx/httptransport/bytecounter_test.go b/internal/engine/netx/httptransport/bytecounter_test.go new file mode 100644 index 0000000..dd5005f --- /dev/null +++ b/internal/engine/netx/httptransport/bytecounter_test.go @@ -0,0 +1,128 @@ +package httptransport_test + +import ( + "errors" + "io" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" +) + +func TestByteCounterFailure(t *testing.T) { + counter := bytecounter.New() + txp := httptransport.ByteCountingTransport{ + Counter: counter, + RoundTripper: httptransport.FakeTransport{ + Err: io.EOF, + }, + } + client := &http.Client{Transport: txp} + req, err := http.NewRequest( + "POST", "https://www.google.com", strings.NewReader("AAAAAA")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("User-Agent", "antani-browser/1.0.0") + resp, err := client.Do(req) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response here") + } + if counter.Sent.Load() != 68 { + t.Fatal("expected around 68 bytes sent") + } + if counter.Received.Load() != 0 { + t.Fatal("expected zero bytes received") + } +} + +func TestByteCounterSuccess(t *testing.T) { + counter := bytecounter.New() + txp := httptransport.ByteCountingTransport{ + Counter: counter, + RoundTripper: httptransport.FakeTransport{ + Resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader("1234567")), + Header: http.Header{ + "Server": []string{"antani/0.1.0"}, + }, + Status: "200 OK", + StatusCode: http.StatusOK, + }, + }, + } + client := &http.Client{Transport: txp} + req, err := http.NewRequest( + "POST", "https://www.google.com", strings.NewReader("AAAAAA")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("User-Agent", "antani-browser/1.0.0") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if string(data) != "1234567" { + t.Fatal("expected a different body here") + } + if counter.Sent.Load() != 68 { + t.Fatal("expected around 68 bytes sent") + } + if counter.Received.Load() != 37 { + t.Fatal("expected zero around 37 bytes received") + } +} + +func TestByteCounterSuccessWithEOF(t *testing.T) { + counter := bytecounter.New() + txp := httptransport.ByteCountingTransport{ + Counter: counter, + RoundTripper: httptransport.FakeTransport{ + Resp: &http.Response{ + Body: bodyReaderWithEOF{}, + Header: http.Header{ + "Server": []string{"antani/0.1.0"}, + }, + Status: "200 OK", + StatusCode: http.StatusOK, + }, + }, + } + client := &http.Client{Transport: txp} + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if string(data) != "A" { + t.Fatal("expected a different body here") + } +} + +type bodyReaderWithEOF struct{} + +func (bodyReaderWithEOF) Read(p []byte) (int, error) { + if len(p) < 1 { + panic("should not happen") + } + p[0] = 'A' + return 1, io.EOF // we want code to be robust to this +} +func (bodyReaderWithEOF) Close() error { + return nil +} diff --git a/internal/engine/netx/httptransport/fake_test.go b/internal/engine/netx/httptransport/fake_test.go new file mode 100644 index 0000000..4f2a25b --- /dev/null +++ b/internal/engine/netx/httptransport/fake_test.go @@ -0,0 +1,56 @@ +package httptransport + +import ( + "context" + "io/ioutil" + "net" + "net/http" + "time" +) + +type FakeDialer struct { + Conn net.Conn + Err error +} + +func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return d.Conn, d.Err +} + +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() {} + +type FakeBody struct { + Err error +} + +func (fb FakeBody) Read(p []byte) (int, error) { + time.Sleep(10 * time.Microsecond) + return 0, fb.Err +} + +func (fb FakeBody) Close() error { + return nil +} diff --git a/internal/engine/netx/httptransport/http3transport.go b/internal/engine/netx/httptransport/http3transport.go new file mode 100644 index 0000000..ae42740 --- /dev/null +++ b/internal/engine/netx/httptransport/http3transport.go @@ -0,0 +1,43 @@ +package httptransport + +import ( + "context" + "crypto/tls" + "net/http" + + "github.com/lucas-clemente/quic-go" + "github.com/lucas-clemente/quic-go/http3" + "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer" +) + +// QUICWrapperDialer is a QUICDialer that wraps a ContextDialer +// This is necessary because the http3 RoundTripper does not support a DialContext method. +type QUICWrapperDialer struct { + Dialer quicdialer.ContextDialer +} + +// Dial implements QUICDialer.Dial +func (d QUICWrapperDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + return d.Dialer.DialContext(context.Background(), network, host, tlsCfg, cfg) +} + +// HTTP3Transport is a httptransport.RoundTripper using the http3 protocol. +type HTTP3Transport struct { + http3.RoundTripper +} + +// CloseIdleConnections closes all the connections opened by this transport. +func (t *HTTP3Transport) CloseIdleConnections() { + t.RoundTripper.Close() +} + +// NewHTTP3Transport creates a new HTTP3Transport instance. +func NewHTTP3Transport(config Config) RoundTripper { + txp := &HTTP3Transport{} + txp.QuicConfig = &quic.Config{} + txp.TLSClientConfig = config.TLSConfig + txp.Dial = config.QUICDialer.Dial + return txp +} + +var _ RoundTripper = &http.Transport{} diff --git a/internal/engine/netx/httptransport/http3transport_test.go b/internal/engine/netx/httptransport/http3transport_test.go new file mode 100644 index 0000000..8e53aa9 --- /dev/null +++ b/internal/engine/netx/httptransport/http3transport_test.go @@ -0,0 +1,157 @@ +package httptransport_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "strings" + "testing" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" +) + +type MockQUICDialer struct{} + +func (d MockQUICDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + return quic.DialAddrEarly(host, tlsCfg, cfg) +} + +type MockSNIQUICDialer struct { + namech chan string +} + +func (d MockSNIQUICDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + d.namech <- tlsCfg.ServerName + return quic.DialAddrEarly(host, tlsCfg, cfg) +} + +type MockCertQUICDialer struct { + certch chan *x509.CertPool +} + +func (d MockCertQUICDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + d.certch <- tlsCfg.RootCAs + return quic.DialAddrEarly(host, tlsCfg, cfg) +} + +func TestHTTP3TransportSNI(t *testing.T) { + namech := make(chan string, 1) + sni := "sni.org" + txp := httptransport.NewHTTP3Transport(httptransport.Config{ + Dialer: selfcensor.SystemDialer{}, QUICDialer: MockSNIQUICDialer{namech: namech}, TLSConfig: &tls.Config{ServerName: sni}}) + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err == nil { + t.Fatal("expected error here") + } + if resp != nil { + t.Fatal("expected nil resp here") + } + if !strings.Contains(err.Error(), "certificate is valid for www.google.com, not "+sni) { + t.Fatal("unexpected error type", err) + } + servername := <-namech + if servername != sni { + t.Fatal("unexpected server name", servername) + } +} + +func TestHTTP3TransportSNINoVerify(t *testing.T) { + namech := make(chan string, 1) + sni := "sni.org" + txp := httptransport.NewHTTP3Transport(httptransport.Config{ + Dialer: selfcensor.SystemDialer{}, QUICDialer: MockSNIQUICDialer{namech: namech}, TLSConfig: &tls.Config{ServerName: sni, InsecureSkipVerify: true}}) + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + if resp == nil { + t.Fatal("unexpected nil resp") + } + servername := <-namech + if servername != sni { + t.Fatal("unexpected server name", servername) + } +} + +func TestHTTP3TransportCABundle(t *testing.T) { + certch := make(chan *x509.CertPool, 1) + certpool := x509.NewCertPool() + txp := httptransport.NewHTTP3Transport(httptransport.Config{ + Dialer: selfcensor.SystemDialer{}, QUICDialer: MockCertQUICDialer{certch: certch}, TLSConfig: &tls.Config{RootCAs: certpool}}) + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err == nil { + t.Fatal("expected error here") + } + if resp != nil { + t.Fatal("expected nil resp here") + } + // since the certificate pool is empty, the unknown authority error should be thrown + if !strings.Contains(err.Error(), "certificate signed by unknown authority") { + t.Fatal("unexpected error type") + } + certs := <-certch + if certs != certpool { + t.Fatal("not the certpool we expected") + } + +} + +func TestUnitHTTP3TransportSuccess(t *testing.T) { + txp := httptransport.NewHTTP3Transport(httptransport.Config{ + Dialer: selfcensor.SystemDialer{}, QUICDialer: MockQUICDialer{}}) + + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("unexpected nil response here") + } + if resp.StatusCode != 200 { + t.Fatal("HTTP statuscode should be 200 OK", resp.StatusCode) + } +} + +func TestUnitHTTP3TransportFailure(t *testing.T) { + txp := httptransport.NewHTTP3Transport(httptransport.Config{ + Dialer: selfcensor.SystemDialer{}, QUICDialer: MockQUICDialer{}}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // so that the request immediately fails + req, err := http.NewRequestWithContext(ctx, "GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err == nil { + t.Fatal("expected error here") + } + // context.Canceled error occurs if the test host supports QUIC + // timeout error ("Handshake did not complete in time") occurs if the test host does not support QUIC + if !(errors.Is(err, context.Canceled) || strings.HasSuffix(err.Error(), "Handshake did not complete in time")) { + t.Fatal("not the error we expected", err) + } + if resp != nil { + t.Fatal("expected nil response here") + } +} diff --git a/internal/engine/netx/httptransport/httptransport.go b/internal/engine/netx/httptransport/httptransport.go new file mode 100644 index 0000000..46b6864 --- /dev/null +++ b/internal/engine/netx/httptransport/httptransport.go @@ -0,0 +1,47 @@ +// Package httptransport contains HTTP transport extensions. +package httptransport + +import ( + "context" + "crypto/tls" + "net" + "net/http" + + "github.com/lucas-clemente/quic-go" +) + +// Config contains the configuration required for constructing an HTTP transport +type Config struct { + Dialer Dialer + QUICDialer QUICDialer + TLSDialer TLSDialer + TLSConfig *tls.Config +} + +// Dialer is the definition of dialer assumed by this package. +type Dialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// TLSDialer is the definition of a TLS dialer assumed by this package. +type TLSDialer interface { + DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// QUICDialer is the definition of dialer for QUIC assumed by this package. +type QUICDialer interface { + Dial(network, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) +} + +// RoundTripper is the definition of http.RoundTripper used by this package. +type RoundTripper interface { + RoundTrip(req *http.Request) (*http.Response, error) + CloseIdleConnections() +} + +// Resolver is the interface we expect from a resolver +type Resolver interface { + LookupHost(ctx context.Context, hostname string) (addrs []string, err error) + Network() string + Address() string +} diff --git a/internal/engine/netx/httptransport/logging.go b/internal/engine/netx/httptransport/logging.go new file mode 100644 index 0000000..07a3bad --- /dev/null +++ b/internal/engine/netx/httptransport/logging.go @@ -0,0 +1,50 @@ +package httptransport + +import "net/http" + +// Logger is the logger assumed by this package +type Logger interface { + Debugf(format string, v ...interface{}) + Debug(message string) +} + +// LoggingTransport is a logging transport +type LoggingTransport struct { + RoundTripper + Logger Logger +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + host := req.Host + if host == "" { + host = req.URL.Host + } + req.Header.Set("Host", host) // anticipate what Go would do + return txp.logTrip(req) +} + +func (txp LoggingTransport) logTrip(req *http.Request) (*http.Response, error) { + txp.Logger.Debugf("> %s %s", req.Method, req.URL.String()) + for key, values := range req.Header { + for _, value := range values { + txp.Logger.Debugf("> %s: %s", key, value) + } + } + txp.Logger.Debug(">") + resp, err := txp.RoundTripper.RoundTrip(req) + if err != nil { + txp.Logger.Debugf("< %s", err) + return nil, err + } + txp.Logger.Debugf("< %d", resp.StatusCode) + for key, values := range resp.Header { + for _, value := range values { + txp.Logger.Debugf("< %s: %s", key, value) + } + } + txp.Logger.Debug("<") + return resp, nil +} + +var _ RoundTripper = LoggingTransport{} diff --git a/internal/engine/netx/httptransport/logging_test.go b/internal/engine/netx/httptransport/logging_test.go new file mode 100644 index 0000000..c9c8a16 --- /dev/null +++ b/internal/engine/netx/httptransport/logging_test.go @@ -0,0 +1,77 @@ +package httptransport_test + +import ( + "errors" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" +) + +func TestLoggingFailure(t *testing.T) { + txp := httptransport.LoggingTransport{ + Logger: log.Log, + RoundTripper: httptransport.FakeTransport{ + Err: io.EOF, + }, + } + client := &http.Client{Transport: txp} + resp, err := client.Get("https://www.google.com") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response here") + } +} + +func TestLoggingFailureWithNoHostHeader(t *testing.T) { + txp := httptransport.LoggingTransport{ + Logger: log.Log, + RoundTripper: httptransport.FakeTransport{ + Err: io.EOF, + }, + } + req := &http.Request{ + Header: http.Header{}, + URL: &url.URL{ + Scheme: "https", + Host: "www.google.com", + Path: "/", + }, + } + resp, err := txp.RoundTrip(req) + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response here") + } +} + +func TestLoggingSuccess(t *testing.T) { + txp := httptransport.LoggingTransport{ + Logger: log.Log, + RoundTripper: httptransport.FakeTransport{ + Resp: &http.Response{ + Body: ioutil.NopCloser(strings.NewReader("")), + Header: http.Header{ + "Server": []string{"antani/0.1.0"}, + }, + StatusCode: 200, + }, + }, + } + client := &http.Client{Transport: txp} + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + ioutil.ReadAll(resp.Body) + resp.Body.Close() +} diff --git a/internal/engine/netx/httptransport/saver.go b/internal/engine/netx/httptransport/saver.go new file mode 100644 index 0000000..25a869d --- /dev/null +++ b/internal/engine/netx/httptransport/saver.go @@ -0,0 +1,158 @@ +package httptransport + +import ( + "bytes" + "io" + "io/ioutil" + "net/http" + "net/http/httptrace" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// SaverPerformanceHTTPTransport is a RoundTripper that saves +// performance events occurring during the round trip +type SaverPerformanceHTTPTransport struct { + RoundTripper + Saver *trace.Saver +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp SaverPerformanceHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + tracep := httptrace.ContextClientTrace(req.Context()) + if tracep == nil { + tracep = &httptrace.ClientTrace{ + WroteHeaders: func() { + txp.Saver.Write(trace.Event{Name: "http_wrote_headers", Time: time.Now()}) + }, + WroteRequest: func(httptrace.WroteRequestInfo) { + txp.Saver.Write(trace.Event{Name: "http_wrote_request", Time: time.Now()}) + }, + GotFirstResponseByte: func() { + txp.Saver.Write(trace.Event{ + Name: "http_first_response_byte", Time: time.Now()}) + }, + } + req = req.WithContext(httptrace.WithClientTrace(req.Context(), tracep)) + } + return txp.RoundTripper.RoundTrip(req) +} + +// SaverMetadataHTTPTransport is a RoundTripper that saves +// events related to HTTP request and response metadata +type SaverMetadataHTTPTransport struct { + RoundTripper + Saver *trace.Saver + Transport string +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp SaverMetadataHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + txp.Saver.Write(trace.Event{ + HTTPHeaders: req.Header, + HTTPMethod: req.Method, + HTTPURL: req.URL.String(), + Transport: txp.Transport, + Name: "http_request_metadata", + Time: time.Now(), + }) + resp, err := txp.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + txp.Saver.Write(trace.Event{ + HTTPHeaders: resp.Header, + HTTPStatusCode: resp.StatusCode, + Name: "http_response_metadata", + Time: time.Now(), + }) + return resp, err +} + +// SaverTransactionHTTPTransport is a RoundTripper that saves +// events related to the HTTP transaction +type SaverTransactionHTTPTransport struct { + RoundTripper + Saver *trace.Saver +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp SaverTransactionHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + txp.Saver.Write(trace.Event{ + Name: "http_transaction_start", + Time: time.Now(), + }) + resp, err := txp.RoundTripper.RoundTrip(req) + txp.Saver.Write(trace.Event{ + Err: err, + Name: "http_transaction_done", + Time: time.Now(), + }) + return resp, err +} + +// SaverBodyHTTPTransport is a RoundTripper that saves +// body events occurring during the round trip +type SaverBodyHTTPTransport struct { + RoundTripper + Saver *trace.Saver + SnapshotSize int +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp SaverBodyHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + const defaultSnapSize = 1 << 17 + snapsize := defaultSnapSize + if txp.SnapshotSize != 0 { + snapsize = txp.SnapshotSize + } + if req.Body != nil { + data, err := saverSnapRead(req.Body, snapsize) + if err != nil { + return nil, err + } + req.Body = saverCompose(data, req.Body) + txp.Saver.Write(trace.Event{ + DataIsTruncated: len(data) >= snapsize, + Data: data, + Name: "http_request_body_snapshot", + Time: time.Now(), + }) + } + resp, err := txp.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + data, err := saverSnapRead(resp.Body, snapsize) + if err != nil { + resp.Body.Close() + return nil, err + } + resp.Body = saverCompose(data, resp.Body) + txp.Saver.Write(trace.Event{ + DataIsTruncated: len(data) >= snapsize, + Data: data, + Name: "http_response_body_snapshot", + Time: time.Now(), + }) + return resp, nil +} + +func saverSnapRead(r io.ReadCloser, snapsize int) ([]byte, error) { + return ioutil.ReadAll(io.LimitReader(r, int64(snapsize))) +} + +func saverCompose(data []byte, r io.ReadCloser) io.ReadCloser { + return saverReadCloser{Closer: r, Reader: io.MultiReader(bytes.NewReader(data), r)} +} + +type saverReadCloser struct { + io.Closer + io.Reader +} + +var _ RoundTripper = SaverPerformanceHTTPTransport{} +var _ RoundTripper = SaverMetadataHTTPTransport{} +var _ RoundTripper = SaverBodyHTTPTransport{} +var _ RoundTripper = SaverTransactionHTTPTransport{} diff --git a/internal/engine/netx/httptransport/saver_test.go b/internal/engine/netx/httptransport/saver_test.go new file mode 100644 index 0000000..8846107 --- /dev/null +++ b/internal/engine/netx/httptransport/saver_test.go @@ -0,0 +1,429 @@ +package httptransport_test + +import ( + "errors" + "io/ioutil" + "net/http" + "strings" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestSaverPerformanceNoMultipleEvents(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := &trace.Saver{} + // register twice - do we see events twice? + txp := httptransport.SaverPerformanceHTTPTransport{ + RoundTripper: http.DefaultTransport.(*http.Transport), + Saver: saver, + } + txp = httptransport.SaverPerformanceHTTPTransport{ + RoundTripper: txp, + Saver: saver, + } + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatal("not the error we expected") + } + if resp == nil { + t.Fatal("expected non nil response here") + } + ev := saver.Read() + // we should specifically see the events not attached to any + // context being submitted twice. This is fine because they are + // explicit, while the context is implicit and hence leads to + // more subtle bugs. For example, this happens when you measure + // every event and combine HTTP with DoH. + if len(ev) != 3 { + t.Fatal("expected three events") + } + expected := []string{ + "http_wrote_headers", // measured with context + "http_wrote_request", // measured with context + "http_first_response_byte", // measured with context + } + for i := 0; i < len(expected); i++ { + if ev[i].Name != expected[i] { + t.Fatal("unexpected event name") + } + } +} + +func TestSaverMetadataSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := &trace.Saver{} + txp := httptransport.SaverMetadataHTTPTransport{ + RoundTripper: http.DefaultTransport.(*http.Transport), + Saver: saver, + } + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Add("User-Agent", "miniooni/0.1.0-dev") + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatal("not the error we expected") + } + if resp == nil { + t.Fatal("expected non nil response here") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("expected two events") + } + // + if ev[0].HTTPMethod != "GET" { + t.Fatal("unexpected Method") + } + if len(ev[0].HTTPHeaders) <= 0 { + t.Fatal("unexpected Headers") + } + if ev[0].HTTPURL != "https://www.google.com" { + t.Fatal("unexpected URL") + } + if ev[0].Name != "http_request_metadata" { + t.Fatal("unexpected Name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("unexpected Time") + } + // + if ev[1].HTTPStatusCode != 200 { + t.Fatal("unexpected StatusCode") + } + if len(ev[1].HTTPHeaders) <= 0 { + t.Fatal("unexpected Headers") + } + if ev[1].Name != "http_response_metadata" { + t.Fatal("unexpected Name") + } + if !ev[1].Time.After(ev[0].Time) { + t.Fatal("unexpected Time") + } +} + +func TestSaverMetadataFailure(t *testing.T) { + expected := errors.New("mocked error") + saver := &trace.Saver{} + txp := httptransport.SaverMetadataHTTPTransport{ + RoundTripper: httptransport.FakeTransport{ + Err: expected, + }, + Saver: saver, + } + req, err := http.NewRequest("GET", "http://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Add("User-Agent", "miniooni/0.1.0-dev") + resp, err := txp.RoundTrip(req) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response here") + } + ev := saver.Read() + if len(ev) != 1 { + t.Fatal("expected one event") + } + if ev[0].HTTPMethod != "GET" { + t.Fatal("unexpected Method") + } + if len(ev[0].HTTPHeaders) <= 0 { + t.Fatal("unexpected Headers") + } + if ev[0].HTTPURL != "http://www.google.com" { + t.Fatal("unexpected URL") + } + if ev[0].Name != "http_request_metadata" { + t.Fatal("unexpected Name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("unexpected Time") + } +} + +func TestSaverTransactionSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := &trace.Saver{} + txp := httptransport.SaverTransactionHTTPTransport{ + RoundTripper: http.DefaultTransport.(*http.Transport), + Saver: saver, + } + req, err := http.NewRequest("GET", "https://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatal("not the error we expected") + } + if resp == nil { + t.Fatal("expected non nil response here") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("expected two events") + } + // + if ev[0].Name != "http_transaction_start" { + t.Fatal("unexpected Name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("unexpected Time") + } + // + if ev[1].Err != nil { + t.Fatal("unexpected Err") + } + if ev[1].Name != "http_transaction_done" { + t.Fatal("unexpected Name") + } + if !ev[1].Time.After(ev[0].Time) { + t.Fatal("unexpected Time") + } +} + +func TestSaverTransactionFailure(t *testing.T) { + expected := errors.New("mocked error") + saver := &trace.Saver{} + txp := httptransport.SaverTransactionHTTPTransport{ + RoundTripper: httptransport.FakeTransport{ + Err: expected, + }, + Saver: saver, + } + req, err := http.NewRequest("GET", "http://www.google.com", nil) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response here") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("expected two events") + } + if ev[0].Name != "http_transaction_start" { + t.Fatal("unexpected Name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("unexpected Time") + } + if ev[1].Name != "http_transaction_done" { + t.Fatal("unexpected Name") + } + if !errors.Is(ev[1].Err, expected) { + t.Fatal("unexpected Err") + } + if !ev[1].Time.After(ev[0].Time) { + t.Fatal("unexpected Time") + } +} + +func TestSaverBodySuccess(t *testing.T) { + saver := new(trace.Saver) + txp := httptransport.SaverBodyHTTPTransport{ + RoundTripper: httptransport.FakeTransport{ + Func: func(req *http.Request) (*http.Response, error) { + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if string(data) != "deadbeef" { + t.Fatal("invalid data") + } + return &http.Response{ + StatusCode: 501, + Body: ioutil.NopCloser(strings.NewReader("abad1dea")), + }, nil + }, + }, + SnapshotSize: 4, + Saver: saver, + } + body := strings.NewReader("deadbeef") + req, err := http.NewRequest("POST", "http://x.org/y", body) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 501 { + t.Fatal("unexpected status code") + } + defer resp.Body.Close() + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(data) != "abad1dea" { + t.Fatal("unexpected body") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("unexpected number of events") + } + if string(ev[0].Data) != "dead" { + t.Fatal("invalid Data") + } + if ev[0].DataIsTruncated != true { + t.Fatal("invalid DataIsTruncated") + } + if ev[0].Name != "http_request_body_snapshot" { + t.Fatal("invalid Name") + } + if ev[0].Time.After(time.Now()) { + t.Fatal("invalid Time") + } + if string(ev[1].Data) != "abad" { + t.Fatal("invalid Data") + } + if ev[1].DataIsTruncated != true { + t.Fatal("invalid DataIsTruncated") + } + if ev[1].Name != "http_response_body_snapshot" { + t.Fatal("invalid Name") + } + if ev[1].Time.Before(ev[0].Time) { + t.Fatal("invalid Time") + } +} + +func TestSaverBodyRequestReadError(t *testing.T) { + saver := new(trace.Saver) + txp := httptransport.SaverBodyHTTPTransport{ + RoundTripper: httptransport.FakeTransport{ + Func: func(req *http.Request) (*http.Response, error) { + panic("should not be called") + }, + }, + SnapshotSize: 4, + Saver: saver, + } + expected := errors.New("mocked error") + body := httptransport.FakeBody{Err: expected} + req, err := http.NewRequest("POST", "http://x.org/y", body) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response") + } + ev := saver.Read() + if len(ev) != 0 { + t.Fatal("unexpected number of events") + } +} + +func TestSaverBodyRoundTripError(t *testing.T) { + saver := new(trace.Saver) + expected := errors.New("mocked error") + txp := httptransport.SaverBodyHTTPTransport{ + RoundTripper: httptransport.FakeTransport{ + Err: expected, + }, + SnapshotSize: 4, + Saver: saver, + } + body := strings.NewReader("deadbeef") + req, err := http.NewRequest("POST", "http://x.org/y", body) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response") + } + ev := saver.Read() + if len(ev) != 1 { + t.Fatal("unexpected number of events") + } + if string(ev[0].Data) != "dead" { + t.Fatal("invalid Data") + } + if ev[0].DataIsTruncated != true { + t.Fatal("invalid DataIsTruncated") + } + if ev[0].Name != "http_request_body_snapshot" { + t.Fatal("invalid Name") + } + if ev[0].Time.After(time.Now()) { + t.Fatal("invalid Time") + } +} + +func TestSaverBodyResponseReadError(t *testing.T) { + saver := new(trace.Saver) + expected := errors.New("mocked error") + txp := httptransport.SaverBodyHTTPTransport{ + RoundTripper: httptransport.FakeTransport{ + Func: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: httptransport.FakeBody{ + Err: expected, + }, + }, nil + }, + }, + SnapshotSize: 4, + Saver: saver, + } + body := strings.NewReader("deadbeef") + req, err := http.NewRequest("POST", "http://x.org/y", body) + if err != nil { + t.Fatal(err) + } + resp, err := txp.RoundTrip(req) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("expected nil response") + } + ev := saver.Read() + if len(ev) != 1 { + t.Fatal("unexpected number of events") + } + if string(ev[0].Data) != "dead" { + t.Fatal("invalid Data") + } + if ev[0].DataIsTruncated != true { + t.Fatal("invalid DataIsTruncated") + } + if ev[0].Name != "http_request_body_snapshot" { + t.Fatal("invalid Name") + } + if ev[0].Time.After(time.Now()) { + t.Fatal("invalid Time") + } +} diff --git a/internal/engine/netx/httptransport/system.go b/internal/engine/netx/httptransport/system.go new file mode 100644 index 0000000..13af6fd --- /dev/null +++ b/internal/engine/netx/httptransport/system.go @@ -0,0 +1,24 @@ +package httptransport + +import ( + "net/http" +) + +// NewSystemTransport creates a new "system" HTTP transport. That is a transport +// using the Go standard library with custom dialer and TLS dialer. +func NewSystemTransport(config Config) RoundTripper { + txp := http.DefaultTransport.(*http.Transport).Clone() + txp.DialContext = config.Dialer.DialContext + txp.DialTLSContext = config.TLSDialer.DialTLSContext + // Better for Cloudflare DNS and also better because we have less + // noisy events and we can better understand what happened. + txp.MaxConnsPerHost = 1 + // The following (1) reduces the number of headers that Go will + // automatically send for us and (2) ensures that we always receive + // back the true headers, such as Content-Length. This change is + // functional to OONI's goal of observing the network. + txp.DisableCompression = true + return txp +} + +var _ RoundTripper = &http.Transport{} diff --git a/internal/engine/netx/httptransport/useragent.go b/internal/engine/netx/httptransport/useragent.go new file mode 100644 index 0000000..1e9bf40 --- /dev/null +++ b/internal/engine/netx/httptransport/useragent.go @@ -0,0 +1,19 @@ +package httptransport + +import "net/http" + +// UserAgentTransport is a transport that ensures that we always +// set an OONI specific default User-Agent header. +type UserAgentTransport struct { + RoundTripper +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "miniooni/0.1.0-dev") + } + return txp.RoundTripper.RoundTrip(req) +} + +var _ RoundTripper = UserAgentTransport{} diff --git a/internal/engine/netx/httptransport/useragent_test.go b/internal/engine/netx/httptransport/useragent_test.go new file mode 100644 index 0000000..c8b8e9d --- /dev/null +++ b/internal/engine/netx/httptransport/useragent_test.go @@ -0,0 +1,51 @@ +package httptransport_test + +import ( + "net/http" + "net/url" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" +) + +func TestUserAgentWithDefault(t *testing.T) { + txp := httptransport.UserAgentTransport{ + RoundTripper: httptransport.FakeTransport{ + Resp: &http.Response{StatusCode: 200}, + }, + } + req := &http.Request{URL: &url.URL{ + Scheme: "https", + Host: "www.google.com", + Path: "/", + }} + req.Header = http.Header{} + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if resp.Request.Header.Get("User-Agent") != "miniooni/0.1.0-dev" { + t.Fatal("not the User-Agent we expected") + } +} + +func TestUserAgentWithExplicitValue(t *testing.T) { + txp := httptransport.UserAgentTransport{ + RoundTripper: httptransport.FakeTransport{ + Resp: &http.Response{StatusCode: 200}, + }, + } + req := &http.Request{URL: &url.URL{ + Scheme: "https", + Host: "www.google.com", + Path: "/", + }} + req.Header = http.Header{"User-Agent": []string{"antani-client/0.1.1"}} + resp, err := txp.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if resp.Request.Header.Get("User-Agent") != "antani-client/0.1.1" { + t.Fatal("not the User-Agent we expected") + } +} diff --git a/internal/engine/netx/integration_test.go b/internal/engine/netx/integration_test.go new file mode 100644 index 0000000..0b7db6d --- /dev/null +++ b/internal/engine/netx/integration_test.go @@ -0,0 +1,93 @@ +package netx_test + +import ( + "context" + "errors" + "io/ioutil" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestSuccess(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + log.SetLevel(log.DebugLevel) + counter := bytecounter.New() + config := netx.Config{ + BogonIsError: true, + ByteCounter: counter, + CacheResolutions: true, + ContextByteCounting: true, + DialSaver: &trace.Saver{}, + HTTPSaver: &trace.Saver{}, + Logger: log.Log, + ReadWriteSaver: &trace.Saver{}, + ResolveSaver: &trace.Saver{}, + TLSSaver: &trace.Saver{}, + } + txp := netx.NewHTTPTransport(config) + client := &http.Client{Transport: txp} + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatal(err) + } + if _, err = ioutil.ReadAll(resp.Body); err != nil { + t.Fatal(err) + } + if err = resp.Body.Close(); err != nil { + t.Fatal(err) + } + if counter.Sent.Load() <= 0 { + t.Fatal("no bytes sent?!") + } + if counter.Received.Load() <= 0 { + t.Fatal("no bytes received?!") + } + if ev := config.DialSaver.Read(); len(ev) <= 0 { + t.Fatal("no dial events?!") + } + if ev := config.HTTPSaver.Read(); len(ev) <= 0 { + t.Fatal("no HTTP events?!") + } + if ev := config.ReadWriteSaver.Read(); len(ev) <= 0 { + t.Fatal("no R/W events?!") + } + if ev := config.ResolveSaver.Read(); len(ev) <= 0 { + t.Fatal("no resolver events?!") + } + if ev := config.TLSSaver.Read(); len(ev) <= 0 { + t.Fatal("no TLS events?!") + } +} + +func TestBogonResolutionNotBroken(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + saver := new(trace.Saver) + r := netx.NewResolver(netx.Config{ + BogonIsError: true, + DNSCache: map[string][]string{ + "www.google.com": {"127.0.0.1"}, + }, + ResolveSaver: saver, + Logger: log.Log, + }) + addrs, err := r.LookupHost(context.Background(), "www.google.com") + if !errors.Is(err, errorx.ErrDNSBogon) { + t.Fatal("not the error we expected") + } + if err.Error() != errorx.FailureDNSBogonError { + t.Fatal("error not correctly wrapped") + } + if len(addrs) != 1 || addrs[0] != "127.0.0.1" { + t.Fatal("address was not returned") + } +} diff --git a/internal/engine/netx/netx.go b/internal/engine/netx/netx.go new file mode 100644 index 0000000..4fb96a2 --- /dev/null +++ b/internal/engine/netx/netx.go @@ -0,0 +1,485 @@ +// Package netx contains code to perform network measurements. +// +// This library contains replacements for commonly used standard library +// interfaces that facilitate seamless network measurements. By using +// such replacements, as opposed to standard library interfaces, we can: +// +// * save the timing of HTTP events (e.g. received response headers) +// * save the timing and result of every Connect, Read, Write, Close operation +// * save the timing and result of the TLS handshake (including certificates) +// +// By default, this library uses the system resolver. In addition, it +// is possible to configure alternative DNS transports and remote +// servers. We support DNS over UDP, DNS over TCP, DNS over TLS (DoT), +// and DNS over HTTPS (DoH). When using an alternative transport, we +// are also able to intercept and save DNS messages, as well as any +// other interaction with the remote server (e.g., the result of the +// TLS handshake for DoT and DoH). +// +// We described the design and implementation of the most recent version of +// this package at . Such +// issue also links to a previous design document. +package netx + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "net" + "net/http" + "net/url" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/gocertifi" + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" + "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// Logger is the logger assumed by this package +type Logger interface { + Debugf(format string, v ...interface{}) + Debug(message string) +} + +// Dialer is the definition of dialer assumed by this package. +type Dialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// QUICDialer is the definition of a dialer for QUIC assumed by this package. +type QUICDialer interface { + Dial(network, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) +} + +// TLSDialer is the definition of a TLS dialer assumed by this package. +type TLSDialer interface { + DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// HTTPRoundTripper is the definition of http.HTTPRoundTripper used by this package. +type HTTPRoundTripper interface { + RoundTrip(req *http.Request) (*http.Response, error) + CloseIdleConnections() +} + +// Resolver is the interface we expect from a resolver +type Resolver interface { + LookupHost(ctx context.Context, hostname string) (addrs []string, err error) + Network() string + Address() string +} + +// Config contains configuration for creating a new transport. When any +// field of Config is nil/empty, we will use a suitable default. +// +// We use different savers for different kind of events such that the +// user of this library can choose what to save. +type Config struct { + BaseResolver Resolver // default: system resolver + BogonIsError bool // default: bogon is not error + ByteCounter *bytecounter.Counter // default: no explicit byte counting + CacheResolutions bool // default: no caching + CertPool *x509.CertPool // default: use vendored gocertifi + ContextByteCounting bool // default: no implicit byte counting + DNSCache map[string][]string // default: cache is empty + DialSaver *trace.Saver // default: not saving dials + Dialer Dialer // default: dialer.DNSDialer + FullResolver Resolver // default: base resolver + goodies + QUICDialer QUICDialer // default: quicdialer.DNSDialer + HTTP3Enabled bool // default: disabled + HTTPSaver *trace.Saver // default: not saving HTTP + Logger Logger // default: no logging + NoTLSVerify bool // default: perform TLS verify + ProxyURL *url.URL // default: no proxy + ReadWriteSaver *trace.Saver // default: not saving read/write + ResolveSaver *trace.Saver // default: not saving resolves + TLSConfig *tls.Config // default: attempt using h2 + TLSDialer TLSDialer // default: dialer.TLSDialer + TLSSaver *trace.Saver // default: not saving TLS +} + +type tlsHandshaker interface { + Handshake(ctx context.Context, conn net.Conn, config *tls.Config) ( + net.Conn, tls.ConnectionState, error) +} + +// NewDefaultCertPool returns a copy of the default x509 +// certificate pool. This function panics on failure. +func NewDefaultCertPool() *x509.CertPool { + pool, err := gocertifi.CACerts() + runtimex.PanicOnError(err, "gocertifi.CACerts() failed") + return pool +} + +var defaultCertPool *x509.CertPool = NewDefaultCertPool() + +// NewResolver creates a new resolver from the specified config +func NewResolver(config Config) Resolver { + if config.BaseResolver == nil { + config.BaseResolver = resolver.SystemResolver{} + } + var r Resolver = config.BaseResolver + if config.CacheResolutions { + r = &resolver.CacheResolver{Resolver: r} + } + if config.DNSCache != nil { + cache := &resolver.CacheResolver{Resolver: r, ReadOnly: true} + for key, values := range config.DNSCache { + cache.Set(key, values) + } + r = cache + } + if config.BogonIsError { + r = resolver.BogonResolver{Resolver: r} + } + r = resolver.ErrorWrapperResolver{Resolver: r} + if config.Logger != nil { + r = resolver.LoggingResolver{Logger: config.Logger, Resolver: r} + } + if config.ResolveSaver != nil { + r = resolver.SaverResolver{Resolver: r, Saver: config.ResolveSaver} + } + r = resolver.AddressResolver{Resolver: r} + return resolver.IDNAResolver{Resolver: r} +} + +// NewDialer creates a new Dialer from the specified config +func NewDialer(config Config) Dialer { + if config.FullResolver == nil { + config.FullResolver = NewResolver(config) + } + var d Dialer = selfcensor.SystemDialer{} + d = dialer.TimeoutDialer{Dialer: d} + d = dialer.ErrorWrapperDialer{Dialer: d} + if config.Logger != nil { + d = dialer.LoggingDialer{Dialer: d, Logger: config.Logger} + } + if config.DialSaver != nil { + d = dialer.SaverDialer{Dialer: d, Saver: config.DialSaver} + } + if config.ReadWriteSaver != nil { + d = dialer.SaverConnDialer{Dialer: d, Saver: config.ReadWriteSaver} + } + d = dialer.DNSDialer{Resolver: config.FullResolver, Dialer: d} + d = dialer.ProxyDialer{ProxyURL: config.ProxyURL, Dialer: d} + if config.ContextByteCounting { + d = dialer.ByteCounterDialer{Dialer: d} + } + d = dialer.ShapingDialer{Dialer: d} + return d +} + +// NewQUICDialer creates a new DNS Dialer for QUIC, with the resolver from the specified config +func NewQUICDialer(config Config) QUICDialer { + if config.FullResolver == nil { + config.FullResolver = NewResolver(config) + } + var d quicdialer.ContextDialer = &quicdialer.SystemDialer{Saver: config.ReadWriteSaver} + d = quicdialer.ErrorWrapperDialer{Dialer: d} + if config.TLSSaver != nil { + d = quicdialer.HandshakeSaver{Saver: config.TLSSaver, Dialer: d} + } + d = &quicdialer.DNSDialer{Resolver: config.FullResolver, Dialer: d} + var dialer QUICDialer = &httptransport.QUICWrapperDialer{Dialer: d} + return dialer +} + +// NewTLSDialer creates a new TLSDialer from the specified config +func NewTLSDialer(config Config) TLSDialer { + if config.Dialer == nil { + config.Dialer = NewDialer(config) + } + var h tlsHandshaker = dialer.SystemTLSHandshaker{} + h = dialer.TimeoutTLSHandshaker{TLSHandshaker: h} + h = dialer.ErrorWrapperTLSHandshaker{TLSHandshaker: h} + if config.Logger != nil { + h = dialer.LoggingTLSHandshaker{Logger: config.Logger, TLSHandshaker: h} + } + if config.TLSSaver != nil { + h = dialer.SaverTLSHandshaker{TLSHandshaker: h, Saver: config.TLSSaver} + } + if config.TLSConfig == nil { + config.TLSConfig = &tls.Config{NextProtos: []string{"h2", "http/1.1"}} + } + if config.CertPool == nil { + config.CertPool = defaultCertPool + } + config.TLSConfig.RootCAs = config.CertPool + config.TLSConfig.InsecureSkipVerify = config.NoTLSVerify + return dialer.TLSDialer{ + Config: config.TLSConfig, + Dialer: config.Dialer, + TLSHandshaker: h, + } +} + +// NewHTTPTransport creates a new HTTPRoundTripper. You can further extend the returned +// HTTPRoundTripper before wrapping it into an http.Client. +func NewHTTPTransport(config Config) HTTPRoundTripper { + if config.Dialer == nil { + config.Dialer = NewDialer(config) + } + if config.TLSDialer == nil { + config.TLSDialer = NewTLSDialer(config) + } + if config.QUICDialer == nil { + config.QUICDialer = NewQUICDialer(config) + } + + tInfo := allTransportsInfo[config.HTTP3Enabled] + txp := tInfo.Factory(httptransport.Config{ + Dialer: config.Dialer, QUICDialer: config.QUICDialer, TLSDialer: config.TLSDialer, + TLSConfig: config.TLSConfig}) + transport := tInfo.TransportName + + if config.ByteCounter != nil { + txp = httptransport.ByteCountingTransport{ + Counter: config.ByteCounter, RoundTripper: txp} + } + if config.Logger != nil { + txp = httptransport.LoggingTransport{Logger: config.Logger, RoundTripper: txp} + } + if config.HTTPSaver != nil { + txp = httptransport.SaverMetadataHTTPTransport{ + RoundTripper: txp, Saver: config.HTTPSaver, Transport: transport} + txp = httptransport.SaverBodyHTTPTransport{ + RoundTripper: txp, Saver: config.HTTPSaver} + txp = httptransport.SaverPerformanceHTTPTransport{ + RoundTripper: txp, Saver: config.HTTPSaver} + txp = httptransport.SaverTransactionHTTPTransport{ + RoundTripper: txp, Saver: config.HTTPSaver} + } + txp = httptransport.UserAgentTransport{RoundTripper: txp} + return txp +} + +// httpTransportInfo contains the constructing function as well as the transport name +type httpTransportInfo struct { + Factory func(httptransport.Config) httptransport.RoundTripper + TransportName string +} + +var allTransportsInfo = map[bool]httpTransportInfo{ + false: { + Factory: httptransport.NewSystemTransport, + TransportName: "tcp", + }, + true: { + Factory: httptransport.NewHTTP3Transport, + TransportName: "quic", + }, +} + +// DNSClient is a DNS client. It wraps a Resolver and it possibly +// also wraps an HTTP client, but only when we're using DoH. +type DNSClient struct { + Resolver + httpClient *http.Client +} + +// CloseIdleConnections closes idle connections, if any. +func (c DNSClient) CloseIdleConnections() { + if c.httpClient != nil { + c.httpClient.CloseIdleConnections() + } +} + +// NewDNSClient creates a new DNS client. The config argument is used to +// create the underlying Dialer and/or HTTP transport, if needed. The URL +// argument describes the kind of client that we want to make: +// +// - if the URL is `doh://powerdns`, `doh://google` or `doh://cloudflare` or the URL +// starts with `https://`, then we create a DoH client. +// +// - if the URL is `` or `system:///`, then we create a system client, +// i.e. a client using the system resolver. +// +// - if the URL starts with `udp://`, then we create a client using +// a resolver that uses the specified UDP endpoint. +// +// We return error if the URL does not parse or the URL scheme does not +// fall into one of the cases described above. +// +// If config.ResolveSaver is not nil and we're creating an underlying +// resolver where this is possible, we will also save events. +func NewDNSClient(config Config, URL string) (DNSClient, error) { + return NewDNSClientWithOverrides(config, URL, "", "", "") +} + +// ErrInvalidTLSVersion indicates that you passed us a string +// that does not represent a valid TLS version. +var ErrInvalidTLSVersion = errors.New("invalid TLS version") + +// ConfigureTLSVersion configures the correct TLS version into +// the specified *tls.Config or returns an error. +func ConfigureTLSVersion(config *tls.Config, version string) error { + switch version { + case "TLSv1.3": + config.MinVersion = tls.VersionTLS13 + config.MaxVersion = tls.VersionTLS13 + case "TLSv1.2": + config.MinVersion = tls.VersionTLS12 + config.MaxVersion = tls.VersionTLS12 + case "TLSv1.1": + config.MinVersion = tls.VersionTLS11 + config.MaxVersion = tls.VersionTLS11 + case "TLSv1.0", "TLSv1": + config.MinVersion = tls.VersionTLS10 + config.MaxVersion = tls.VersionTLS10 + case "": + // nothing + default: + return ErrInvalidTLSVersion + } + return nil +} + +// NewDNSClientWithOverrides creates a new DNS client, similar to NewDNSClient, +// with the option to override the default Hostname and SNI. +func NewDNSClientWithOverrides(config Config, URL, hostOverride, SNIOverride, + TLSVersion string) (DNSClient, error) { + var c DNSClient + switch URL { + case "doh://powerdns": + URL = "https://doh.powerdns.org/" + case "doh://google": + URL = "https://dns.google/dns-query" + case "doh://cloudflare": + URL = "https://cloudflare-dns.com/dns-query" + case "": + URL = "system:///" + } + resolverURL, err := url.Parse(URL) + if err != nil { + return c, err + } + config.TLSConfig = &tls.Config{ServerName: SNIOverride} + if err := ConfigureTLSVersion(config.TLSConfig, TLSVersion); err != nil { + return c, err + } + switch resolverURL.Scheme { + case "system": + c.Resolver = resolver.SystemResolver{} + return c, nil + case "https": + config.TLSConfig.NextProtos = []string{"h2", "http/1.1"} + c.httpClient = &http.Client{Transport: NewHTTPTransport(config)} + var txp resolver.RoundTripper = resolver.NewDNSOverHTTPSWithHostOverride( + c.httpClient, URL, hostOverride) + if config.ResolveSaver != nil { + txp = resolver.SaverDNSTransport{ + RoundTripper: txp, + Saver: config.ResolveSaver, + } + } + c.Resolver = resolver.NewSerialResolver(txp) + return c, nil + case "udp": + dialer := NewDialer(config) + endpoint, err := makeValidEndpoint(resolverURL) + if err != nil { + return c, err + } + var txp resolver.RoundTripper = resolver.NewDNSOverUDP(dialer, endpoint) + if config.ResolveSaver != nil { + txp = resolver.SaverDNSTransport{ + RoundTripper: txp, + Saver: config.ResolveSaver, + } + } + c.Resolver = resolver.NewSerialResolver(txp) + return c, nil + case "dot": + config.TLSConfig.NextProtos = []string{"dot"} + tlsDialer := NewTLSDialer(config) + endpoint, err := makeValidEndpoint(resolverURL) + if err != nil { + return c, err + } + var txp resolver.RoundTripper = resolver.NewDNSOverTLS( + tlsDialer.DialTLSContext, endpoint) + if config.ResolveSaver != nil { + txp = resolver.SaverDNSTransport{ + RoundTripper: txp, + Saver: config.ResolveSaver, + } + } + c.Resolver = resolver.NewSerialResolver(txp) + return c, nil + case "tcp": + dialer := NewDialer(config) + endpoint, err := makeValidEndpoint(resolverURL) + if err != nil { + return c, err + } + var txp resolver.RoundTripper = resolver.NewDNSOverTCP( + dialer.DialContext, endpoint) + if config.ResolveSaver != nil { + txp = resolver.SaverDNSTransport{ + RoundTripper: txp, + Saver: config.ResolveSaver, + } + } + c.Resolver = resolver.NewSerialResolver(txp) + return c, nil + default: + return c, errors.New("unsupported resolver scheme") + } +} + +// makeValidEndpoint makes a valid endpoint for DoT and Do53 given the +// input URL representing such endpoint. Specifically, we are +// concerned with the case where the port is missing. In such a +// case, we ensure that we are using the default port 853 for DoT +// and default port 53 for TCP and UDP. +func makeValidEndpoint(URL *url.URL) (string, error) { + // Implementation note: when we're using a quoted IPv6 + // address, URL.Host contains the quotes but instead the + // return value from URL.Hostname() does not. + // + // For example: + // + // - Host: [2620:fe::9] + // - Hostname(): 2620:fe::9 + // + // We need to keep this in mind when trying to determine + // whether there is also a port or not. + // + // So the first step is to check whether URL.Host is already + // a whatever valid TCP/UDP endpoint and, if so, use it. + if _, _, err := net.SplitHostPort(URL.Host); err == nil { + return URL.Host, nil + } + // The second step is to assume that appending the default port + // to a host parsed by url.Parse should be giving us a valid + // endpoint. The possibilities in fact are: + // + // 1. domain w/o port + // 2. IPv4 w/o port + // 3. square bracket quoted IPv6 w/o port + // 4. other + // + // In the first three cases, appending a port leads us to a + // good endpoint. The fourth case does not. + // + // For this reason we check again whether we can split it using + // net.SplitHostPort. If we cannot, we were in case four. + host := URL.Host + if URL.Scheme == "dot" { + host += ":853" + } else { + host += ":53" + } + if _, _, err := net.SplitHostPort(host); err != nil { + return "", err + } + // Otherwise it's one of the three valid cases above. + return host, nil +} diff --git a/internal/engine/netx/netx_internal_test.go b/internal/engine/netx/netx_internal_test.go new file mode 100644 index 0000000..9784316 --- /dev/null +++ b/internal/engine/netx/netx_internal_test.go @@ -0,0 +1,8 @@ +package netx + +import "crypto/x509" + +// DefaultCertPool allows tests to access the default cert pool. +func DefaultCertPool() *x509.CertPool { + return defaultCertPool +} diff --git a/internal/engine/netx/netx_test.go b/internal/engine/netx/netx_test.go new file mode 100644 index 0000000..00bf58e --- /dev/null +++ b/internal/engine/netx/netx_test.go @@ -0,0 +1,1261 @@ +package netx_test + +import ( + "crypto/tls" + "errors" + "net/http" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestNewResolverVanilla(t *testing.T) { + r := netx.NewResolver(netx.Config{}) + ir, ok := r.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ar, ok := ir.Resolver.(resolver.AddressResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + _, ok = ewr.Resolver.(resolver.SystemResolver) + if !ok { + t.Fatal("not the resolver we expected") + } +} + +func TestNewResolverSpecificResolver(t *testing.T) { + r := netx.NewResolver(netx.Config{ + BaseResolver: resolver.BogonResolver{ + // not initialized because it doesn't matter in this context + }, + }) + ir, ok := r.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ar, ok := ir.Resolver.(resolver.AddressResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + _, ok = ewr.Resolver.(resolver.BogonResolver) + if !ok { + t.Fatal("not the resolver we expected") + } +} + +func TestNewResolverWithBogonFilter(t *testing.T) { + r := netx.NewResolver(netx.Config{ + BogonIsError: true, + }) + ir, ok := r.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ar, ok := ir.Resolver.(resolver.AddressResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + br, ok := ewr.Resolver.(resolver.BogonResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + _, ok = br.Resolver.(resolver.SystemResolver) + if !ok { + t.Fatal("not the resolver we expected") + } +} + +func TestNewResolverWithLogging(t *testing.T) { + r := netx.NewResolver(netx.Config{ + Logger: log.Log, + }) + ir, ok := r.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ar, ok := ir.Resolver.(resolver.AddressResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + lr, ok := ar.Resolver.(resolver.LoggingResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if lr.Logger != log.Log { + t.Fatal("not the logger we expected") + } + ewr, ok := lr.Resolver.(resolver.ErrorWrapperResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + _, ok = ewr.Resolver.(resolver.SystemResolver) + if !ok { + t.Fatal("not the resolver we expected") + } +} + +func TestNewResolverWithSaver(t *testing.T) { + saver := new(trace.Saver) + r := netx.NewResolver(netx.Config{ + ResolveSaver: saver, + }) + ir, ok := r.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ar, ok := ir.Resolver.(resolver.AddressResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + sr, ok := ar.Resolver.(resolver.SaverResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if sr.Saver != saver { + t.Fatal("not the saver we expected") + } + ewr, ok := sr.Resolver.(resolver.ErrorWrapperResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + _, ok = ewr.Resolver.(resolver.SystemResolver) + if !ok { + t.Fatal("not the resolver we expected") + } +} + +func TestNewResolverWithReadWriteCache(t *testing.T) { + r := netx.NewResolver(netx.Config{ + CacheResolutions: true, + }) + ir, ok := r.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ar, ok := ir.Resolver.(resolver.AddressResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + cr, ok := ewr.Resolver.(*resolver.CacheResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if cr.ReadOnly != false { + t.Fatal("expected readwrite cache here") + } + _, ok = cr.Resolver.(resolver.SystemResolver) + if !ok { + t.Fatal("not the resolver we expected") + } +} + +func TestNewResolverWithPrefilledReadonlyCache(t *testing.T) { + r := netx.NewResolver(netx.Config{ + DNSCache: map[string][]string{ + "dns.google.com": {"8.8.8.8"}, + }, + }) + ir, ok := r.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ar, ok := ir.Resolver.(resolver.AddressResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + cr, ok := ewr.Resolver.(*resolver.CacheResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if cr.ReadOnly != true { + t.Fatal("expected readonly cache here") + } + if cr.Get("dns.google.com")[0] != "8.8.8.8" { + t.Fatal("cache not correctly prefilled") + } + _, ok = cr.Resolver.(resolver.SystemResolver) + if !ok { + t.Fatal("not the resolver we expected") + } +} + +func TestNewDialerVanilla(t *testing.T) { + d := netx.NewDialer(netx.Config{}) + sd, ok := d.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + pd, ok := sd.Dialer.(dialer.ProxyDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if pd.ProxyURL != nil { + t.Fatal("not the proxy URL we expected") + } + dnsd, ok := pd.Dialer.(dialer.DNSDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if dnsd.Resolver == nil { + t.Fatal("not the resolver we expected") + } + ir, ok := dnsd.Resolver.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := ir.Resolver.(resolver.AddressResolver); !ok { + t.Fatal("not the resolver we expected") + } + ewd, ok := dnsd.Dialer.(dialer.ErrorWrapperDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + td, ok := ewd.Dialer.(dialer.TimeoutDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok { + t.Fatal("not the dialer we expected") + } +} + +func TestNewDialerWithResolver(t *testing.T) { + d := netx.NewDialer(netx.Config{ + FullResolver: resolver.BogonResolver{ + // not initialized because it doesn't matter in this context + }, + }) + sd, ok := d.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + pd, ok := sd.Dialer.(dialer.ProxyDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if pd.ProxyURL != nil { + t.Fatal("not the proxy URL we expected") + } + dnsd, ok := pd.Dialer.(dialer.DNSDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if dnsd.Resolver == nil { + t.Fatal("not the resolver we expected") + } + if _, ok := dnsd.Resolver.(resolver.BogonResolver); !ok { + t.Fatal("not the resolver we expected") + } + ewd, ok := dnsd.Dialer.(dialer.ErrorWrapperDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + td, ok := ewd.Dialer.(dialer.TimeoutDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok { + t.Fatal("not the dialer we expected") + } +} + +func TestNewDialerWithLogger(t *testing.T) { + d := netx.NewDialer(netx.Config{ + Logger: log.Log, + }) + sd, ok := d.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + pd, ok := sd.Dialer.(dialer.ProxyDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if pd.ProxyURL != nil { + t.Fatal("not the proxy URL we expected") + } + dnsd, ok := pd.Dialer.(dialer.DNSDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if dnsd.Resolver == nil { + t.Fatal("not the resolver we expected") + } + ir, ok := dnsd.Resolver.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := ir.Resolver.(resolver.AddressResolver); !ok { + t.Fatal("not the resolver we expected") + } + ld, ok := dnsd.Dialer.(dialer.LoggingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if ld.Logger != log.Log { + t.Fatal("not the logger we expected") + } + ewd, ok := ld.Dialer.(dialer.ErrorWrapperDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + td, ok := ewd.Dialer.(dialer.TimeoutDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok { + t.Fatal("not the dialer we expected") + } +} + +func TestNewDialerWithDialSaver(t *testing.T) { + saver := new(trace.Saver) + d := netx.NewDialer(netx.Config{ + DialSaver: saver, + }) + sd, ok := d.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + pd, ok := sd.Dialer.(dialer.ProxyDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if pd.ProxyURL != nil { + t.Fatal("not the proxy URL we expected") + } + dnsd, ok := pd.Dialer.(dialer.DNSDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if dnsd.Resolver == nil { + t.Fatal("not the resolver we expected") + } + ir, ok := dnsd.Resolver.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := ir.Resolver.(resolver.AddressResolver); !ok { + t.Fatal("not the resolver we expected") + } + sad, ok := dnsd.Dialer.(dialer.SaverDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if sad.Saver != saver { + t.Fatal("not the logger we expected") + } + ewd, ok := sad.Dialer.(dialer.ErrorWrapperDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + td, ok := ewd.Dialer.(dialer.TimeoutDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok { + t.Fatal("not the dialer we expected") + } +} + +func TestNewDialerWithReadWriteSaver(t *testing.T) { + saver := new(trace.Saver) + d := netx.NewDialer(netx.Config{ + ReadWriteSaver: saver, + }) + sd, ok := d.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + pd, ok := sd.Dialer.(dialer.ProxyDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if pd.ProxyURL != nil { + t.Fatal("not the proxy URL we expected") + } + dnsd, ok := pd.Dialer.(dialer.DNSDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if dnsd.Resolver == nil { + t.Fatal("not the resolver we expected") + } + ir, ok := dnsd.Resolver.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := ir.Resolver.(resolver.AddressResolver); !ok { + t.Fatal("not the resolver we expected") + } + scd, ok := dnsd.Dialer.(dialer.SaverConnDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if scd.Saver != saver { + t.Fatal("not the logger we expected") + } + ewd, ok := scd.Dialer.(dialer.ErrorWrapperDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + td, ok := ewd.Dialer.(dialer.TimeoutDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok { + t.Fatal("not the dialer we expected") + } +} + +func TestNewDialerWithContextByteCounting(t *testing.T) { + d := netx.NewDialer(netx.Config{ + ContextByteCounting: true, + }) + sd, ok := d.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + bcd, ok := sd.Dialer.(dialer.ByteCounterDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + pd, ok := bcd.Dialer.(dialer.ProxyDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if pd.ProxyURL != nil { + t.Fatal("not the proxy URL we expected") + } + dnsd, ok := pd.Dialer.(dialer.DNSDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if dnsd.Resolver == nil { + t.Fatal("not the resolver we expected") + } + ir, ok := dnsd.Resolver.(resolver.IDNAResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := ir.Resolver.(resolver.AddressResolver); !ok { + t.Fatal("not the resolver we expected") + } + ewd, ok := dnsd.Dialer.(dialer.ErrorWrapperDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + td, ok := ewd.Dialer.(dialer.TimeoutDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok { + t.Fatal("not the dialer we expected") + } +} + +func TestNewTLSDialerVanilla(t *testing.T) { + td := netx.NewTLSDialer(netx.Config{}) + rtd, ok := td.(dialer.TLSDialer) + if !ok { + t.Fatal("not the TLSDialer we expected") + } + if len(rtd.Config.NextProtos) != 2 { + t.Fatal("invalid len(config.NextProtos)") + } + if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" { + t.Fatal("invalid Config.NextProtos") + } + if rtd.Config.RootCAs != netx.DefaultCertPool() { + t.Fatal("invalid Config.RootCAs") + } + if rtd.Dialer == nil { + t.Fatal("invalid Dialer") + } + sd, ok := rtd.Dialer.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok { + t.Fatal("not the Dialer we expected") + } + if rtd.TLSHandshaker == nil { + t.Fatal("invalid TLSHandshaker") + } + ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok { + t.Fatal("not the TLSHandshaker we expected") + } +} + +func TestNewTLSDialerWithConfig(t *testing.T) { + td := netx.NewTLSDialer(netx.Config{ + TLSConfig: new(tls.Config), + }) + rtd, ok := td.(dialer.TLSDialer) + if !ok { + t.Fatal("not the TLSDialer we expected") + } + if len(rtd.Config.NextProtos) != 0 { + t.Fatal("invalid len(config.NextProtos)") + } + if rtd.Config.RootCAs != netx.DefaultCertPool() { + t.Fatal("invalid Config.RootCAs") + } + if rtd.Dialer == nil { + t.Fatal("invalid Dialer") + } + sd, ok := rtd.Dialer.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok { + t.Fatal("not the Dialer we expected") + } + if rtd.TLSHandshaker == nil { + t.Fatal("invalid TLSHandshaker") + } + ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok { + t.Fatal("not the TLSHandshaker we expected") + } +} + +func TestNewTLSDialerWithLogging(t *testing.T) { + td := netx.NewTLSDialer(netx.Config{ + Logger: log.Log, + }) + rtd, ok := td.(dialer.TLSDialer) + if !ok { + t.Fatal("not the TLSDialer we expected") + } + if len(rtd.Config.NextProtos) != 2 { + t.Fatal("invalid len(config.NextProtos)") + } + if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" { + t.Fatal("invalid Config.NextProtos") + } + if rtd.Config.RootCAs != netx.DefaultCertPool() { + t.Fatal("invalid Config.RootCAs") + } + if rtd.Dialer == nil { + t.Fatal("invalid Dialer") + } + sd, ok := rtd.Dialer.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok { + t.Fatal("not the Dialer we expected") + } + if rtd.TLSHandshaker == nil { + t.Fatal("invalid TLSHandshaker") + } + lth, ok := rtd.TLSHandshaker.(dialer.LoggingTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if lth.Logger != log.Log { + t.Fatal("not the Logger we expected") + } + ewth, ok := lth.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok { + t.Fatal("not the TLSHandshaker we expected") + } +} + +func TestNewTLSDialerWithSaver(t *testing.T) { + saver := new(trace.Saver) + td := netx.NewTLSDialer(netx.Config{ + TLSSaver: saver, + }) + rtd, ok := td.(dialer.TLSDialer) + if !ok { + t.Fatal("not the TLSDialer we expected") + } + if len(rtd.Config.NextProtos) != 2 { + t.Fatal("invalid len(config.NextProtos)") + } + if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" { + t.Fatal("invalid Config.NextProtos") + } + if rtd.Config.RootCAs != netx.DefaultCertPool() { + t.Fatal("invalid Config.RootCAs") + } + if rtd.Dialer == nil { + t.Fatal("invalid Dialer") + } + sd, ok := rtd.Dialer.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok { + t.Fatal("not the Dialer we expected") + } + if rtd.TLSHandshaker == nil { + t.Fatal("invalid TLSHandshaker") + } + sth, ok := rtd.TLSHandshaker.(dialer.SaverTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if sth.Saver != saver { + t.Fatal("not the Logger we expected") + } + ewth, ok := sth.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok { + t.Fatal("not the TLSHandshaker we expected") + } +} + +func TestNewTLSDialerWithNoTLSVerifyAndConfig(t *testing.T) { + td := netx.NewTLSDialer(netx.Config{ + TLSConfig: new(tls.Config), + NoTLSVerify: true, + }) + rtd, ok := td.(dialer.TLSDialer) + if !ok { + t.Fatal("not the TLSDialer we expected") + } + if len(rtd.Config.NextProtos) != 0 { + t.Fatal("invalid len(config.NextProtos)") + } + if rtd.Config.InsecureSkipVerify != true { + t.Fatal("expected true InsecureSkipVerify") + } + if rtd.Config.RootCAs != netx.DefaultCertPool() { + t.Fatal("invalid Config.RootCAs") + } + if rtd.Dialer == nil { + t.Fatal("invalid Dialer") + } + sd, ok := rtd.Dialer.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok { + t.Fatal("not the Dialer we expected") + } + if rtd.TLSHandshaker == nil { + t.Fatal("invalid TLSHandshaker") + } + ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok { + t.Fatal("not the TLSHandshaker we expected") + } +} + +func TestNewTLSDialerWithNoTLSVerifyAndNoConfig(t *testing.T) { + td := netx.NewTLSDialer(netx.Config{ + NoTLSVerify: true, + }) + rtd, ok := td.(dialer.TLSDialer) + if !ok { + t.Fatal("not the TLSDialer we expected") + } + if len(rtd.Config.NextProtos) != 2 { + t.Fatal("invalid len(config.NextProtos)") + } + if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" { + t.Fatal("invalid Config.NextProtos") + } + if rtd.Config.InsecureSkipVerify != true { + t.Fatal("expected true InsecureSkipVerify") + } + if rtd.Config.RootCAs != netx.DefaultCertPool() { + t.Fatal("invalid Config.RootCAs") + } + if rtd.Dialer == nil { + t.Fatal("invalid Dialer") + } + sd, ok := rtd.Dialer.(dialer.ShapingDialer) + if !ok { + t.Fatal("not the dialer we expected") + } + if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok { + t.Fatal("not the Dialer we expected") + } + if rtd.TLSHandshaker == nil { + t.Fatal("invalid TLSHandshaker") + } + ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker) + if !ok { + t.Fatal("not the TLSHandshaker we expected") + } + if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok { + t.Fatal("not the TLSHandshaker we expected") + } +} + +func TestNewVanilla(t *testing.T) { + txp := netx.NewHTTPTransport(netx.Config{}) + uatxp, ok := txp.(httptransport.UserAgentTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if _, ok := uatxp.RoundTripper.(*http.Transport); !ok { + t.Fatal("not the transport we expected") + } +} + +func TestNewWithDialer(t *testing.T) { + expected := errors.New("mocked error") + dialer := netx.FakeDialer{Err: expected} + txp := netx.NewHTTPTransport(netx.Config{ + Dialer: dialer, + }) + client := &http.Client{Transport: txp} + resp, err := client.Get("http://www.google.com") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("not the response we expected") + } +} + +func TestNewWithTLSDialer(t *testing.T) { + expected := errors.New("mocked error") + tlsDialer := dialer.TLSDialer{ + Config: new(tls.Config), + Dialer: netx.FakeDialer{Err: expected}, + TLSHandshaker: dialer.SystemTLSHandshaker{}, + } + txp := netx.NewHTTPTransport(netx.Config{ + TLSDialer: tlsDialer, + }) + client := &http.Client{Transport: txp} + resp, err := client.Get("https://www.google.com") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if resp != nil { + t.Fatal("not the response we expected") + } +} + +func TestNewWithByteCounter(t *testing.T) { + counter := bytecounter.New() + txp := netx.NewHTTPTransport(netx.Config{ + ByteCounter: counter, + }) + uatxp, ok := txp.(httptransport.UserAgentTransport) + if !ok { + t.Fatal("not the transport we expected") + } + bctxp, ok := uatxp.RoundTripper.(httptransport.ByteCountingTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if bctxp.Counter != counter { + t.Fatal("not the byte counter we expected") + } + if _, ok := bctxp.RoundTripper.(*http.Transport); !ok { + t.Fatal("not the transport we expected") + } +} + +func TestNewWithLogger(t *testing.T) { + txp := netx.NewHTTPTransport(netx.Config{ + Logger: log.Log, + }) + uatxp, ok := txp.(httptransport.UserAgentTransport) + if !ok { + t.Fatal("not the transport we expected") + } + ltxp, ok := uatxp.RoundTripper.(httptransport.LoggingTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if ltxp.Logger != log.Log { + t.Fatal("not the logger we expected") + } + if _, ok := ltxp.RoundTripper.(*http.Transport); !ok { + t.Fatal("not the transport we expected") + } +} + +func TestNewWithSaver(t *testing.T) { + saver := new(trace.Saver) + txp := netx.NewHTTPTransport(netx.Config{ + HTTPSaver: saver, + }) + uatxp, ok := txp.(httptransport.UserAgentTransport) + if !ok { + t.Fatal("not the transport we expected") + } + stxptxp, ok := uatxp.RoundTripper.(httptransport.SaverTransactionHTTPTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if stxptxp.Saver != saver { + t.Fatal("not the logger we expected") + } + sptxp, ok := stxptxp.RoundTripper.(httptransport.SaverPerformanceHTTPTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if sptxp.Saver != saver { + t.Fatal("not the logger we expected") + } + sbtxp, ok := sptxp.RoundTripper.(httptransport.SaverBodyHTTPTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if sbtxp.Saver != saver { + t.Fatal("not the logger we expected") + } + smtxp, ok := sbtxp.RoundTripper.(httptransport.SaverMetadataHTTPTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if smtxp.Saver != saver { + t.Fatal("not the logger we expected") + } + if _, ok := smtxp.RoundTripper.(*http.Transport); !ok { + t.Fatal("not the transport we expected") + } +} + +func TestNewDNSClientInvalidURL(t *testing.T) { + dnsclient, err := netx.NewDNSClient(netx.Config{}, "\t\t\t") + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + if dnsclient.Resolver != nil { + t.Fatal("expected nil resolver here") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientUnsupportedScheme(t *testing.T) { + dnsclient, err := netx.NewDNSClient(netx.Config{}, "antani:///") + if err == nil || err.Error() != "unsupported resolver scheme" { + t.Fatal("not the error we expected") + } + if dnsclient.Resolver != nil { + t.Fatal("expected nil resolver here") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientSystemResolver(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "system:///") + if err != nil { + t.Fatal(err) + } + if _, ok := dnsclient.Resolver.(resolver.SystemResolver); !ok { + t.Fatal("not the resolver we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientEmpty(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "") + if err != nil { + t.Fatal(err) + } + if _, ok := dnsclient.Resolver.(resolver.SystemResolver); !ok { + t.Fatal("not the resolver we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientPowerdnsDoH(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "doh://powerdns") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := r.Transport().(resolver.DNSOverHTTPS); !ok { + t.Fatal("not the transport we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientGoogleDoH(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "doh://google") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := r.Transport().(resolver.DNSOverHTTPS); !ok { + t.Fatal("not the transport we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientCloudflareDoH(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "doh://cloudflare") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := r.Transport().(resolver.DNSOverHTTPS); !ok { + t.Fatal("not the transport we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientCloudflareDoHSaver(t *testing.T) { + saver := new(trace.Saver) + dnsclient, err := netx.NewDNSClient( + netx.Config{ResolveSaver: saver}, "doh://cloudflare") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + txp, ok := r.Transport().(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if _, ok := txp.RoundTripper.(resolver.DNSOverHTTPS); !ok { + t.Fatal("not the transport we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientUDP(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "udp://8.8.8.8:53") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + if _, ok := r.Transport().(resolver.DNSOverUDP); !ok { + t.Fatal("not the transport we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientUDPDNSSaver(t *testing.T) { + saver := new(trace.Saver) + dnsclient, err := netx.NewDNSClient( + netx.Config{ResolveSaver: saver}, "udp://8.8.8.8:53") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + txp, ok := r.Transport().(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the transport we expected") + } + if _, ok := txp.RoundTripper.(resolver.DNSOverUDP); !ok { + t.Fatal("not the transport we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientTCP(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "tcp://8.8.8.8:53") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + txp, ok := r.Transport().(resolver.DNSOverTCP) + if !ok { + t.Fatal("not the transport we expected") + } + if txp.Network() != "tcp" { + t.Fatal("not the Network we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientTCPDNSSaver(t *testing.T) { + saver := new(trace.Saver) + dnsclient, err := netx.NewDNSClient( + netx.Config{ResolveSaver: saver}, "tcp://8.8.8.8:53") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + txp, ok := r.Transport().(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the transport we expected") + } + dotcp, ok := txp.RoundTripper.(resolver.DNSOverTCP) + if !ok { + t.Fatal("not the transport we expected") + } + if dotcp.Network() != "tcp" { + t.Fatal("not the Network we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientDoT(t *testing.T) { + dnsclient, err := netx.NewDNSClient( + netx.Config{}, "dot://8.8.8.8:53") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + txp, ok := r.Transport().(resolver.DNSOverTCP) + if !ok { + t.Fatal("not the transport we expected") + } + if txp.Network() != "dot" { + t.Fatal("not the Network we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSClientDoTDNSSaver(t *testing.T) { + saver := new(trace.Saver) + dnsclient, err := netx.NewDNSClient( + netx.Config{ResolveSaver: saver}, "dot://8.8.8.8:53") + if err != nil { + t.Fatal(err) + } + r, ok := dnsclient.Resolver.(resolver.SerialResolver) + if !ok { + t.Fatal("not the resolver we expected") + } + txp, ok := r.Transport().(resolver.SaverDNSTransport) + if !ok { + t.Fatal("not the transport we expected") + } + dotls, ok := txp.RoundTripper.(resolver.DNSOverTCP) + if !ok { + t.Fatal("not the transport we expected") + } + if dotls.Network() != "dot" { + t.Fatal("not the Network we expected") + } + dnsclient.CloseIdleConnections() +} + +func TestNewDNSCLientDoTWithoutPort(t *testing.T) { + c, err := netx.NewDNSClientWithOverrides( + netx.Config{}, "dot://8.8.8.8", "", "8.8.8.8", "") + if err != nil { + t.Fatal(err) + } + if c.Resolver.Address() != "8.8.8.8:853" { + t.Fatal("expected default port to be added") + } +} + +func TestNewDNSCLientTCPWithoutPort(t *testing.T) { + c, err := netx.NewDNSClientWithOverrides( + netx.Config{}, "tcp://8.8.8.8", "", "8.8.8.8", "") + if err != nil { + t.Fatal(err) + } + if c.Resolver.Address() != "8.8.8.8:53" { + t.Fatal("expected default port to be added") + } +} + +func TestNewDNSCLientUDPWithoutPort(t *testing.T) { + c, err := netx.NewDNSClientWithOverrides( + netx.Config{}, "udp://8.8.8.8", "", "8.8.8.8", "") + if err != nil { + t.Fatal(err) + } + if c.Resolver.Address() != "8.8.8.8:53" { + t.Fatal("expected default port to be added") + } +} + +func TestNewDNSClientBadDoTEndpoint(t *testing.T) { + _, err := netx.NewDNSClient( + netx.Config{}, "dot://bad:endpoint:53") + if err == nil || !strings.Contains(err.Error(), "too many colons in address") { + t.Fatal("expected error with bad endpoint") + } +} + +func TestNewDNSClientBadTCPEndpoint(t *testing.T) { + _, err := netx.NewDNSClient( + netx.Config{}, "tcp://bad:endpoint:853") + if err == nil || !strings.Contains(err.Error(), "too many colons in address") { + t.Fatal("expected error with bad endpoint") + } +} + +func TestNewDNSClientBadUDPEndpoint(t *testing.T) { + _, err := netx.NewDNSClient( + netx.Config{}, "udp://bad:endpoint:853") + if err == nil || !strings.Contains(err.Error(), "too many colons in address") { + t.Fatal("expected error with bad endpoint") + } +} + +func TestNewDNSCLientWithInvalidTLSVersion(t *testing.T) { + _, err := netx.NewDNSClientWithOverrides( + netx.Config{}, "dot://8.8.8.8", "", "", "TLSv999") + if !errors.Is(err, netx.ErrInvalidTLSVersion) { + t.Fatalf("not the error we expected: %+v", err) + } +} + +func TestConfigureTLSVersion(t *testing.T) { + tests := []struct { + name string + version string + wantErr error + versionMin int + versionMax int + }{{ + name: "with TLSv1.3", + version: "TLSv1.3", + wantErr: nil, + versionMin: tls.VersionTLS13, + versionMax: tls.VersionTLS13, + }, { + name: "with TLSv1.2", + version: "TLSv1.2", + wantErr: nil, + versionMin: tls.VersionTLS12, + versionMax: tls.VersionTLS12, + }, { + name: "with TLSv1.1", + version: "TLSv1.1", + wantErr: nil, + versionMin: tls.VersionTLS11, + versionMax: tls.VersionTLS11, + }, { + name: "with TLSv1.0", + version: "TLSv1.0", + wantErr: nil, + versionMin: tls.VersionTLS10, + versionMax: tls.VersionTLS10, + }, { + name: "with TLSv1", + version: "TLSv1", + wantErr: nil, + versionMin: tls.VersionTLS10, + versionMax: tls.VersionTLS10, + }, { + name: "with default", + version: "", + wantErr: nil, + versionMin: 0, + versionMax: 0, + }, { + name: "with invalid version", + version: "TLSv999", + wantErr: netx.ErrInvalidTLSVersion, + versionMin: 0, + versionMax: 0, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := new(tls.Config) + err := netx.ConfigureTLSVersion(conf, tt.version) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("not the error we expected: %+v", err) + } + if conf.MinVersion != uint16(tt.versionMin) { + t.Fatalf("not the min version we expected: %+v", conf.MinVersion) + } + if conf.MaxVersion != uint16(tt.versionMax) { + t.Fatalf("not the max version we expected: %+v", conf.MaxVersion) + } + }) + } +} diff --git a/internal/engine/netx/quicdialer/connectionstate_go1.14.go b/internal/engine/netx/quicdialer/connectionstate_go1.14.go new file mode 100644 index 0000000..80d6f3f --- /dev/null +++ b/internal/engine/netx/quicdialer/connectionstate_go1.14.go @@ -0,0 +1,14 @@ +// +build !go1.15 + +package quicdialer + +import ( + "crypto/tls" + + "github.com/lucas-clemente/quic-go" +) + +// ConnectionState returns the ConnectionState of a QUIC Session. +func ConnectionState(sess quic.EarlySession) tls.ConnectionState { + return tls.ConnectionState{} +} diff --git a/internal/engine/netx/quicdialer/connectionstate_go1.15.go b/internal/engine/netx/quicdialer/connectionstate_go1.15.go new file mode 100644 index 0000000..c43ca00 --- /dev/null +++ b/internal/engine/netx/quicdialer/connectionstate_go1.15.go @@ -0,0 +1,14 @@ +// +build go1.15 + +package quicdialer + +import ( + "crypto/tls" + + "github.com/lucas-clemente/quic-go" +) + +// ConnectionState returns the ConnectionState of a QUIC Session. +func ConnectionState(sess quic.EarlySession) tls.ConnectionState { + return sess.ConnectionState().ConnectionState +} diff --git a/internal/engine/netx/quicdialer/dns.go b/internal/engine/netx/quicdialer/dns.go new file mode 100644 index 0000000..7402ddf --- /dev/null +++ b/internal/engine/netx/quicdialer/dns.go @@ -0,0 +1,59 @@ +package quicdialer + +import ( + "context" + "crypto/tls" + "net" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer" +) + +// DNSDialer is a dialer that uses the configured Resolver to resolve a +// domain name to IP addresses +type DNSDialer struct { + Dialer ContextDialer + Resolver Resolver +} + +// DialContext implements ContextDialer.DialContext +func (d DNSDialer) DialContext( + ctx context.Context, network, host string, + tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + onlyhost, onlyport, err := net.SplitHostPort(host) + if err != nil { + return nil, err + } + // TODO(kelmenhorst): Should this be somewhere else? + // failure if tlsCfg is nil but that should not happen + if tlsCfg.ServerName == "" { + tlsCfg.ServerName = onlyhost + } + ctx = dialid.WithDialID(ctx) + var addrs []string + addrs, err = d.LookupHost(ctx, onlyhost) + if err != nil { + return nil, err + } + var errorslist []error + for _, addr := range addrs { + target := net.JoinHostPort(addr, onlyport) + sess, err := d.Dialer.DialContext( + ctx, network, target, tlsCfg, cfg) + if err == nil { + return sess, nil + } + errorslist = append(errorslist, err) + } + // TODO(bassosimone): maybe ReduceErrors could be in netx/internal. + return nil, dialer.ReduceErrors(errorslist) +} + +// LookupHost implements Resolver.LookupHost +func (d DNSDialer) LookupHost(ctx context.Context, hostname string) ([]string, error) { + if net.ParseIP(hostname) != nil { + return []string{hostname}, nil + } + return d.Resolver.LookupHost(ctx, hostname) +} diff --git a/internal/engine/netx/quicdialer/dns_test.go b/internal/engine/netx/quicdialer/dns_test.go new file mode 100644 index 0000000..ad6ded2 --- /dev/null +++ b/internal/engine/netx/quicdialer/dns_test.go @@ -0,0 +1,142 @@ +package quicdialer_test + +import ( + "context" + "crypto/tls" + "errors" + "net" + "strconv" + "strings" + "testing" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer" +) + +type MockableResolver struct { + Addresses []string + Err error +} + +func (r MockableResolver) LookupHost(ctx context.Context, host string) ([]string, error) { + return r.Addresses, r.Err +} + +func TestDNSDialerSuccess(t *testing.T) { + tlsConf := &tls.Config{NextProtos: []string{"h3-29"}} + dialer := quicdialer.DNSDialer{ + Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}} + sess, err := dialer.DialContext( + context.Background(), "udp", "www.google.com:443", + tlsConf, &quic.Config{}) + if err != nil { + t.Fatal("unexpected error", err) + } + if sess == nil { + t.Fatal("non nil sess expected") + } +} + +func TestDNSDialerNoPort(t *testing.T) { + tlsConf := &tls.Config{NextProtos: []string{"h3-29"}} + dialer := quicdialer.DNSDialer{ + Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}} + sess, err := dialer.DialContext( + context.Background(), "udp", "www.google.com", + tlsConf, &quic.Config{}) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected a nil sess here") + } + if err.Error() != "address www.google.com: missing port in address" { + t.Fatal("not the error we expected") + } +} + +func TestDNSDialerLookupHostAddress(t *testing.T) { + dialer := quicdialer.DNSDialer{Resolver: MockableResolver{ + Err: errors.New("mocked error"), + }} + addrs, err := dialer.LookupHost(context.Background(), "1.1.1.1") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "1.1.1.1" { + t.Fatal("not the result we expected") + } +} + +func TestDNSDialerLookupHostFailure(t *testing.T) { + tlsConf := &tls.Config{NextProtos: []string{"h3-29"}} + expected := errors.New("mocked error") + dialer := quicdialer.DNSDialer{Resolver: MockableResolver{ + Err: expected, + }} + sess, err := dialer.DialContext( + context.Background(), "udp", "dns.google.com:853", + tlsConf, &quic.Config{}) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if sess != nil { + t.Fatal("expected nil sess") + } +} + +func TestDNSDialerInvalidPort(t *testing.T) { + tlsConf := &tls.Config{NextProtos: []string{"h3-29"}} + dialer := quicdialer.DNSDialer{ + Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}} + sess, err := dialer.DialContext( + context.Background(), "udp", "www.google.com:0", + tlsConf, &quic.Config{}) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected nil sess") + } + if !strings.HasSuffix(err.Error(), "sendto: invalid argument") && + !strings.HasSuffix(err.Error(), "sendto: can't assign requested address") { + t.Fatal("not the error we expected") + } +} + +func TestDNSDialerInvalidPortSyntax(t *testing.T) { + tlsConf := &tls.Config{NextProtos: []string{"h3-29"}} + dialer := quicdialer.DNSDialer{ + Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}} + sess, err := dialer.DialContext( + context.Background(), "udp", "www.google.com:port", + tlsConf, &quic.Config{}) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected nil sess") + } + if !errors.Is(err, strconv.ErrSyntax) { + t.Fatal("not the error we expected") + } +} + +func TestDNSDialerDialEarlyFails(t *testing.T) { + tlsConf := &tls.Config{NextProtos: []string{"h3-29"}} + expected := errors.New("mocked DialEarly error") + dialer := quicdialer.DNSDialer{ + Resolver: new(net.Resolver), Dialer: MockDialer{Err: expected}} + sess, err := dialer.DialContext( + context.Background(), "udp", "www.google.com:443", + tlsConf, &quic.Config{}) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected nil sess") + } + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} diff --git a/internal/engine/netx/quicdialer/errorwrapper.go b/internal/engine/netx/quicdialer/errorwrapper.go new file mode 100644 index 0000000..01978f5 --- /dev/null +++ b/internal/engine/netx/quicdialer/errorwrapper.go @@ -0,0 +1,34 @@ +package quicdialer + +import ( + "context" + "crypto/tls" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// ErrorWrapperDialer is a dialer that performs quic err wrapping +type ErrorWrapperDialer struct { + Dialer ContextDialer +} + +// DialContext implements ContextDialer.DialContext +func (d ErrorWrapperDialer) DialContext( + ctx context.Context, network string, host string, + tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + dialID := dialid.ContextDialID(ctx) + sess, err := d.Dialer.DialContext(ctx, network, host, tlsCfg, cfg) + err = errorx.SafeErrWrapperBuilder{ + // ConnID does not make any sense if we've failed and the error + // does not make any sense (and is nil) if we succeded. + DialID: dialID, + Error: err, + Operation: errorx.QUICHandshakeOperation, + }.MaybeBuild() + if err != nil { + return nil, err + } + return sess, nil +} diff --git a/internal/engine/netx/quicdialer/errorwrapper_test.go b/internal/engine/netx/quicdialer/errorwrapper_test.go new file mode 100644 index 0000000..094d094 --- /dev/null +++ b/internal/engine/netx/quicdialer/errorwrapper_test.go @@ -0,0 +1,61 @@ +package quicdialer_test + +import ( + "context" + "crypto/tls" + "errors" + "io" + "testing" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer" +) + +func TestErrorWrapperFailure(t *testing.T) { + ctx := dialid.WithDialID(context.Background()) + d := quicdialer.ErrorWrapperDialer{ + Dialer: MockDialer{Sess: nil, Err: io.EOF}} + sess, err := d.DialContext( + ctx, "udp", "www.google.com:443", &tls.Config{}, &quic.Config{}) + if sess != nil { + t.Fatal("expected a nil sess here") + } + errorWrapperCheckErr(t, err, errorx.QUICHandshakeOperation) +} + +func errorWrapperCheckErr(t *testing.T, err error, op string) { + if !errors.Is(err, io.EOF) { + t.Fatal("expected another error here") + } + var errWrapper *errorx.ErrWrapper + if !errors.As(err, &errWrapper) { + t.Fatal("cannot cast to ErrWrapper") + } + if errWrapper.DialID == 0 { + t.Fatal("unexpected DialID") + } + if errWrapper.Operation != op { + t.Fatal("unexpected Operation") + } + if errWrapper.Failure != errorx.FailureEOFError { + t.Fatal("unexpected failure") + } +} + +func TestErrorWrapperSuccess(t *testing.T) { + ctx := dialid.WithDialID(context.Background()) + tlsConf := &tls.Config{ + NextProtos: []string{"h3-29"}, + ServerName: "www.google.com", + } + d := quicdialer.ErrorWrapperDialer{Dialer: quicdialer.SystemDialer{}} + sess, err := d.DialContext(ctx, "udp", "216.58.212.164:443", tlsConf, &quic.Config{}) + if err != nil { + t.Fatal(err) + } + if sess == nil { + t.Fatal("expected non-nil sess here") + } +} diff --git a/internal/engine/netx/quicdialer/quicdialer.go b/internal/engine/netx/quicdialer/quicdialer.go new file mode 100644 index 0000000..4b5aa99 --- /dev/null +++ b/internal/engine/netx/quicdialer/quicdialer.go @@ -0,0 +1,26 @@ +package quicdialer + +import ( + "context" + "crypto/tls" + + "github.com/lucas-clemente/quic-go" +) + +// ContextDialer is a dialer for QUIC using Context. +type ContextDialer interface { + // Note: assumes that tlsCfg and cfg are not nil. + DialContext(ctx context.Context, network, host string, + tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) +} + +// Dialer dials QUIC connections. +type Dialer interface { + // Note: assumes that tlsCfg and cfg are not nil. + Dial(network, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) +} + +// Resolver is the interface we expect from a resolver. +type Resolver interface { + LookupHost(ctx context.Context, hostname string) (addrs []string, err error) +} diff --git a/internal/engine/netx/quicdialer/saver.go b/internal/engine/netx/quicdialer/saver.go new file mode 100644 index 0000000..88fa2de --- /dev/null +++ b/internal/engine/netx/quicdialer/saver.go @@ -0,0 +1,62 @@ +package quicdialer + +import ( + "context" + "crypto/tls" + "time" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// HandshakeSaver saves events occurring during the handshake +type HandshakeSaver struct { + Saver *trace.Saver + Dialer ContextDialer +} + +// DialContext implements ContextDialer.DialContext +func (h HandshakeSaver) DialContext(ctx context.Context, network string, + host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + start := time.Now() + // TODO(bassosimone): in the future we probably want to also save + // information about what versions we're willing to accept. + h.Saver.Write(trace.Event{ + Address: host, + Name: "quic_handshake_start", + NoTLSVerify: tlsCfg.InsecureSkipVerify, + Proto: network, + TLSNextProtos: tlsCfg.NextProtos, + TLSServerName: tlsCfg.ServerName, + Time: start, + }) + sess, err := h.Dialer.DialContext(ctx, network, host, tlsCfg, cfg) + stop := time.Now() + if err != nil { + h.Saver.Write(trace.Event{ + Duration: stop.Sub(start), + Err: err, + Name: "quic_handshake_done", + NoTLSVerify: tlsCfg.InsecureSkipVerify, + TLSNextProtos: tlsCfg.NextProtos, + TLSServerName: tlsCfg.ServerName, + Time: stop, + }) + return nil, err + } + state := ConnectionState(sess) + h.Saver.Write(trace.Event{ + Duration: stop.Sub(start), + Name: "quic_handshake_done", + NoTLSVerify: tlsCfg.InsecureSkipVerify, + TLSCipherSuite: tlsx.CipherSuiteString(state.CipherSuite), + TLSNegotiatedProto: state.NegotiatedProtocol, + TLSNextProtos: tlsCfg.NextProtos, + TLSPeerCerts: trace.PeerCerts(state, err), + TLSServerName: tlsCfg.ServerName, + TLSVersion: tlsx.VersionString(state.Version), + Time: stop, + }) + return sess, nil +} diff --git a/internal/engine/netx/quicdialer/saver_test.go b/internal/engine/netx/quicdialer/saver_test.go new file mode 100644 index 0000000..0e90fd3 --- /dev/null +++ b/internal/engine/netx/quicdialer/saver_test.go @@ -0,0 +1,118 @@ +package quicdialer_test + +import ( + "context" + "crypto/tls" + "reflect" + "strings" + "testing" + "time" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +type MockDialer struct { + Dialer quicdialer.ContextDialer + Sess quic.EarlySession + Err error +} + +func (d MockDialer) DialContext(ctx context.Context, network, host string, + tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + if d.Dialer != nil { + return d.Dialer.DialContext(ctx, network, host, tlsCfg, cfg) + } + return d.Sess, d.Err +} + +func TestHandshakeSaverSuccess(t *testing.T) { + nextprotos := []string{"h3-29"} + servername := "www.google.com" + tlsConf := &tls.Config{ + NextProtos: nextprotos, + ServerName: servername, + } + saver := &trace.Saver{} + dlr := quicdialer.HandshakeSaver{ + Dialer: quicdialer.SystemDialer{}, + Saver: saver, + } + sess, err := dlr.DialContext(context.Background(), "udp", + "216.58.212.164:443", tlsConf, &quic.Config{}) + if err != nil { + t.Fatal("unexpected error", err) + } + if sess == nil { + t.Fatal("unexpected nil sess") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("unexpected number of events") + } + if ev[0].Name != "quic_handshake_start" { + t.Fatal("unexpected Name") + } + if ev[0].TLSServerName != "www.google.com" { + t.Fatal("unexpected TLSServerName") + } + if !reflect.DeepEqual(ev[0].TLSNextProtos, nextprotos) { + t.Fatal("unexpected TLSNextProtos") + } + if ev[0].Time.After(time.Now()) { + t.Fatal("unexpected Time") + } + if ev[1].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if ev[1].Err != nil { + t.Fatal("unexpected Err", ev[1].Err) + } + if ev[1].Name != "quic_handshake_done" { + t.Fatal("unexpected Name") + } + if !reflect.DeepEqual(ev[1].TLSNextProtos, nextprotos) { + t.Fatal("unexpected TLSNextProtos") + } + if ev[1].TLSServerName != "www.google.com" { + t.Fatal("unexpected TLSServerName") + } + if ev[1].Time.Before(ev[0].Time) { + t.Fatal("unexpected Time") + } +} + +func TestHandshakeSaverHostNameError(t *testing.T) { + nextprotos := []string{"h3-29"} + servername := "wrong.host.badssl.com" + tlsConf := &tls.Config{ + NextProtos: nextprotos, + ServerName: servername, + } + saver := &trace.Saver{} + dlr := quicdialer.HandshakeSaver{ + Dialer: quicdialer.SystemDialer{}, + Saver: saver, + } + sess, err := dlr.DialContext(context.Background(), "udp", + "216.58.212.164:443", tlsConf, &quic.Config{}) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected nil sess here") + } + for _, ev := range saver.Read() { + if ev.Name != "quic_handshake_done" { + continue + } + if ev.NoTLSVerify == true { + t.Fatal("expected NoTLSVerify to be false") + } + if !strings.Contains(ev.Err.Error(), + "certificate is valid for www.google.com, not "+servername) { + t.Fatal("unexpected error", ev.Err) + } + } +} diff --git a/internal/engine/netx/quicdialer/system.go b/internal/engine/netx/quicdialer/system.go new file mode 100644 index 0000000..eae55d4 --- /dev/null +++ b/internal/engine/netx/quicdialer/system.go @@ -0,0 +1,87 @@ +package quicdialer + +import ( + "context" + "crypto/tls" + "errors" + "net" + "strconv" + "time" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// SystemDialer is the basic dialer for QUIC +type SystemDialer struct { + // Saver saves read/write events on the underlying UDP + // connection. (Implementation note: we need it here since + // this is the only part in the codebase that is able to + // observe the underlying UDP connection.) + Saver *trace.Saver +} + +// DialContext implements ContextDialer.DialContext +func (d SystemDialer) DialContext(ctx context.Context, network string, + host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) { + onlyhost, onlyport, err := net.SplitHostPort(host) + port, err := strconv.Atoi(onlyport) + if err != nil { + return nil, err + } + ip := net.ParseIP(onlyhost) + if ip == nil { + // TODO(kelmenhorst): write test for this error condition. + return nil, errors.New("quicdialer: invalid IP representation") + } + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + var pconn net.PacketConn = udpConn + if d.Saver != nil { + pconn = saverUDPConn{UDPConn: udpConn, saver: d.Saver} + } + udpAddr := &net.UDPAddr{IP: ip, Port: port, Zone: ""} + return quic.DialEarlyContext(ctx, pconn, udpAddr, host, tlsCfg, cfg) + +} + +type saverUDPConn struct { + *net.UDPConn + saver *trace.Saver +} + +func (c saverUDPConn) WriteTo(p []byte, addr net.Addr) (int, error) { + start := time.Now() + count, err := c.UDPConn.WriteTo(p, addr) + stop := time.Now() + c.saver.Write(trace.Event{ + Address: addr.String(), + Data: p[:count], + Duration: stop.Sub(start), + Err: err, + NumBytes: count, + Name: errorx.WriteToOperation, + Time: stop, + }) + return count, err +} + +func (c saverUDPConn) ReadMsgUDP(b, oob []byte) (int, int, int, *net.UDPAddr, error) { + start := time.Now() + n, oobn, flags, addr, err := c.UDPConn.ReadMsgUDP(b, oob) + stop := time.Now() + var data []byte + if n > 0 { + data = b[:n] + } + c.saver.Write(trace.Event{ + Address: addr.String(), + Data: data, + Duration: stop.Sub(start), + Err: err, + NumBytes: n, + Name: errorx.ReadFromOperation, + Time: stop, + }) + return n, oobn, flags, addr, err +} diff --git a/internal/engine/netx/quicdialer/system_test.go b/internal/engine/netx/quicdialer/system_test.go new file mode 100644 index 0000000..24efa8c --- /dev/null +++ b/internal/engine/netx/quicdialer/system_test.go @@ -0,0 +1,75 @@ +package quicdialer_test + +import ( + "context" + "crypto/tls" + "testing" + + "github.com/lucas-clemente/quic-go" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestSystemDialerInvalidIPFailure(t *testing.T) { + tlsConf := &tls.Config{ + NextProtos: []string{"h3-29"}, + ServerName: "www.google.com", + } + saver := &trace.Saver{} + systemdialer := quicdialer.SystemDialer{ + Saver: saver, + } + sess, err := systemdialer.DialContext(context.Background(), "udp", "a.b.c.d:0", tlsConf, &quic.Config{}) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected nil sess here") + } + if err.Error() != "quicdialer: invalid IP representation" { + t.Fatal("expected another error here") + } +} + +func TestSystemDialerSuccessWithReadWrite(t *testing.T) { + // This is the most common use case for collecting reads, writes + tlsConf := &tls.Config{ + NextProtos: []string{"h3-29"}, + ServerName: "www.google.com", + } + saver := &trace.Saver{} + systemdialer := quicdialer.SystemDialer{Saver: saver} + _, err := systemdialer.DialContext(context.Background(), "udp", + "216.58.212.164:443", tlsConf, &quic.Config{}) + if err != nil { + t.Fatal(err) + } + ev := saver.Read() + if len(ev) < 2 { + t.Fatal("unexpected number of events") + } + last := len(ev) - 1 + for idx := 1; idx < last; idx++ { + if ev[idx].Data == nil { + t.Fatal("unexpected Data") + } + if ev[idx].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if ev[idx].Err != nil { + t.Fatal("unexpected Err") + } + if ev[idx].NumBytes <= 0 { + t.Fatal("unexpected NumBytes") + } + switch ev[idx].Name { + case errorx.ReadFromOperation, errorx.WriteToOperation: + default: + t.Fatal("unexpected Name") + } + if ev[idx].Time.Before(ev[idx-1].Time) { + t.Fatal("unexpected Time") + } + } +} diff --git a/internal/engine/netx/resolver/address.go b/internal/engine/netx/resolver/address.go new file mode 100644 index 0000000..c6cb4a5 --- /dev/null +++ b/internal/engine/netx/resolver/address.go @@ -0,0 +1,22 @@ +package resolver + +import ( + "context" + "net" +) + +// AddressResolver is a resolver that knows how to correctly +// resolve IP addresses to themselves. +type AddressResolver struct { + Resolver +} + +// LookupHost implements Resolver.LookupHost +func (r AddressResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + if net.ParseIP(hostname) != nil { + return []string{hostname}, nil + } + return r.Resolver.LookupHost(ctx, hostname) +} + +var _ Resolver = AddressResolver{} diff --git a/internal/engine/netx/resolver/address_test.go b/internal/engine/netx/resolver/address_test.go new file mode 100644 index 0000000..9d3e14d --- /dev/null +++ b/internal/engine/netx/resolver/address_test.go @@ -0,0 +1,36 @@ +package resolver_test + +import ( + "context" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestAddressSuccess(t *testing.T) { + r := resolver.AddressResolver{} + addrs, err := r.LookupHost(context.Background(), "8.8.8.8") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "8.8.8.8" { + t.Fatal("not the result we expected") + } +} + +func TestAddressFailure(t *testing.T) { + expected := errors.New("mocked error") + r := resolver.AddressResolver{ + Resolver: resolver.FakeResolver{ + Err: expected, + }, + } + addrs, err := r.LookupHost(context.Background(), "dns.google.com") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs") + } +} diff --git a/internal/engine/netx/resolver/bogon.go b/internal/engine/netx/resolver/bogon.go new file mode 100644 index 0000000..7dddf32 --- /dev/null +++ b/internal/engine/netx/resolver/bogon.go @@ -0,0 +1,71 @@ +package resolver + +import ( + "context" + "net" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +var privateIPBlocks []*net.IPNet + +func init() { + for _, cidr := range []string{ + "0.0.0.0/8", // "This" network (however, Linux...) + "10.0.0.0/8", // RFC1918 + "100.64.0.0/10", // Carrier grade NAT + "127.0.0.0/8", // IPv4 loopback + "169.254.0.0/16", // RFC3927 link-local + "172.16.0.0/12", // RFC1918 + "192.168.0.0/16", // RFC1918 + "224.0.0.0/4", // Multicast + "::1/128", // IPv6 loopback + "fe80::/10", // IPv6 link-local + "fc00::/7", // IPv6 unique local addr + } { + _, block, err := net.ParseCIDR(cidr) + runtimex.PanicOnError(err, "net.ParseCIDR failed") + privateIPBlocks = append(privateIPBlocks, block) + } +} + +func isPrivate(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + for _, block := range privateIPBlocks { + if block.Contains(ip) { + return true + } + } + return false +} + +// IsBogon returns whether if an IP address is bogon. Passing to this +// function a non-IP address causes it to return bogon. +func IsBogon(address string) bool { + ip := net.ParseIP(address) + return ip == nil || isPrivate(ip) +} + +// BogonResolver is a bogon aware resolver. When a bogon is encountered in +// a reply, this resolver will return an error. +type BogonResolver struct { + Resolver +} + +// LookupHost implements Resolver.LookupHost +func (r BogonResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + addrs, err := r.Resolver.LookupHost(ctx, hostname) + for _, addr := range addrs { + if IsBogon(addr) == true { + // We need to return the addrs otherwise the caller cannot see/log/save + // the specific addresses that triggered our bogon filter + return addrs, errorx.ErrDNSBogon + } + } + return addrs, err +} + +var _ Resolver = BogonResolver{} diff --git a/internal/engine/netx/resolver/bogon_test.go b/internal/engine/netx/resolver/bogon_test.go new file mode 100644 index 0000000..17aebfe --- /dev/null +++ b/internal/engine/netx/resolver/bogon_test.go @@ -0,0 +1,52 @@ +package resolver_test + +import ( + "context" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestResolverIsBogon(t *testing.T) { + if resolver.IsBogon("antani") != true { + t.Fatal("unexpected result") + } + if resolver.IsBogon("127.0.0.1") != true { + t.Fatal("unexpected result") + } + if resolver.IsBogon("1.1.1.1") != false { + t.Fatal("unexpected result") + } + if resolver.IsBogon("10.0.1.1") != true { + t.Fatal("unexpected result") + } +} + +func TestBogonAwareResolverWithBogon(t *testing.T) { + r := resolver.BogonResolver{ + Resolver: resolver.NewFakeResolverWithResult([]string{"127.0.0.1"}), + } + addrs, err := r.LookupHost(context.Background(), "dns.google.com") + if !errors.Is(err, errorx.ErrDNSBogon) { + t.Fatal("not the error we expected") + } + if len(addrs) != 1 || addrs[0] != "127.0.0.1" { + t.Fatal("expected to see address here") + } +} + +func TestBogonAwareResolverWithoutBogon(t *testing.T) { + orig := []string{"8.8.8.8"} + r := resolver.BogonResolver{ + Resolver: resolver.NewFakeResolverWithResult(orig), + } + addrs, err := r.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatal(err) + } + if len(addrs) != len(orig) || addrs[0] != orig[0] { + t.Fatal("not the error we expected") + } +} diff --git a/internal/engine/netx/resolver/cache.go b/internal/engine/netx/resolver/cache.go new file mode 100644 index 0000000..86eae9b --- /dev/null +++ b/internal/engine/netx/resolver/cache.go @@ -0,0 +1,47 @@ +package resolver + +import ( + "context" + "sync" +) + +// CacheResolver is a resolver that caches successful replies. +type CacheResolver struct { + ReadOnly bool + Resolver + mu sync.Mutex + cache map[string][]string +} + +// LookupHost implements Resolver.LookupHost +func (r *CacheResolver) LookupHost( + ctx context.Context, hostname string) ([]string, error) { + if entry := r.Get(hostname); entry != nil { + return entry, nil + } + entry, err := r.Resolver.LookupHost(ctx, hostname) + if err != nil { + return nil, err + } + if r.ReadOnly == false { + r.Set(hostname, entry) + } + return entry, nil +} + +// Get gets the currently configured entry for domain, or nil +func (r *CacheResolver) Get(domain string) []string { + r.mu.Lock() + defer r.mu.Unlock() + return r.cache[domain] +} + +// Set allows to pre-populate the cache +func (r *CacheResolver) Set(domain string, addresses []string) { + r.mu.Lock() + if r.cache == nil { + r.cache = make(map[string][]string) + } + r.cache[domain] = addresses + r.mu.Unlock() +} diff --git a/internal/engine/netx/resolver/cache_test.go b/internal/engine/netx/resolver/cache_test.go new file mode 100644 index 0000000..05dea44 --- /dev/null +++ b/internal/engine/netx/resolver/cache_test.go @@ -0,0 +1,76 @@ +package resolver_test + +import ( + "context" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestCacheFailure(t *testing.T) { + expected := errors.New("mocked error") + var r resolver.Resolver = resolver.FakeResolver{ + Err: expected, + } + cache := &resolver.CacheResolver{Resolver: r} + addrs, err := cache.LookupHost(context.Background(), "www.google.com") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs here") + } + if cache.Get("www.google.com") != nil { + t.Fatal("expected empty cache here") + } +} + +func TestCacheHitSuccess(t *testing.T) { + var r resolver.Resolver = resolver.FakeResolver{ + Err: errors.New("mocked error"), + } + cache := &resolver.CacheResolver{Resolver: r} + cache.Set("dns.google.com", []string{"8.8.8.8"}) + addrs, err := cache.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "8.8.8.8" { + t.Fatal("not the result we expected") + } +} + +func TestCacheMissSuccess(t *testing.T) { + var r resolver.Resolver = resolver.FakeResolver{ + Result: []string{"8.8.8.8"}, + } + cache := &resolver.CacheResolver{Resolver: r} + addrs, err := cache.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "8.8.8.8" { + t.Fatal("not the result we expected") + } + if cache.Get("dns.google.com")[0] != "8.8.8.8" { + t.Fatal("expected full cache here") + } +} + +func TestCacheReadonlySuccess(t *testing.T) { + var r resolver.Resolver = resolver.FakeResolver{ + Result: []string{"8.8.8.8"}, + } + cache := &resolver.CacheResolver{Resolver: r, ReadOnly: true} + addrs, err := cache.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "8.8.8.8" { + t.Fatal("not the result we expected") + } + if cache.Get("dns.google.com") != nil { + t.Fatal("expected empty cache here") + } +} diff --git a/internal/engine/netx/resolver/chain.go b/internal/engine/netx/resolver/chain.go new file mode 100644 index 0000000..def7534 --- /dev/null +++ b/internal/engine/netx/resolver/chain.go @@ -0,0 +1,33 @@ +package resolver + +import ( + "context" +) + +// ChainResolver is a chain resolver. The primary resolver is used first and, if that +// fails, we then attempt with the secondary resolver. +type ChainResolver struct { + Primary Resolver + Secondary Resolver +} + +// LookupHost implements Resolver.LookupHost +func (c ChainResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + addrs, err := c.Primary.LookupHost(ctx, hostname) + if err != nil { + addrs, err = c.Secondary.LookupHost(ctx, hostname) + } + return addrs, err +} + +// Network implements Resolver.Network +func (c ChainResolver) Network() string { + return "chain" +} + +// Address implements Resolver.Address +func (c ChainResolver) Address() string { + return "" +} + +var _ Resolver = ChainResolver{} diff --git a/internal/engine/netx/resolver/chain_test.go b/internal/engine/netx/resolver/chain_test.go new file mode 100644 index 0000000..e6aa1ae --- /dev/null +++ b/internal/engine/netx/resolver/chain_test.go @@ -0,0 +1,28 @@ +package resolver_test + +import ( + "context" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestChainLookupHost(t *testing.T) { + r := resolver.ChainResolver{ + Primary: resolver.NewFakeResolverThatFails(), + Secondary: resolver.SystemResolver{}, + } + if r.Address() != "" { + t.Fatal("invalid address") + } + if r.Network() != "chain" { + t.Fatal("invalid network") + } + addrs, err := r.LookupHost(context.Background(), "www.google.com") + if err != nil { + t.Fatal(err) + } + if addrs == nil { + t.Fatal("expect non nil return value here") + } +} diff --git a/internal/engine/netx/resolver/decoder.go b/internal/engine/netx/resolver/decoder.go new file mode 100644 index 0000000..fa101e2 --- /dev/null +++ b/internal/engine/netx/resolver/decoder.go @@ -0,0 +1,54 @@ +package resolver + +import ( + "errors" + + "github.com/miekg/dns" +) + +// The Decoder decodes a DNS reply into A or AAAA entries. It will use the +// provided qtype and only look for mathing entries. It will return error if +// there are no entries for the requested qtype inside the reply. +type Decoder interface { + Decode(qtype uint16, data []byte) ([]string, error) +} + +// MiekgDecoder uses github.com/miekg/dns to implement the Decoder. +type MiekgDecoder struct{} + +// Decode implements Decoder.Decode. +func (d MiekgDecoder) Decode(qtype uint16, data []byte) ([]string, error) { + reply := new(dns.Msg) + if err := reply.Unpack(data); err != nil { + return nil, err + } + // TODO(bassosimone): map more errors to net.DNSError names + switch reply.Rcode { + case dns.RcodeSuccess: + case dns.RcodeNameError: + return nil, errors.New("ooniresolver: no such host") + default: + return nil, errors.New("ooniresolver: query failed") + } + var addrs []string + for _, answer := range reply.Answer { + switch qtype { + case dns.TypeA: + if rra, ok := answer.(*dns.A); ok { + ip := rra.A + addrs = append(addrs, ip.String()) + } + case dns.TypeAAAA: + if rra, ok := answer.(*dns.AAAA); ok { + ip := rra.AAAA + addrs = append(addrs, ip.String()) + } + } + } + if len(addrs) <= 0 { + return nil, errors.New("ooniresolver: no response returned") + } + return addrs, nil +} + +var _ Decoder = MiekgDecoder{} diff --git a/internal/engine/netx/resolver/decoder_test.go b/internal/engine/netx/resolver/decoder_test.go new file mode 100644 index 0000000..d5982fb --- /dev/null +++ b/internal/engine/netx/resolver/decoder_test.go @@ -0,0 +1,113 @@ +package resolver_test + +import ( + "strings" + "testing" + + "github.com/miekg/dns" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestDecoderUnpackError(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode(dns.TypeA, nil) + if err == nil { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected nil data here") + } +} + +func TestDecoderNXDOMAIN(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode(dns.TypeA, resolver.GenReplyError(t, dns.RcodeNameError)) + if err == nil || !strings.HasSuffix(err.Error(), "no such host") { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected nil data here") + } +} + +func TestDecoderOtherError(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode(dns.TypeA, resolver.GenReplyError(t, dns.RcodeRefused)) + if err == nil || !strings.HasSuffix(err.Error(), "query failed") { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected nil data here") + } +} + +func TestDecoderNoAddress(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode(dns.TypeA, resolver.GenReplySuccess(t, dns.TypeA)) + if err == nil || !strings.HasSuffix(err.Error(), "no response returned") { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected nil data here") + } +} + +func TestDecoderDecodeA(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode( + dns.TypeA, resolver.GenReplySuccess(t, dns.TypeA, "1.1.1.1", "8.8.8.8")) + if err != nil { + t.Fatal(err) + } + if len(data) != 2 { + t.Fatal("expected two entries here") + } + if data[0] != "1.1.1.1" { + t.Fatal("invalid first IPv4 entry") + } + if data[1] != "8.8.8.8" { + t.Fatal("invalid second IPv4 entry") + } +} + +func TestDecoderDecodeAAAA(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode( + dns.TypeAAAA, resolver.GenReplySuccess(t, dns.TypeAAAA, "::1", "fe80::1")) + if err != nil { + t.Fatal(err) + } + if len(data) != 2 { + t.Fatal("expected two entries here") + } + if data[0] != "::1" { + t.Fatal("invalid first IPv6 entry") + } + if data[1] != "fe80::1" { + t.Fatal("invalid second IPv6 entry") + } +} + +func TestDecoderUnexpectedAReply(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode( + dns.TypeA, resolver.GenReplySuccess(t, dns.TypeAAAA, "::1", "fe80::1")) + if err == nil || !strings.HasSuffix(err.Error(), "no response returned") { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected nil data here") + } +} + +func TestDecoderUnexpectedAAAAReply(t *testing.T) { + d := resolver.MiekgDecoder{} + data, err := d.Decode( + dns.TypeAAAA, resolver.GenReplySuccess(t, dns.TypeA, "1.1.1.1", "8.8.4.4.")) + if err == nil || !strings.HasSuffix(err.Error(), "no response returned") { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected nil data here") + } +} diff --git a/internal/engine/netx/resolver/dnsoverhttps.go b/internal/engine/netx/resolver/dnsoverhttps.go new file mode 100644 index 0000000..50f10bc --- /dev/null +++ b/internal/engine/netx/resolver/dnsoverhttps.go @@ -0,0 +1,77 @@ +package resolver + +import ( + "bytes" + "context" + "errors" + "io/ioutil" + "net/http" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" +) + +// DNSOverHTTPS is a DNS over HTTPS RoundTripper. Requests are submitted over +// an HTTP/HTTPS channel provided by URL using the Do function. +type DNSOverHTTPS struct { + Do func(req *http.Request) (*http.Response, error) + URL string + HostOverride string +} + +// NewDNSOverHTTPS creates a new DNSOverHTTP instance from the +// specified http.Client and URL, as a convenience. +func NewDNSOverHTTPS(client *http.Client, URL string) DNSOverHTTPS { + return NewDNSOverHTTPSWithHostOverride(client, URL, "") +} + +// NewDNSOverHTTPSWithHostOverride is like NewDNSOverHTTPS except that +// it's creating a resolver where we use the specified host. +func NewDNSOverHTTPSWithHostOverride(client *http.Client, URL, hostOverride string) DNSOverHTTPS { + return DNSOverHTTPS{Do: client.Do, URL: URL, HostOverride: hostOverride} +} + +// RoundTrip implements RoundTripper.RoundTrip. +func (t DNSOverHTTPS) RoundTrip(ctx context.Context, query []byte) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + req, err := http.NewRequest("POST", t.URL, bytes.NewReader(query)) + if err != nil { + return nil, err + } + req.Host = t.HostOverride + req.Header.Set("user-agent", httpheader.UserAgent()) + req.Header.Set("content-type", "application/dns-message") + var resp *http.Response + resp, err = t.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + // TODO(bassosimone): we should map the status code to a + // proper Error in the DNS context. + return nil, errors.New("doh: server returned error") + } + if resp.Header.Get("content-type") != "application/dns-message" { + return nil, errors.New("doh: invalid content-type") + } + return ioutil.ReadAll(resp.Body) +} + +// RequiresPadding returns true for DoH according to RFC8467 +func (t DNSOverHTTPS) RequiresPadding() bool { + return true +} + +// Network returns the transport network (e.g., doh, dot) +func (t DNSOverHTTPS) Network() string { + return "doh" +} + +// Address returns the upstream server address. +func (t DNSOverHTTPS) Address() string { + return t.URL +} + +var _ RoundTripper = DNSOverHTTPS{} diff --git a/internal/engine/netx/resolver/dnsoverhttps_test.go b/internal/engine/netx/resolver/dnsoverhttps_test.go new file mode 100644 index 0000000..f02727f --- /dev/null +++ b/internal/engine/netx/resolver/dnsoverhttps_test.go @@ -0,0 +1,165 @@ +package resolver_test + +import ( + "bytes" + "context" + "errors" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestDNSOverHTTPSNewRequestFailure(t *testing.T) { + const invalidURL = "\t" + txp := resolver.NewDNSOverHTTPS(http.DefaultClient, invalidURL) + data, err := txp.RoundTrip(context.Background(), nil) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverHTTPSClientDoFailure(t *testing.T) { + expected := errors.New("mocked error") + txp := resolver.DNSOverHTTPS{ + Do: func(*http.Request) (*http.Response, error) { + return nil, expected + }, + URL: "https://cloudflare-dns.com/dns-query", + } + data, err := txp.RoundTrip(context.Background(), nil) + if !errors.Is(err, expected) { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverHTTPSHTTPFailure(t *testing.T) { + txp := resolver.DNSOverHTTPS{ + Do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 500, + Body: ioutil.NopCloser(strings.NewReader("")), + }, nil + }, + URL: "https://cloudflare-dns.com/dns-query", + } + data, err := txp.RoundTrip(context.Background(), nil) + if err == nil || err.Error() != "doh: server returned error" { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverHTTPSMissingContentType(t *testing.T) { + txp := resolver.DNSOverHTTPS{ + Do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(strings.NewReader("")), + }, nil + }, + URL: "https://cloudflare-dns.com/dns-query", + } + data, err := txp.RoundTrip(context.Background(), nil) + if err == nil || err.Error() != "doh: invalid content-type" { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverHTTPSSuccess(t *testing.T) { + body := []byte("AAA") + txp := resolver.DNSOverHTTPS{ + Do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader(body)), + Header: http.Header{ + "Content-Type": []string{"application/dns-message"}, + }, + }, nil + }, + URL: "https://cloudflare-dns.com/dns-query", + } + data, err := txp.RoundTrip(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, body) { + t.Fatal("not the response we expected") + } +} + +func TestDNSOverHTTPTransportOK(t *testing.T) { + const queryURL = "https://cloudflare-dns.com/dns-query" + txp := resolver.NewDNSOverHTTPS(http.DefaultClient, queryURL) + if txp.Network() != "doh" { + t.Fatal("invalid network") + } + if txp.RequiresPadding() != true { + t.Fatal("should require padding") + } + if txp.Address() != queryURL { + t.Fatal("invalid address") + } +} + +func TestDNSOverHTTPSClientSetsUserAgent(t *testing.T) { + expected := errors.New("mocked error") + var correct bool + txp := resolver.DNSOverHTTPS{ + Do: func(req *http.Request) (*http.Response, error) { + correct = req.Header.Get("User-Agent") == httpheader.UserAgent() + return nil, expected + }, + URL: "https://cloudflare-dns.com/dns-query", + } + data, err := txp.RoundTrip(context.Background(), nil) + if !errors.Is(err, expected) { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected no response here") + } + if !correct { + t.Fatal("did not see correct user agent") + } +} + +func TestDNSOverHTTPSHostOverride(t *testing.T) { + var correct bool + expected := errors.New("mocked error") + + hostOverride := "test.com" + txp := resolver.DNSOverHTTPS{ + Do: func(req *http.Request) (*http.Response, error) { + correct = req.Host == hostOverride + return nil, expected + }, + URL: "https://cloudflare-dns.com/dns-query", + HostOverride: hostOverride, + } + data, err := txp.RoundTrip(context.Background(), nil) + if !errors.Is(err, expected) { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected no response here") + } + if !correct { + t.Fatal("did not see correct host override") + } +} diff --git a/internal/engine/netx/resolver/dnsovertcp.go b/internal/engine/netx/resolver/dnsovertcp.go new file mode 100644 index 0000000..0c90f36 --- /dev/null +++ b/internal/engine/netx/resolver/dnsovertcp.go @@ -0,0 +1,97 @@ +package resolver + +import ( + "context" + "errors" + "io" + "math" + "net" + "time" +) + +// DialContextFunc is a generic function for dialing a connection. +type DialContextFunc func(context.Context, string, string) (net.Conn, error) + +// DNSOverTCP is a DNS over TCP/TLS RoundTripper. Use NewDNSOverTCP +// and NewDNSOverTLS to create specific instances that use plaintext +// queries or encrypted queries over TLS. +// +// As a known bug, this implementation always creates a new connection +// for each incoming query, thus increasing the response delay. +type DNSOverTCP struct { + dial DialContextFunc + address string + network string + requiresPadding bool +} + +// NewDNSOverTCP creates a new DNSOverTCP transport. +func NewDNSOverTCP(dial DialContextFunc, address string) DNSOverTCP { + return DNSOverTCP{ + dial: dial, + address: address, + network: "tcp", + requiresPadding: false, + } +} + +// NewDNSOverTLS creates a new DNSOverTLS transport. +func NewDNSOverTLS(dial DialContextFunc, address string) DNSOverTCP { + return DNSOverTCP{ + dial: dial, + address: address, + network: "dot", + requiresPadding: true, + } +} + +// RoundTrip implements RoundTripper.RoundTrip. +func (t DNSOverTCP) RoundTrip(ctx context.Context, query []byte) ([]byte, error) { + if len(query) > math.MaxUint16 { + return nil, errors.New("query too long") + } + conn, err := t.dial(ctx, "tcp", t.address) + if err != nil { + return nil, err + } + defer conn.Close() + if err = conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + return nil, err + } + // Write request + buf := []byte{byte(len(query) >> 8)} + buf = append(buf, byte(len(query))) + buf = append(buf, query...) + if _, err = conn.Write(buf); err != nil { + return nil, err + } + // Read response + header := make([]byte, 2) + if _, err = io.ReadFull(conn, header); err != nil { + return nil, err + } + length := int(header[0])<<8 | int(header[1]) + reply := make([]byte, length) + if _, err = io.ReadFull(conn, reply); err != nil { + return nil, err + } + return reply, nil +} + +// RequiresPadding returns true for DoT and false for TCP +// according to RFC8467. +func (t DNSOverTCP) RequiresPadding() bool { + return t.requiresPadding +} + +// Network returns the transport network (e.g., doh, dot) +func (t DNSOverTCP) Network() string { + return t.network +} + +// Address returns the upstream server address. +func (t DNSOverTCP) Address() string { + return t.address +} + +var _ RoundTripper = DNSOverTCP{} diff --git a/internal/engine/netx/resolver/dnsovertcp_test.go b/internal/engine/netx/resolver/dnsovertcp_test.go new file mode 100644 index 0000000..c3d035f --- /dev/null +++ b/internal/engine/netx/resolver/dnsovertcp_test.go @@ -0,0 +1,146 @@ +package resolver_test + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestDNSOverTCPTransportQueryTooLarge(t *testing.T) { + const address = "9.9.9.9:53" + txp := resolver.NewDNSOverTCP(new(net.Dialer).DialContext, address) + reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<18)) + if err == nil { + t.Fatal("expected an error here") + } + if reply != nil { + t.Fatal("expected nil reply here") + } +} + +func TestDNSOverTCPTransportDialFailure(t *testing.T) { + const address = "9.9.9.9:53" + mocked := errors.New("mocked error") + fakedialer := resolver.FakeDialer{Err: mocked} + txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address) + reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11)) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if reply != nil { + t.Fatal("expected nil reply here") + } +} + +func TestDNSOverTCPTransportSetDealineFailure(t *testing.T) { + const address = "9.9.9.9:53" + mocked := errors.New("mocked error") + fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{ + SetDeadlineError: mocked, + }} + txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address) + reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11)) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if reply != nil { + t.Fatal("expected nil reply here") + } +} + +func TestDNSOverTCPTransportWriteFailure(t *testing.T) { + const address = "9.9.9.9:53" + mocked := errors.New("mocked error") + fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{ + WriteError: mocked, + }} + txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address) + reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11)) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if reply != nil { + t.Fatal("expected nil reply here") + } +} + +func TestDNSOverTCPTransportReadFailure(t *testing.T) { + const address = "9.9.9.9:53" + mocked := errors.New("mocked error") + fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{ + ReadError: mocked, + }} + txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address) + reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11)) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if reply != nil { + t.Fatal("expected nil reply here") + } +} + +func TestDNSOverTCPTransportSecondReadFailure(t *testing.T) { + const address = "9.9.9.9:53" + mocked := errors.New("mocked error") + fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{ + ReadError: mocked, + ReadData: []byte{byte(0), byte(2)}, + }} + txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address) + reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11)) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if reply != nil { + t.Fatal("expected nil reply here") + } +} + +func TestDNSOverTCPTransportAllGood(t *testing.T) { + const address = "9.9.9.9:53" + mocked := errors.New("mocked error") + fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{ + ReadError: mocked, + ReadData: []byte{byte(0), byte(1), byte(1)}, + }} + txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address) + reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11)) + if err != nil { + t.Fatal(err) + } + if len(reply) != 1 || reply[0] != 1 { + t.Fatal("not the response we expected") + } +} + +func TestDNSOverTCPTransportOK(t *testing.T) { + const address = "9.9.9.9:53" + txp := resolver.NewDNSOverTCP(new(net.Dialer).DialContext, address) + if txp.RequiresPadding() != false { + t.Fatal("invalid RequiresPadding") + } + if txp.Network() != "tcp" { + t.Fatal("invalid Network") + } + if txp.Address() != address { + t.Fatal("invalid Address") + } +} + +func TestDNSOverTLSTransportOK(t *testing.T) { + const address = "9.9.9.9:853" + txp := resolver.NewDNSOverTLS(resolver.DialTLSContext, address) + if txp.RequiresPadding() != true { + t.Fatal("invalid RequiresPadding") + } + if txp.Network() != "dot" { + t.Fatal("invalid Network") + } + if txp.Address() != address { + t.Fatal("invalid Address") + } +} diff --git a/internal/engine/netx/resolver/dnsoverudp.go b/internal/engine/netx/resolver/dnsoverudp.go new file mode 100644 index 0000000..bb4fb3d --- /dev/null +++ b/internal/engine/netx/resolver/dnsoverudp.go @@ -0,0 +1,64 @@ +package resolver + +import ( + "context" + "net" + "time" +) + +// Dialer is the network dialer interface assumed by this package. +type Dialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// DNSOverUDP is a DNS over UDP RoundTripper. +type DNSOverUDP struct { + dialer Dialer + address string +} + +// NewDNSOverUDP creates a DNSOverUDP instance. +func NewDNSOverUDP(dialer Dialer, address string) DNSOverUDP { + return DNSOverUDP{dialer: dialer, address: address} +} + +// RoundTrip implements RoundTripper.RoundTrip. +func (t DNSOverUDP) RoundTrip(ctx context.Context, query []byte) ([]byte, error) { + conn, err := t.dialer.DialContext(ctx, "udp", t.address) + if err != nil { + return nil, err + } + defer conn.Close() + // Use five seconds timeout like Bionic does. See + // https://labs.ripe.net/Members/baptiste_jonglez_1/persistent-dns-connections-for-reliability-and-performance + if err = conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return nil, err + } + if _, err = conn.Write(query); err != nil { + return nil, err + } + reply := make([]byte, 1<<17) + var n int + n, err = conn.Read(reply) + if err != nil { + return nil, err + } + return reply[:n], nil +} + +// RequiresPadding returns false for UDP according to RFC8467 +func (t DNSOverUDP) RequiresPadding() bool { + return false +} + +// Network returns the transport network (e.g., doh, dot) +func (t DNSOverUDP) Network() string { + return "udp" +} + +// Address returns the upstream server address. +func (t DNSOverUDP) Address() string { + return t.address +} + +var _ RoundTripper = DNSOverUDP{} diff --git a/internal/engine/netx/resolver/dnsoverudp_test.go b/internal/engine/netx/resolver/dnsoverudp_test.go new file mode 100644 index 0000000..9c9a4ce --- /dev/null +++ b/internal/engine/netx/resolver/dnsoverudp_test.go @@ -0,0 +1,107 @@ +package resolver_test + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestDNSOverUDPDialFailure(t *testing.T) { + mocked := errors.New("mocked error") + const address = "9.9.9.9:53" + txp := resolver.NewDNSOverUDP(resolver.FakeDialer{Err: mocked}, address) + data, err := txp.RoundTrip(context.Background(), nil) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverUDPSetDeadlineError(t *testing.T) { + mocked := errors.New("mocked error") + txp := resolver.NewDNSOverUDP( + resolver.FakeDialer{ + Conn: &resolver.FakeConn{ + SetDeadlineError: mocked, + }, + }, "9.9.9.9:53", + ) + data, err := txp.RoundTrip(context.Background(), nil) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverUDPWriteFailure(t *testing.T) { + mocked := errors.New("mocked error") + txp := resolver.NewDNSOverUDP( + resolver.FakeDialer{ + Conn: &resolver.FakeConn{ + WriteError: mocked, + }, + }, "9.9.9.9:53", + ) + data, err := txp.RoundTrip(context.Background(), nil) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverUDPReadFailure(t *testing.T) { + mocked := errors.New("mocked error") + txp := resolver.NewDNSOverUDP( + resolver.FakeDialer{ + Conn: &resolver.FakeConn{ + ReadError: mocked, + }, + }, "9.9.9.9:53", + ) + data, err := txp.RoundTrip(context.Background(), nil) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if data != nil { + t.Fatal("expected no response here") + } +} + +func TestDNSOverUDPReadSuccess(t *testing.T) { + const expected = 17 + txp := resolver.NewDNSOverUDP( + resolver.FakeDialer{ + Conn: &resolver.FakeConn{ReadData: make([]byte, 17)}, + }, "9.9.9.9:53", + ) + data, err := txp.RoundTrip(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if len(data) != expected { + t.Fatal("expected non nil data") + } +} + +func TestDNSOverUDPTransportOK(t *testing.T) { + const address = "9.9.9.9:53" + txp := resolver.NewDNSOverUDP(&net.Dialer{}, address) + if txp.RequiresPadding() != false { + t.Fatal("invalid RequiresPadding") + } + if txp.Network() != "udp" { + t.Fatal("invalid Network") + } + if txp.Address() != address { + t.Fatal("invalid Address") + } +} diff --git a/internal/engine/netx/resolver/emitter.go b/internal/engine/netx/resolver/emitter.go new file mode 100644 index 0000000..c4b095a --- /dev/null +++ b/internal/engine/netx/resolver/emitter.go @@ -0,0 +1,89 @@ +package resolver + +import ( + "context" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" +) + +// EmitterTransport is a RoundTripper that emits events when they occur. +type EmitterTransport struct { + RoundTripper +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp EmitterTransport) RoundTrip(ctx context.Context, querydata []byte) ([]byte, error) { + root := modelx.ContextMeasurementRootOrDefault(ctx) + root.Handler.OnMeasurement(modelx.Measurement{ + DNSQuery: &modelx.DNSQueryEvent{ + Data: querydata, + DialID: dialid.ContextDialID(ctx), + DurationSinceBeginning: time.Now().Sub(root.Beginning), + }, + }) + replydata, err := txp.RoundTripper.RoundTrip(ctx, querydata) + if err != nil { + return nil, err + } + root.Handler.OnMeasurement(modelx.Measurement{ + DNSReply: &modelx.DNSReplyEvent{ + Data: replydata, + DialID: dialid.ContextDialID(ctx), + DurationSinceBeginning: time.Now().Sub(root.Beginning), + }, + }) + return replydata, nil +} + +// EmitterResolver is a resolver that emits events +type EmitterResolver struct { + Resolver +} + +// LookupHost returns the IP addresses of a host +func (r EmitterResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + var ( + network string + address string + ) + type queryableResolver interface { + Transport() RoundTripper + } + if qr, ok := r.Resolver.(queryableResolver); ok { + txp := qr.Transport() + network, address = txp.Network(), txp.Address() + } + dialID := dialid.ContextDialID(ctx) + txID := transactionid.ContextTransactionID(ctx) + root := modelx.ContextMeasurementRootOrDefault(ctx) + root.Handler.OnMeasurement(modelx.Measurement{ + ResolveStart: &modelx.ResolveStartEvent{ + DialID: dialID, + DurationSinceBeginning: time.Now().Sub(root.Beginning), + Hostname: hostname, + TransactionID: txID, + TransportAddress: address, + TransportNetwork: network, + }, + }) + addrs, err := r.Resolver.LookupHost(ctx, hostname) + root.Handler.OnMeasurement(modelx.Measurement{ + ResolveDone: &modelx.ResolveDoneEvent{ + Addresses: addrs, + DialID: dialID, + DurationSinceBeginning: time.Now().Sub(root.Beginning), + Error: err, + Hostname: hostname, + TransactionID: txID, + TransportAddress: address, + TransportNetwork: network, + }, + }) + return addrs, err +} + +var _ RoundTripper = EmitterTransport{} +var _ Resolver = EmitterResolver{} diff --git a/internal/engine/netx/resolver/emitter_test.go b/internal/engine/netx/resolver/emitter_test.go new file mode 100644 index 0000000..6245d76 --- /dev/null +++ b/internal/engine/netx/resolver/emitter_test.go @@ -0,0 +1,220 @@ +package resolver_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestEmitterTransportSuccess(t *testing.T) { + ctx := context.Background() + ctx = dialid.WithDialID(ctx) + handler := &handlers.SavingHandler{} + root := &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: handler, + } + ctx = modelx.WithMeasurementRoot(ctx, root) + txp := resolver.EmitterTransport{RoundTripper: resolver.FakeTransport{ + Data: resolver.GenReplySuccess(t, dns.TypeA, "8.8.8.8"), + }} + e := resolver.MiekgEncoder{} + querydata, err := e.Encode("www.google.com", dns.TypeAAAA, true) + if err != nil { + t.Fatal(err) + } + replydata, err := txp.RoundTrip(ctx, querydata) + if err != nil { + t.Fatal(err) + } + events := handler.Read() + if len(events) != 2 { + t.Fatal("unexpected number of events") + } + if events[0].DNSQuery == nil { + t.Fatal("missing DNSQuery field") + } + if !bytes.Equal(events[0].DNSQuery.Data, querydata) { + t.Fatal("invalid query data") + } + if events[0].DNSQuery.DialID == 0 { + t.Fatal("invalid query DialID") + } + if events[0].DNSQuery.DurationSinceBeginning <= 0 { + t.Fatal("invalid duration since beginning") + } + if events[1].DNSReply == nil { + t.Fatal("missing DNSReply field") + } + if !bytes.Equal(events[1].DNSReply.Data, replydata) { + t.Fatal("missing reply data") + } + if events[1].DNSReply.DialID != 1 { + t.Fatal("invalid query DialID") + } + if events[1].DNSReply.DurationSinceBeginning <= 0 { + t.Fatal("invalid duration since beginning") + } +} + +func TestEmitterTransportFailure(t *testing.T) { + ctx := context.Background() + ctx = dialid.WithDialID(ctx) + handler := &handlers.SavingHandler{} + root := &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: handler, + } + ctx = modelx.WithMeasurementRoot(ctx, root) + mocked := errors.New("mocked error") + txp := resolver.EmitterTransport{RoundTripper: resolver.FakeTransport{ + Err: mocked, + }} + e := resolver.MiekgEncoder{} + querydata, err := e.Encode("www.google.com", dns.TypeAAAA, true) + if err != nil { + t.Fatal(err) + } + replydata, err := txp.RoundTrip(ctx, querydata) + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if replydata != nil { + t.Fatal("expected nil replydata") + } + events := handler.Read() + if len(events) != 1 { + t.Fatal("unexpected number of events") + } + if events[0].DNSQuery == nil { + t.Fatal("missing DNSQuery field") + } + if !bytes.Equal(events[0].DNSQuery.Data, querydata) { + t.Fatal("invalid query data") + } + if events[0].DNSQuery.DialID == 0 { + t.Fatal("invalid query DialID") + } + if events[0].DNSQuery.DurationSinceBeginning <= 0 { + t.Fatal("invalid duration since beginning") + } +} + +func TestEmitterResolverFailure(t *testing.T) { + ctx := context.Background() + ctx = dialid.WithDialID(ctx) + ctx = transactionid.WithTransactionID(ctx) + handler := &handlers.SavingHandler{} + root := &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: handler, + } + ctx = modelx.WithMeasurementRoot(ctx, root) + r := resolver.EmitterResolver{Resolver: resolver.NewSerialResolver( + resolver.DNSOverHTTPS{ + Do: func(req *http.Request) (*http.Response, error) { + return nil, io.EOF + }, + URL: "https://dns.google.com/", + }, + )} + replies, err := r.LookupHost(ctx, "www.google.com") + if !errors.Is(err, io.EOF) { + t.Fatal("not the error we expected") + } + if replies != nil { + t.Fatal("expected nil replies") + } + events := handler.Read() + if len(events) != 2 { + t.Fatal("unexpected number of events") + } + if events[0].ResolveStart == nil { + t.Fatal("missing ResolveStart field") + } + if events[0].ResolveStart.DialID == 0 { + t.Fatal("invalid DialID") + } + if events[0].ResolveStart.DurationSinceBeginning <= 0 { + t.Fatal("invalid duration since beginning") + } + if events[0].ResolveStart.Hostname != "www.google.com" { + t.Fatal("invalid Hostname") + } + if events[0].ResolveStart.TransactionID == 0 { + t.Fatal("invalid TransactionID") + } + if events[0].ResolveStart.TransportAddress != "https://dns.google.com/" { + t.Fatal("invalid TransportAddress") + } + if events[0].ResolveStart.TransportNetwork != "doh" { + t.Fatal("invalid TransportNetwork") + } + if events[1].ResolveDone == nil { + t.Fatal("missing ResolveDone field") + } + if events[1].ResolveDone.DialID == 0 { + t.Fatal("invalid DialID") + } + if events[1].ResolveDone.DurationSinceBeginning <= 0 { + t.Fatal("invalid duration since beginning") + } + if events[1].ResolveDone.Error != io.EOF { + t.Fatal("invalid Error") + } + if events[1].ResolveDone.Hostname != "www.google.com" { + t.Fatal("invalid Hostname") + } + if events[1].ResolveDone.TransactionID == 0 { + t.Fatal("invalid TransactionID") + } + if events[1].ResolveDone.TransportAddress != "https://dns.google.com/" { + t.Fatal("invalid TransportAddress") + } + if events[1].ResolveDone.TransportNetwork != "doh" { + t.Fatal("invalid TransportNetwork") + } +} + +func TestEmitterResolverSuccess(t *testing.T) { + ctx := context.Background() + ctx = dialid.WithDialID(ctx) + ctx = transactionid.WithTransactionID(ctx) + handler := &handlers.SavingHandler{} + root := &modelx.MeasurementRoot{ + Beginning: time.Now(), + Handler: handler, + } + ctx = modelx.WithMeasurementRoot(ctx, root) + r := resolver.EmitterResolver{Resolver: resolver.NewFakeResolverWithResult( + []string{"8.8.8.8"}, + )} + replies, err := r.LookupHost(ctx, "dns.google.com") + if err != nil { + t.Fatal(err) + } + if len(replies) != 1 { + t.Fatal("expected a single replies") + } + events := handler.Read() + if len(events) != 2 { + t.Fatal("unexpected number of events") + } + if events[1].ResolveDone == nil { + t.Fatal("missing ResolveDone field") + } + if events[1].ResolveDone.Addresses[0] != "8.8.8.8" { + t.Fatal("invalid Addresses") + } +} diff --git a/internal/engine/netx/resolver/encoder.go b/internal/engine/netx/resolver/encoder.go new file mode 100644 index 0000000..aca7bc7 --- /dev/null +++ b/internal/engine/netx/resolver/encoder.go @@ -0,0 +1,52 @@ +package resolver + +import "github.com/miekg/dns" + +// The Encoder encodes DNS queries to bytes +type Encoder interface { + Encode(domain string, qtype uint16, padding bool) ([]byte, error) +} + +// MiekgEncoder uses github.com/miekg/dns to implement the Encoder. +type MiekgEncoder struct{} + +const ( + // PaddingDesiredBlockSize is the size that the padded query should be multiple of + PaddingDesiredBlockSize = 128 + + // EDNS0MaxResponseSize is the maximum response size for EDNS0 + EDNS0MaxResponseSize = 4096 + + // DNSSECEnabled turns on support for DNSSEC when using EDNS0 + DNSSECEnabled = true +) + +// Encode implements Encoder.Encode +func (e MiekgEncoder) Encode(domain string, qtype uint16, padding bool) ([]byte, error) { + question := dns.Question{ + Name: dns.Fqdn(domain), + Qtype: qtype, + Qclass: dns.ClassINET, + } + query := new(dns.Msg) + query.Id = dns.Id() + query.RecursionDesired = true + query.Question = make([]dns.Question, 1) + query.Question[0] = question + if padding { + query.SetEdns0(EDNS0MaxResponseSize, DNSSECEnabled) + // Clients SHOULD pad queries to the closest multiple of + // 128 octets RFC8467#section-4.1. We inflate the query + // length by the size of the option (i.e. 4 octets). The + // cast to uint is necessary to make the modulus operation + // work as intended when the desiredBlockSize is smaller + // than (query.Len()+4) ¯\_(ツ)_/¯. + remainder := (PaddingDesiredBlockSize - uint(query.Len()+4)) % PaddingDesiredBlockSize + opt := new(dns.EDNS0_PADDING) + opt.Padding = make([]byte, remainder) + query.IsEdns0().Option = append(query.IsEdns0().Option, opt) + } + return query.Pack() +} + +var _ Encoder = MiekgEncoder{} diff --git a/internal/engine/netx/resolver/encoder_test.go b/internal/engine/netx/resolver/encoder_test.go new file mode 100644 index 0000000..eaf8bfa --- /dev/null +++ b/internal/engine/netx/resolver/encoder_test.go @@ -0,0 +1,99 @@ +package resolver_test + +import ( + "strings" + "testing" + + "github.com/miekg/dns" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestEncoderEncodeA(t *testing.T) { + e := resolver.MiekgEncoder{} + data, err := e.Encode("x.org", dns.TypeA, false) + if err != nil { + t.Fatal(err) + } + validate(t, data, byte(dns.TypeA)) +} + +func TestEncoderEncodeAAAA(t *testing.T) { + e := resolver.MiekgEncoder{} + data, err := e.Encode("x.org", dns.TypeAAAA, false) + if err != nil { + t.Fatal(err) + } + validate(t, data, byte(dns.TypeA)) +} + +func validate(t *testing.T, data []byte, qtype byte) { + // skipping over the query ID + if data[2] != 1 { + t.Fatal("FLAGS should only have RD set") + } + if data[3] != 0 { + t.Fatal("RA|Z|Rcode should be zero") + } + if data[4] != 0 || data[5] != 1 { + t.Fatal("QCOUNT high should be one") + } + if data[6] != 0 || data[7] != 0 { + t.Fatal("ANCOUNT should be zero") + } + if data[8] != 0 || data[9] != 0 { + t.Fatal("NSCOUNT should be zero") + } + if data[10] != 0 || data[11] != 0 { + t.Fatal("ARCOUNT should be zero") + } + t.Log(data[12]) + if data[12] != 1 || data[13] != byte('x') { + t.Fatal("The name does not contain 1:x") + } + if data[14] != 3 || data[15] != byte('o') || data[16] != byte('r') || data[17] != byte('g') { + t.Fatal("The name does not containg 3:org") + } + if data[18] != 0 { + t.Fatal("The name does not terminate where expected") + } + if data[19] != 0 && data[20] != qtype { + t.Fatal("The query is not for the expected type") + } + if data[21] != 0 && data[22] != 1 { + t.Fatal("The query is not IN") + } +} + +func TestEncoderPadding(t *testing.T) { + // The purpose of this unit test is to make sure that for a wide + // array of values we obtain the right query size. + getquerylen := func(domainlen int, padding bool) int { + e := resolver.MiekgEncoder{} + data, err := e.Encode( + // This is not a valid name because it ends up being way + // longer than 255 octets. However, the library is allowing + // us to generate such name and we are not going to send + // it on the wire. Also, we check below that the query that + // we generate is long enough, so we should be good. + dns.Fqdn(strings.Repeat("x.", domainlen)), + dns.TypeA, padding, + ) + if err != nil { + t.Fatal(err) + } + return len(data) + } + for domainlen := 1; domainlen <= 4000; domainlen++ { + vanillalen := getquerylen(domainlen, false) + paddedlen := getquerylen(domainlen, true) + if vanillalen < domainlen { + t.Fatal("vanillalen is smaller than domainlen") + } + if (paddedlen % resolver.PaddingDesiredBlockSize) != 0 { + t.Fatal("paddedlen is not a multiple of PaddingDesiredBlockSize") + } + if paddedlen < vanillalen { + t.Fatal("paddedlen is smaller than vanillalen") + } + } +} diff --git a/internal/engine/netx/resolver/errorwrapper.go b/internal/engine/netx/resolver/errorwrapper.go new file mode 100644 index 0000000..1699477 --- /dev/null +++ b/internal/engine/netx/resolver/errorwrapper.go @@ -0,0 +1,30 @@ +package resolver + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" +) + +// ErrorWrapperResolver is a Resolver that knows about wrapping errors. +type ErrorWrapperResolver struct { + Resolver +} + +// LookupHost implements Resolver.LookupHost +func (r ErrorWrapperResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + dialID := dialid.ContextDialID(ctx) + txID := transactionid.ContextTransactionID(ctx) + addrs, err := r.Resolver.LookupHost(ctx, hostname) + err = errorx.SafeErrWrapperBuilder{ + DialID: dialID, + Error: err, + Operation: errorx.ResolveOperation, + TransactionID: txID, + }.MaybeBuild() + return addrs, err +} + +var _ Resolver = ErrorWrapperResolver{} diff --git a/internal/engine/netx/resolver/errorwrapper_test.go b/internal/engine/netx/resolver/errorwrapper_test.go new file mode 100644 index 0000000..9212f42 --- /dev/null +++ b/internal/engine/netx/resolver/errorwrapper_test.go @@ -0,0 +1,58 @@ +package resolver_test + +import ( + "context" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid" + "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid" + "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestErrorWrapperSuccess(t *testing.T) { + orig := []string{"8.8.8.8"} + r := resolver.ErrorWrapperResolver{ + Resolver: resolver.NewFakeResolverWithResult(orig), + } + addrs, err := r.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatal(err) + } + if len(addrs) != len(orig) || addrs[0] != orig[0] { + t.Fatal("not the result we expected") + } +} + +func TestErrorWrapperFailure(t *testing.T) { + r := resolver.ErrorWrapperResolver{ + Resolver: resolver.NewFakeResolverThatFails(), + } + ctx := context.Background() + ctx = dialid.WithDialID(ctx) + ctx = transactionid.WithTransactionID(ctx) + addrs, err := r.LookupHost(ctx, "dns.google.com") + if addrs != nil { + t.Fatal("expected nil addr here") + } + var errWrapper *errorx.ErrWrapper + if !errors.As(err, &errWrapper) { + t.Fatal("cannot properly cast the returned error") + } + if errWrapper.Failure != errorx.FailureDNSNXDOMAINError { + t.Fatal("unexpected failure") + } + if errWrapper.ConnID != 0 { + t.Fatal("unexpected ConnID") + } + if errWrapper.DialID == 0 { + t.Fatal("unexpected DialID") + } + if errWrapper.TransactionID == 0 { + t.Fatal("unexpected TransactionID") + } + if errWrapper.Operation != errorx.ResolveOperation { + t.Fatal("unexpected Operation") + } +} diff --git a/internal/engine/netx/resolver/fake_test.go b/internal/engine/netx/resolver/fake_test.go new file mode 100644 index 0000000..e406381 --- /dev/null +++ b/internal/engine/netx/resolver/fake_test.go @@ -0,0 +1,142 @@ +package resolver + +import ( + "context" + "io" + "net" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" +) + +type FakeDialer struct { + Conn net.Conn + Err error +} + +func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + time.Sleep(10 * time.Microsecond) + return d.Conn, d.Err +} + +type FakeConn struct { + ReadError error + ReadData []byte + SetDeadlineError error + SetReadDeadlineError error + SetWriteDeadlineError error + WriteError error +} + +func (c *FakeConn) Read(b []byte) (int, error) { + if len(c.ReadData) > 0 { + n := copy(b, c.ReadData) + c.ReadData = c.ReadData[n:] + return n, nil + } + if c.ReadError != nil { + return 0, c.ReadError + } + return 0, io.EOF +} + +func (c *FakeConn) Write(b []byte) (n int, err error) { + if c.WriteError != nil { + return 0, c.WriteError + } + n = len(b) + return +} + +func (*FakeConn) Close() (err error) { + return +} + +func (*FakeConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*FakeConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (c *FakeConn) SetDeadline(t time.Time) (err error) { + return c.SetDeadlineError +} + +func (c *FakeConn) SetReadDeadline(t time.Time) (err error) { + return c.SetReadDeadlineError +} + +func (c *FakeConn) SetWriteDeadline(t time.Time) (err error) { + return c.SetWriteDeadlineError +} + +type FakeTransport struct { + Data []byte + Err error +} + +func (ft FakeTransport) RoundTrip(ctx context.Context, query []byte) ([]byte, error) { + return ft.Data, ft.Err +} + +func (ft FakeTransport) RequiresPadding() bool { + return false +} + +func (ft FakeTransport) Address() string { + return "" +} + +func (ft FakeTransport) Network() string { + return "fake" +} + +type FakeEncoder struct { + Data []byte + Err error +} + +func (fe FakeEncoder) Encode(domain string, qtype uint16, padding bool) ([]byte, error) { + return fe.Data, fe.Err +} + +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 _ Resolver = FakeResolver{} diff --git a/internal/engine/netx/resolver/genreply_test.go b/internal/engine/netx/resolver/genreply_test.go new file mode 100644 index 0000000..0901588 --- /dev/null +++ b/internal/engine/netx/resolver/genreply_test.go @@ -0,0 +1,76 @@ +package resolver + +import ( + "net" + "testing" + + "github.com/miekg/dns" +) + +func GenReplyError(t *testing.T, code int) []byte { + question := dns.Question{ + Name: dns.Fqdn("x.org"), + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + } + query := new(dns.Msg) + query.Id = dns.Id() + query.RecursionDesired = true + query.Question = make([]dns.Question, 1) + query.Question[0] = question + reply := new(dns.Msg) + reply.Compress = true + reply.MsgHdr.RecursionAvailable = true + reply.SetRcode(query, code) + data, err := reply.Pack() + if err != nil { + t.Fatal(err) + } + return data +} + +func GenReplySuccess(t *testing.T, qtype uint16, ips ...string) []byte { + question := dns.Question{ + Name: dns.Fqdn("x.org"), + Qtype: qtype, + Qclass: dns.ClassINET, + } + query := new(dns.Msg) + query.Id = dns.Id() + query.RecursionDesired = true + query.Question = make([]dns.Question, 1) + query.Question[0] = question + reply := new(dns.Msg) + reply.Compress = true + reply.MsgHdr.RecursionAvailable = true + reply.SetReply(query) + for _, ip := range ips { + switch qtype { + case dns.TypeA: + reply.Answer = append(reply.Answer, &dns.A{ + Hdr: dns.RR_Header{ + Name: dns.Fqdn("x.org"), + Rrtype: qtype, + Class: dns.ClassINET, + Ttl: 0, + }, + A: net.ParseIP(ip), + }) + case dns.TypeAAAA: + reply.Answer = append(reply.Answer, &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: dns.Fqdn("x.org"), + Rrtype: qtype, + Class: dns.ClassINET, + Ttl: 0, + }, + AAAA: net.ParseIP(ip), + }) + } + } + data, err := reply.Pack() + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/internal/engine/netx/resolver/idna.go b/internal/engine/netx/resolver/idna.go new file mode 100644 index 0000000..1fed5a7 --- /dev/null +++ b/internal/engine/netx/resolver/idna.go @@ -0,0 +1,34 @@ +package resolver + +import ( + "context" + + "golang.org/x/net/idna" +) + +// IDNAResolver is to support resolving Internationalized Domain Names. +// See RFC3492 for more information. +type IDNAResolver struct { + Resolver +} + +// LookupHost implements Resolver.LookupHost +func (r IDNAResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + host, err := idna.ToASCII(hostname) + if err != nil { + return nil, err + } + return r.Resolver.LookupHost(ctx, host) +} + +// Network implements Resolver.Network. +func (r IDNAResolver) Network() string { + return "idna" +} + +// Address implements Resolver.Address. +func (r IDNAResolver) Address() string { + return "" +} + +var _ Resolver = IDNAResolver{} diff --git a/internal/engine/netx/resolver/idna_test.go b/internal/engine/netx/resolver/idna_test.go new file mode 100644 index 0000000..a3865ed --- /dev/null +++ b/internal/engine/netx/resolver/idna_test.go @@ -0,0 +1,76 @@ +package resolver_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +var ErrUnexpectedPunycode = errors.New("unexpected punycode value") + +type CheckIDNAResolver struct { + Addresses []string + Error error + Expect string +} + +func (resolv CheckIDNAResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + if resolv.Error != nil { + return nil, resolv.Error + } + if hostname != resolv.Expect { + return nil, ErrUnexpectedPunycode + } + return resolv.Addresses, nil +} + +func (r CheckIDNAResolver) Network() string { + return "checkidna" +} + +func (r CheckIDNAResolver) Address() string { + return "" +} + +func TestIDNAResolverSuccess(t *testing.T) { + expectedIPs := []string{"77.88.55.66"} + resolv := resolver.IDNAResolver{Resolver: CheckIDNAResolver{ + Addresses: expectedIPs, + Expect: "xn--d1acpjx3f.xn--p1ai", + }} + addrs, err := resolv.LookupHost(context.Background(), "яндекс.рф") + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedIPs, addrs); diff != "" { + t.Fatal(diff) + } +} + +func TestIDNAResolverFailure(t *testing.T) { + resolv := resolver.IDNAResolver{Resolver: CheckIDNAResolver{ + Error: errors.New("we should not arrive here"), + }} + // See https://www.farsightsecurity.com/blog/txt-record/punycode-20180711/ + addrs, err := resolv.LookupHost(context.Background(), "xn--0000h") + if err == nil || !strings.HasPrefix(err.Error(), "idna: invalid label") { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected no response here") + } +} + +func TestIDNAResolverTransportOK(t *testing.T) { + resolv := resolver.IDNAResolver{Resolver: CheckIDNAResolver{}} + if resolv.Network() != "idna" { + t.Fatal("invalid network") + } + if resolv.Address() != "" { + t.Fatal("invalid address") + } +} diff --git a/internal/engine/netx/resolver/integration_test.go b/internal/engine/netx/resolver/integration_test.go new file mode 100644 index 0000000..72e2452 --- /dev/null +++ b/internal/engine/netx/resolver/integration_test.go @@ -0,0 +1,111 @@ +package resolver_test + +import ( + "context" + "net" + "net/http" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func init() { + log.SetLevel(log.DebugLevel) +} + +func testresolverquick(t *testing.T, reso resolver.Resolver) { + if testing.Short() { + t.Skip("skip test in short mode") + } + reso = resolver.LoggingResolver{Logger: log.Log, Resolver: reso} + addrs, err := reso.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatal(err) + } + if addrs == nil { + t.Fatal("expected non-nil addrs here") + } + var foundquad8 bool + for _, addr := range addrs { + // See https://github.com/ooni/probe-cli/v3/internal/engine/pull/954/checks?check_run_id=1182269025 + if addr == "8.8.8.8" || addr == "2001:4860:4860::8888" { + foundquad8 = true + } + } + if !foundquad8 { + t.Fatalf("did not find 8.8.8.8 in ouput; output=%+v", addrs) + } +} + +// Ensuring we can handle Internationalized Domain Names (IDNs) without issues +func testresolverquickidna(t *testing.T, reso resolver.Resolver) { + if testing.Short() { + t.Skip("skip test in short mode") + } + reso = resolver.IDNAResolver{ + resolver.LoggingResolver{Logger: log.Log, Resolver: reso}, + } + addrs, err := reso.LookupHost(context.Background(), "яндекс.рф") + if err != nil { + t.Fatal(err) + } + if addrs == nil { + t.Fatal("expected non-nil addrs here") + } +} + +func TestNewResolverSystem(t *testing.T) { + reso := resolver.SystemResolver{} + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} + +func TestNewResolverUDPAddress(t *testing.T) { + reso := resolver.NewSerialResolver( + resolver.NewDNSOverUDP(new(net.Dialer), "8.8.8.8:53")) + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} + +func TestNewResolverUDPDomain(t *testing.T) { + reso := resolver.NewSerialResolver( + resolver.NewDNSOverUDP(new(net.Dialer), "dns.google.com:53")) + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} + +func TestNewResolverTCPAddress(t *testing.T) { + reso := resolver.NewSerialResolver( + resolver.NewDNSOverTCP(new(net.Dialer).DialContext, "8.8.8.8:53")) + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} + +func TestNewResolverTCPDomain(t *testing.T) { + reso := resolver.NewSerialResolver( + resolver.NewDNSOverTCP(new(net.Dialer).DialContext, "dns.google.com:53")) + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} + +func TestNewResolverDoTAddress(t *testing.T) { + reso := resolver.NewSerialResolver( + resolver.NewDNSOverTLS(resolver.DialTLSContext, "8.8.8.8:853")) + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} + +func TestNewResolverDoTDomain(t *testing.T) { + reso := resolver.NewSerialResolver( + resolver.NewDNSOverTLS(resolver.DialTLSContext, "dns.google.com:853")) + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} + +func TestNewResolverDoH(t *testing.T) { + reso := resolver.NewSerialResolver( + resolver.NewDNSOverHTTPS(http.DefaultClient, "https://cloudflare-dns.com/dns-query")) + testresolverquick(t, reso) + testresolverquickidna(t, reso) +} diff --git a/internal/engine/netx/resolver/logging.go b/internal/engine/netx/resolver/logging.go new file mode 100644 index 0000000..40e17f2 --- /dev/null +++ b/internal/engine/netx/resolver/logging.go @@ -0,0 +1,30 @@ +package resolver + +import ( + "context" + "time" +) + +// Logger is the logger assumed by this package +type Logger interface { + Debugf(format string, v ...interface{}) + Debug(message string) +} + +// LoggingResolver is a resolver that emits events +type LoggingResolver struct { + Resolver + Logger Logger +} + +// LookupHost returns the IP addresses of a host +func (r LoggingResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + r.Logger.Debugf("resolve %s...", hostname) + start := time.Now() + addrs, err := r.Resolver.LookupHost(ctx, hostname) + stop := time.Now() + r.Logger.Debugf("resolve %s... (%+v, %+v) in %s", hostname, addrs, err, stop.Sub(start)) + return addrs, err +} + +var _ Resolver = LoggingResolver{} diff --git a/internal/engine/netx/resolver/logging_test.go b/internal/engine/netx/resolver/logging_test.go new file mode 100644 index 0000000..4cc18da --- /dev/null +++ b/internal/engine/netx/resolver/logging_test.go @@ -0,0 +1,23 @@ +package resolver_test + +import ( + "context" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestLoggingResolver(t *testing.T) { + r := resolver.LoggingResolver{ + Logger: log.Log, + Resolver: resolver.NewFakeResolverThatFails(), + } + addrs, err := r.LookupHost(context.Background(), "www.google.com") + if err == nil { + t.Fatal("expected an error here") + } + if addrs != nil { + t.Fatal("expected nil addr here") + } +} diff --git a/internal/engine/netx/resolver/resolver.go b/internal/engine/netx/resolver/resolver.go new file mode 100644 index 0000000..96db0de --- /dev/null +++ b/internal/engine/netx/resolver/resolver.go @@ -0,0 +1,18 @@ +package resolver + +import ( + "context" +) + +// Resolver is a DNS resolver. The *net.Resolver used by Go implements +// this interface, but other implementations are possible. +type Resolver interface { + // LookupHost resolves a hostname to a list of IP addresses. + LookupHost(ctx context.Context, hostname string) (addrs []string, err error) + + // Network returns the network being used by the resolver + Network() string + + // Address returns the address being used by the resolver + Address() string +} diff --git a/internal/engine/netx/resolver/saver.go b/internal/engine/netx/resolver/saver.go new file mode 100644 index 0000000..f16f941 --- /dev/null +++ b/internal/engine/netx/resolver/saver.go @@ -0,0 +1,73 @@ +package resolver + +import ( + "context" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +// SaverResolver is a resolver that saves events +type SaverResolver struct { + Resolver + Saver *trace.Saver +} + +// LookupHost implements Resolver.LookupHost +func (r SaverResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + start := time.Now() + r.Saver.Write(trace.Event{ + Address: r.Resolver.Address(), + Hostname: hostname, + Name: "resolve_start", + Proto: r.Resolver.Network(), + Time: start, + }) + addrs, err := r.Resolver.LookupHost(ctx, hostname) + stop := time.Now() + r.Saver.Write(trace.Event{ + Addresses: addrs, + Address: r.Resolver.Address(), + Duration: stop.Sub(start), + Err: err, + Hostname: hostname, + Name: "resolve_done", + Proto: r.Resolver.Network(), + Time: stop, + }) + return addrs, err +} + +// SaverDNSTransport is a DNS transport that saves events +type SaverDNSTransport struct { + RoundTripper + Saver *trace.Saver +} + +// RoundTrip implements RoundTripper.RoundTrip +func (txp SaverDNSTransport) RoundTrip(ctx context.Context, query []byte) ([]byte, error) { + start := time.Now() + txp.Saver.Write(trace.Event{ + Address: txp.Address(), + DNSQuery: query, + Name: "dns_round_trip_start", + Proto: txp.Network(), + Time: start, + }) + reply, err := txp.RoundTripper.RoundTrip(ctx, query) + stop := time.Now() + txp.Saver.Write(trace.Event{ + Address: txp.Address(), + DNSQuery: query, + DNSReply: reply, + Duration: stop.Sub(start), + Err: err, + Name: "dns_round_trip_done", + Proto: txp.Network(), + Time: stop, + }) + return reply, err +} + +var _ Resolver = SaverResolver{} +var _ RoundTripper = SaverDNSTransport{} diff --git a/internal/engine/netx/resolver/saver_test.go b/internal/engine/netx/resolver/saver_test.go new file mode 100644 index 0000000..ad762ff --- /dev/null +++ b/internal/engine/netx/resolver/saver_test.go @@ -0,0 +1,211 @@ +package resolver_test + +import ( + "bytes" + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestSaverResolverFailure(t *testing.T) { + expected := errors.New("no such host") + saver := &trace.Saver{} + reso := resolver.SaverResolver{ + Resolver: resolver.FakeResolver{ + Err: expected, + }, + Saver: saver, + } + addrs, err := reso.LookupHost(context.Background(), "www.google.com") + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil address here") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("expected number of events") + } + if ev[0].Hostname != "www.google.com" { + t.Fatal("unexpected Hostname") + } + if ev[0].Name != "resolve_start" { + t.Fatal("unexpected name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("the saved time is wrong") + } + if ev[1].Addresses != nil { + t.Fatal("unexpected Addresses") + } + if ev[1].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if !errors.Is(ev[1].Err, expected) { + t.Fatal("unexpected Err") + } + if ev[1].Hostname != "www.google.com" { + t.Fatal("unexpected Hostname") + } + if ev[1].Name != "resolve_done" { + t.Fatal("unexpected name") + } + if !ev[1].Time.After(ev[0].Time) { + t.Fatal("the saved time is wrong") + } +} + +func TestSaverResolverSuccess(t *testing.T) { + expected := []string{"8.8.8.8", "8.8.4.4"} + saver := &trace.Saver{} + reso := resolver.SaverResolver{ + Resolver: resolver.FakeResolver{ + Result: expected, + }, + Saver: saver, + } + addrs, err := reso.LookupHost(context.Background(), "www.google.com") + if err != nil { + t.Fatal("expected nil error here") + } + if !reflect.DeepEqual(addrs, expected) { + t.Fatal("not the result we expected") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("expected number of events") + } + if ev[0].Hostname != "www.google.com" { + t.Fatal("unexpected Hostname") + } + if ev[0].Name != "resolve_start" { + t.Fatal("unexpected name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("the saved time is wrong") + } + if !reflect.DeepEqual(ev[1].Addresses, expected) { + t.Fatal("unexpected Addresses") + } + if ev[1].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if ev[1].Err != nil { + t.Fatal("unexpected Err") + } + if ev[1].Hostname != "www.google.com" { + t.Fatal("unexpected Hostname") + } + if ev[1].Name != "resolve_done" { + t.Fatal("unexpected name") + } + if !ev[1].Time.After(ev[0].Time) { + t.Fatal("the saved time is wrong") + } +} + +func TestSaverDNSTransportFailure(t *testing.T) { + expected := errors.New("no such host") + saver := &trace.Saver{} + txp := resolver.SaverDNSTransport{ + RoundTripper: resolver.FakeTransport{ + Err: expected, + }, + Saver: saver, + } + query := []byte("abc") + reply, err := txp.RoundTrip(context.Background(), query) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if reply != nil { + t.Fatal("expected nil reply here") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("expected number of events") + } + if !bytes.Equal(ev[0].DNSQuery, query) { + t.Fatal("unexpected DNSQuery") + } + if ev[0].Name != "dns_round_trip_start" { + t.Fatal("unexpected name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("the saved time is wrong") + } + if !bytes.Equal(ev[1].DNSQuery, query) { + t.Fatal("unexpected DNSQuery") + } + if ev[1].DNSReply != nil { + t.Fatal("unexpected DNSReply") + } + if ev[1].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if !errors.Is(ev[1].Err, expected) { + t.Fatal("unexpected Err") + } + if ev[1].Name != "dns_round_trip_done" { + t.Fatal("unexpected name") + } + if !ev[1].Time.After(ev[0].Time) { + t.Fatal("the saved time is wrong") + } +} + +func TestSaverDNSTransportSuccess(t *testing.T) { + expected := []byte("def") + saver := &trace.Saver{} + txp := resolver.SaverDNSTransport{ + RoundTripper: resolver.FakeTransport{ + Data: expected, + }, + Saver: saver, + } + query := []byte("abc") + reply, err := txp.RoundTrip(context.Background(), query) + if err != nil { + t.Fatal("we expected nil error here") + } + if !bytes.Equal(reply, expected) { + t.Fatal("expected another reply here") + } + ev := saver.Read() + if len(ev) != 2 { + t.Fatal("expected number of events") + } + if !bytes.Equal(ev[0].DNSQuery, query) { + t.Fatal("unexpected DNSQuery") + } + if ev[0].Name != "dns_round_trip_start" { + t.Fatal("unexpected name") + } + if !ev[0].Time.Before(time.Now()) { + t.Fatal("the saved time is wrong") + } + if !bytes.Equal(ev[1].DNSQuery, query) { + t.Fatal("unexpected DNSQuery") + } + if !bytes.Equal(ev[1].DNSReply, expected) { + t.Fatal("unexpected DNSReply") + } + if ev[1].Duration <= 0 { + t.Fatal("unexpected Duration") + } + if ev[1].Err != nil { + t.Fatal("unexpected Err") + } + if ev[1].Name != "dns_round_trip_done" { + t.Fatal("unexpected name") + } + if !ev[1].Time.After(ev[0].Time) { + t.Fatal("the saved time is wrong") + } +} diff --git a/internal/engine/netx/resolver/serial.go b/internal/engine/netx/resolver/serial.go new file mode 100644 index 0000000..6f82caa --- /dev/null +++ b/internal/engine/netx/resolver/serial.go @@ -0,0 +1,113 @@ +package resolver + +import ( + "context" + "errors" + "net" + + "github.com/miekg/dns" + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" +) + +// RoundTripper represents an abstract DNS transport. +type RoundTripper interface { + // RoundTrip sends a DNS query and receives the reply. + RoundTrip(ctx context.Context, query []byte) (reply []byte, err error) + + // RequiresPadding return true for DoH and DoT according to RFC8467 + RequiresPadding() bool + + // Network is the network of the round tripper (e.g. "dot") + Network() string + + // Address is the address of the round tripper (e.g. "1.1.1.1:853") + Address() string +} + +// SerialResolver is a resolver that first issues an A query and then +// issues an AAAA query for the requested domain. +type SerialResolver struct { + Encoder Encoder + Decoder Decoder + NumTimeouts *atomicx.Int64 + Txp RoundTripper +} + +// NewSerialResolver creates a new OONI Resolver instance. +func NewSerialResolver(t RoundTripper) SerialResolver { + return SerialResolver{ + Encoder: MiekgEncoder{}, + Decoder: MiekgDecoder{}, + NumTimeouts: atomicx.NewInt64(), + Txp: t, + } +} + +// Transport returns the transport being used. +func (r SerialResolver) Transport() RoundTripper { + return r.Txp +} + +// Network implements Resolver.Network +func (r SerialResolver) Network() string { + return r.Txp.Network() +} + +// Address implements Resolver.Address +func (r SerialResolver) Address() string { + return r.Txp.Address() +} + +// LookupHost implements Resolver.LookupHost. +func (r SerialResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + var addrs []string + addrsA, errA := r.roundTripWithRetry(ctx, hostname, dns.TypeA) + addrsAAAA, errAAAA := r.roundTripWithRetry(ctx, hostname, dns.TypeAAAA) + if errA != nil && errAAAA != nil { + return nil, errA + } + addrs = append(addrs, addrsA...) + addrs = append(addrs, addrsAAAA...) + return addrs, nil +} + +func (r SerialResolver) roundTripWithRetry( + ctx context.Context, hostname string, qtype uint16) ([]string, error) { + var errorslist []error + for i := 0; i < 3; i++ { + replies, err := r.roundTrip(ctx, hostname, qtype) + if err == nil { + return replies, nil + } + errorslist = append(errorslist, err) + var operr *net.OpError + if errors.As(err, &operr) == false || operr.Timeout() == false { + // The first error is the one that is most likely to be caused + // by the network. Subsequent errors are more likely to be caused + // by context deadlines. So, the first error is attached to an + // operation, while subsequent errors may possibly not be. If + // so, the resulting failing operation is not correct. + break + } + r.NumTimeouts.Add(1) + } + // bugfix: we MUST return one of the errors otherwise we confuse the + // mechanism in errwrap that classifies the root cause operation, since + // it would not be able to find a child with a major operation error + return nil, errorslist[0] +} + +func (r SerialResolver) roundTrip( + ctx context.Context, hostname string, qtype uint16) ([]string, error) { + querydata, err := r.Encoder.Encode(hostname, qtype, r.Txp.RequiresPadding()) + if err != nil { + return nil, err + } + replydata, err := r.Txp.RoundTrip(ctx, querydata) + if err != nil { + return nil, err + } + return r.Decoder.Decode(qtype, replydata) +} + +var _ Resolver = SerialResolver{} diff --git a/internal/engine/netx/resolver/serial_test.go b/internal/engine/netx/resolver/serial_test.go new file mode 100644 index 0000000..e1092e4 --- /dev/null +++ b/internal/engine/netx/resolver/serial_test.go @@ -0,0 +1,111 @@ +package resolver_test + +import ( + "context" + "errors" + "net" + "strings" + "syscall" + "testing" + + "github.com/miekg/dns" + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestOONIGettingTransport(t *testing.T) { + txp := resolver.NewDNSOverTLS(resolver.DialTLSContext, "8.8.8.8:853") + r := resolver.NewSerialResolver(txp) + rtx := r.Transport() + if rtx.Network() != "dot" || rtx.Address() != "8.8.8.8:853" { + t.Fatal("not the transport we expected") + } + if r.Network() != rtx.Network() { + t.Fatal("invalid network seen from the resolver") + } + if r.Address() != rtx.Address() { + t.Fatal("invalid address seen from the resolver") + } +} + +func TestOONIEncodeError(t *testing.T) { + mocked := errors.New("mocked error") + txp := resolver.NewDNSOverTLS(resolver.DialTLSContext, "8.8.8.8:853") + r := resolver.SerialResolver{Encoder: resolver.FakeEncoder{Err: mocked}, Txp: txp} + addrs, err := r.LookupHost(context.Background(), "www.gogle.com") + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil address here") + } +} + +func TestOONIRoundTripError(t *testing.T) { + mocked := errors.New("mocked error") + txp := resolver.FakeTransport{Err: mocked} + r := resolver.NewSerialResolver(txp) + addrs, err := r.LookupHost(context.Background(), "www.gogle.com") + if !errors.Is(err, mocked) { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil address here") + } +} + +func TestOONIWithEmptyReply(t *testing.T) { + txp := resolver.FakeTransport{Data: resolver.GenReplySuccess(t, dns.TypeA)} + r := resolver.NewSerialResolver(txp) + addrs, err := r.LookupHost(context.Background(), "www.gogle.com") + if err == nil || !strings.HasSuffix(err.Error(), "no response returned") { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil address here") + } +} + +func TestOONIWithAReply(t *testing.T) { + txp := resolver.FakeTransport{ + Data: resolver.GenReplySuccess(t, dns.TypeA, "8.8.8.8"), + } + r := resolver.NewSerialResolver(txp) + addrs, err := r.LookupHost(context.Background(), "www.gogle.com") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "8.8.8.8" { + t.Fatal("not the result we expected") + } +} + +func TestOONIWithAAAAReply(t *testing.T) { + txp := resolver.FakeTransport{ + Data: resolver.GenReplySuccess(t, dns.TypeAAAA, "::1"), + } + r := resolver.NewSerialResolver(txp) + addrs, err := r.LookupHost(context.Background(), "www.gogle.com") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "::1" { + t.Fatal("not the result we expected") + } +} + +func TestOONIWithTimeout(t *testing.T) { + txp := resolver.FakeTransport{ + Err: &net.OpError{Err: syscall.ETIMEDOUT, Op: "dial"}, + } + r := resolver.NewSerialResolver(txp) + addrs, err := r.LookupHost(context.Background(), "www.gogle.com") + if !errors.Is(err, syscall.ETIMEDOUT) { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil address here") + } + if r.NumTimeouts.Load() <= 0 { + t.Fatal("we didn't actually take the timeouts") + } +} diff --git a/internal/engine/netx/resolver/system.go b/internal/engine/netx/resolver/system.go new file mode 100644 index 0000000..784bc42 --- /dev/null +++ b/internal/engine/netx/resolver/system.go @@ -0,0 +1,10 @@ +package resolver + +import "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" + +// SystemResolver is the system resolver. It is implemented using +// selfcensor.SystemResolver so that we can perform integration testing +// by forcing the code to return specific responses. +type SystemResolver = selfcensor.SystemResolver + +var _ Resolver = SystemResolver{} diff --git a/internal/engine/netx/resolver/system_test.go b/internal/engine/netx/resolver/system_test.go new file mode 100644 index 0000000..a30fb34 --- /dev/null +++ b/internal/engine/netx/resolver/system_test.go @@ -0,0 +1,25 @@ +package resolver_test + +import ( + "context" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" +) + +func TestSystemResolverLookupHost(t *testing.T) { + r := resolver.SystemResolver{} + if r.Network() != "system" { + t.Fatal("invalid Network") + } + if r.Address() != "" { + t.Fatal("invalid Address") + } + addrs, err := r.LookupHost(context.Background(), "dns.google.com") + if err != nil { + t.Fatal(err) + } + if addrs == nil { + t.Fatal("expected non-nil result here") + } +} diff --git a/internal/engine/netx/resolver/tls_test.go b/internal/engine/netx/resolver/tls_test.go new file mode 100644 index 0000000..3280e46 --- /dev/null +++ b/internal/engine/netx/resolver/tls_test.go @@ -0,0 +1,32 @@ +package resolver + +import ( + "context" + "crypto/tls" + "net" +) + +func DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) { + connch := make(chan net.Conn) + errch := make(chan error, 1) + go func() { + conn, err := tls.Dial(network, address, new(tls.Config)) + if err != nil { + errch <- err + return + } + select { + case <-ctx.Done(): + conn.Close() + case connch <- conn: + } + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case conn := <-connch: + return conn, nil + case err := <-errch: + return nil, err + } +} diff --git a/internal/engine/netx/selfcensor/selfcensor.go b/internal/engine/netx/selfcensor/selfcensor.go new file mode 100644 index 0000000..406ded4 --- /dev/null +++ b/internal/engine/netx/selfcensor/selfcensor.go @@ -0,0 +1,230 @@ +// Package selfcensor contains code that triggers censorship. We use +// this functionality to implement integration tests. +// +// The self censoring functionality is disabled by default. To enable it, +// call Enable with a JSON-serialized Spec structure as its argument. +// +// The following example causes NXDOMAIN to be returned for `dns.google`: +// +// selfcensor.Enable(`{"PoisonSystemDNS":{"dns.google":["NXDOMAIN"]}}`) +// +// The following example blocks connecting to `8.8.8.8:443`: +// +// selfcensor.Enable(`{"BlockedEndpoints":{"8.8.8.8:443":"REJECT"}}`) +// +// The following example blocks packets containing dns.google: +// +// selfcensor.Enable(`{"BlockedFingerprints":{"dns.google":"RST"}}`) +// +// The documentation of the Spec structure contains further information on +// how to populate the JSON. Miniooni uses the `--self-censor-spec flag` to +// which you are supposed to pass a serialized JSON. +package selfcensor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log" + "net" + "sync" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" +) + +// Spec indicates what self censorship techniques to implement. +type Spec struct { + // PoisonSystemDNS allows you to change the behaviour of the system + // DNS regarding specific domains. They keys are the domains and the + // values are the IP addresses to return. If you set the values for + // a domain to `[]string{"NXDOMAIN"}`, the system resolver will return + // an NXDOMAIN response. If you set the values for a domain to + // `[]string{"TIMEOUT"}` the system resolver will return "i/o timeout". + PoisonSystemDNS map[string][]string + + // BlockedEndpoints allows you to block specific IP endpoints. The key is + // `IP:port` to block. The format is the same of net.JoinHostPort. If + // the value is "REJECT", then the connection attempt will fail with + // ECONNREFUSED. If the value is "TIMEOUT", then the connector will return + // claiming "i/o timeout". If the value is anything else, we will + // perform a "REJECT". + BlockedEndpoints map[string]string + + // BlockedFingerprints allows you to block packets whose body contains + // specific fingerprints. Of course, the key is the fingerprint. If + // the value is "RST", then the connection will be reset. If the value + // is "TIMEOUT", then the code will return claiming "i/o timeout". If + // the value is anything else, we will perform a "RST". + BlockedFingerprints map[string]string +} + +var ( + attempts *atomicx.Int64 = atomicx.NewInt64() + enabled *atomicx.Int64 = atomicx.NewInt64() + mu sync.Mutex + spec *Spec +) + +// Enabled returns whether self censorship is enabled +func Enabled() bool { + return enabled.Load() != 0 +} + +// Attempts returns the number of self censorship attempts so far. A self +// censorship attempt is defined as the code entering into the branch that +// _may_ perform self censorship. We expected to see this counter being +// equal to zero when Enabled() returns false. +func Attempts() int64 { + return attempts.Load() +} + +// Enable turns on the self censorship engine. This function returns +// an error if we cannot parse a Spec from the serialized JSON inside +// data. Each time you call Enable you overwrite the previous spec. +func Enable(data string) error { + mu.Lock() + defer mu.Unlock() + s := new(Spec) + if err := json.Unmarshal([]byte(data), s); err != nil { + return err + } + spec = s + enabled.Add(1) + log.Printf("selfcensor: spec %+v", *spec) + return nil +} + +// MaybeEnable is like enable except that it does nothing in case +// the string provided as argument is an empty string. +func MaybeEnable(data string) (err error) { + if data != "" { + err = Enable(data) + } + return +} + +// SystemResolver is a self-censoring system resolver. This resolver does +// not censor anything unless you call selfcensor.Enable(). +type SystemResolver struct{} + +// errTimeout indicates that a timeout error has occurred. +var errTimeout = errors.New("i/o timeout") + +// LookupHost implements Resolver.LookupHost +func (r SystemResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { + if enabled.Load() != 0 { // jumps not taken by default + mu.Lock() + defer mu.Unlock() + attempts.Add(1) + if spec.PoisonSystemDNS != nil { + values := spec.PoisonSystemDNS[hostname] + if len(values) == 1 && values[0] == "NXDOMAIN" { + return nil, errors.New("no such host") + } + if len(values) == 1 && values[0] == "TIMEOUT" { + return nil, errTimeout + } + if len(values) > 0 { + return values, nil + } + } + // FALLTHROUGH + } + return net.DefaultResolver.LookupHost(ctx, hostname) +} + +// Network implements Resolver.Network +func (r SystemResolver) Network() string { + return "system" +} + +// Address implements Resolver.Address +func (r SystemResolver) Address() string { + return "" +} + +// SystemDialer is a self-censoring system dialer. This dialer does +// not censor anything unless you call selfcensor.Enable(). +type SystemDialer struct{} + +// defaultNetDialer is the dialer we use by default. +var defaultNetDialer = &net.Dialer{ + Timeout: 15 * time.Second, + KeepAlive: 15 * time.Second, +} + +// DefaultDialer is the dialer you should use in code that wants +// to take advantage of selfcensor capabilities. +var DefaultDialer = SystemDialer{} + +// DialContext implements Dialer.DialContext +func (d SystemDialer) DialContext( + ctx context.Context, network, address string) (net.Conn, error) { + if enabled.Load() != 0 { // jumps not taken by default + mu.Lock() + defer mu.Unlock() + attempts.Add(1) + if spec.BlockedEndpoints != nil { + action, ok := spec.BlockedEndpoints[address] + if ok && action == "TIMEOUT" { + return nil, errTimeout + } + if ok { + switch network { + case "tcp", "tcp4", "tcp6": + return nil, errors.New("connection refused") + default: + // not applicable + } + } + } + if spec.BlockedFingerprints != nil { + conn, err := defaultNetDialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return connWrapper{Conn: conn, closed: make(chan interface{}, 128), + fingerprints: spec.BlockedFingerprints}, nil + } + // FALLTHROUGH + } + return defaultNetDialer.DialContext(ctx, network, address) +} + +type connWrapper struct { + net.Conn + closed chan interface{} + fingerprints map[string]string +} + +func (c connWrapper) Write(p []byte) (int, error) { + // TODO(bassosimone): implement reassembly to workaround the + // splitting of the ClientHello message. + if _, err := c.match(p, len(p)); err != nil { + return 0, err + } + return c.Conn.Write(p) +} + +func (c connWrapper) match(p []byte, n int) (int, error) { + p = p[:n] // trim + for key, value := range c.fingerprints { + if bytes.Index(p, []byte(key)) != -1 { + if value == "TIMEOUT" { + return 0, errTimeout + } + return 0, errors.New("connection reset by peer") + } + } + return n, nil +} + +func (c connWrapper) Close() error { + // Implementation note: we will block here if we attempt to close + // too many times and noone's reading. Because we have a large buffer, + // and because this is integration testing code, that's fine. + c.closed <- true + return c.Conn.Close() +} diff --git a/internal/engine/netx/selfcensor/selfcensor_test.go b/internal/engine/netx/selfcensor/selfcensor_test.go new file mode 100644 index 0000000..9938fda --- /dev/null +++ b/internal/engine/netx/selfcensor/selfcensor_test.go @@ -0,0 +1,271 @@ +package selfcensor_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor" +) + +// TestDisabled MUST be the first test in this file. +func TestDisabled(t *testing.T) { + if selfcensor.Enabled() != false { + t.Fatal("self censorship should be disabled by default") + } + if selfcensor.Attempts() != 0 { + t.Fatal("we expect no self censorship attempts at the beginning") + } + t.Run("the system resolver does not trigger selfcensor events", func(t *testing.T) { + addrs, err := selfcensor.SystemResolver{}.LookupHost( + context.Background(), "dns.google", + ) + if err != nil { + t.Fatal(err) + } + if addrs == nil { + t.Fatal("expected non-nil addrs here") + } + if selfcensor.Attempts() != 0 { + t.Fatal("we expect no self censorship attempts by default") + } + }) + t.Run("the system dialer does not trigger selfcensor events", func(t *testing.T) { + conn, err := selfcensor.SystemDialer{}.DialContext( + context.Background(), "tcp", "8.8.8.8:443", + ) + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("expected non-nil conn here") + } + conn.Close() + if selfcensor.Attempts() != 0 { + t.Fatal("we expect no self censorship attempts by default") + } + }) +} + +// TestDisabled MUST be the second test in this file. +func TestEnableInvalidJSON(t *testing.T) { + if selfcensor.Enabled() != false { + t.Fatal("we need to start with self censorship not enabled") + } + err := selfcensor.Enable("{") + if err == nil || !strings.HasSuffix(err.Error(), "unexpected end of JSON input") { + t.Fatal("not the error we expectd") + } + if selfcensor.Enabled() != false { + t.Fatal("we expected self censorship to still be not enabled") + } +} + +// TestMaybeEnableWorksAsIntended MUST be the second test in this file. +func TestMaybeEnableWorksAsIntended(t *testing.T) { + if selfcensor.Enabled() != false { + t.Fatal("we need to start with self censorship not enabled") + } + err := selfcensor.MaybeEnable("") + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != false { + t.Fatal("we expected self censorship to still be not enabled") + } +} + +func TestResolveCauseNXDOMAIN(t *testing.T) { + err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["NXDOMAIN"]}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + addrs, err := selfcensor.SystemResolver{}.LookupHost( + context.Background(), "dns.google", + ) + if err == nil || !strings.HasSuffix(err.Error(), "no such host") { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs here") + } +} + +func TestResolveCauseTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["TIMEOUT"]}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + addrs, err := selfcensor.SystemResolver{}.LookupHost(ctx, "dns.google") + if err == nil || err.Error() != "i/o timeout" { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs here") + } +} + +func TestResolveCauseBogon(t *testing.T) { + err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["10.0.0.7"]}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + addrs, err := selfcensor.SystemResolver{}.LookupHost( + context.Background(), "dns.google") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "10.0.0.7" { + t.Fatal("not the addrs we expected") + } +} + +func TestResolveCheckNetworkAndAddress(t *testing.T) { + err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["10.0.0.7"]}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + reso := selfcensor.SystemResolver{} + if reso.Network() != "system" { + t.Fatal("invalid Network") + } + if reso.Address() != "" { + t.Fatal("invalid Address") + } +} + +func TestDialHandlesErrorsWithBlockedFingerprints(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + cancel() // so we should fail immediately! + err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"dns.google":"TIMEOUT"}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + addrs, err := selfcensor.SystemDialer{}.DialContext(ctx, "tcp", "8.8.8.8:443") + if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs here") + } +} + +func TestDialCauseTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := selfcensor.MaybeEnable(`{"BlockedEndpoints":{"8.8.8.8:443":"TIMEOUT"}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + addrs, err := selfcensor.SystemDialer{}.DialContext(ctx, "tcp", "8.8.8.8:443") + if err == nil || err.Error() != "i/o timeout" { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs here") + } +} + +func TestDialCauseConnectionRefused(t *testing.T) { + err := selfcensor.MaybeEnable(`{"BlockedEndpoints":{"8.8.8.8:443":"REJECT"}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + addrs, err := selfcensor.SystemDialer{}.DialContext( + context.Background(), "tcp", "8.8.8.8:443") + if err == nil || !strings.HasSuffix(err.Error(), "connection refused") { + t.Fatal("not the error we expected") + } + if addrs != nil { + t.Fatal("expected nil addrs here") + } +} + +func TestBlockedFingerprintsTimeout(t *testing.T) { + err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"dns.google":"TIMEOUT"}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + tlsDialer := netx.NewTLSDialer(netx.Config{ + Dialer: selfcensor.SystemDialer{}, + }) + conn, err := tlsDialer.DialTLSContext( + context.Background(), "tcp", "dns.google:443") + if err == nil || err.Error() != "generic_timeout_error" { + t.Fatal("not the error expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} + +func TestBlockedFingerprintsNoMatch(t *testing.T) { + err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"ooni.io":"TIMEOUT"}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + tlsDialer := netx.NewTLSDialer(netx.Config{ + Dialer: selfcensor.SystemDialer{}, + }) + conn, err := tlsDialer.DialTLSContext( + context.Background(), "tcp", "dns.google:443") + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("expected non-nil conn here") + } + conn.Close() +} + +func TestBlockedFingerprintsConnectionReset(t *testing.T) { + err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"dns.google":"RST"}}`) + if err != nil { + t.Fatal(err) + } + if selfcensor.Enabled() != true { + t.Fatal("we expected self censorship to be enabled now") + } + tlsDialer := netx.NewTLSDialer(netx.Config{ + Dialer: selfcensor.SystemDialer{}, + }) + conn, err := tlsDialer.DialTLSContext( + context.Background(), "tcp", "dns.google:443") + if err == nil || err.Error() != "connection_reset" { + t.Fatal("not the error we expected") + } + if conn != nil { + t.Fatal("expected nil conn here") + } +} diff --git a/internal/engine/netx/trace/event.go b/internal/engine/netx/trace/event.go new file mode 100644 index 0000000..f98a84c --- /dev/null +++ b/internal/engine/netx/trace/event.go @@ -0,0 +1,60 @@ +package trace + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "time" +) + +// Event is one of the events within a trace +type Event struct { + Addresses []string `json:",omitempty"` + Address string `json:",omitempty"` + DNSQuery []byte `json:",omitempty"` + DNSReply []byte `json:",omitempty"` + DataIsTruncated bool `json:",omitempty"` + Data []byte `json:",omitempty"` + Duration time.Duration `json:",omitempty"` + Err error `json:",omitempty"` + HTTPHeaders http.Header `json:",omitempty"` + HTTPMethod string `json:",omitempty"` + HTTPStatusCode int `json:",omitempty"` + HTTPURL string `json:",omitempty"` + Hostname string `json:",omitempty"` + Name string `json:",omitempty"` + NoTLSVerify bool `json:",omitempty"` + NumBytes int `json:",omitempty"` + Proto string `json:",omitempty"` + TLSServerName string `json:",omitempty"` + TLSCipherSuite string `json:",omitempty"` + TLSNegotiatedProto string `json:",omitempty"` + TLSNextProtos []string `json:",omitempty"` + TLSPeerCerts []*x509.Certificate `json:",omitempty"` + TLSVersion string `json:",omitempty"` + Time time.Time `json:",omitempty"` + Transport string `json:",omitempty"` +} + +// PeerCerts returns the certificates presented by the peer regardless +// of whether the TLS handshake was successful +func PeerCerts(state tls.ConnectionState, err error) []*x509.Certificate { + var x509HostnameError x509.HostnameError + if errors.As(err, &x509HostnameError) { + // Test case: https://wrong.host.badssl.com/ + return []*x509.Certificate{x509HostnameError.Certificate} + } + var x509UnknownAuthorityError x509.UnknownAuthorityError + if errors.As(err, &x509UnknownAuthorityError) { + // Test case: https://self-signed.badssl.com/. This error has + // never been among the ones returned by MK. + return []*x509.Certificate{x509UnknownAuthorityError.Cert} + } + var x509CertificateInvalidError x509.CertificateInvalidError + if errors.As(err, &x509CertificateInvalidError) { + // Test case: https://expired.badssl.com/ + return []*x509.Certificate{x509CertificateInvalidError.Cert} + } + return state.PeerCertificates +} diff --git a/internal/engine/netx/trace/saver.go b/internal/engine/netx/trace/saver.go new file mode 100644 index 0000000..c05dda9 --- /dev/null +++ b/internal/engine/netx/trace/saver.go @@ -0,0 +1,27 @@ +package trace + +import "sync" + +// The Saver saves a trace +type Saver struct { + ops []Event + mu sync.Mutex +} + +// Read reads and returns events inside the trace. It advances +// the read pointer so you won't see such events again. +func (s *Saver) Read() []Event { + s.mu.Lock() + defer s.mu.Unlock() + v := s.ops + s.ops = nil + return v +} + +// Write adds the given event to the trace. A subsequent call +// to Read will read this event. +func (s *Saver) Write(ev Event) { + s.mu.Lock() + defer s.mu.Unlock() + s.ops = append(s.ops, ev) +} diff --git a/internal/engine/netx/trace/trace_test.go b/internal/engine/netx/trace/trace_test.go new file mode 100644 index 0000000..1cf1029 --- /dev/null +++ b/internal/engine/netx/trace/trace_test.go @@ -0,0 +1,26 @@ +package trace_test + +import ( + "sync" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/netx/trace" +) + +func TestGood(t *testing.T) { + saver := trace.Saver{} + var wg sync.WaitGroup + const parallel = 10 + wg.Add(parallel) + for idx := 0; idx < parallel; idx++ { + go func() { + saver.Write(trace.Event{}) + wg.Done() + }() + } + wg.Wait() + ev := saver.Read() + if len(ev) != parallel { + t.Fatal("unexpected number of events read") + } +} diff --git a/internal/engine/oonimkall/README.md b/internal/engine/oonimkall/README.md new file mode 100644 index 0000000..5e09efb --- /dev/null +++ b/internal/engine/oonimkall/README.md @@ -0,0 +1,20 @@ +# Package github.com/ooni/probe-engine/oonimkall + +Package oonimkall implements APIs used by OONI mobile apps. We +expose these APIs to mobile apps using gomobile. + +We expose two APIs: the task API, which is derived from the +API originally exposed by Measurement Kit, and the session API, +which is a Go API that mobile apps can use via `gomobile`. + +This package is named oonimkall because it contains a partial +reimplementation of the mkall API implemented by Measurement Kit +in, e.g., [mkall-ios](https://github.com/measurement-kit/mkall-ios). + +The basic tenet of the task API is that you define an experiment +task you wanna run using a JSON, then you start a task for it, and +you receive events as serialized JSONs. In addition to this +functionality, we also include extra APIs used by OONI mobile. + +The basic tenet of the session API is that you create an instance +of `Session` and use it to perform the operations you need. diff --git a/internal/engine/oonimkall/session.go b/internal/engine/oonimkall/session.go new file mode 100644 index 0000000..01ab65e --- /dev/null +++ b/internal/engine/oonimkall/session.go @@ -0,0 +1,377 @@ +package oonimkall + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "runtime" + "sync" + + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" +) + +// AtomicInt64 allows us to export atomicx.Int64 variables to +// mobile libraries so we can use them in testing. +type AtomicInt64 struct { + *atomicx.Int64 +} + +// The following two variables contain metrics pertaining to the number +// of Sessions and Contexts that are currently being used. +var ( + ActiveSessions = &AtomicInt64{atomicx.NewInt64()} + ActiveContexts = &AtomicInt64{atomicx.NewInt64()} +) + +// Logger is the logger used by a Session. You should implement a class +// compatible with this interface in Java/ObjC and then save a reference +// to this instance in the SessionConfig object. All log messages that +// the Session will generate will be routed to this Logger. +type Logger interface { + Debug(msg string) + Info(msg string) + Warn(msg string) +} + +// SessionConfig contains configuration for a Session. You should +// fill all the mandatory fields and could also optionally fill some of +// the optional fields. Then pass this struct to NewSession. +type SessionConfig struct { + // AssetsDir is the mandatory directory where to store assets + // required by a Session, e.g. MaxMind DB files. + AssetsDir string + + // Logger is the optional logger that will receive all the + // log messages generated by a Session. If this field is nil + // then the session will not emit any log message. + Logger Logger + + // ProbeServicesURL allows you to optionally force the + // usage of an alternative probe service instance. This setting + // should only be used for implementing integration tests. + ProbeServicesURL string + + // SoftwareName is the mandatory name of the application + // that will be using the new Session. + SoftwareName string + + // SoftwareVersion is the mandatory version of the application + // that will be using the new Session. + SoftwareVersion string + + // StateDir is the mandatory directory where to store state + // information required by a Session. + StateDir string + + // TempDir is the mandatory directory where the Session shall + // store temporary files. Among other tasks, Session.Close will + // remove any temporary file created within this Session. + TempDir string + + // Verbose is optional. If there is a non-null Logger and this + // field is true, then the Logger will also receive Debug messages, + // otherwise it will not receive such messages. + Verbose bool +} + +// Session contains shared state for running experiments and/or other +// OONI related task (e.g. geolocation). Note that the Session isn't +// mean to be a long living object. The workflow is to create a Session, +// do the operations you need to do with it now, then make sure it is +// not referenced by other variables, so the Go GC can finalize it. +// +// Future directions +// +// We will eventually rewrite the code for running new experiments such +// that a Task will be created from a Session, such that experiments +// could share the same Session and save geolookups, etc. For now, we +// are in the suboptimal situations where Tasks create, use, and close +// their own session, thus running more lookups than needed. +type Session struct { + cl []context.CancelFunc + mtx sync.Mutex + submitter *probeservices.Submitter + sessp *engine.Session + + // Hooks for testing (should not appear in Java/ObjC) + TestingCheckInBeforeNewProbeServicesClient func(ctx *Context) + TestingCheckInBeforeCheckIn func(ctx *Context) +} + +// NewSession creates a new session. You should use a session for running +// a set of operations in a relatively short time frame. You SHOULD NOT create +// a single session and keep it all alive for the whole app lifecyle, since +// the Session code is not specifically designed for this use case. +func NewSession(config *SessionConfig) (*Session, error) { + kvstore, err := engine.NewFileSystemKVStore(config.StateDir) + if err != nil { + return nil, err + } + var availableps []model.Service + if config.ProbeServicesURL != "" { + availableps = append(availableps, model.Service{ + Address: config.ProbeServicesURL, + Type: "https", + }) + } + engineConfig := engine.SessionConfig{ + AssetsDir: config.AssetsDir, + AvailableProbeServices: availableps, + KVStore: kvstore, + Logger: newLogger(config.Logger, config.Verbose), + SoftwareName: config.SoftwareName, + SoftwareVersion: config.SoftwareVersion, + TempDir: config.TempDir, + } + sessp, err := engine.NewSession(engineConfig) + if err != nil { + return nil, err + } + sess := &Session{sessp: sessp} + runtime.SetFinalizer(sess, sessionFinalizer) + ActiveSessions.Add(1) + return sess, nil +} + +// sessionFinalizer finalizes a Session. While in general in Go code using a +// finalizer is probably unclean, it seems that using a finalizer when binding +// with Java/ObjC code is actually useful to simplify the apps. +func sessionFinalizer(sess *Session) { + for _, fn := range sess.cl { + fn() + } + sess.sessp.Close() // ignore return value + ActiveSessions.Add(-1) +} + +// Context is the context of an operation. You use this context +// to cancel a long running operation by calling Cancel(). Because +// you create a Context from a Session and because the Session is +// keeping track of the Context instances it owns, you do don't +// need to call the Cancel method when you're done. +type Context struct { + cancel context.CancelFunc + ctx context.Context +} + +// Cancel cancels pending operations using this context. +func (ctx *Context) Cancel() { + ctx.cancel() +} + +// NewContext creates an new interruptible Context. +func (sess *Session) NewContext() *Context { + return sess.NewContextWithTimeout(-1) +} + +// NewContextWithTimeout creates an new interruptible Context that will automatically +// cancel itself after the given timeout. Setting a zero or negative timeout implies +// there is no actual timeout configured for the Context. +func (sess *Session) NewContextWithTimeout(timeout int64) *Context { + sess.mtx.Lock() + defer sess.mtx.Unlock() + ctx, origcancel := newContext(timeout) + ActiveContexts.Add(1) + var once sync.Once + cancel := func() { + once.Do(func() { + ActiveContexts.Add(-1) + origcancel() + }) + } + sess.cl = append(sess.cl, cancel) + return &Context{cancel: cancel, ctx: ctx} +} + +// GeolocateResults contains the GeolocateTask results. +type GeolocateResults struct { + // ASN is the autonomous system number. + ASN string + + // Country is the country code. + Country string + + // IP is the IP address. + IP string + + // Org is the commercial name of the ASN. + Org string +} + +// MaybeUpdateResources ensures that resources are up to date. +func (sess *Session) MaybeUpdateResources(ctx *Context) error { + sess.mtx.Lock() + defer sess.mtx.Unlock() + return sess.sessp.MaybeUpdateResources(ctx.ctx) +} + +// Geolocate performs a geolocate operation and returns the results. This method +// is (in Java terminology) synchronized with the session instance. +func (sess *Session) Geolocate(ctx *Context) (*GeolocateResults, error) { + sess.mtx.Lock() + defer sess.mtx.Unlock() + info, err := sess.sessp.LookupLocationContext(ctx.ctx) + if err != nil { + return nil, err + } + return &GeolocateResults{ + ASN: fmt.Sprintf("AS%d", info.ASN), + Country: info.CountryCode, + IP: info.ProbeIP, + Org: info.NetworkName, + }, nil +} + +// SubmitMeasurementResults contains the results of a single measurement submission +// to the OONI backends using the OONI collector API. +type SubmitMeasurementResults struct { + UpdatedMeasurement string + UpdatedReportID string +} + +// Submit submits the given measurement and returns the results. This method is (in +// Java terminology) synchronized with the Session instance. +func (sess *Session) Submit(ctx *Context, measurement string) (*SubmitMeasurementResults, error) { + sess.mtx.Lock() + defer sess.mtx.Unlock() + if sess.submitter == nil { + psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx) + if err != nil { + return nil, err + } + sess.submitter = probeservices.NewSubmitter(psc, sess.sessp.Logger()) + } + var mm model.Measurement + if err := json.Unmarshal([]byte(measurement), &mm); err != nil { + return nil, err + } + if err := sess.submitter.Submit(ctx.ctx, &mm); err != nil { + return nil, err + } + data, err := json.Marshal(mm) + runtimex.PanicOnError(err, "json.Marshal should not fail here") + return &SubmitMeasurementResults{ + UpdatedMeasurement: string(data), + UpdatedReportID: mm.ReportID, + }, nil +} + +// CheckInConfigWebConnectivity is the configuration for the WebConnectivity test +type CheckInConfigWebConnectivity struct { + CategoryCodes []string // CategoryCodes is an array of category codes +} + +// Add a category code to the array in CheckInConfigWebConnectivity +func (ckw *CheckInConfigWebConnectivity) Add(cat string) { + ckw.CategoryCodes = append(ckw.CategoryCodes, cat) +} + +func (ckw *CheckInConfigWebConnectivity) toModel() model.CheckInConfigWebConnectivity { + return model.CheckInConfigWebConnectivity{ + CategoryCodes: ckw.CategoryCodes, + } +} + +// CheckInConfig contains configuration for calling the checkin API. +type CheckInConfig struct { + Charging bool // Charging indicate if the phone is actually charging + OnWiFi bool // OnWiFi indicate if the phone is actually connected to a WiFi network + Platform string // Platform of the probe + RunType string // RunType + SoftwareName string // SoftwareName of the probe + SoftwareVersion string // SoftwareVersion of the probe + WebConnectivity *CheckInConfigWebConnectivity // WebConnectivity class contain an array of categories +} + +// CheckInInfoWebConnectivity contains the array of URLs returned by the checkin API +type CheckInInfoWebConnectivity struct { + ReportID string + URLs []model.URLInfo +} + +// URLInfo contains info on a test lists URL +type URLInfo struct { + CategoryCode string + CountryCode string + URL string +} + +// Size returns the number of URLs. +func (ckw *CheckInInfoWebConnectivity) Size() int64 { + return int64(len(ckw.URLs)) +} + +// At gets the URLInfo at position idx from CheckInInfoWebConnectivity.URLs +func (ckw *CheckInInfoWebConnectivity) At(idx int64) *URLInfo { + if idx < 0 || int(idx) >= len(ckw.URLs) { + return nil + } + w := ckw.URLs[idx] + return &URLInfo{ + CategoryCode: w.CategoryCode, + CountryCode: w.CountryCode, + URL: w.URL, + } +} + +func newCheckInInfoWebConnectivity(ckw *model.CheckInInfoWebConnectivity) *CheckInInfoWebConnectivity { + if ckw == nil { + return nil + } + out := new(CheckInInfoWebConnectivity) + out.ReportID = ckw.ReportID + out.URLs = ckw.URLs + return out +} + +// CheckInInfo contains the return test objects from the checkin API +type CheckInInfo struct { + WebConnectivity *CheckInInfoWebConnectivity +} + +// CheckIn function is called by probes asking if there are tests to be run +// The config argument contains the mandatory settings. +// Returns the list of tests to run and the URLs, on success, or an explanatory error, in case of failure. +func (sess *Session) CheckIn(ctx *Context, config *CheckInConfig) (*CheckInInfo, error) { + sess.mtx.Lock() + defer sess.mtx.Unlock() + if config.WebConnectivity == nil { + return nil, errors.New("oonimkall: missing webconnectivity config") + } + info, err := sess.sessp.LookupLocationContext(ctx.ctx) + if err != nil { + return nil, err + } + if sess.TestingCheckInBeforeNewProbeServicesClient != nil { + sess.TestingCheckInBeforeNewProbeServicesClient(ctx) + } + psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx) + if err != nil { + return nil, err + } + if sess.TestingCheckInBeforeCheckIn != nil { + sess.TestingCheckInBeforeCheckIn(ctx) + } + cfg := model.CheckInConfig{ + Charging: config.Charging, + OnWiFi: config.OnWiFi, + Platform: config.Platform, + ProbeASN: info.ASNString(), + ProbeCC: info.CountryCode, + RunType: config.RunType, + SoftwareVersion: config.SoftwareVersion, + WebConnectivity: config.WebConnectivity.toModel(), + } + result, err := psc.CheckIn(ctx.ctx, cfg) + if err != nil { + return nil, err + } + return &CheckInInfo{ + WebConnectivity: newCheckInInfoWebConnectivity(result.WebConnectivity), + }, nil +} diff --git a/internal/engine/oonimkall/session_integration_test.go b/internal/engine/oonimkall/session_integration_test.go new file mode 100644 index 0000000..04dc1b7 --- /dev/null +++ b/internal/engine/oonimkall/session_integration_test.go @@ -0,0 +1,440 @@ +package oonimkall_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "runtime" + "strings" + "testing" + "time" + + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/geolocate" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall" +) + +func NewSessionWithAssetsDir(assetsDir string) (*oonimkall.Session, error) { + return oonimkall.NewSession(&oonimkall.SessionConfig{ + AssetsDir: assetsDir, + ProbeServicesURL: "https://ams-pg-test.ooni.org/", + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + StateDir: "../testdata/oonimkall/state", + TempDir: "../testdata/", + }) +} + +func NewSession() (*oonimkall.Session, error) { + return NewSessionWithAssetsDir("../testdata/oonimkall/assets") +} + +func TestNewSessionWithInvalidStateDir(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := oonimkall.NewSession(&oonimkall.SessionConfig{ + StateDir: "", + }) + if err == nil || !strings.HasSuffix(err.Error(), "no such file or directory") { + t.Fatal("not the error we expected") + } + if sess != nil { + t.Fatal("expected a nil Session here") + } +} + +func TestNewSessionWithMissingSoftwareName(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := oonimkall.NewSession(&oonimkall.SessionConfig{ + StateDir: "../testdata/oonimkall/state", + }) + if err == nil || err.Error() != "AssetsDir is empty" { + t.Fatal("not the error we expected") + } + if sess != nil { + t.Fatal("expected a nil Session here") + } +} + +func TestMaybeUpdateResourcesWithCancelledContext(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + dir, err := ioutil.TempDir("", "xx") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + sess, err := NewSessionWithAssetsDir(dir) + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + ctx.Cancel() // cause immediate failure + err = sess.MaybeUpdateResources(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } +} + +func ReduceErrorForGeolocate(err error) error { + if err == nil { + return errors.New("we expected an error here") + } + if errors.Is(err, context.Canceled) { + return nil // when we have not downloaded the resources yet + } + if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) { + return nil // otherwise + } + return fmt.Errorf("not the error we expected: %w", err) +} + +func TestGeolocateWithCancelledContext(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + ctx.Cancel() // cause immediate failure + location, err := sess.Geolocate(ctx) + if err := ReduceErrorForGeolocate(err); err != nil { + t.Fatal(err) + } + if location != nil { + t.Fatal("expected nil location here") + } +} + +func TestGeolocateGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + location, err := sess.Geolocate(ctx) + if err != nil { + t.Fatal(err) + } + if location.ASN == "" { + t.Fatal("location.ASN is empty") + } + if location.Country == "" { + t.Fatal("location.Country is empty") + } + if location.IP == "" { + t.Fatal("location.IP is empty") + } + if location.Org == "" { + t.Fatal("location.Org is empty") + } +} + +func ReduceErrorForSubmitter(err error) error { + if err == nil { + return errors.New("we expected an error here") + } + if errors.Is(err, context.Canceled) { + return nil // when we have not downloaded the resources yet + } + if err.Error() == "all available probe services failed" { + return nil // otherwise + } + return fmt.Errorf("not the error we expected: %w", err) +} + +func TestSubmitWithCancelledContext(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + ctx.Cancel() // cause immediate failure + result, err := sess.Submit(ctx, "{}") + if err := ReduceErrorForSubmitter(err); err != nil { + t.Fatalf("not the error we expected: %+v", err) + } + if result != nil { + t.Fatal("expected nil result here") + } +} + +func TestSubmitWithInvalidJSON(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + result, err := sess.Submit(ctx, "{") + if err == nil || err.Error() != "unexpected end of JSON input" { + t.Fatalf("not the error we expected: %+v", err) + } + if result != nil { + t.Fatal("expected nil result here") + } +} + +func DoSubmission(ctx *oonimkall.Context, sess *oonimkall.Session) error { + inputm := model.Measurement{ + DataFormatVersion: "0.2.0", + MeasurementStartTime: "2019-10-28 12:51:07", + MeasurementRuntime: 1.71, + ProbeASN: "AS30722", + ProbeCC: "IT", + ProbeIP: "127.0.0.1", + ReportID: "", + ResolverIP: "172.217.33.129", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + TestKeys: map[string]bool{"success": true}, + TestName: "example", + TestVersion: "0.1.0", + } + inputd, err := json.Marshal(inputm) + if err != nil { + return err + } + result, err := sess.Submit(ctx, string(inputd)) + if err != nil { + return fmt.Errorf("session_test.go: submit failed: %w", err) + } + if result.UpdatedMeasurement == "" { + return errors.New("expected non empty measurement") + } + if result.UpdatedReportID == "" { + return errors.New("expected non empty report ID") + } + var outputm model.Measurement + return json.Unmarshal([]byte(result.UpdatedMeasurement), &outputm) +} + +func TestSubmitMeasurementGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + if err := DoSubmission(ctx, sess); err != nil { + t.Fatal(err) + } +} + +func TestSubmitCancelContextAfterFirstSubmission(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + if err := DoSubmission(ctx, sess); err != nil { + t.Fatal(err) + } + ctx.Cancel() // fail second submission + err = DoSubmission(ctx, sess) + if err == nil || !strings.HasPrefix(err.Error(), "session_test.go: submit failed") { + t.Fatalf("not the error we expected: %+v", err) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } +} + +func TestCheckInSuccess(t *testing.T) { + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + config := oonimkall.CheckInConfig{ + Charging: true, + OnWiFi: true, + Platform: "android", + RunType: "timed", + SoftwareName: "ooniprobe-android", + SoftwareVersion: "2.7.1", + WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{}, + } + config.WebConnectivity.Add("NEWS") + config.WebConnectivity.Add("CULTR") + result, err := sess.CheckIn(ctx, &config) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + if result == nil || result.WebConnectivity == nil { + t.Fatal("got nil result or WebConnectivity") + } + if len(result.WebConnectivity.URLs) < 1 { + t.Fatal("unexpected number of URLs") + } + if result.WebConnectivity.ReportID == "" { + t.Fatal("got empty report ID") + } + siz := result.WebConnectivity.Size() + if siz <= 0 { + t.Fatal("unexpected number of URLs") + } + for idx := int64(0); idx < siz; idx++ { + entry := result.WebConnectivity.At(idx) + if entry.CategoryCode != "NEWS" && entry.CategoryCode != "CULTR" { + t.Fatalf("unexpected category code: %+v", entry) + } + } + if result.WebConnectivity.At(-1) != nil { + t.Fatal("expected nil here") + } + if result.WebConnectivity.At(siz) != nil { + t.Fatal("expected nil here") + } +} + +func TestCheckInLookupLocationFailure(t *testing.T) { + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + config := oonimkall.CheckInConfig{ + Charging: true, + OnWiFi: true, + Platform: "android", + RunType: "timed", + SoftwareName: "ooniprobe-android", + SoftwareVersion: "2.7.1", + WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{}, + } + config.WebConnectivity.Add("NEWS") + config.WebConnectivity.Add("CULTR") + ctx.Cancel() // immediate failure + result, err := sess.CheckIn(ctx, &config) + if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) { + t.Fatalf("not the error we expected: %+v", err) + } + if result != nil { + t.Fatal("expected nil result here") + } +} + +func TestCheckInNewProbeServicesFailure(t *testing.T) { + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + sess.TestingCheckInBeforeNewProbeServicesClient = func(ctx *oonimkall.Context) { + ctx.Cancel() // cancel execution + } + ctx := sess.NewContext() + config := oonimkall.CheckInConfig{ + Charging: true, + OnWiFi: true, + Platform: "android", + RunType: "timed", + SoftwareName: "ooniprobe-android", + SoftwareVersion: "2.7.1", + WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{}, + } + config.WebConnectivity.Add("NEWS") + config.WebConnectivity.Add("CULTR") + result, err := sess.CheckIn(ctx, &config) + if !errors.Is(err, engine.ErrAllProbeServicesFailed) { + t.Fatalf("not the error we expected: %+v", err) + } + if result != nil { + t.Fatal("expected nil result here") + } +} + +func TestCheckInCheckInFailure(t *testing.T) { + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + sess.TestingCheckInBeforeCheckIn = func(ctx *oonimkall.Context) { + ctx.Cancel() // cancel execution + } + ctx := sess.NewContext() + config := oonimkall.CheckInConfig{ + Charging: true, + OnWiFi: true, + Platform: "android", + RunType: "timed", + SoftwareName: "ooniprobe-android", + SoftwareVersion: "2.7.1", + WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{}, + } + config.WebConnectivity.Add("NEWS") + config.WebConnectivity.Add("CULTR") + result, err := sess.CheckIn(ctx, &config) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } + if result != nil { + t.Fatal("expected nil result here") + } +} + +func TestCheckInNoParams(t *testing.T) { + sess, err := NewSession() + if err != nil { + t.Fatal(err) + } + ctx := sess.NewContext() + config := oonimkall.CheckInConfig{ + Charging: true, + OnWiFi: true, + Platform: "android", + RunType: "timed", + SoftwareName: "ooniprobe-android", + SoftwareVersion: "2.7.1", + } + result, err := sess.CheckIn(ctx, &config) + if err == nil || err.Error() != "oonimkall: missing webconnectivity config" { + t.Fatalf("not the error we expected: %+v", err) + } + if result != nil { + t.Fatal("unexpected not nil result here") + } +} + +func TestMain(m *testing.M) { + // Here we're basically testing whether eventually the finalizers + // will run and the number of active sessions and cancels will become + // balanced. Especially for the number of active cancels, this is an + // indication that we've correctly cleaned them up in the session. + if exitcode := m.Run(); exitcode != 0 { + os.Exit(exitcode) + } + for { + runtime.GC() + m, n := oonimkall.ActiveContexts.Load(), oonimkall.ActiveSessions.Load() + fmt.Printf("./oonimkall: ActiveContexts: %d; ActiveSessions: %d\n", m, n) + if m == 0 && n == 0 { + break + } + time.Sleep(1 * time.Second) + } + os.Exit(0) +} diff --git a/internal/engine/oonimkall/session_test.go b/internal/engine/oonimkall/session_test.go new file mode 100644 index 0000000..19ca838 --- /dev/null +++ b/internal/engine/oonimkall/session_test.go @@ -0,0 +1,10 @@ +package oonimkall + +import "testing" + +func TestNewCheckInInfoWebConnectivityNilPointer(t *testing.T) { + out := newCheckInInfoWebConnectivity(nil) + if out != nil { + t.Fatal("expected nil pointer") + } +} diff --git a/internal/engine/oonimkall/sessioncontext.go b/internal/engine/oonimkall/sessioncontext.go new file mode 100644 index 0000000..295e117 --- /dev/null +++ b/internal/engine/oonimkall/sessioncontext.go @@ -0,0 +1,29 @@ +package oonimkall + +import ( + "context" + "math" + "time" +) + +const maxTimeout = int64(time.Duration(math.MaxInt64) / time.Second) + +func clampTimeout(timeout, max int64) int64 { + if timeout > max { + timeout = max + } + return timeout +} + +func newContext(timeout int64) (context.Context, context.CancelFunc) { + return newContextEx(timeout, maxTimeout) +} + +func newContextEx(timeout, max int64) (context.Context, context.CancelFunc) { + if timeout > 0 { + timeout = clampTimeout(timeout, max) + return context.WithTimeout( + context.Background(), time.Duration(timeout)*time.Second) + } + return context.WithCancel(context.Background()) +} diff --git a/internal/engine/oonimkall/sessioncontext_test.go b/internal/engine/oonimkall/sessioncontext_test.go new file mode 100644 index 0000000..0d3a96b --- /dev/null +++ b/internal/engine/oonimkall/sessioncontext_test.go @@ -0,0 +1,102 @@ +package oonimkall + +import ( + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" +) + +func TestClampTimeout(t *testing.T) { + if clampTimeout(-1, maxTimeout) != -1 { + t.Fatal("unexpected result here") + } + if clampTimeout(0, maxTimeout) != 0 { + t.Fatal("unexpected result here") + } + if clampTimeout(60, maxTimeout) != 60 { + t.Fatal("unexpected result here") + } + if clampTimeout(maxTimeout, maxTimeout) != maxTimeout { + t.Fatal("unexpected result here") + } + if clampTimeout(maxTimeout+1, maxTimeout) != maxTimeout { + t.Fatal("unexpected result here") + } +} + +func TestNewContextWithZeroTimeout(t *testing.T) { + here := atomicx.NewInt64() + ctx, cancel := newContext(0) + defer cancel() + go func() { + <-time.After(250 * time.Millisecond) + here.Add(1) + cancel() + }() + <-ctx.Done() + if here.Load() != 1 { + t.Fatal("context timeout not working as intended") + } +} + +func TestNewContextWithNegativeTimeout(t *testing.T) { + here := atomicx.NewInt64() + ctx, cancel := newContext(-1) + defer cancel() + go func() { + <-time.After(250 * time.Millisecond) + here.Add(1) + cancel() + }() + <-ctx.Done() + if here.Load() != 1 { + t.Fatal("context timeout not working as intended") + } +} + +func TestNewContextWithHugeTimeout(t *testing.T) { + here := atomicx.NewInt64() + ctx, cancel := newContext(maxTimeout + 1) + defer cancel() + go func() { + <-time.After(250 * time.Millisecond) + here.Add(1) + cancel() + }() + <-ctx.Done() + if here.Load() != 1 { + t.Fatal("context timeout not working as intended") + } +} + +func TestNewContextWithReasonableTimeout(t *testing.T) { + here := atomicx.NewInt64() + ctx, cancel := newContext(1) + defer cancel() + go func() { + <-time.After(5 * time.Second) + here.Add(1) + cancel() + }() + <-ctx.Done() + if here.Load() != 0 { + t.Fatal("context timeout not working as intended") + } +} + +func TestNewContextWithArtificiallyLowMaxTimeout(t *testing.T) { + here := atomicx.NewInt64() + const maxTimeout = 2 + ctx, cancel := newContextEx(maxTimeout+1, maxTimeout) + defer cancel() + go func() { + <-time.After(30 * time.Second) + here.Add(1) + cancel() + }() + <-ctx.Done() + if here.Load() != 0 { + t.Fatal("context timeout not working as intended") + } +} diff --git a/internal/engine/oonimkall/sessionlogger.go b/internal/engine/oonimkall/sessionlogger.go new file mode 100644 index 0000000..9d9ab55 --- /dev/null +++ b/internal/engine/oonimkall/sessionlogger.go @@ -0,0 +1,69 @@ +package oonimkall + +import ( + "fmt" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type loggerVerbose struct { + Logger +} + +func (slv loggerVerbose) Debugf(format string, v ...interface{}) { + slv.Debug(fmt.Sprintf(format, v...)) +} +func (slv loggerVerbose) Infof(format string, v ...interface{}) { + slv.Info(fmt.Sprintf(format, v...)) +} +func (slv loggerVerbose) Warnf(format string, v ...interface{}) { + slv.Warn(fmt.Sprintf(format, v...)) +} + +type loggerNormal struct { + Logger +} + +func (sln loggerNormal) Debugf(format string, v ...interface{}) { + // nothing +} +func (sln loggerNormal) Debug(msg string) { + // nothing +} +func (sln loggerNormal) Infof(format string, v ...interface{}) { + sln.Info(fmt.Sprintf(format, v...)) +} +func (sln loggerNormal) Warnf(format string, v ...interface{}) { + sln.Warn(fmt.Sprintf(format, v...)) +} + +type loggerQuiet struct{} + +func (loggerQuiet) Debugf(format string, v ...interface{}) { + // nothing +} +func (loggerQuiet) Debug(msg string) { + // nothing +} +func (loggerQuiet) Infof(format string, v ...interface{}) { + // nothing +} +func (loggerQuiet) Info(msg string) { + // nothing +} +func (loggerQuiet) Warnf(format string, v ...interface{}) { + // nothing +} +func (loggerQuiet) Warn(msg string) { + // nothing +} + +func newLogger(logger Logger, verbose bool) model.Logger { + if logger == nil { + return loggerQuiet{} + } + if verbose { + return loggerVerbose{Logger: logger} + } + return loggerNormal{Logger: logger} +} diff --git a/internal/engine/oonimkall/sessionlogger_test.go b/internal/engine/oonimkall/sessionlogger_test.go new file mode 100644 index 0000000..c0c4e08 --- /dev/null +++ b/internal/engine/oonimkall/sessionlogger_test.go @@ -0,0 +1,118 @@ +package oonimkall + +import ( + "errors" + "fmt" + "io" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type RecordingLogger struct { + DebugLogs []string + InfoLogs []string + WarnLogs []string + mu sync.Mutex +} + +func (rl *RecordingLogger) Debug(msg string) { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.DebugLogs = append(rl.DebugLogs, msg) +} + +func (rl *RecordingLogger) Info(msg string) { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.InfoLogs = append(rl.InfoLogs, msg) +} + +func (rl *RecordingLogger) Warn(msg string) { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.WarnLogs = append(rl.WarnLogs, msg) +} + +func LoggerEmitMessages(logger model.Logger) { + logger.Warnf("a formatted warn message: %+v", io.EOF) + logger.Warn("a warn string") + logger.Infof("a formatted info message: %+v", io.EOF) + logger.Info("a info string") + logger.Debugf("a formatted debug message: %+v", io.EOF) + logger.Debug("a debug string") +} + +func TestNewLoggerNilLogger(t *testing.T) { + // The objective of this test is to make sure that, even if the + // Logger instance is nil, we get back something that works, that + // is, something that does not crash when it is used. + logger := newLogger(nil, true) + LoggerEmitMessages(logger) +} + +func (rl *RecordingLogger) VerifyNumberOfEntries(debugEntries int) error { + if len(rl.DebugLogs) != debugEntries { + return errors.New("unexpected number of debug messages") + } + if len(rl.InfoLogs) != 2 { + return errors.New("unexpected number of info messages") + } + if len(rl.WarnLogs) != 2 { + return errors.New("unexpected number of warn messages") + } + return nil +} + +func (rl *RecordingLogger) ExpectedEntries(level string) []string { + return []string{ + fmt.Sprintf("a formatted %s message: %+v", level, io.EOF), + fmt.Sprintf("a %s string", level), + } +} + +func (rl *RecordingLogger) CheckNonVerboseEntries() error { + if diff := cmp.Diff(rl.InfoLogs, rl.ExpectedEntries("info")); diff != "" { + return errors.New(diff) + } + if diff := cmp.Diff(rl.WarnLogs, rl.ExpectedEntries("warn")); diff != "" { + return errors.New(diff) + } + return nil +} + +func (rl *RecordingLogger) CheckVerboseEntries() error { + if diff := cmp.Diff(rl.DebugLogs, rl.ExpectedEntries("debug")); diff != "" { + return errors.New(diff) + } + return nil +} + +func TestNewLoggerQuietLogger(t *testing.T) { + handler := new(RecordingLogger) + logger := newLogger(handler, false) + LoggerEmitMessages(logger) + if err := handler.VerifyNumberOfEntries(0); err != nil { + t.Fatal(err) + } + if err := handler.CheckNonVerboseEntries(); err != nil { + t.Fatal(err) + } +} + +func TestNewLoggerVerboseLogger(t *testing.T) { + handler := new(RecordingLogger) + logger := newLogger(handler, true) + LoggerEmitMessages(logger) + if err := handler.VerifyNumberOfEntries(2); err != nil { + t.Fatal(err) + } + if err := handler.CheckNonVerboseEntries(); err != nil { + t.Fatal(err) + } + if err := handler.CheckVerboseEntries(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/engine/oonimkall/task.go b/internal/engine/oonimkall/task.go new file mode 100644 index 0000000..c256467 --- /dev/null +++ b/internal/engine/oonimkall/task.go @@ -0,0 +1,114 @@ +// Package oonimkall implements APIs used by OONI mobile apps. We +// expose these APIs to mobile apps using gomobile. +// +// We expose two APIs: the task API, which is derived from the +// API originally exposed by Measurement Kit, and the session API, +// which is a Go API that mobile apps can use via `gomobile`. +// +// This package is named oonimkall because it contains a partial +// reimplementation of the mkall API implemented by Measurement Kit +// in, e.g., https://github.com/measurement-kit/mkall-ios. +// +// Task API +// +// The basic tenet of the task API is that you define an experiment +// task you wanna run using a JSON, then you start a task for it, and +// you receive events as serialized JSONs. In addition to this +// functionality, we also include extra APIs used by OONI mobile. +// +// The task API was first defined in Measurement Kit v0.9.0. In this +// context, it was called "the FFI API". The API we expose here is not +// strictly an FFI API, but is close enough for the purpose of using +// OONI from Android and iOS. See https://git.io/Jv4Rv +// (measurement-kit/measurement-kit@v0.10.9) for a comprehensive +// description of MK's FFI API. +// +// See also https://github.com/ooni/probe-cli/v3/internal/engine/pull/347 for the +// design document describing the task API. +// +// See also https://github.com/ooni/probe-cli/v3/internal/engine/blob/master/DESIGN.md, +// which explains why we implemented the oonimkall API. +// +// Session API +// +// The Session API is a Go API that can be exported to mobile apps +// using the gomobile tool. The latest design document for this API is +// at https://github.com/ooni/probe-cli/v3/internal/engine/pull/954. +// +// The basic tenet of the session API is that you create an instance +// of `Session` and use it to perform the operations you need. +package oonimkall + +import ( + "context" + "encoding/json" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks" +) + +// Task is an asynchronous task running an experiment. It mimics the +// namesake concept initially implemented in Measurement Kit. +// +// Future directions +// +// Currently Task and Session are two unrelated APIs. As part of +// evolving the APIs with which apps interact with the engine, we +// will modify Task to run in the context of a Session. We will +// do that to save extra lookups and to allow several experiments +// running as subsequent Tasks to reuse the Session connections +// created with the OONI probe services backends. +type Task struct { + cancel context.CancelFunc + isdone *atomicx.Int64 + isstopped *atomicx.Int64 + out chan *tasks.Event +} + +// StartTask starts an asynchronous task. The input argument is a +// serialized JSON conforming to MK v0.10.9's API. +func StartTask(input string) (*Task, error) { + var settings tasks.Settings + if err := json.Unmarshal([]byte(input), &settings); err != nil { + return nil, err + } + const bufsiz = 128 // common case: we don't want runner to block + ctx, cancel := context.WithCancel(context.Background()) + task := &Task{ + cancel: cancel, + isdone: atomicx.NewInt64(), + isstopped: atomicx.NewInt64(), + out: make(chan *tasks.Event, bufsiz), + } + go func() { + defer close(task.out) + defer task.isstopped.Add(1) + tasks.Run(ctx, &settings, task.out) + }() + return task, nil +} + +// WaitForNextEvent blocks until the next event occurs. The returned +// string is a serialized JSON following MK v0.10.9's API. +func (t *Task) WaitForNextEvent() string { + const terminated = `{"key":"task_terminated","value":{}}` // like MK + evp := <-t.out + if evp == nil { + t.isdone.Add(1) + return terminated + } + data, err := json.Marshal(evp) + runtimex.PanicOnError(err, "json.Marshal failed") + return string(data) +} + +// IsDone returns true if the task is done. +func (t *Task) IsDone() bool { + return t.isdone.Load() != 0 +} + +// Interrupt interrupts the task. +func (t *Task) Interrupt() { + t.cancel() +} diff --git a/internal/engine/oonimkall/task_integration_test.go b/internal/engine/oonimkall/task_integration_test.go new file mode 100644 index 0000000..7415778 --- /dev/null +++ b/internal/engine/oonimkall/task_integration_test.go @@ -0,0 +1,560 @@ +package oonimkall_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall" + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks" +) + +type eventlike struct { + Key string `json:"key"` + Value map[string]interface{} `json:"value"` +} + +func TestGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + // interrupt the task so we also exercise this functionality + go func() { + <-time.After(time.Second) + task.Interrupt() + }() + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + t.Fatal("unexpected failure.startup event") + } + } + // make sure we only see task_terminated at this point + for { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key != "task_terminated" { + t.Fatalf("unexpected event.Key: %s", event.Key) + } + break + } +} + +func TestWithMeasurementFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "ExampleWithFailure", + "options": { + "no_geoip": true, + "no_resolver_lookup": true, + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + t.Fatal("unexpected failure.startup event") + } + } +} + +func TestInvalidJSON(t *testing.T) { + task, err := oonimkall.StartTask(`{`) + var syntaxerr *json.SyntaxError + if !errors.As(err, &syntaxerr) { + t.Fatal("not the expected error") + } + if task != nil { + t.Fatal("task is not nil") + } +} + +func TestUnsupportedSetting(t *testing.T) { + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state" + }`) + if err != nil { + t.Fatal(err) + } + var seen bool + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + if strings.Contains(eventstr, tasks.FailureInvalidVersion) { + seen = true + } + } + } + if !seen { + t.Fatal("did not see failure.startup with invalid version info") + } +} + +func TestEmptyStateDir(t *testing.T) { + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + var seen bool + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + if strings.Contains(eventstr, "mkdir : no such file or directory") { + seen = true + } + } + } + if !seen { + t.Fatal("did not see failure.startup with info that state dir is empty") + } +} + +func TestEmptyAssetsDir(t *testing.T) { + task, err := oonimkall.StartTask(`{ + "log_level": "DEBUG", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + var seen bool + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + if strings.Contains(eventstr, "AssetsDir is empty") { + seen = true + } + } + } + if !seen { + t.Fatal("did not see failure.startup") + } +} + +func TestUnknownExperiment(t *testing.T) { + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "Antani", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + var seen bool + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + if strings.Contains(eventstr, "no such experiment: ") { + seen = true + } + } + } + if !seen { + t.Fatal("did not see failure.startup") + } +} + +func TestInputIsRequired(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "log_level": "DEBUG", + "name": "ExampleWithInput", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + var seen bool + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + if strings.Contains(eventstr, "no input provided") { + seen = true + } + } + } + if !seen { + t.Fatal("did not see failure.startup") + } +} + +func TestMaxRuntime(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + begin := time.Now() + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "inputs": ["a", "b", "c"], + "name": "ExampleWithInput", + "options": { + "max_runtime": 1, + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + t.Fatal(eventstr) + } + } + // The runtime is long because of ancillary operations and is even more + // longer because of self shaping we may be performing (especially in + // CI builds) using `-tags shaping`). We have experimentally determined + // that ~10 seconds is the typical CI test run time. See: + // + // 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788 + // + // 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855 + // + // In case there are further timeouts, e.g. in the sessionresolver, the + // time used by the experiment will be much more. This is for example the + // case in https://github.com/ooni/probe-cli/v3/internal/engine/issues/1005. + if time.Now().Sub(begin) > 10*time.Second { + t.Fatal("expected shorter runtime") + } +} + +func TestInterruptExampleWithInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + t.Skip("Skipping broken test; see https://github.com/ooni/probe-cli/v3/internal/engine/issues/992") + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "inputs": [ + "http://www.kernel.org/", + "http://www.x.org/", + "http://www.microsoft.com/", + "http://www.slashdot.org/", + "http://www.repubblica.it/", + "http://www.google.it/", + "http://ooni.org/" + ], + "name": "ExampleWithInputNonInterruptible", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + var keys []string + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + switch event.Key { + case "failure.startup": + t.Fatal(eventstr) + case "status.measurement_start": + go task.Interrupt() + } + // We compress the keys. What matters is basically that we + // see just one of the many possible measurements here. + if keys == nil || keys[len(keys)-1] != event.Key { + keys = append(keys, event.Key) + } + } + expect := []string{ + "status.queued", + "status.started", + "status.progress", + "status.geoip_lookup", + "status.resolver_lookup", + "status.progress", + "status.report_create", + "status.measurement_start", + "log", + "status.progress", + "measurement", + "status.measurement_submission", + "status.measurement_done", + "status.end", + "task_terminated", + } + if diff := cmp.Diff(expect, keys); diff != "" { + t.Fatal(diff) + } +} + +func TestInterruptNdt7(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "name": "Ndt7", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + go func() { + <-time.After(11 * time.Second) + task.Interrupt() + }() + var keys []string + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + if event.Key == "failure.startup" { + t.Fatal(eventstr) + } + // We compress the keys because we don't know how many + // status.progress we will see. What matters is that we + // don't see a measurement submission, since it means + // that we have interrupted the measurement. + if keys == nil || keys[len(keys)-1] != event.Key { + keys = append(keys, event.Key) + } + } + expect := []string{ + "status.queued", + "status.started", + "status.progress", + "status.geoip_lookup", + "status.resolver_lookup", + "status.progress", + "status.report_create", + "status.measurement_start", + "status.progress", + "status.end", + "task_terminated", + } + if diff := cmp.Diff(expect, keys); diff != "" { + t.Fatal(diff) + } +} + +func TestCountBytesForExample(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + var downloadKB, uploadKB float64 + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + switch event.Key { + case "failure.startup": + t.Fatal(eventstr) + case "status.end": + downloadKB = event.Value["downloaded_kb"].(float64) + uploadKB = event.Value["uploaded_kb"].(float64) + } + } + if downloadKB == 0 { + t.Fatal("downloadKB is zero") + } + if uploadKB == 0 { + t.Fatal("uploadKB is zero") + } +} + +func TestPrivacyAndScrubbing(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + var m *model.Measurement + for !task.IsDone() { + eventstr := task.WaitForNextEvent() + var event eventlike + if err := json.Unmarshal([]byte(eventstr), &event); err != nil { + t.Fatal(err) + } + switch event.Key { + case "failure.startup": + t.Fatal(eventstr) + case "measurement": + v := []byte(event.Value["json_str"].(string)) + m = new(model.Measurement) + if err := json.Unmarshal(v, &m); err != nil { + t.Fatal(err) + } + } + } + if m == nil { + t.Fatal("measurement is nil") + } + if m.ProbeASN == "AS0" || m.ProbeCC == "ZZ" || m.ProbeIP != "127.0.0.1" { + t.Fatal("unexpected result") + } +} + +func TestNonblock(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + task, err := oonimkall.StartTask(`{ + "assets_dir": "../testdata/oonimkall/assets", + "name": "Example", + "options": { + "software_name": "oonimkall-test", + "software_version": "0.1.0" + }, + "state_dir": "../testdata/oonimkall/state", + "version": 1 + }`) + if err != nil { + t.Fatal(err) + } + if !task.IsRunning() { + t.Fatal("The runner should be running at this point") + } + // If the task blocks because it emits too much events, this test + // will run forever and will be killed. Because we have room for up + // to 128 events in the buffer, we should hopefully be fine. + for task.IsRunning() { + time.Sleep(time.Second) + } + for !task.IsDone() { + task.WaitForNextEvent() + } +} diff --git a/internal/engine/oonimkall/task_internal_test.go b/internal/engine/oonimkall/task_internal_test.go new file mode 100644 index 0000000..71fcbd0 --- /dev/null +++ b/internal/engine/oonimkall/task_internal_test.go @@ -0,0 +1,5 @@ +package oonimkall + +func (t *Task) IsRunning() bool { + return t.isstopped.Load() == 0 +} diff --git a/internal/engine/oonimkall/tasks/chanlogger.go b/internal/engine/oonimkall/tasks/chanlogger.go new file mode 100644 index 0000000..8295349 --- /dev/null +++ b/internal/engine/oonimkall/tasks/chanlogger.go @@ -0,0 +1,87 @@ +package tasks + +import ( + "fmt" +) + +// ChanLogger is a logger targeting a channel +type ChanLogger struct { + emitter *EventEmitter + hasdebug bool + hasinfo bool + haswarning bool + out chan<- *Event +} + +// Debug implements Logger.Debug +func (cl *ChanLogger) Debug(msg string) { + if cl.hasdebug { + cl.emitter.Emit("log", EventLog{ + LogLevel: "DEBUG", + Message: msg, + }) + } +} + +// Debugf implements Logger.Debugf +func (cl *ChanLogger) Debugf(format string, v ...interface{}) { + if cl.hasdebug { + cl.Debug(fmt.Sprintf(format, v...)) + } +} + +// Info implements Logger.Info +func (cl *ChanLogger) Info(msg string) { + if cl.hasinfo { + cl.emitter.Emit("log", EventLog{ + LogLevel: "INFO", + Message: msg, + }) + } +} + +// Infof implements Logger.Infof +func (cl *ChanLogger) Infof(format string, v ...interface{}) { + if cl.hasinfo { + cl.Info(fmt.Sprintf(format, v...)) + } +} + +// Warn implements Logger.Warn +func (cl *ChanLogger) Warn(msg string) { + if cl.haswarning { + cl.emitter.Emit("log", EventLog{ + LogLevel: "WARNING", + Message: msg, + }) + } +} + +// Warnf implements Logger.Warnf +func (cl *ChanLogger) Warnf(format string, v ...interface{}) { + if cl.haswarning { + cl.Warn(fmt.Sprintf(format, v...)) + } +} + +// NewChanLogger creates a new ChanLogger instance. +func NewChanLogger(emitter *EventEmitter, logLevel string, + out chan<- *Event) *ChanLogger { + cl := &ChanLogger{ + emitter: emitter, + out: out, + } + switch logLevel { + case "DEBUG", "DEBUG2": + cl.hasdebug = true + fallthrough + case "INFO": + cl.hasinfo = true + fallthrough + case "ERR", "WARNING": + fallthrough + default: + cl.haswarning = true + } + return cl +} diff --git a/internal/engine/oonimkall/tasks/event.go b/internal/engine/oonimkall/tasks/event.go new file mode 100644 index 0000000..86c0080 --- /dev/null +++ b/internal/engine/oonimkall/tasks/event.go @@ -0,0 +1,57 @@ +package tasks + +type eventEmpty struct{} + +// EventFailure contains information on a failure. +type EventFailure struct { + Failure string `json:"failure"` +} + +// EventLog is an event containing a log message. +type EventLog struct { + LogLevel string `json:"log_level"` + Message string `json:"message"` +} + +type eventMeasurementGeneric struct { + Failure string `json:"failure,omitempty"` + Idx int64 `json:"idx"` + Input string `json:"input"` + JSONStr string `json:"json_str,omitempty"` +} + +type eventStatusEnd struct { + DownloadedKB float64 `json:"downloaded_kb"` + Failure string `json:"failure"` + UploadedKB float64 `json:"uploaded_kb"` +} + +type eventStatusGeoIPLookup struct { + ProbeASN string `json:"probe_asn"` + ProbeCC string `json:"probe_cc"` + ProbeIP string `json:"probe_ip"` + ProbeNetworkName string `json:"probe_network_name"` +} + +// EventStatusProgress reports progress information. +type EventStatusProgress struct { + Message string `json:"message"` + Percentage float64 `json:"percentage"` +} + +type eventStatusReportGeneric struct { + ReportID string `json:"report_id"` +} + +type eventStatusResolverLookup struct { + ResolverASN string `json:"resolver_asn"` + ResolverIP string `json:"resolver_ip"` + ResolverNetworkName string `json:"resolver_network_name"` +} + +// Event is an event emitted by a task. This structure extends the event +// described by MK v0.10.9 FFI API (https://git.io/Jv4Rv). +type Event struct { + Key string `json:"key"` + Value interface{} `json:"value"` +} diff --git a/internal/engine/oonimkall/tasks/eventemitter.go b/internal/engine/oonimkall/tasks/eventemitter.go new file mode 100644 index 0000000..5d17be7 --- /dev/null +++ b/internal/engine/oonimkall/tasks/eventemitter.go @@ -0,0 +1,40 @@ +package tasks + +// EventEmitter emits event on a channel +type EventEmitter struct { + disabled map[string]bool + out chan<- *Event +} + +// NewEventEmitter creates a new Emitter +func NewEventEmitter(disabledEvents []string, out chan<- *Event) *EventEmitter { + ee := &EventEmitter{out: out} + ee.disabled = make(map[string]bool) + for _, eventname := range disabledEvents { + ee.disabled[eventname] = true + } + return ee +} + +// EmitFailureStartup emits the failureStartup event +func (ee *EventEmitter) EmitFailureStartup(failure string) { + ee.EmitFailureGeneric(failureStartup, failure) +} + +// EmitFailureGeneric emits a failure event +func (ee *EventEmitter) EmitFailureGeneric(name, failure string) { + ee.Emit(name, EventFailure{Failure: failure}) +} + +// EmitStatusProgress emits the status.Progress event +func (ee *EventEmitter) EmitStatusProgress(percentage float64, message string) { + ee.Emit(statusProgress, EventStatusProgress{Message: message, Percentage: percentage}) +} + +// Emit emits the specified event +func (ee *EventEmitter) Emit(key string, value interface{}) { + if ee.disabled[key] == true { + return + } + ee.out <- &Event{Key: key, Value: value} +} diff --git a/internal/engine/oonimkall/tasks/eventemitter_test.go b/internal/engine/oonimkall/tasks/eventemitter_test.go new file mode 100644 index 0000000..ab24f7c --- /dev/null +++ b/internal/engine/oonimkall/tasks/eventemitter_test.go @@ -0,0 +1,67 @@ +package tasks_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks" +) + +func TestDisabledEvents(t *testing.T) { + out := make(chan *tasks.Event) + emitter := tasks.NewEventEmitter([]string{"log"}, out) + go func() { + emitter.Emit("log", tasks.EventLog{Message: "foo"}) + close(out) + }() + var count int64 + for ev := range out { + if ev.Key == "log" { + count++ + } + } + if count > 0 { + t.Fatal("cannot disable events") + } +} + +func TestEmitFailureStartup(t *testing.T) { + out := make(chan *tasks.Event) + emitter := tasks.NewEventEmitter([]string{}, out) + go func() { + emitter.EmitFailureStartup("mocked error") + close(out) + }() + var found bool + for ev := range out { + if ev.Key == "failure.startup" { + evv := ev.Value.(tasks.EventFailure) // panic if not castable + if evv.Failure == "mocked error" { + found = true + } + } + } + if !found { + t.Fatal("did not see expected event") + } +} + +func TestEmitStatusProgress(t *testing.T) { + out := make(chan *tasks.Event) + emitter := tasks.NewEventEmitter([]string{}, out) + go func() { + emitter.EmitStatusProgress(0.7, "foo") + close(out) + }() + var found bool + for ev := range out { + if ev.Key == "status.progress" { + evv := ev.Value.(tasks.EventStatusProgress) // panic if not castable + if evv.Message == "foo" && evv.Percentage == 0.7 { + found = true + } + } + } + if !found { + t.Fatal("did not see expected event") + } +} diff --git a/internal/engine/oonimkall/tasks/runner.go b/internal/engine/oonimkall/tasks/runner.go new file mode 100644 index 0000000..ffd1ec2 --- /dev/null +++ b/internal/engine/oonimkall/tasks/runner.go @@ -0,0 +1,299 @@ +// Package tasks implements tasks run using the oonimkall API. +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "time" + + engine "github.com/ooni/probe-cli/v3/internal/engine" + "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const ( + failureIPLookup = "failure.ip_lookup" + failureASNLookup = "failure.asn_lookup" + failureCCLookup = "failure.cc_lookup" + failureMeasurement = "failure.measurement" + failureMeasurementSubmission = "failure.measurement_submission" + failureReportCreate = "failure.report_create" + failureResolverLookup = "failure.resolver_lookup" + failureStartup = "failure.startup" + measurement = "measurement" + statusEnd = "status.end" + statusGeoIPLookup = "status.geoip_lookup" + statusMeasurementDone = "status.measurement_done" + statusMeasurementStart = "status.measurement_start" + statusMeasurementSubmission = "status.measurement_submission" + statusProgress = "status.progress" + statusQueued = "status.queued" + statusReportCreate = "status.report_create" + statusResolverLookup = "status.resolver_lookup" + statusStarted = "status.started" +) + +// Run runs the task specified by settings.Name until completion. This is the +// top-level API that should be called by oonimkall. +func Run(ctx context.Context, settings *Settings, out chan<- *Event) { + r := NewRunner(settings, out) + r.Run(ctx) +} + +// Runner runs a specific task +type Runner struct { + emitter *EventEmitter + maybeLookupLocation func(*engine.Session) error + out chan<- *Event + settings *Settings +} + +// NewRunner creates a new task runner +func NewRunner(settings *Settings, out chan<- *Event) *Runner { + return &Runner{ + emitter: NewEventEmitter(settings.DisabledEvents, out), + out: out, + settings: settings, + } +} + +// FailureInvalidVersion is the failure returned when Version is invalid +const FailureInvalidVersion = "invalid Settings.Version number" + +func (r *Runner) hasUnsupportedSettings(logger *ChanLogger) bool { + if r.settings.Version < 1 { + r.emitter.EmitFailureStartup(FailureInvalidVersion) + return true + } + return false +} + +func (r *Runner) newsession(logger *ChanLogger) (*engine.Session, error) { + kvstore, err := engine.NewFileSystemKVStore(r.settings.StateDir) + if err != nil { + return nil, err + } + config := engine.SessionConfig{ + AssetsDir: r.settings.AssetsDir, + KVStore: kvstore, + Logger: logger, + SoftwareName: r.settings.Options.SoftwareName, + SoftwareVersion: r.settings.Options.SoftwareVersion, + TempDir: r.settings.TempDir, + } + if r.settings.Options.ProbeServicesBaseURL != "" { + config.AvailableProbeServices = []model.Service{{ + Type: "https", + Address: r.settings.Options.ProbeServicesBaseURL, + }} + } + return engine.NewSession(config) +} + +func (r *Runner) contextForExperiment( + ctx context.Context, builder *engine.ExperimentBuilder, +) context.Context { + if builder.Interruptible() { + return ctx + } + return context.Background() +} + +type runnerCallbacks struct { + emitter *EventEmitter +} + +func (cb *runnerCallbacks) OnProgress(percentage float64, message string) { + cb.emitter.Emit(statusProgress, EventStatusProgress{ + Percentage: 0.4 + (percentage * 0.6), // open report is 40% + Message: message, + }) +} + +// Run runs the runner until completion. The context argument controls +// when to stop when processing multiple inputs, as well as when to stop +// experiments explicitly marked as interruptible. +func (r *Runner) Run(ctx context.Context) { + logger := NewChanLogger(r.emitter, r.settings.LogLevel, r.out) + r.emitter.Emit(statusQueued, eventEmpty{}) + if r.hasUnsupportedSettings(logger) { + return + } + r.emitter.Emit(statusStarted, eventEmpty{}) + sess, err := r.newsession(logger) + if err != nil { + r.emitter.EmitFailureStartup(err.Error()) + return + } + endEvent := new(eventStatusEnd) + defer func() { + sess.Close() + r.emitter.Emit(statusEnd, endEvent) + }() + + builder, err := sess.NewExperimentBuilder(r.settings.Name) + if err != nil { + r.emitter.EmitFailureStartup(err.Error()) + return + } + + logger.Info("Looking up OONI backends... please, be patient") + if err := sess.MaybeLookupBackends(); err != nil { + r.emitter.EmitFailureStartup(err.Error()) + return + } + r.emitter.EmitStatusProgress(0.1, "contacted bouncer") + + logger.Info("Looking up your location... please, be patient") + maybeLookupLocation := r.maybeLookupLocation + if maybeLookupLocation == nil { + maybeLookupLocation = func(sess *engine.Session) error { + return sess.MaybeLookupLocation() + } + } + if err := maybeLookupLocation(sess); err != nil { + r.emitter.EmitFailureGeneric(failureIPLookup, err.Error()) + r.emitter.EmitFailureGeneric(failureASNLookup, err.Error()) + r.emitter.EmitFailureGeneric(failureCCLookup, err.Error()) + r.emitter.EmitFailureGeneric(failureResolverLookup, err.Error()) + return + } + r.emitter.EmitStatusProgress(0.2, "geoip lookup") + r.emitter.EmitStatusProgress(0.3, "resolver lookup") + r.emitter.Emit(statusGeoIPLookup, eventStatusGeoIPLookup{ + ProbeIP: sess.ProbeIP(), + ProbeASN: sess.ProbeASNString(), + ProbeCC: sess.ProbeCC(), + ProbeNetworkName: sess.ProbeNetworkName(), + }) + r.emitter.Emit(statusResolverLookup, eventStatusResolverLookup{ + ResolverASN: sess.ResolverASNString(), + ResolverIP: sess.ResolverIP(), + ResolverNetworkName: sess.ResolverNetworkName(), + }) + + builder.SetCallbacks(&runnerCallbacks{emitter: r.emitter}) + if len(r.settings.Inputs) <= 0 { + switch builder.InputPolicy() { + case engine.InputOrQueryTestLists, engine.InputStrictlyRequired: + r.emitter.EmitFailureStartup("no input provided") + return + } + r.settings.Inputs = append(r.settings.Inputs, "") + } + experiment := builder.NewExperiment() + defer func() { + endEvent.DownloadedKB = experiment.KibiBytesReceived() + endEvent.UploadedKB = experiment.KibiBytesSent() + }() + if !r.settings.Options.NoCollector { + logger.Info("Opening report... please, be patient") + if err := experiment.OpenReport(); err != nil { + r.emitter.EmitFailureGeneric(failureReportCreate, err.Error()) + return + } + r.emitter.EmitStatusProgress(0.4, "open report") + r.emitter.Emit(statusReportCreate, eventStatusReportGeneric{ + ReportID: experiment.ReportID(), + }) + } + // This deviates a little bit from measurement-kit, for which + // a zero timeout is actually valid. Since it does not make much + // sense, here we're changing the behaviour. + // + // See https://github.com/measurement-kit/measurement-kit/issues/1922 + if r.settings.Options.MaxRuntime > 0 { + // We want to honour max_runtime only when we're running an + // experiment that clearly wants specific input. We could refine + // this policy in the future, but for now this covers in a + // reasonable way web connectivity, so we should be ok. + switch builder.InputPolicy() { + case engine.InputOrQueryTestLists, engine.InputStrictlyRequired: + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout( + ctx, time.Duration(r.settings.Options.MaxRuntime)*time.Second, + ) + defer cancel() + } + } + inputCount := len(r.settings.Inputs) + start := time.Now() + inflatedMaxRuntime := r.settings.Options.MaxRuntime + r.settings.Options.MaxRuntime/10 + eta := start.Add(time.Duration(inflatedMaxRuntime) * time.Second) + for idx, input := range r.settings.Inputs { + if ctx.Err() != nil { + break + } + logger.Infof("Starting measurement with index %d", idx) + r.emitter.Emit(statusMeasurementStart, eventMeasurementGeneric{ + Idx: int64(idx), + Input: input, + }) + if input != "" && inputCount > 0 { + var percentage float64 + if r.settings.Options.MaxRuntime > 0 { + now := time.Now() + percentage = (now.Sub(start).Seconds()/eta.Sub(start).Seconds())*0.6 + 0.4 + } else { + percentage = (float64(idx)/float64(inputCount))*0.6 + 0.4 + } + r.emitter.EmitStatusProgress(percentage, fmt.Sprintf( + "processing %s", input, + )) + } + m, err := experiment.MeasureWithContext( + r.contextForExperiment(ctx, builder), + input, + ) + if builder.Interruptible() && ctx.Err() != nil { + // We want to stop here only if interruptible otherwise we want to + // submit measurement and stop at beginning of next iteration + break + } + m.AddAnnotations(r.settings.Annotations) + if err != nil { + r.emitter.Emit(failureMeasurement, eventMeasurementGeneric{ + Failure: err.Error(), + Idx: int64(idx), + Input: input, + }) + // fallthrough: we want to submit the report anyway + } + data, err := json.Marshal(m) + runtimex.PanicOnError(err, "measurement.MarshalJSON failed") + r.emitter.Emit(measurement, eventMeasurementGeneric{ + Idx: int64(idx), + Input: input, + JSONStr: string(data), + }) + if !r.settings.Options.NoCollector { + logger.Info("Submitting measurement... please, be patient") + err := experiment.SubmitAndUpdateMeasurement(m) + r.emitter.Emit(measurementSubmissionEventName(err), eventMeasurementGeneric{ + Idx: int64(idx), + Input: input, + JSONStr: string(data), + Failure: measurementSubmissionFailure(err), + }) + } + r.emitter.Emit(statusMeasurementDone, eventMeasurementGeneric{ + Idx: int64(idx), + Input: input, + }) + } +} + +func measurementSubmissionEventName(err error) string { + if err != nil { + return failureMeasurementSubmission + } + return statusMeasurementSubmission +} + +func measurementSubmissionFailure(err error) string { + if err != nil { + return err.Error() + } + return "" +} diff --git a/internal/engine/oonimkall/tasks/runner_integration_test.go b/internal/engine/oonimkall/tasks/runner_integration_test.go new file mode 100644 index 0000000..31c88fa --- /dev/null +++ b/internal/engine/oonimkall/tasks/runner_integration_test.go @@ -0,0 +1,404 @@ +package tasks_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks" +) + +func TestRunnerMaybeLookupBackendsFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer server.Close() + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + Name: "Example", + Options: tasks.SettingsOptions{ + ProbeServicesBaseURL: server.URL, + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var failures []string + for ev := range out { + switch ev.Key { + case "failure.startup": + failure := ev.Value.(tasks.EventFailure).Failure + failures = append(failures, failure) + case "status.queued", "status.started", "log", "status.end": + default: + panic(fmt.Sprintf("unexpected key: %s", ev.Key)) + } + } + if len(failures) != 1 { + t.Fatal("unexpected number of failures") + } + if failures[0] != "all available probe services failed" { + t.Fatal("invalid failure") + } +} + +func TestRunnerOpenReportFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + var ( + nreq int64 + mu sync.Mutex + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + nreq++ + if nreq == 1 { + w.Write([]byte(`{}`)) + return + } + w.WriteHeader(500) + })) + defer server.Close() + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + Name: "Example", + Options: tasks.SettingsOptions{ + ProbeServicesBaseURL: server.URL, + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + seench := make(chan int64) + go func() { + var seen int64 + for ev := range out { + switch ev.Key { + case "failure.report_create": + seen++ + case "status.progress": + evv := ev.Value.(tasks.EventStatusProgress) + if evv.Percentage >= 0.4 { + panic(fmt.Sprintf("too much progress: %+v", ev)) + } + case "status.queued", "status.started", "log", "status.end", + "status.geoip_lookup", "status.resolver_lookup": + default: + panic(fmt.Sprintf("unexpected key: %s", ev.Key)) + } + } + seench <- seen + }() + tasks.Run(context.Background(), settings, out) + close(out) + if n := <-seench; n != 1 { + t.Fatal("unexpected number of events") + } +} + +func TestRunnerGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + LogLevel: "DEBUG", + Name: "Example", + Options: tasks.SettingsOptions{ + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var found bool + for ev := range out { + if ev.Key == "status.end" { + found = true + } + } + if !found { + t.Fatal("status.end event not found") + } +} + +func TestRunnerWithUnsupportedSettings(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + LogLevel: "DEBUG", + Name: "Example", + Options: tasks.SettingsOptions{ + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + } + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var failures []string + for ev := range out { + if ev.Key == "failure.startup" { + failure := ev.Value.(tasks.EventFailure).Failure + failures = append(failures, failure) + } + } + if len(failures) != 1 { + t.Fatal("invalid number of failures") + } + if failures[0] != tasks.FailureInvalidVersion { + t.Fatal("not the failure we expected") + } +} + +func TestRunnerWithInvalidKVStorePath(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + LogLevel: "DEBUG", + Name: "Example", + Options: tasks.SettingsOptions{ + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "", // must be empty to cause the failure below + Version: 1, + } + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var failures []string + for ev := range out { + if ev.Key == "failure.startup" { + failure := ev.Value.(tasks.EventFailure).Failure + failures = append(failures, failure) + } + } + if len(failures) != 1 { + t.Fatal("invalid number of failures") + } + if failures[0] != "mkdir : no such file or directory" { + t.Fatal("not the failure we expected") + } +} + +func TestRunnerWithInvalidExperimentName(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + LogLevel: "DEBUG", + Name: "Nonexistent", + Options: tasks.SettingsOptions{ + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var failures []string + for ev := range out { + if ev.Key == "failure.startup" { + failure := ev.Value.(tasks.EventFailure).Failure + failures = append(failures, failure) + } + } + if len(failures) != 1 { + t.Fatal("invalid number of failures") + } + if failures[0] != "no such experiment: Nonexistent" { + t.Fatalf("not the failure we expected: %s", failures[0]) + } +} + +func TestRunnerWithMissingInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + LogLevel: "DEBUG", + Name: "ExampleWithInput", + Options: tasks.SettingsOptions{ + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var failures []string + for ev := range out { + if ev.Key == "failure.startup" { + failure := ev.Value.(tasks.EventFailure).Failure + failures = append(failures, failure) + } + } + if len(failures) != 1 { + t.Fatal("invalid number of failures") + } + if failures[0] != "no input provided" { + t.Fatalf("not the failure we expected: %s", failures[0]) + } +} + +func TestRunnerWithMaxRuntime(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"}, + LogLevel: "DEBUG", + Name: "ExampleWithInput", + Options: tasks.SettingsOptions{ + MaxRuntime: 1, + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + begin := time.Now() + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var found bool + for ev := range out { + if ev.Key == "status.end" { + found = true + } + } + if !found { + t.Fatal("status.end event not found") + } + // The runtime is long because of ancillary operations and is even more + // longer because of self shaping we may be performing (especially in + // CI builds) using `-tags shaping`). We have experimentally determined + // that ~10 seconds is the typical CI test run time. See: + // + // 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788 + // + // 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855 + if time.Now().Sub(begin) > 10*time.Second { + t.Fatal("expected shorter runtime") + } +} + +func TestRunnerWithMaxRuntimeNonInterruptible(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"}, + LogLevel: "DEBUG", + Name: "ExampleWithInputNonInterruptible", + Options: tasks.SettingsOptions{ + MaxRuntime: 1, + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + begin := time.Now() + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var found bool + for ev := range out { + if ev.Key == "status.end" { + found = true + } + } + if !found { + t.Fatal("status.end event not found") + } + // The runtime is long because of ancillary operations and is even more + // longer because of self shaping we may be performing (especially in + // CI builds) using `-tags shaping`). We have experimentally determined + // that ~10 seconds is the typical CI test run time. See: + // + // 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788 + // + // 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855 + if time.Now().Sub(begin) > 10*time.Second { + t.Fatal("expected shorter runtime") + } +} + +func TestRunnerWithFailedMeasurement(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + out := make(chan *tasks.Event) + settings := &tasks.Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"}, + LogLevel: "DEBUG", + Name: "ExampleWithFailure", + Options: tasks.SettingsOptions{ + MaxRuntime: 1, + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + go func() { + tasks.Run(context.Background(), settings, out) + close(out) + }() + var found bool + for ev := range out { + if ev.Key == "failure.measurement" { + found = true + } + } + if !found { + t.Fatal("failure.measurement event not found") + } +} diff --git a/internal/engine/oonimkall/tasks/runner_internal_test.go b/internal/engine/oonimkall/tasks/runner_internal_test.go new file mode 100644 index 0000000..85d7cd1 --- /dev/null +++ b/internal/engine/oonimkall/tasks/runner_internal_test.go @@ -0,0 +1,72 @@ +package tasks + +import ( + "context" + "errors" + "fmt" + "testing" + + engine "github.com/ooni/probe-cli/v3/internal/engine" +) + +func TestMeasurementSubmissionEventName(t *testing.T) { + if measurementSubmissionEventName(nil) != statusMeasurementSubmission { + t.Fatal("unexpected submission event name") + } + if measurementSubmissionEventName(errors.New("mocked error")) != failureMeasurementSubmission { + t.Fatal("unexpected submission event name") + } +} + +func TestMeasurementSubmissionFailure(t *testing.T) { + if measurementSubmissionFailure(nil) != "" { + t.Fatal("unexpected submission failure") + } + if measurementSubmissionFailure(errors.New("mocked error")) != "mocked error" { + t.Fatal("unexpected submission failure") + } +} + +func TestRunnerMaybeLookupLocationFailure(t *testing.T) { + out := make(chan *Event) + settings := &Settings{ + AssetsDir: "../../testdata/oonimkall/assets", + Name: "Example", + Options: SettingsOptions{ + SoftwareName: "oonimkall-test", + SoftwareVersion: "0.1.0", + }, + StateDir: "../../testdata/oonimkall/state", + Version: 1, + } + seench := make(chan int64) + go func() { + var seen int64 + for ev := range out { + switch ev.Key { + case "failure.ip_lookup", "failure.asn_lookup", + "failure.cc_lookup", "failure.resolver_lookup": + seen++ + case "status.progress": + evv := ev.Value.(EventStatusProgress) + if evv.Percentage >= 0.2 { + panic(fmt.Sprintf("too much progress: %+v", ev)) + } + case "status.queued", "status.started", "status.end": + default: + panic(fmt.Sprintf("unexpected key: %s", ev.Key)) + } + } + seench <- seen + }() + expected := errors.New("mocked error") + r := NewRunner(settings, out) + r.maybeLookupLocation = func(*engine.Session) error { + return expected + } + r.Run(context.Background()) + close(out) + if n := <-seench; n != 4 { + t.Fatal("unexpected number of events") + } +} diff --git a/internal/engine/oonimkall/tasks/settings.go b/internal/engine/oonimkall/tasks/settings.go new file mode 100644 index 0000000..5a90553 --- /dev/null +++ b/internal/engine/oonimkall/tasks/settings.go @@ -0,0 +1,73 @@ +package tasks + +// Settings contains settings for a task. This structure derives from +// the one described by MK v0.10.9 FFI API (https://git.io/Jv4Rv), yet +// since 2020-12-03 we're not backwards compatible anymore. +type Settings struct { + // Annotations contains the annotations to be added + // to every measurements performed by the task. + Annotations map[string]string `json:"annotations,omitempty"` + + // AssetsDir is the directory where to store assets. This + // field is an extension of MK's specification. If + // this field is empty, the task won't start. + AssetsDir string `json:"assets_dir"` + + // DisabledEvents contains disabled events. See + // https://git.io/Jv4Rv for the events names. + DisabledEvents []string `json:"disabled_events,omitempty"` + + // Inputs contains the inputs. The task will fail if it + // requires input and you provide no input. + Inputs []string `json:"inputs,omitempty"` + + // LogLevel contains the logs level. See https://git.io/Jv4Rv + // for the names of the available log levels. + LogLevel string `json:"log_level,omitempty"` + + // Name contains the task name. By https://git.io/Jv4Rv the + // names are in camel case, e.g. `Ndt`. + Name string `json:"name"` + + // Options contains the task options. + Options SettingsOptions `json:"options"` + + // StateDir is the directory where to store persistent data. This + // field is an extension of MK's specification. If + // this field is empty, the task won't start. + StateDir string `json:"state_dir"` + + // TempDir is the temporary directory. This field is an extension of MK's + // specification. If this field is empty, we will pick the tempdir that + // ioutil.TempDir uses by default, which may not work on mobile. According + // to our experiments as of 2020-06-10, leaving the TempDir empty works + // for iOS and does not work for Android. + TempDir string `json:"temp_dir"` + + // Version indicates the version of this structure. + Version int64 `json:"version"` +} + +// SettingsOptions contains the settings options +type SettingsOptions struct { + // MaxRuntime is the maximum runtime expressed in seconds. A negative + // value for this field disables the maximum runtime. Using + // a zero value will also mean disabled. This is not the + // original behaviour of Measurement Kit, which used to run + // for zero time in such case. + MaxRuntime float64 `json:"max_runtime,omitempty"` + + // NoCollector indicates whether to use a collector + NoCollector bool `json:"no_collector,omitempty"` + + // ProbeServicesBaseURL contains the probe services base URL. + ProbeServicesBaseURL string `json:"probe_services_base_url,omitempty"` + + // SoftwareName is the software name. If this option is not + // present, then the library startup will fail. + SoftwareName string `json:"software_name,omitempty"` + + // SoftwareVersion is the software version. If this option is not + // present, then the library startup will fail. + SoftwareVersion string `json:"software_version,omitempty"` +} diff --git a/internal/engine/oonimkall/uuid.go b/internal/engine/oonimkall/uuid.go new file mode 100644 index 0000000..d0c096f --- /dev/null +++ b/internal/engine/oonimkall/uuid.go @@ -0,0 +1,9 @@ +package oonimkall + +import "github.com/google/uuid" + +// NewUUID4 generates a new UUID4 string. This functionality is typically +// used by mobile apps to generate random unique identifiers. +func NewUUID4() string { + return uuid.Must(uuid.NewRandom()).String() +} diff --git a/internal/engine/oonimkall/uuid_test.go b/internal/engine/oonimkall/uuid_test.go new file mode 100644 index 0000000..02c8bf1 --- /dev/null +++ b/internal/engine/oonimkall/uuid_test.go @@ -0,0 +1,13 @@ +package oonimkall_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/oonimkall" +) + +func TestNewUUID4(t *testing.T) { + if out := oonimkall.NewUUID4(); len(out) != 36 { + t.Fatal("not the expected output") + } +} diff --git a/internal/engine/probeservices/README.md b/internal/engine/probeservices/README.md new file mode 100644 index 0000000..7f393f0 --- /dev/null +++ b/internal/engine/probeservices/README.md @@ -0,0 +1,8 @@ +# Package github.com/ooni/probe-engine/probeservices + +This package contains code to contact OONI probe services. + +The probe services are HTTPS endpoints distributed across a bunch of data +centres implementing a bunch of OONI APIs. When started, OONI will benchmark +the available probe services and select the fastest one. Eventually all the +possible OONI APIs will run as probe services. diff --git a/internal/engine/probeservices/benchselect.go b/internal/engine/probeservices/benchselect.go new file mode 100644 index 0000000..6ff54dd --- /dev/null +++ b/internal/engine/probeservices/benchselect.go @@ -0,0 +1,148 @@ +package probeservices + +import ( + "context" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// Default returns the default probe services +func Default() []model.Service { + return []model.Service{{ + Address: "https://ps1.ooni.io", + Type: "https", + }, { + Address: "https://ps2.ooni.io", + Type: "https", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }} +} + +// SortEndpoints gives priority to https, then cloudfronted, then onion. +func SortEndpoints(in []model.Service) (out []model.Service) { + for _, entry := range in { + if entry.Type == "https" { + out = append(out, entry) + } + } + for _, entry := range in { + if entry.Type == "cloudfront" { + out = append(out, entry) + } + } + for _, entry := range in { + if entry.Type == "onion" { + out = append(out, entry) + } + } + return +} + +// OnlyHTTPS returns the HTTPS endpoints only. +func OnlyHTTPS(in []model.Service) (out []model.Service) { + for _, entry := range in { + if entry.Type == "https" { + out = append(out, entry) + } + } + return +} + +// OnlyFallbacks returns the fallback endpoints only. +func OnlyFallbacks(in []model.Service) (out []model.Service) { + for _, entry := range SortEndpoints(in) { + if entry.Type != "https" { + out = append(out, entry) + } + } + return +} + +// Candidate is a candidate probe service. +type Candidate struct { + // Duration is the time it took to access the service. + Duration time.Duration + + // Err indicates whether the service works. + Err error + + // Endpoint is the service endpoint. + Endpoint model.Service + + // TestHelpers contains the data returned by the endpoint. + TestHelpers map[string][]model.Service +} + +func (c *Candidate) try(ctx context.Context, sess Session) { + client, err := NewClient(sess, c.Endpoint) + if err != nil { + c.Err = err + return + } + start := time.Now() + testhelpers, err := client.GetTestHelpers(ctx) + c.Duration = time.Now().Sub(start) + c.Err = err + c.TestHelpers = testhelpers + sess.Logger().Debugf("probe services: %+v: %+v %s", c.Endpoint, err, c.Duration) +} + +func try(ctx context.Context, sess Session, svc model.Service) *Candidate { + candidate := &Candidate{Endpoint: svc} + candidate.try(ctx, sess) + return candidate +} + +// TryAll tries all the input services using the provided context and session. It +// returns a list containing information on each candidate that was tried. We will +// try all the HTTPS candidates first. So, the beginning of the list will contain +// all of them, and for each of them you will know whether it worked (by checking the +// Err field) and how fast it was (by checking the Duration field). You should pick +// the fastest one that worked. If none of them works, then TryAll will subsequently +// attempt with all the available fallbacks, and return at the first success. In +// such case, you will see a list of N failing HTTPS candidates, followed by a single +// successful fallback candidate (e.g. cloudfronted). If all candidates fail, you +// see in output a list containing all entries where Err is not nil. +func TryAll(ctx context.Context, sess Session, in []model.Service) (out []*Candidate) { + var found bool + for _, svc := range OnlyHTTPS(in) { + candidate := try(ctx, sess, svc) + out = append(out, candidate) + if candidate.Err == nil { + found = true + } + } + if !found { + for _, svc := range OnlyFallbacks(in) { + candidate := try(ctx, sess, svc) + out = append(out, candidate) + if candidate.Err == nil { + return + } + } + } + return +} + +// SelectBest selects the best among the candidates. If there is no +// suitable candidate, then this function returns nil. +func SelectBest(candidates []*Candidate) (selected *Candidate) { + for _, e := range candidates { + if e.Err != nil { + continue + } + if selected == nil { + selected = e + continue + } + if selected.Duration > e.Duration { + selected = e + continue + } + } + return +} diff --git a/internal/engine/probeservices/bouncer.go b/internal/engine/probeservices/bouncer.go new file mode 100644 index 0000000..db66455 --- /dev/null +++ b/internal/engine/probeservices/bouncer.go @@ -0,0 +1,14 @@ +package probeservices + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// GetTestHelpers is like GetCollectors but for test helpers. +func (c Client) GetTestHelpers( + ctx context.Context) (output map[string][]model.Service, err error) { + err = c.Client.GetJSON(ctx, "/api/v1/test-helpers", &output) + return +} diff --git a/internal/engine/probeservices/bouncer_test.go b/internal/engine/probeservices/bouncer_test.go new file mode 100644 index 0000000..000007b --- /dev/null +++ b/internal/engine/probeservices/bouncer_test.go @@ -0,0 +1,16 @@ +package probeservices_test + +import ( + "context" + "testing" +) + +func TestGetTestHelpers(t *testing.T) { + testhelpers, err := newclient().GetTestHelpers(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(testhelpers) <= 1 { + t.Fatal("no returned test helpers?!") + } +} diff --git a/internal/engine/probeservices/checkin.go b/internal/engine/probeservices/checkin.go new file mode 100644 index 0000000..81be9ec --- /dev/null +++ b/internal/engine/probeservices/checkin.go @@ -0,0 +1,23 @@ +package probeservices + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type checkInResult struct { + Tests model.CheckInInfo `json:"tests"` + V int `json:"v"` +} + +// CheckIn function is called by probes asking if there are tests to be run +// The config argument contains the mandatory settings. +// Returns the list of tests to run and the URLs, on success, or an explanatory error, in case of failure. +func (c Client) CheckIn(ctx context.Context, config model.CheckInConfig) (*model.CheckInInfo, error) { + var response checkInResult + if err := c.Client.PostJSON(ctx, "/api/v1/check-in", config, &response); err != nil { + return nil, err + } + return &response.Tests, nil +} diff --git a/internal/engine/probeservices/checkin_test.go b/internal/engine/probeservices/checkin_test.go new file mode 100644 index 0000000..427eb4f --- /dev/null +++ b/internal/engine/probeservices/checkin_test.go @@ -0,0 +1,72 @@ +package probeservices_test + +import ( + "context" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestCheckInSuccess(t *testing.T) { + client := newclient() + client.BaseURL = "https://ams-pg-test.ooni.org" + config := model.CheckInConfig{ + Charging: true, + OnWiFi: true, + Platform: "android", + ProbeASN: "AS12353", + ProbeCC: "PT", + RunType: "timed", + SoftwareName: "ooniprobe-android", + SoftwareVersion: "2.7.1", + WebConnectivity: model.CheckInConfigWebConnectivity{ + CategoryCodes: []string{"NEWS", "CULTR"}, + }, + } + ctx := context.Background() + result, err := client.CheckIn(ctx, config) + if err != nil { + t.Fatal(err) + } + if result == nil || result.WebConnectivity == nil { + t.Fatal("got nil result or WebConnectivity") + } + if result.WebConnectivity.ReportID == "" { + t.Fatal("ReportID is empty") + } + if len(result.WebConnectivity.URLs) < 1 { + t.Fatal("unexpected number of URLs") + } + for _, entry := range result.WebConnectivity.URLs { + if entry.CategoryCode != "NEWS" && entry.CategoryCode != "CULTR" { + t.Fatalf("unexpected category code: %+v", entry) + } + } +} + +func TestCheckInFailure(t *testing.T) { + client := newclient() + client.BaseURL = "https://\t\t\t/" // cause test to fail + config := model.CheckInConfig{ + Charging: true, + OnWiFi: true, + Platform: "android", + ProbeASN: "AS12353", + ProbeCC: "PT", + RunType: "timed", + SoftwareName: "ooniprobe-android", + SoftwareVersion: "2.7.1", + WebConnectivity: model.CheckInConfigWebConnectivity{ + CategoryCodes: []string{"NEWS", "CULTR"}, + }, + } + ctx := context.Background() + result, err := client.CheckIn(ctx, config) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + if result != nil { + t.Fatal("results?!") + } +} diff --git a/internal/engine/probeservices/checkreportid.go b/internal/engine/probeservices/checkreportid.go new file mode 100644 index 0000000..4d53ddf --- /dev/null +++ b/internal/engine/probeservices/checkreportid.go @@ -0,0 +1,29 @@ +package probeservices + +import ( + "context" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" +) + +type checkReportIDResponse struct { + Found bool `json:"found"` +} + +// CheckReportID checks whether the given ReportID exists. +func (c Client) CheckReportID(ctx context.Context, reportID string) (bool, error) { + query := url.Values{} + query.Add("report_id", reportID) + var response checkReportIDResponse + err := (httpx.Client{ + BaseURL: c.BaseURL, + HTTPClient: c.HTTPClient, + Logger: c.Logger, + UserAgent: c.UserAgent, + }).GetJSONWithQuery(ctx, "/api/_/check_report_id", query, &response) + if err != nil { + return false, err + } + return response.Found, nil +} diff --git a/internal/engine/probeservices/checkreportid_test.go b/internal/engine/probeservices/checkreportid_test.go new file mode 100644 index 0000000..292e439 --- /dev/null +++ b/internal/engine/probeservices/checkreportid_test.go @@ -0,0 +1,61 @@ +package probeservices_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "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/probeservices" +) + +func TestCheckReportIDWorkingAsIntended(t *testing.T) { + client := probeservices.Client{ + Client: httpx.Client{ + BaseURL: "https://ams-pg.ooni.org/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "miniooni/0.1.0-dev", + }, + LoginCalls: atomicx.NewInt64(), + RegisterCalls: atomicx.NewInt64(), + StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()), + } + reportID := `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU` + ctx := context.Background() + found, err := client.CheckReportID(ctx, reportID) + if err != nil { + t.Fatal(err) + } + if found != true { + t.Fatal("unexpected found value") + } +} + +func TestCheckReportIDWorkingWithCancelledContext(t *testing.T) { + client := probeservices.Client{ + Client: httpx.Client{ + BaseURL: "https://ams-pg.ooni.org/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "miniooni/0.1.0-dev", + }, + LoginCalls: atomicx.NewInt64(), + RegisterCalls: atomicx.NewInt64(), + StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()), + } + reportID := `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU` + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + found, err := client.CheckReportID(ctx, reportID) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } + if found != false { + t.Fatal("unexpected found value") + } +} diff --git a/internal/engine/probeservices/collector.go b/internal/engine/probeservices/collector.go new file mode 100644 index 0000000..35431ec --- /dev/null +++ b/internal/engine/probeservices/collector.go @@ -0,0 +1,214 @@ +package probeservices + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +const ( + // DefaultDataFormatVersion is the default data format version. + // + // See https://github.com/ooni/spec/tree/master/data-formats#history. + DefaultDataFormatVersion = "0.2.0" + + // DefaultFormat is the default format + DefaultFormat = "json" +) + +var ( + // ErrUnsupportedDataFormatVersion indicates that the user provided + // in input a data format version that we do not support. + ErrUnsupportedDataFormatVersion = errors.New("Unsupported data format version") + + // ErrUnsupportedFormat indicates that the format is not supported. + ErrUnsupportedFormat = errors.New("Unsupported format") + + // ErrJSONFormatNotSupported indicates that the collector we're using + // does not support the JSON report format. + ErrJSONFormatNotSupported = errors.New("JSON format not supported") +) + +// ReportTemplate is the template for opening a report +type ReportTemplate struct { + // DataFormatVersion is unconditionally set to DefaultDataFormatVersion + // and you don't need to be concerned about it. + DataFormatVersion string `json:"data_format_version"` + + // Format is unconditionally set to `json` and you don't need + // to be concerned about it. + Format string `json:"format"` + + // ProbeASN is the probe's autonomous system number (e.g. `AS1234`) + ProbeASN string `json:"probe_asn"` + + // ProbeCC is the probe's country code (e.g. `IT`) + ProbeCC string `json:"probe_cc"` + + // SoftwareName is the app name (e.g. `measurement-kit`) + SoftwareName string `json:"software_name"` + + // SoftwareVersion is the app version (e.g. `0.9.1`) + SoftwareVersion string `json:"software_version"` + + // TestName is the test name (e.g. `ndt`) + TestName string `json:"test_name"` + + // TestStartTime contains the test start time + TestStartTime string `json:"test_start_time"` + + // TestVersion is the test version (e.g. `1.0.1`) + TestVersion string `json:"test_version"` +} + +// NewReportTemplate creates a new ReportTemplate from a Measurement. +func NewReportTemplate(m *model.Measurement) ReportTemplate { + return ReportTemplate{ + DataFormatVersion: DefaultDataFormatVersion, + Format: DefaultFormat, + ProbeASN: m.ProbeASN, + ProbeCC: m.ProbeCC, + SoftwareName: m.SoftwareName, + SoftwareVersion: m.SoftwareVersion, + TestName: m.TestName, + TestStartTime: m.TestStartTime, + TestVersion: m.TestVersion, + } +} + +type collectorOpenResponse struct { + ID string `json:"report_id"` + SupportedFormats []string `json:"supported_formats"` +} + +type reportChan struct { + // ID is the report ID + ID string + + // client is the client that was used. + client Client + + // tmpl is the template used when opening this report. + tmpl ReportTemplate +} + +// OpenReport opens a new report. +func (c Client) OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error) { + if rt.DataFormatVersion != DefaultDataFormatVersion { + return nil, ErrUnsupportedDataFormatVersion + } + if rt.Format != DefaultFormat { + return nil, ErrUnsupportedFormat + } + var cor collectorOpenResponse + if err := c.Client.PostJSON(ctx, "/report", rt, &cor); err != nil { + return nil, err + } + for _, format := range cor.SupportedFormats { + if format == "json" { + return &reportChan{ID: cor.ID, client: c, tmpl: rt}, nil + } + } + return nil, ErrJSONFormatNotSupported +} + +type collectorUpdateRequest struct { + // Format is the data format + Format string `json:"format"` + + // Content is the actual report + Content interface{} `json:"content"` +} + +type collectorUpdateResponse struct { + // ID is the measurement ID + ID string `json:"measurement_id"` +} + +// CanSubmit returns true whether the provided measurement belongs to +// this report, false otherwise. We say that a given measurement belongs +// to this report if its report template matches the report's one. +func (r reportChan) CanSubmit(m *model.Measurement) bool { + return reflect.DeepEqual(NewReportTemplate(m), r.tmpl) +} + +// SubmitMeasurement submits a measurement belonging to the report +// to the OONI collector. On success, we will modify the measurement +// such that it contains the report ID for which it has been +// submitted. Otherwise, we'll set the report ID to the empty +// string, so that you know which measurements weren't submitted. +func (r reportChan) SubmitMeasurement(ctx context.Context, m *model.Measurement) error { + var updateResponse collectorUpdateResponse + m.ReportID = r.ID + err := r.client.Client.PostJSON( + ctx, fmt.Sprintf("/report/%s", r.ID), collectorUpdateRequest{ + Format: "json", + Content: m, + }, &updateResponse, + ) + if err != nil { + m.ReportID = "" + return err + } + return nil +} + +// ReportID returns the report ID. +func (r reportChan) ReportID() string { + return r.ID +} + +// ReportChannel is a channel through which one could submit measurements +// belonging to the same report. The Report struct belongs to this interface. +type ReportChannel interface { + CanSubmit(m *model.Measurement) bool + ReportID() string + SubmitMeasurement(ctx context.Context, m *model.Measurement) error +} + +var _ ReportChannel = &reportChan{} + +// ReportOpener is any struct that is able to open a new ReportChannel. The +// Client struct belongs to this interface. +type ReportOpener interface { + OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error) +} + +var _ ReportOpener = Client{} + +// Submitter is an abstraction allowing you to submit arbitrary measurements +// to a given OONI backend. This implementation will take care of opening +// reports when needed as well as of closing reports when needed. Nonetheless +// you need to remember to call its Close method when done, because there is +// likely an open report that has not been closed yet. +type Submitter struct { + channel ReportChannel + logger model.Logger + mu sync.Mutex + opener ReportOpener +} + +// NewSubmitter creates a new Submitter instance. +func NewSubmitter(opener ReportOpener, logger model.Logger) *Submitter { + return &Submitter{opener: opener, logger: logger} +} + +// Submit submits the current measurement to the OONI backend created using +// the ReportOpener passed to the constructor. +func (sub *Submitter) Submit(ctx context.Context, m *model.Measurement) error { + var err error + sub.mu.Lock() + defer sub.mu.Unlock() + if sub.channel == nil || !sub.channel.CanSubmit(m) { + sub.channel, err = sub.opener.OpenReport(ctx, NewReportTemplate(m)) + if err != nil { + return err + } + sub.logger.Infof("New reportID: %s", sub.channel.ReportID()) + } + return sub.channel.SubmitMeasurement(ctx, m) +} diff --git a/internal/engine/probeservices/collector_test.go b/internal/engine/probeservices/collector_test.go new file mode 100644 index 0000000..c95b6dd --- /dev/null +++ b/internal/engine/probeservices/collector_test.go @@ -0,0 +1,459 @@ +package probeservices_test + +import ( + "context" + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" +) + +type fakeTestKeys struct { + Failure *string `json:"failure"` +} + +func makeMeasurement(rt probeservices.ReportTemplate, ID string) model.Measurement { + return model.Measurement{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + ID: "bdd20d7a-bba5-40dd-a111-9863d7908572", + MeasurementRuntime: 5.0565230846405, + MeasurementStartTime: "2018-11-01 15:33:20", + ProbeIP: "1.2.3.4", + ProbeASN: rt.ProbeASN, + ProbeCC: rt.ProbeCC, + ReportID: ID, + ResolverASN: "AS15169", + ResolverIP: "8.8.8.8", + ResolverNetworkName: "Google LLC", + SoftwareName: rt.SoftwareName, + SoftwareVersion: rt.SoftwareVersion, + TestKeys: fakeTestKeys{Failure: nil}, + TestName: rt.TestName, + TestStartTime: rt.TestStartTime, + TestVersion: rt.TestVersion, + } +} + +func TestNewReportTemplate(t *testing.T) { + m := &model.Measurement{ + ProbeASN: "AS117", + ProbeCC: "IT", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + rt := probeservices.NewReportTemplate(m) + expect := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS117", + ProbeCC: "IT", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + if diff := cmp.Diff(expect, rt); diff != "" { + t.Fatal(diff) + } +} + +func TestReportLifecycle(t *testing.T) { + ctx := context.Background() + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + report, err := client.OpenReport(ctx, template) + if err != nil { + t.Fatal(err) + } + measurement := makeMeasurement(template, report.ReportID()) + if report.CanSubmit(&measurement) != true { + t.Fatal("report should be able to submit this measurement") + } + if err = report.SubmitMeasurement(ctx, &measurement); err != nil { + t.Fatal(err) + } + if measurement.ReportID != report.ReportID() { + t.Fatal("report ID mismatch") + } +} + +func TestReportLifecycleWrongExperiment(t *testing.T) { + ctx := context.Background() + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + report, err := client.OpenReport(ctx, template) + if err != nil { + t.Fatal(err) + } + measurement := makeMeasurement(template, report.ReportID()) + measurement.TestName = "antani" + if report.CanSubmit(&measurement) != false { + t.Fatal("report should not be able to submit this measurement") + } +} + +func TestOpenReportInvalidDataFormatVersion(t *testing.T) { + ctx := context.Background() + template := probeservices.ReportTemplate{ + DataFormatVersion: "0.1.0", + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + report, err := client.OpenReport(ctx, template) + if !errors.Is(err, probeservices.ErrUnsupportedDataFormatVersion) { + t.Fatal("not the error we expected") + } + if report != nil { + t.Fatal("expected a nil report here") + } +} + +func TestOpenReportInvalidFormat(t *testing.T) { + ctx := context.Background() + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: "yaml", + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + report, err := client.OpenReport(ctx, template) + if !errors.Is(err, probeservices.ErrUnsupportedFormat) { + t.Fatal("not the error we expected") + } + if report != nil { + t.Fatal("expected a nil report here") + } +} + +func TestJSONAPIClientCreateFailure(t *testing.T) { + ctx := context.Background() + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + client.BaseURL = "\t" // breaks the URL parser + report, err := client.OpenReport(ctx, template) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + if report != nil { + t.Fatal("expected a nil report here") + } +} + +func TestOpenResponseNoJSONSupport(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + writer.Write([]byte(`{"ID":"abc","supported_formats":["yaml"]}`)) + }), + ) + defer server.Close() + ctx := context.Background() + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + client.BaseURL = server.URL + report, err := client.OpenReport(ctx, template) + if !errors.Is(err, probeservices.ErrJSONFormatNotSupported) { + t.Fatal("expected an error here") + } + if report != nil { + t.Fatal("expected a nil report here") + } +} + +func TestEndToEnd(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.RequestURI == "/report" { + w.Write([]byte(`{"report_id":"_id","supported_formats":["json"]}`)) + return + } + if r.RequestURI == "/report/_id" { + data, err := ioutil.ReadAll(r.Body) + if err != nil { + panic(err) + } + sdata, err := ioutil.ReadFile("../testdata/collector-expected.jsonl") + if err != nil { + panic(err) + } + if diff := cmp.Diff(data, sdata); diff != "" { + panic(diff) + } + w.Write([]byte(`{"measurement_id":"e00c584e6e9e5326"}`)) + return + } + if r.RequestURI == "/report/_id/close" { + w.Write([]byte(`{}`)) + return + } + panic(r.RequestURI) + }), + ) + defer server.Close() + ctx := context.Background() + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2018-11-01 15:33:17", + TestVersion: "0.1.0", + } + client := newclient() + client.BaseURL = server.URL + report, err := client.OpenReport(ctx, template) + if err != nil { + t.Fatal(err) + } + measurement := makeMeasurement(template, report.ReportID()) + if err = report.SubmitMeasurement(ctx, &measurement); err != nil { + t.Fatal(err) + } +} + +type RecordingReportChannel struct { + tmpl probeservices.ReportTemplate + m []*model.Measurement + mu sync.Mutex +} + +func (rrc *RecordingReportChannel) CanSubmit(m *model.Measurement) bool { + return reflect.DeepEqual(probeservices.NewReportTemplate(m), rrc.tmpl) +} + +func (rrc *RecordingReportChannel) SubmitMeasurement(ctx context.Context, m *model.Measurement) error { + if ctx.Err() != nil { + return ctx.Err() + } + rrc.mu.Lock() + defer rrc.mu.Unlock() + rrc.m = append(rrc.m, m) + return nil +} + +func (rrc *RecordingReportChannel) Close(ctx context.Context) error { + if ctx.Err() != nil { + return ctx.Err() + } + rrc.mu.Lock() + defer rrc.mu.Unlock() + return nil +} + +func (rrc *RecordingReportChannel) ReportID() string { + return "" +} + +type RecordingReportOpener struct { + channels []*RecordingReportChannel + mu sync.Mutex +} + +func (rro *RecordingReportOpener) OpenReport( + ctx context.Context, rt probeservices.ReportTemplate, +) (probeservices.ReportChannel, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + rrc := &RecordingReportChannel{tmpl: rt} + rro.mu.Lock() + defer rro.mu.Unlock() + rro.channels = append(rro.channels, rrc) + return rrc, nil +} + +func TestOpenReportCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately abort + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + report, err := client.OpenReport(ctx, template) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if report != nil { + t.Fatal("expected nil report here") + } +} + +func TestSubmitMeasurementCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + template := probeservices.ReportTemplate{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + Format: probeservices.DefaultFormat, + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.1.0", + TestName: "dummy", + TestStartTime: "2019-10-28 12:51:06", + TestVersion: "0.1.0", + } + client := newclient() + report, err := client.OpenReport(ctx, template) + if err != nil { + t.Fatal(err) + } + measurement := makeMeasurement(template, report.ReportID()) + if report.CanSubmit(&measurement) != true { + t.Fatal("report should be able to submit this measurement") + } + cancel() // cause submission to fail + err = report.SubmitMeasurement(ctx, &measurement) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } + if measurement.ReportID != "" { + t.Fatal("report ID should be empty here") + } +} + +func makeMeasurementWithoutTemplate(failure, testName string) *model.Measurement { + return &model.Measurement{ + DataFormatVersion: probeservices.DefaultDataFormatVersion, + ID: "bdd20d7a-bba5-40dd-a111-9863d7908572", + MeasurementRuntime: 5.0565230846405, + MeasurementStartTime: "2018-11-01 15:33:20", + ProbeIP: "1.2.3.4", + ProbeASN: "AS123", + ProbeCC: "IT", + ReportID: "", + ResolverASN: "AS15169", + ResolverIP: "8.8.8.8", + ResolverNetworkName: "Google LLC", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + TestKeys: fakeTestKeys{Failure: &failure}, + TestName: testName, + TestStartTime: "2018-11-01 15:33:17", + TestVersion: "0.1.0", + } +} + +func TestSubmitterLifecyle(t *testing.T) { + rro := &RecordingReportOpener{} + submitter := probeservices.NewSubmitter(rro, log.Log) + ctx := context.Background() + m1 := makeMeasurementWithoutTemplate("antani", "example") + if err := submitter.Submit(ctx, m1); err != nil { + t.Fatal(err) + } + m2 := makeMeasurementWithoutTemplate("mascetti", "example") + if err := submitter.Submit(ctx, m2); err != nil { + t.Fatal(err) + } + m3 := makeMeasurementWithoutTemplate("antani", "example_extended") + if err := submitter.Submit(ctx, m3); err != nil { + t.Fatal(err) + } + if len(rro.channels) != 2 { + t.Fatal("unexpected number of channels") + } + if len(rro.channels[0].m) != 2 { + t.Fatal("unexpected number of measurements in first channel") + } + if len(rro.channels[1].m) != 1 { + t.Fatal("unexpected number of measurements in second channel") + } +} + +func TestSubmitterCannotOpenNewChannel(t *testing.T) { + rro := &RecordingReportOpener{} + submitter := probeservices.NewSubmitter(rro, log.Log) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + m1 := makeMeasurementWithoutTemplate("antani", "example") + if err := submitter.Submit(ctx, m1); !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + m2 := makeMeasurementWithoutTemplate("mascetti", "example") + if err := submitter.Submit(ctx, m2); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + m3 := makeMeasurementWithoutTemplate("antani", "example_extended") + if err := submitter.Submit(ctx, m3); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if len(rro.channels) != 0 { + t.Fatal("unexpected number of channels") + } +} diff --git a/internal/engine/probeservices/login.go b/internal/engine/probeservices/login.go new file mode 100644 index 0000000..4d0c2d0 --- /dev/null +++ b/internal/engine/probeservices/login.go @@ -0,0 +1,38 @@ +package probeservices + +import ( + "context" + "time" +) + +// LoginCredentials contains the login credentials +type LoginCredentials struct { + ClientID string `json:"username"` + Password string `json:"password"` +} + +// LoginAuth contains authentication info +type LoginAuth struct { + Expire time.Time `json:"expire"` + Token string `json:"token"` +} + +// MaybeLogin performs login if necessary +func (c Client) MaybeLogin(ctx context.Context) error { + state := c.StateFile.Get() + if state.Auth() != nil { + return nil // we're already good + } + creds := state.Credentials() + if creds == nil { + return ErrNotRegistered + } + c.LoginCalls.Add(1) + var auth LoginAuth + if err := c.Client.PostJSON(ctx, "/api/v1/login", *creds, &auth); err != nil { + return err + } + state.Expire = auth.Expire + state.Token = auth.Token + return c.StateFile.Set(state) +} diff --git a/internal/engine/probeservices/login_test.go b/internal/engine/probeservices/login_test.go new file mode 100644 index 0000000..707a9e0 --- /dev/null +++ b/internal/engine/probeservices/login_test.go @@ -0,0 +1,73 @@ +package probeservices_test + +import ( + "context" + "testing" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra" +) + +func TestMaybeLogin(t *testing.T) { + t.Run("when we already have a token", func(t *testing.T) { + clnt := newclient() + state := probeservices.State{ + Expire: time.Now().Add(time.Hour), + Token: "xx-xxx-x-xxxx", + } + if err := clnt.StateFile.Set(state); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := clnt.MaybeLogin(ctx); err != nil { + t.Fatal(err) + } + }) + t.Run("when we have already registered", func(t *testing.T) { + clnt := newclient() + state := probeservices.State{ + // Explicitly empty to clarify what this test does + } + if err := clnt.StateFile.Set(state); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := clnt.MaybeLogin(ctx); err == nil { + t.Fatal("expected an error here") + } + }) + t.Run("when the API call fails", func(t *testing.T) { + clnt := newclient() + clnt.BaseURL = "\t\t\t" // causes the code to fail + state := probeservices.State{ + ClientID: "xx-xxx-x-xxxx", + Password: "xx", + } + if err := clnt.StateFile.Set(state); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := clnt.MaybeLogin(ctx); err == nil { + t.Fatal("expected an error here") + } + }) +} + +func TestMaybeLoginIdempotent(t *testing.T) { + clnt := newclient() + ctx := context.Background() + metadata := testorchestra.MetadataFixture() + if err := clnt.MaybeRegister(ctx, metadata); err != nil { + t.Fatal(err) + } + if err := clnt.MaybeLogin(ctx); err != nil { + t.Fatal(err) + } + if err := clnt.MaybeLogin(ctx); err != nil { + t.Fatal(err) + } + if clnt.LoginCalls.Load() != 1 { + t.Fatal("called login API too many times") + } +} diff --git a/internal/engine/probeservices/measurementmeta.go b/internal/engine/probeservices/measurementmeta.go new file mode 100644 index 0000000..de9cfbf --- /dev/null +++ b/internal/engine/probeservices/measurementmeta.go @@ -0,0 +1,67 @@ +package probeservices + +import ( + "context" + "net/url" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" +) + +// MeasurementMetaConfig contains configuration for GetMeasurementMeta. +type MeasurementMetaConfig struct { + // ReportID is the mandatory report ID. + ReportID string + + // Full indicates whether we also want the full measurement body. + Full bool + + // Input is the optional input. + Input string +} + +// MeasurementMeta contains measurement metadata. +type MeasurementMeta struct { + // Fields returned by the API server whenever we are + // calling /api/v1/measurement_meta. + Anomaly bool `json:"anomaly"` + CategoryCode string `json:"category_code"` + Confirmed bool `json:"confirmed"` + Failure bool `json:"failure"` + Input *string `json:"input"` + MeasurementStartTime time.Time `json:"measurement_start_time"` + ProbeASN int64 `json:"probe_asn"` + ProbeCC string `json:"probe_cc"` + ReportID string `json:"report_id"` + Scores string `json:"scores"` + TestName string `json:"test_name"` + TestStartTime time.Time `json:"test_start_time"` + + // This field is only included if the user has specified + // the config.Full option, otherwise it's empty. + RawMeasurement string `json:"raw_measurement"` +} + +// GetMeasurementMeta returns meta information about a measurement. +func (c Client) GetMeasurementMeta( + ctx context.Context, config MeasurementMetaConfig) (*MeasurementMeta, error) { + query := url.Values{} + query.Add("report_id", config.ReportID) + if config.Input != "" { + query.Add("input", config.Input) + } + if config.Full { + query.Add("full", "true") + } + var response MeasurementMeta + err := (httpx.Client{ + BaseURL: c.BaseURL, + HTTPClient: c.HTTPClient, + Logger: c.Logger, + UserAgent: c.UserAgent, + }).GetJSONWithQuery(ctx, "/api/v1/measurement_meta", query, &response) + if err != nil { + return nil, err + } + return &response, nil +} diff --git a/internal/engine/probeservices/measurementmeta_test.go b/internal/engine/probeservices/measurementmeta_test.go new file mode 100644 index 0000000..cd92d5a --- /dev/null +++ b/internal/engine/probeservices/measurementmeta_test.go @@ -0,0 +1,111 @@ +package probeservices_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "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/probeservices" +) + +func TestGetMeasurementMetaWorkingAsIntended(t *testing.T) { + client := probeservices.Client{ + Client: httpx.Client{ + BaseURL: "https://ams-pg.ooni.org/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "miniooni/0.1.0-dev", + }, + LoginCalls: atomicx.NewInt64(), + RegisterCalls: atomicx.NewInt64(), + StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()), + } + config := probeservices.MeasurementMetaConfig{ + ReportID: `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU`, + Full: true, + Input: `https://www.example.org`, + } + ctx := context.Background() + mmeta, err := client.GetMeasurementMeta(ctx, config) + if err != nil { + t.Fatal(err) + } + if mmeta.Anomaly != false { + t.Fatal("unexpected anomaly value") + } + if mmeta.CategoryCode != "" { + t.Fatal("unexpected category code value") + } + if mmeta.Confirmed != false { + t.Fatal("unexpected confirmed value") + } + if mmeta.Failure != true { + // TODO(bassosimone): this field seems wrong + t.Fatal("unexpected failure value") + } + if mmeta.Input == nil || *mmeta.Input != config.Input { + t.Fatal("unexpected input value") + } + if mmeta.MeasurementStartTime.String() != "2020-12-09 05:22:25 +0000 UTC" { + t.Fatal("unexpected measurement start time value") + } + if mmeta.ProbeASN != 30722 { + t.Fatal("unexpected probe asn value") + } + if mmeta.ProbeCC != "IT" { + t.Fatal("unexpected probe cc value") + } + if mmeta.ReportID != config.ReportID { + t.Fatal("unexpected report id value") + } + // TODO(bassosimone): we could better this check + var scores interface{} + if err := json.Unmarshal([]byte(mmeta.Scores), &scores); err != nil { + t.Fatalf("cannot parse scores value: %+v", err) + } + if mmeta.TestName != "urlgetter" { + t.Fatal("unexpected test name value") + } + if mmeta.TestStartTime.String() != "2020-12-09 05:22:25 +0000 UTC" { + t.Fatal("unexpected test start time value") + } + // TODO(bassosimone): we could better this check + var rawmeas interface{} + if err := json.Unmarshal([]byte(mmeta.RawMeasurement), &rawmeas); err != nil { + t.Fatalf("cannot parse raw measurement: %+v", err) + } +} + +func TestGetMeasurementMetaWorkingWithCancelledContext(t *testing.T) { + client := probeservices.Client{ + Client: httpx.Client{ + BaseURL: "https://ams-pg.ooni.org/", + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "miniooni/0.1.0-dev", + }, + LoginCalls: atomicx.NewInt64(), + RegisterCalls: atomicx.NewInt64(), + StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()), + } + config := probeservices.MeasurementMetaConfig{ + ReportID: `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU`, + Full: true, + Input: `https://www.example.org`, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + mmeta, err := client.GetMeasurementMeta(ctx, config) + if !errors.Is(err, context.Canceled) { + t.Fatalf("not the error we expected: %+v", err) + } + if mmeta != nil { + t.Fatal("we expected a nil mmeta here") + } +} diff --git a/internal/engine/probeservices/metadata.go b/internal/engine/probeservices/metadata.go new file mode 100644 index 0000000..2b7b0e9 --- /dev/null +++ b/internal/engine/probeservices/metadata.go @@ -0,0 +1,52 @@ +package probeservices + +// Metadata contains metadata about a probe. This message is +// included into a bunch of messages sent to orchestra. +type Metadata struct { + AvailableBandwidth string `json:"available_bandwidth,omitempty"` + DeviceToken string `json:"device_token,omitempty"` + Language string `json:"language,omitempty"` + NetworkType string `json:"network_type,omitempty"` + Platform string `json:"platform"` + ProbeASN string `json:"probe_asn"` + ProbeCC string `json:"probe_cc"` + ProbeFamily string `json:"probe_family,omitempty"` + ProbeTimezone string `json:"probe_timezone,omitempty"` + SoftwareName string `json:"software_name"` + SoftwareVersion string `json:"software_version"` + SupportedTests []string `json:"supported_tests"` +} + +// Valid returns true if metadata is valid, false otherwise. Metadata is +// considered valid if all the mandatory fields are not empty. If a field +// is marked `json:",omitempty"` in the structure definition, then it's +// for sure mandatory. The "device_token" field is mandatory only if the +// platform is "ios" or "android", because there's currently no device +// token that we know of for desktop devices. +func (m Metadata) Valid() bool { + if m.ProbeCC == "" { + return false + } + if m.ProbeASN == "" { + return false + } + if m.Platform == "" { + return false + } + if m.SoftwareName == "" { + return false + } + if m.SoftwareVersion == "" { + return false + } + if len(m.SupportedTests) < 1 { + return false + } + switch m.Platform { + case "ios", "android": + if m.DeviceToken == "" { + return false + } + } + return true +} diff --git a/internal/engine/probeservices/metadata_test.go b/internal/engine/probeservices/metadata_test.go new file mode 100644 index 0000000..840f469 --- /dev/null +++ b/internal/engine/probeservices/metadata_test.go @@ -0,0 +1,106 @@ +package probeservices_test + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" +) + +func TestValid(t *testing.T) { + t.Run("fail on probe_cc", func(t *testing.T) { + var m probeservices.Metadata + if m.Valid() != false { + t.Fatal("expected false here") + } + }) + t.Run("fail on probe_asn", func(t *testing.T) { + m := probeservices.Metadata{ + ProbeCC: "IT", + } + if m.Valid() != false { + t.Fatal("expected false here") + } + }) + t.Run("fail on platform", func(t *testing.T) { + m := probeservices.Metadata{ + ProbeCC: "IT", + ProbeASN: "AS1234", + } + if m.Valid() != false { + t.Fatal("expected false here") + } + }) + t.Run("fail on software_name", func(t *testing.T) { + m := probeservices.Metadata{ + ProbeCC: "IT", + ProbeASN: "AS1234", + Platform: "linux", + } + if m.Valid() != false { + t.Fatal("expected false here") + } + }) + t.Run("fail on software_version", func(t *testing.T) { + m := probeservices.Metadata{ + ProbeCC: "IT", + ProbeASN: "AS1234", + Platform: "linux", + SoftwareName: "miniooni", + } + if m.Valid() != false { + t.Fatal("expected false here") + } + }) + t.Run("fail on supported_tests", func(t *testing.T) { + m := probeservices.Metadata{ + ProbeCC: "IT", + ProbeASN: "AS1234", + Platform: "linux", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + } + if m.Valid() != false { + t.Fatal("expected false here") + } + }) + t.Run("fail on missing device_token", func(t *testing.T) { + m := probeservices.Metadata{ + ProbeCC: "IT", + ProbeASN: "AS1234", + Platform: "ios", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + SupportedTests: []string{"web_connectivity"}, + } + if m.Valid() != false { + t.Fatal("expected false here") + } + }) + t.Run("success for desktop", func(t *testing.T) { + m := probeservices.Metadata{ + ProbeCC: "IT", + ProbeASN: "AS1234", + Platform: "linux", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + SupportedTests: []string{"web_connectivity"}, + } + if m.Valid() != true { + t.Fatal("expected true here") + } + }) + t.Run("success for mobile", func(t *testing.T) { + m := probeservices.Metadata{ + DeviceToken: "xx-xxx-xx-xxxx", + ProbeCC: "IT", + ProbeASN: "AS1234", + Platform: "android", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + SupportedTests: []string{"web_connectivity"}, + } + if m.Valid() != true { + t.Fatal("expected true here") + } + }) +} diff --git a/internal/engine/probeservices/probeservices.go b/internal/engine/probeservices/probeservices.go new file mode 100644 index 0000000..10a1388 --- /dev/null +++ b/internal/engine/probeservices/probeservices.go @@ -0,0 +1,129 @@ +// Package probeservices contains code to contact OONI probe services. +// +// The probe services are HTTPS endpoints distributed across a bunch of data +// centres implementing a bunch of OONI APIs. When started, OONI will benchmark +// the available probe services and select the fastest one. Eventually all the +// possible OONI APIs will run as probe services. +// +// This package implements the following APIs: +// +// 1. v2.0.0 of the OONI bouncer specification defined +// in https://github.com/ooni/spec/blob/master/backends/bk-004-bouncer; +// +// 2. v2.0.0 of the OONI collector specification defined +// in https://github.com/ooni/spec/blob/master/backends/bk-003-collector.md; +// +// 3. most of the OONI orchestra API: login, register, fetch URLs for +// the Web Connectivity experiment, input for Tor and Psiphon. +// +// Orchestra is a set of OONI APIs for probe orchestration. We currently mainly +// using it for fetching inputs for the tor, psiphon, and web experiments. +// +// In addition, this package also contains code to benchmark the available +// probe services, discard non working ones, select the fastest. +package probeservices + +import ( + "errors" + "net/http" + "net/url" + + "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/model" +) + +var ( + // ErrUnsupportedEndpoint indicates that we don't support this endpoint type. + ErrUnsupportedEndpoint = errors.New("probe services: unsupported endpoint type") + + // ErrUnsupportedCloudFrontAddress indicates that we don't support this + // cloudfront address (e.g. wrong scheme, presence of port). + ErrUnsupportedCloudFrontAddress = errors.New( + "probe services: unsupported cloud front address", + ) + + // ErrNotRegistered indicates that the probe is not registered + // with the OONI orchestra backend. + ErrNotRegistered = errors.New("not registered") + + // ErrNotLoggedIn indicates that we are not logged in + ErrNotLoggedIn = errors.New("not logged in") + + // ErrInvalidMetadata indicates that the metadata is not valid + ErrInvalidMetadata = errors.New("invalid metadata") +) + +// Session is how this package sees a Session. +type Session interface { + DefaultHTTPClient() *http.Client + KeyValueStore() model.KeyValueStore + Logger() model.Logger + ProxyURL() *url.URL + UserAgent() string +} + +// Client is a client for the OONI probe services API. +type Client struct { + httpx.Client + LoginCalls *atomicx.Int64 + RegisterCalls *atomicx.Int64 + StateFile StateFile +} + +// GetCredsAndAuth is an utility function that returns the credentials with +// which we are registered and the token with which we're logged in. If we're +// not registered or not logged in, an error is returned instead. +func (c Client) GetCredsAndAuth() (*LoginCredentials, *LoginAuth, error) { + state := c.StateFile.Get() + creds := state.Credentials() + if creds == nil { + return nil, nil, ErrNotRegistered + } + auth := state.Auth() + if auth == nil { + return nil, nil, ErrNotLoggedIn + } + return creds, auth, nil +} + +// NewClient creates a new client for the specified probe services endpoint. This +// function fails, e.g., we don't support the specified endpoint. +func NewClient(sess Session, endpoint model.Service) (*Client, error) { + client := &Client{ + Client: httpx.Client{ + BaseURL: endpoint.Address, + HTTPClient: sess.DefaultHTTPClient(), + Logger: sess.Logger(), + ProxyURL: sess.ProxyURL(), + UserAgent: sess.UserAgent(), + }, + LoginCalls: atomicx.NewInt64(), + RegisterCalls: atomicx.NewInt64(), + StateFile: NewStateFile(sess.KeyValueStore()), + } + switch endpoint.Type { + case "https": + return client, nil + case "cloudfront": + // Do the cloudfronting dance. The front must appear inside of the + // URL, so that we use it for DNS resolution and SNI. The real domain + // must instead appear inside of the Host header. + URL, err := url.Parse(client.BaseURL) + if err != nil { + return nil, err + } + if URL.Scheme != "https" || URL.Host != URL.Hostname() { + return nil, ErrUnsupportedCloudFrontAddress + } + client.Client.Host = URL.Hostname() + URL.Host = endpoint.Front + client.BaseURL = URL.String() + if _, err := url.Parse(client.BaseURL); err != nil { + return nil, err + } + return client, nil + default: + return nil, ErrUnsupportedEndpoint + } +} diff --git a/internal/engine/probeservices/probeservices_test.go b/internal/engine/probeservices/probeservices_test.go new file mode 100644 index 0000000..fdb9123 --- /dev/null +++ b/internal/engine/probeservices/probeservices_test.go @@ -0,0 +1,627 @@ +package probeservices_test + +import ( + "context" + "errors" + "io" + "io/ioutil" + "net/http" + "regexp" + "strings" + "testing" + "time" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra" +) + +func newclient() *probeservices.Client { + client, err := probeservices.NewClient( + &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + model.Service{ + Address: "https://ams-pg-test.ooni.org/", + Type: "https", + }, + ) + if err != nil { + panic(err) // so fail the test + } + return client +} + +func TestNewClientHTTPS(t *testing.T) { + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "https://x.org", + Type: "https", + }) + if err != nil { + t.Fatal(err) + } + if client.BaseURL != "https://x.org" { + t.Fatal("not the URL we expected") + } +} + +func TestNewClientUnsupportedEndpoint(t *testing.T) { + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "https://x.org", + Type: "onion", + }) + if !errors.Is(err, probeservices.ErrUnsupportedEndpoint) { + t.Fatal("not the error we expected") + } + if client != nil { + t.Fatal("expected nil client here") + } +} + +func TestNewClientCloudfrontInvalidURL(t *testing.T) { + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "\t\t\t", + Type: "cloudfront", + }) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + if client != nil { + t.Fatal("expected nil client here") + } +} + +func TestNewClientCloudfrontInvalidURLScheme(t *testing.T) { + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "http://x.org", + Type: "cloudfront", + }) + if !errors.Is(err, probeservices.ErrUnsupportedCloudFrontAddress) { + t.Fatal("not the error we expected") + } + if client != nil { + t.Fatal("expected nil client here") + } +} + +func TestNewClientCloudfrontInvalidURLWithPort(t *testing.T) { + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "https://x.org:54321", + Type: "cloudfront", + }) + if !errors.Is(err, probeservices.ErrUnsupportedCloudFrontAddress) { + t.Fatal("not the error we expected") + } + if client != nil { + t.Fatal("expected nil client here") + } +} + +func TestNewClientCloudfrontInvalidFront(t *testing.T) { + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "https://x.org", + Type: "cloudfront", + Front: "\t\t\t", + }) + if err == nil || !strings.HasSuffix(err.Error(), `invalid URL escape "%09"`) { + t.Fatal("not the error we expected") + } + if client != nil { + t.Fatal("expected nil client here") + } +} + +func TestNewClientCloudfrontGood(t *testing.T) { + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "https://x.org", + Type: "cloudfront", + Front: "google.com", + }) + if err != nil { + t.Fatal(err) + } + if client.BaseURL != "https://google.com" { + t.Fatal("not the BaseURL we expected") + } + if client.Host != "x.org" { + t.Fatal("not the Host we expected") + } +} + +func TestCloudfront(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + client, err := probeservices.NewClient( + &mockable.Session{}, model.Service{ + Address: "https://meek.azureedge.net", + Type: "cloudfront", + Front: "ajax.aspnetcdn.com", + }) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest("GET", client.BaseURL, nil) + if err != nil { + t.Fatal(err) + } + req.Host = client.Host + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatal("unexpected status code") + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(data) != "I’m just a happy little web server.\n" { + t.Fatal("unexpected response body") + } +} + +func TestDefaultProbeServicesWorkAsIntended(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + for _, e := range probeservices.Default() { + client, err := probeservices.NewClient(&mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, e) + if err != nil { + t.Fatal(err) + } + testhelpers, err := client.GetTestHelpers(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(testhelpers) < 1 { + t.Fatal("no test helpers?!") + } + } +} + +func TestSortEndpoints(t *testing.T) { + in := []model.Service{{ + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "https", + Address: "https://ams-ps2.ooni.nu:443", + }} + expect := []model.Service{{ + Type: "https", + Address: "https://ams-ps2.ooni.nu:443", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }} + out := probeservices.SortEndpoints(in) + diff := cmp.Diff(out, expect) + if diff != "" { + t.Fatal(diff) + } +} + +func TestOnlyHTTPS(t *testing.T) { + in := []model.Service{{ + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }, { + Type: "https", + Address: "https://ams-ps-nonexistent.ooni.io", + }, { + Type: "https", + Address: "https://hkg-ps-nonexistent.ooni.io", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "https", + Address: "https://mia-ps-nonexistent.ooni.io", + }} + expect := []model.Service{{ + Type: "https", + Address: "https://ams-ps-nonexistent.ooni.io", + }, { + Type: "https", + Address: "https://hkg-ps-nonexistent.ooni.io", + }, { + Type: "https", + Address: "https://mia-ps-nonexistent.ooni.io", + }} + out := probeservices.OnlyHTTPS(in) + diff := cmp.Diff(out, expect) + if diff != "" { + t.Fatal(diff) + } +} + +func TestOnlyFallbacks(t *testing.T) { + // put onion first so we also verify that we sort the endpoints + in := []model.Service{{ + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }, { + Type: "https", + Address: "https://ams-ps-nonexistent.ooni.io", + }, { + Type: "https", + Address: "https://hkg-ps-nonexistent.ooni.io", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "https", + Address: "https://mia-ps-nonexistent.ooni.io", + }} + expect := []model.Service{{ + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }} + out := probeservices.OnlyFallbacks(in) + diff := cmp.Diff(out, expect) + if diff != "" { + t.Fatal(diff) + } +} + +func TestTryAllCanceledContext(t *testing.T) { + // put onion first so we also verify that we sort the endpoints + in := []model.Service{{ + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }, { + Type: "https", + Address: "https://ams-ps-nonexistent.ooni.io", + }, { + Type: "https", + Address: "https://hkg-ps-nonexistent.ooni.io", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "https", + Address: "https://mia-ps-nonexistent.ooni.io", + }} + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel and cause every attempt to fail + sess := &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + } + out := probeservices.TryAll(ctx, sess, in) + if len(out) != 5 { + t.Fatal("invalid number of entries") + } + // + if out[0].Duration <= 0 { + t.Fatal("invalid duration") + } + if !errors.Is(out[0].Err, context.Canceled) { + t.Fatal("invalid error") + } + if out[0].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if out[0].Endpoint.Address != "https://ams-ps-nonexistent.ooni.io" { + t.Fatal("invalid endpoint type") + } + // + if out[1].Duration <= 0 { + t.Fatal("invalid duration") + } + if !errors.Is(out[1].Err, context.Canceled) { + t.Fatal("invalid error") + } + if out[1].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if out[1].Endpoint.Address != "https://hkg-ps-nonexistent.ooni.io" { + t.Fatal("invalid endpoint type") + } + // + if out[2].Duration <= 0 { + t.Fatal("invalid duration") + } + if !errors.Is(out[2].Err, context.Canceled) { + t.Fatal("invalid error") + } + if out[2].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if out[2].Endpoint.Address != "https://mia-ps-nonexistent.ooni.io" { + t.Fatal("invalid endpoint type") + } + // + if out[3].Duration <= 0 { + t.Fatal("invalid duration") + } + if !errors.Is(out[3].Err, context.Canceled) { + t.Fatal("invalid error") + } + if out[3].Endpoint.Type != "cloudfront" { + t.Fatal("invalid endpoint type") + } + if out[3].Endpoint.Front != "dkyhjv0wpi2dk.cloudfront.net" { + t.Fatal("invalid endpoint type") + } + if out[3].Endpoint.Address != "https://dkyhjv0wpi2dk.cloudfront.net" { + t.Fatal("invalid endpoint type") + } + // + // Note: here duration may be zero because the endpoint is not supported + // and so we don't basically do anything. But it also may be nonzero since + // we also run tests in the cloud, which is slower than my desktop. So, I + // have not written a specific test concerning out[4].Duration. + if !errors.Is(out[4].Err, probeservices.ErrUnsupportedEndpoint) { + t.Fatal("invalid error") + } + if out[4].Endpoint.Type != "onion" { + t.Fatal("invalid endpoint type") + } + if out[4].Endpoint.Address != "httpo://jehhrikjjqrlpufu.onion" { + t.Fatal("invalid endpoint type") + } +} + +func TestTryAllIntegrationWeRaceForFastestHTTPS(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + const pattern = "^https://ps[1-4].ooni.io$" + // put onion first so we also verify that we sort the endpoints + in := []model.Service{{ + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }, { + Type: "https", + Address: "https://ps1.ooni.io", + }, { + Type: "https", + Address: "https://ps2.ooni.io", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "https", + Address: "https://ps3.ooni.io", + }} + sess := &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + } + out := probeservices.TryAll(context.Background(), sess, in) + if len(out) != 3 { + t.Fatal("invalid number of entries") + } + // + if out[0].Duration <= 0 { + t.Fatal("invalid duration") + } + if out[0].Err != nil { + t.Fatal("invalid error") + } + if out[0].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if ok, _ := regexp.MatchString(pattern, out[0].Endpoint.Address); !ok { + t.Fatal("invalid endpoint type") + } + // + if out[1].Duration <= 0 { + t.Fatal("invalid duration") + } + if out[1].Err != nil { + t.Fatal("invalid error") + } + if out[1].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if ok, _ := regexp.MatchString(pattern, out[1].Endpoint.Address); !ok { + t.Fatal("invalid endpoint type") + } + // + if out[2].Duration <= 0 { + t.Fatal("invalid duration") + } + if out[2].Err != nil { + t.Fatal("invalid error") + } + if out[2].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if ok, _ := regexp.MatchString(pattern, out[2].Endpoint.Address); !ok { + t.Fatal("invalid endpoint type") + } +} + +func TestTryAllIntegrationWeFallback(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + // put onion first so we also verify that we sort the endpoints + in := []model.Service{{ + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }, { + Type: "https", + Address: "https://ps-nonexistent.ooni.io", + }, { + Type: "https", + Address: "https://hkg-ps-nonexistent.ooni.nu", + }, { + Front: "dkyhjv0wpi2dk.cloudfront.net", + Type: "cloudfront", + Address: "https://dkyhjv0wpi2dk.cloudfront.net", + }, { + Type: "https", + Address: "https://mia-ps2-nonexistent.ooni.nu", + }} + sess := &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + } + out := probeservices.TryAll(context.Background(), sess, in) + if len(out) != 4 { + t.Fatal("invalid number of entries") + } + // + if out[0].Duration <= 0 { + t.Fatal("invalid duration") + } + if !strings.HasSuffix(out[0].Err.Error(), "no such host") { + t.Fatal("invalid error") + } + if out[0].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if out[0].Endpoint.Address != "https://ps-nonexistent.ooni.io" { + t.Fatal("invalid endpoint type") + } + // + if out[1].Duration <= 0 { + t.Fatal("invalid duration") + } + if !strings.HasSuffix(out[1].Err.Error(), "no such host") { + t.Fatal("invalid error") + } + if out[1].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if out[1].Endpoint.Address != "https://hkg-ps-nonexistent.ooni.nu" { + t.Fatal("invalid endpoint type") + } + // + if out[2].Duration <= 0 { + t.Fatal("invalid duration") + } + if !strings.HasSuffix(out[2].Err.Error(), "no such host") { + t.Fatal("invalid error") + } + if out[2].Endpoint.Type != "https" { + t.Fatal("invalid endpoint type") + } + if out[2].Endpoint.Address != "https://mia-ps2-nonexistent.ooni.nu" { + t.Fatal("invalid endpoint type") + } + // + if out[3].Duration <= 0 { + t.Fatal("invalid duration") + } + if out[3].Err != nil { + t.Fatal("invalid error") + } + if out[3].Endpoint.Type != "cloudfront" { + t.Fatal("invalid endpoint type") + } + if out[3].Endpoint.Address != "https://dkyhjv0wpi2dk.cloudfront.net" { + t.Fatal("invalid endpoint type") + } + if out[3].Endpoint.Front != "dkyhjv0wpi2dk.cloudfront.net" { + t.Fatal("invalid front") + } +} + +func TestSelectBestEmptyInput(t *testing.T) { + if out := probeservices.SelectBest(nil); out != nil { + t.Fatal("expected nil output here") + } +} + +func TestSelectBestOnlyFailures(t *testing.T) { + in := []*probeservices.Candidate{{ + Duration: 10 * time.Millisecond, + Err: io.EOF, + }} + if out := probeservices.SelectBest(in); out != nil { + t.Fatal("expected nil output here") + } +} + +func TestSelectBestSelectsTheFastest(t *testing.T) { + in := []*probeservices.Candidate{{ + Duration: 10 * time.Millisecond, + Endpoint: model.Service{ + Address: "https://ps1.ooni.io", + Type: "https", + }, + }, { + Duration: 4 * time.Millisecond, + Endpoint: model.Service{ + Address: "https://ps2.ooni.io", + Type: "https", + }, + }, { + Duration: 7 * time.Millisecond, + Endpoint: model.Service{ + Address: "https://ps3.ooni.io", + Type: "https", + }, + }, { + Duration: 11 * time.Millisecond, + Endpoint: model.Service{ + Address: "https://ps4.ooni.io", + Type: "https", + }, + }} + expected := &probeservices.Candidate{ + Duration: 4 * time.Millisecond, + Endpoint: model.Service{ + Address: "https://ps2.ooni.io", + Type: "https", + }, + } + out := probeservices.SelectBest(in) + if diff := cmp.Diff(out, expected); diff != "" { + t.Fatal(diff) + } +} + +func TestGetCredsAndAuthNotLoggedIn(t *testing.T) { + clnt := newclient() + if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil { + t.Fatal(err) + } + creds, auth, err := clnt.GetCredsAndAuth() + if !errors.Is(err, probeservices.ErrNotLoggedIn) { + t.Fatal("not the error we expected") + } + if creds != nil { + t.Fatal("expected nil creds here") + } + if auth != nil { + t.Fatal("expected nil auth here") + } +} diff --git a/internal/engine/probeservices/psiphon.go b/internal/engine/probeservices/psiphon.go new file mode 100644 index 0000000..e208491 --- /dev/null +++ b/internal/engine/probeservices/psiphon.go @@ -0,0 +1,17 @@ +package probeservices + +import ( + "context" + "fmt" +) + +// FetchPsiphonConfig fetches psiphon config from authenticated OONI orchestra. +func (c Client) FetchPsiphonConfig(ctx context.Context) ([]byte, error) { + _, auth, err := c.GetCredsAndAuth() + if err != nil { + return nil, err + } + client := c.Client + client.Authorization = fmt.Sprintf("Bearer %s", auth.Token) + return client.FetchResource(ctx, "/api/v1/test-list/psiphon-config") +} diff --git a/internal/engine/probeservices/psiphon_test.go b/internal/engine/probeservices/psiphon_test.go new file mode 100644 index 0000000..bf194ff --- /dev/null +++ b/internal/engine/probeservices/psiphon_test.go @@ -0,0 +1,46 @@ +package probeservices_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra" +) + +func TestFetchPsiphonConfig(t *testing.T) { + clnt := newclient() + if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil { + t.Fatal(err) + } + if err := clnt.MaybeLogin(context.Background()); err != nil { + t.Fatal(err) + } + data, err := clnt.FetchPsiphonConfig(context.Background()) + if err != nil { + t.Fatal(err) + } + var config interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } +} + +func TestFetchPsiphonConfigNotRegistered(t *testing.T) { + clnt := newclient() + state := probeservices.State{ + // Explicitly empty so the test is more clear + } + if err := clnt.StateFile.Set(state); err != nil { + t.Fatal(err) + } + data, err := clnt.FetchPsiphonConfig(context.Background()) + if !errors.Is(err, probeservices.ErrNotRegistered) { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected nil data here") + } +} diff --git a/internal/engine/probeservices/register.go b/internal/engine/probeservices/register.go new file mode 100644 index 0000000..44dd16a --- /dev/null +++ b/internal/engine/probeservices/register.go @@ -0,0 +1,40 @@ +package probeservices + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/randx" +) + +type registerRequest struct { + Metadata + Password string `json:"password"` +} + +type registerResult struct { + ClientID string `json:"client_id"` +} + +// MaybeRegister registers this client if not already registered +func (c Client) MaybeRegister(ctx context.Context, metadata Metadata) error { + if !metadata.Valid() { + return ErrInvalidMetadata + } + state := c.StateFile.Get() + if state.Credentials() != nil { + return nil // we're already good + } + c.RegisterCalls.Add(1) + pwd := randx.Letters(64) + req := ®isterRequest{ + Metadata: metadata, + Password: pwd, + } + var resp registerResult + if err := c.Client.PostJSON(ctx, "/api/v1/register", req, &resp); err != nil { + return err + } + state.ClientID = resp.ClientID + state.Password = pwd + return c.StateFile.Set(state) +} diff --git a/internal/engine/probeservices/register_test.go b/internal/engine/probeservices/register_test.go new file mode 100644 index 0000000..963ffd5 --- /dev/null +++ b/internal/engine/probeservices/register_test.go @@ -0,0 +1,63 @@ +package probeservices_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra" +) + +func TestMaybeRegister(t *testing.T) { + t.Run("when metadata is not valid", func(t *testing.T) { + clnt := newclient() + ctx := context.Background() + var metadata probeservices.Metadata + err := clnt.MaybeRegister(ctx, metadata) + if !errors.Is(err, probeservices.ErrInvalidMetadata) { + t.Fatal("expected an error here") + } + }) + t.Run("when we have already registered", func(t *testing.T) { + clnt := newclient() + state := probeservices.State{ + ClientID: "xx-xxx-x-xxxx", + Password: "xx", + } + if err := clnt.StateFile.Set(state); err != nil { + t.Fatal(err) + } + ctx := context.Background() + metadata := testorchestra.MetadataFixture() + if err := clnt.MaybeRegister(ctx, metadata); err != nil { + t.Fatal(err) + } + }) + t.Run("when the API call fails", func(t *testing.T) { + clnt := newclient() + clnt.BaseURL = "\t\t\t" // makes it fail + ctx := context.Background() + metadata := testorchestra.MetadataFixture() + err := clnt.MaybeRegister(ctx, metadata) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("expected an error here") + } + }) +} + +func TestMaybeRegisterIdempotent(t *testing.T) { + clnt := newclient() + ctx := context.Background() + metadata := testorchestra.MetadataFixture() + if err := clnt.MaybeRegister(ctx, metadata); err != nil { + t.Fatal(err) + } + if err := clnt.MaybeRegister(ctx, metadata); err != nil { + t.Fatal(err) + } + if clnt.RegisterCalls.Load() != 1 { + t.Fatal("called register API too many times") + } +} diff --git a/internal/engine/probeservices/statefile.go b/internal/engine/probeservices/statefile.go new file mode 100644 index 0000000..a08939b --- /dev/null +++ b/internal/engine/probeservices/statefile.go @@ -0,0 +1,87 @@ +package probeservices + +import ( + "encoding/json" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// State is the state stored inside the state file +type State struct { + ClientID string + Expire time.Time + Password string + Token string +} + +// Auth returns an authentication structure, if possible, otherwise +// it returns nil, meaning that you should login again. +func (s State) Auth() *LoginAuth { + if s.Token == "" { + return nil + } + if time.Now().Add(30 * time.Second).After(s.Expire) { + return nil + } + return &LoginAuth{Expire: s.Expire, Token: s.Token} +} + +// Credentials returns login credentials, if possible, otherwise it +// returns nil, meaning that you should create an account. +func (s State) Credentials() *LoginCredentials { + if s.ClientID == "" { + return nil + } + if s.Password == "" { + return nil + } + return &LoginCredentials{ClientID: s.ClientID, Password: s.Password} +} + +// StateFile is the orchestra state file. It is backed by +// a generic key-value store configured by the user. +type StateFile struct { + Store model.KeyValueStore + key string +} + +// NewStateFile creates a new state file backed by a key-value store +func NewStateFile(kvstore model.KeyValueStore) StateFile { + return StateFile{key: "orchestra.state", Store: kvstore} +} + +// SetMockable is a mockable version of Set +func (sf StateFile) SetMockable(s State, mf func(interface{}) ([]byte, error)) error { + data, err := mf(s) + if err != nil { + return err + } + return sf.Store.Set(sf.key, data) +} + +// Set saves the current state on the key-value store. +func (sf StateFile) Set(s State) error { + return sf.SetMockable(s, json.Marshal) +} + +// GetMockable is a mockable version of Get +func (sf StateFile) GetMockable(sfget func(string) ([]byte, error), + unmarshal func([]byte, interface{}) error) (State, error) { + value, err := sfget(sf.key) + if err != nil { + return State{}, err + } + var state State + if err := unmarshal(value, &state); err != nil { + return State{}, err + } + return state, nil +} + +// Get returns the current state. In case of any error with the +// underlying key-value store, we return an empty state. +func (sf StateFile) Get() (state State) { + state, _ = sf.GetMockable(sf.Store.Get, json.Unmarshal) + return +} diff --git a/internal/engine/probeservices/statefile_test.go b/internal/engine/probeservices/statefile_test.go new file mode 100644 index 0000000..d6bfc88 --- /dev/null +++ b/internal/engine/probeservices/statefile_test.go @@ -0,0 +1,153 @@ +package probeservices_test + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" +) + +func TestStateAuth(t *testing.T) { + t.Run("with no Token", func(t *testing.T) { + state := probeservices.State{Expire: time.Now().Add(10 * time.Hour)} + if state.Auth() != nil { + t.Fatal("expected nil here") + } + }) + t.Run("with expired Token", func(t *testing.T) { + state := probeservices.State{ + Expire: time.Now().Add(-1 * time.Hour), + Token: "xx-x-xxx-xx", + } + if state.Auth() != nil { + t.Fatal("expected nil here") + } + }) + t.Run("with good Token", func(t *testing.T) { + state := probeservices.State{ + Expire: time.Now().Add(10 * time.Hour), + Token: "xx-x-xxx-xx", + } + if state.Auth() == nil { + t.Fatal("expected valid auth here") + } + }) +} + +func TestStateCredentials(t *testing.T) { + t.Run("with no ClientID", func(t *testing.T) { + state := probeservices.State{} + if state.Credentials() != nil { + t.Fatal("expected nil here") + } + }) + t.Run("with no Password", func(t *testing.T) { + state := probeservices.State{ + ClientID: "xx-x-xxx-xx", + } + if state.Credentials() != nil { + t.Fatal("expected nil here") + } + }) + t.Run("with all good", func(t *testing.T) { + state := probeservices.State{ + ClientID: "xx-x-xxx-xx", + Password: "xx", + } + if state.Credentials() == nil { + t.Fatal("expected valid auth here") + } + }) +} + +func TestStateFileMemoryIntegration(t *testing.T) { + // Does the StateFile have the property that we can write + // values into it and then read again the same files? + sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()) + s := probeservices.State{ + Expire: time.Now(), + Password: "xy", + Token: "abc", + ClientID: "xx", + } + if err := sf.Set(s); err != nil { + t.Fatal(err) + } + os := sf.Get() + diff := cmp.Diff(s, os) + if diff != "" { + t.Fatal(diff) + } +} + +func TestStateFileSetMarshalError(t *testing.T) { + sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()) + s := probeservices.State{ + Expire: time.Now(), + Password: "xy", + Token: "abc", + ClientID: "xx", + } + expected := errors.New("mocked error") + failingfunc := func(v interface{}) ([]byte, error) { + return nil, expected + } + if err := sf.SetMockable(s, failingfunc); !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestStateFileGetKVStoreGetError(t *testing.T) { + sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()) + expected := errors.New("mocked error") + failingfunc := func(string) ([]byte, error) { + return nil, expected + } + s, err := sf.GetMockable(failingfunc, json.Unmarshal) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if s.ClientID != "" { + t.Fatal("unexpected ClientID field") + } + if !s.Expire.IsZero() { + t.Fatal("unexpected Expire field") + } + if s.Password != "" { + t.Fatal("unexpected Password field") + } + if s.Token != "" { + t.Fatal("unexpected Token field") + } +} + +func TestStateFileGetUnmarshalError(t *testing.T) { + sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()) + if err := sf.Set(probeservices.State{}); err != nil { + t.Fatal(err) + } + expected := errors.New("mocked error") + failingfunc := func([]byte, interface{}) error { + return expected + } + s, err := sf.GetMockable(sf.Store.Get, failingfunc) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if s.ClientID != "" { + t.Fatal("unexpected ClientID field") + } + if !s.Expire.IsZero() { + t.Fatal("unexpected Expire field") + } + if s.Password != "" { + t.Fatal("unexpected Password field") + } + if s.Token != "" { + t.Fatal("unexpected Token field") + } +} diff --git a/internal/engine/probeservices/testorchestra/testorchestra.go b/internal/engine/probeservices/testorchestra/testorchestra.go new file mode 100644 index 0000000..8e5a0a9 --- /dev/null +++ b/internal/engine/probeservices/testorchestra/testorchestra.go @@ -0,0 +1,19 @@ +// Package testorchestra helps with testing the OONI orchestra API. +package testorchestra + +import "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + +// MetadataFixture returns a valid metadata struct. This is mostly +// useful for testing. (We should see if we can make this private.) +func MetadataFixture() probeservices.Metadata { + return probeservices.Metadata{ + Platform: "linux", + ProbeASN: "AS15169", + ProbeCC: "US", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + SupportedTests: []string{ + "web_connectivity", + }, + } +} diff --git a/internal/engine/probeservices/tor.go b/internal/engine/probeservices/tor.go new file mode 100644 index 0000000..3b480b2 --- /dev/null +++ b/internal/engine/probeservices/tor.go @@ -0,0 +1,24 @@ +package probeservices + +import ( + "context" + "fmt" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// FetchTorTargets returns the targets for the tor experiment. +func (c Client) FetchTorTargets(ctx context.Context, cc string) (result map[string]model.TorTarget, err error) { + _, auth, err := c.GetCredsAndAuth() + if err != nil { + return nil, err + } + client := c.Client + client.Authorization = fmt.Sprintf("Bearer %s", auth.Token) + query := url.Values{} + query.Add("country_code", cc) + err = client.GetJSONWithQuery( + ctx, "/api/v1/test-list/tor-targets", query, &result) + return +} diff --git a/internal/engine/probeservices/tor_test.go b/internal/engine/probeservices/tor_test.go new file mode 100644 index 0000000..127e87c --- /dev/null +++ b/internal/engine/probeservices/tor_test.go @@ -0,0 +1,82 @@ +package probeservices_test + +import ( + "context" + "net/http" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra" +) + +func TestFetchTorTargets(t *testing.T) { + clnt := newclient() + if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil { + t.Fatal(err) + } + if err := clnt.MaybeLogin(context.Background()); err != nil { + t.Fatal(err) + } + data, err := clnt.FetchTorTargets(context.Background(), "ZZ") + if err != nil { + t.Fatal(err) + } + if data == nil || len(data) <= 0 { + t.Fatal("invalid data") + } +} + +func TestFetchTorTargetsNotRegistered(t *testing.T) { + clnt := newclient() + state := probeservices.State{ + // Explicitly empty so the test is more clear + } + if err := clnt.StateFile.Set(state); err != nil { + t.Fatal(err) + } + data, err := clnt.FetchTorTargets(context.Background(), "ZZ") + if err == nil { + t.Fatal("expected an error here") + } + if data != nil { + t.Fatal("expected nil data here") + } +} + +type FetchTorTargetsHTTPTransport struct { + Response *http.Response +} + +func (clnt *FetchTorTargetsHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return nil, err + } + if req.URL.Path == "/api/v1/test-list/tor-targets" { + clnt.Response = resp + } + return resp, err +} + +func TestFetchTorTargetsSetsQueryString(t *testing.T) { + clnt := newclient() + txp := new(FetchTorTargetsHTTPTransport) + clnt.HTTPClient.Transport = txp + if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil { + t.Fatal(err) + } + if err := clnt.MaybeLogin(context.Background()); err != nil { + t.Fatal(err) + } + data, err := clnt.FetchTorTargets(context.Background(), "ZZ") + if err != nil { + t.Fatal(err) + } + if data == nil || len(data) <= 0 { + t.Fatal("invalid data") + } + requestURL := txp.Response.Request.URL + if requestURL.Query().Get("country_code") != "ZZ" { + t.Fatal("invalid country code") + } +} diff --git a/internal/engine/probeservices/urls.go b/internal/engine/probeservices/urls.go new file mode 100644 index 0000000..7066b88 --- /dev/null +++ b/internal/engine/probeservices/urls.go @@ -0,0 +1,36 @@ +package probeservices + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +type urlListResult struct { + Results []model.URLInfo `json:"results"` +} + +// FetchURLList fetches the list of URLs used by WebConnectivity. The config +// argument contains the optional settings. Returns the list of URLs, on success, +// or an explanatory error, in case of failure. +func (c Client) FetchURLList(ctx context.Context, config model.URLListConfig) ([]model.URLInfo, error) { + query := url.Values{} + if config.CountryCode != "" { + query.Set("country_code", config.CountryCode) + } + if config.Limit > 0 { + query.Set("limit", fmt.Sprintf("%d", config.Limit)) + } + if len(config.Categories) > 0 { + query.Set("category_codes", strings.Join(config.Categories, ",")) + } + var response urlListResult + err := c.Client.GetJSONWithQuery(ctx, "/api/v1/test-list/urls", query, &response) + if err != nil { + return nil, err + } + return response.Results, nil +} diff --git a/internal/engine/probeservices/urls_test.go b/internal/engine/probeservices/urls_test.go new file mode 100644 index 0000000..5cd7843 --- /dev/null +++ b/internal/engine/probeservices/urls_test.go @@ -0,0 +1,50 @@ +package probeservices_test + +import ( + "context" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestFetchURLListSuccess(t *testing.T) { + client := newclient() + client.BaseURL = "https://ams-pg-test.ooni.org" + config := model.URLListConfig{ + Categories: []string{"NEWS", "CULTR"}, + CountryCode: "IT", + Limit: 17, + } + ctx := context.Background() + result, err := client.FetchURLList(ctx, config) + if err != nil { + t.Fatal(err) + } + if len(result) != 17 { + t.Fatal("unexpected number of results") + } + for _, entry := range result { + if entry.CategoryCode != "NEWS" && entry.CategoryCode != "CULTR" { + t.Fatalf("unexpected category code: %+v", entry) + } + } +} + +func TestFetchURLListFailure(t *testing.T) { + client := newclient() + client.BaseURL = "https://\t\t\t/" // cause test to fail + config := model.URLListConfig{ + Categories: []string{"NEWS", "CULTR"}, + CountryCode: "IT", + Limit: 17, + } + ctx := context.Background() + result, err := client.FetchURLList(ctx, config) + if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { + t.Fatal("not the error we expected") + } + if len(result) != 0 { + t.Fatal("results?!") + } +} diff --git a/internal/engine/publish-android.bash b/internal/engine/publish-android.bash new file mode 100755 index 0000000..c2090e8 --- /dev/null +++ b/internal/engine/publish-android.bash @@ -0,0 +1,28 @@ +#!/bin/bash +set -e +pkgname=oonimkall +version=$(date -u +%Y.%m.%d-%H%M%S) +baseurl=https://api.bintray.com/content/ooni/android/$pkgname/$version/org/ooni/$pkgname/$version +aarfile=./MOBILE/android/$pkgname.aar +aarfile_version=./MOBILE/android/$pkgname-$version.aar +ln $aarfile $aarfile_version +sourcesfile=./MOBILE/android/$pkgname-sources.jar +sourcesfile_version=./MOBILE/android/$pkgname-$version-sources.jar +ln $sourcesfile $sourcesfile_version +pomfile=./MOBILE/android/$pkgname-$version.pom +pomtemplate=./MOBILE/template.pom +user=bassosimone +cat $pomtemplate|sed "s/@VERSION@/$version/g" > $pomfile +if [ -z $BINTRAY_API_KEY ]; then + echo "FATAL: missing BINTRAY_API_KEY variable" 1>&2 + exit 1 +fi +# We currently publish the mobile-staging branch. To cleanup we can fetch all the versions using +# the +# query, which returns a list of versions. From such list, we can delete the versions we +# don't need using . +for filename in $aarfile_version $sourcesfile_version $pomfile; do + basefilename=$(basename $filename) + curl -sT $filename -u $user:$BINTRAY_API_KEY $baseurl/$basefilename?publish=1 >/dev/null +done +echo "implementation 'org.ooni:oonimkall:$version'" diff --git a/internal/engine/publish-ios.bash b/internal/engine/publish-ios.bash new file mode 100755 index 0000000..8b5a8b3 --- /dev/null +++ b/internal/engine/publish-ios.bash @@ -0,0 +1,23 @@ +#!/bin/bash +set -e +pkgname=oonimkall +version=$(date -u +%Y.%m.%d-%H%M%S) +baseurl=https://api.bintray.com/content/ooni/ios/$pkgname/$version +framework=./MOBILE/ios/$pkgname.framework +frameworkzip=./MOBILE/ios/$pkgname.framework.zip +podspecfile=./MOBILE/ios/$pkgname.podspec +podspectemplate=./MOBILE/template.podspec +user=bassosimone +(cd ./MOBILE/ios && rm -f $pkgname.framework.zip && zip -yr $pkgname.framework.zip $pkgname.framework) +cat $podspectemplate|sed "s/@VERSION@/$version/g" > $podspecfile +if [ -z $BINTRAY_API_KEY ]; then + echo "FATAL: missing BINTRAY_API_KEY variable" 1>&2 + exit 1 +fi +# We currently publish the mobile-staging branch. To cleanup we can fetch all the versions using +# the +# query, which returns a list of versions. From such list, we can delete the versions we +# don't need using . +curl -sT $frameworkzip -u $user:$BINTRAY_API_KEY $baseurl/$pkgname-$version.framework.zip?publish=1 >/dev/null +curl -sT $podspecfile -u $user:$BINTRAY_API_KEY $baseurl/$pkgname-$version.podspec?publish=1 >/dev/null +echo "pod 'oonimkall', :podspec => 'https://dl.bintray.com/ooni/ios/$pkgname-$version.podspec'" diff --git a/internal/engine/resources/README.md b/internal/engine/resources/README.md new file mode 100644 index 0000000..f1e9428 --- /dev/null +++ b/internal/engine/resources/README.md @@ -0,0 +1,3 @@ +# Package github.com/ooni/probe-engine/resources + +This package contains code to download OONI resources. diff --git a/internal/engine/resources/assets.go b/internal/engine/resources/assets.go new file mode 100644 index 0000000..0209750 --- /dev/null +++ b/internal/engine/resources/assets.go @@ -0,0 +1,42 @@ +package resources + +const ( + // Version contains the assets version. + Version = 20210129095811 + + // ASNDatabaseName is the ASN-DB file name + ASNDatabaseName = "asn.mmdb" + + // CountryDatabaseName is country-DB file name + CountryDatabaseName = "country.mmdb" + + // BaseURL is the asset's repository base URL + BaseURL = "https://github.com/" +) + +// ResourceInfo contains information on a resource. +type ResourceInfo struct { + // URLPath is the resource's URL path. + URLPath string + + // GzSHA256 is used to validate the downloaded file. + GzSHA256 string + + // SHA256 is used to check whether the assets file + // stored locally is still up-to-date. + SHA256 string +} + +// All contains info on all known assets. +var All = map[string]ResourceInfo{ + "asn.mmdb": { + URLPath: "/ooni/probe-assets/releases/download/20210129095811/asn.mmdb.gz", + GzSHA256: "ef1759bf8b77128723436c4ec5a3d7f2e695fb5a959e741ba39012ced325132c", + SHA256: "0afa5afc48ba913933f17b11213c3044499c8338cf63b8f9af2778faa5875474", + }, + "country.mmdb": { + URLPath: "/ooni/probe-assets/releases/download/20210129095811/country.mmdb.gz", + GzSHA256: "5d465224ab02242a8a79652161d2768e64dd91fc1ed840ca3d0746f4cd29a914", + SHA256: "b4aa1292d072d9b2631711e6d3ac69c1e89687b4d513d43a1c330a92b7345e4d", + }, +} diff --git a/internal/engine/resources/resources.go b/internal/engine/resources/resources.go new file mode 100644 index 0000000..d41ba8e --- /dev/null +++ b/internal/engine/resources/resources.go @@ -0,0 +1,104 @@ +// Package resources contains code to download resources. +package resources + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + + "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// Client is a client for fetching resources. +type Client struct { + // HTTPClient is the HTTP client to use. + HTTPClient *http.Client + + // Logger is the logger to use. + Logger model.Logger + + // OSMkdirAll allows testing os.MkdirAll failures. + OSMkdirAll func(path string, perm os.FileMode) error + + // UserAgent is the user agent to use. + UserAgent string + + // WorkDir is the directory where to save resources. + WorkDir string +} + +// Ensure ensures that resources are downloaded and current. +func (c *Client) Ensure(ctx context.Context) error { + mkdirall := c.OSMkdirAll + if mkdirall == nil { + mkdirall = os.MkdirAll + } + if err := mkdirall(c.WorkDir, 0700); err != nil { + return err + } + for name, resource := range All { + if err := c.EnsureForSingleResource( + ctx, name, resource, func(real, expected string) bool { + return real == expected + }, + gzip.NewReader, ioutil.ReadAll, + ); err != nil { + return err + } + } + return nil +} + +// EnsureForSingleResource ensures that a single resource +// is downloaded and is current. +func (c *Client) EnsureForSingleResource( + ctx context.Context, name string, resource ResourceInfo, + equal func(real, expected string) bool, + gzipNewReader func(r io.Reader) (*gzip.Reader, error), + ioutilReadAll func(r io.Reader) ([]byte, error), +) error { + fullpath := filepath.Join(c.WorkDir, name) + data, err := ioutil.ReadFile(fullpath) + if err == nil { + sha256sum := fmt.Sprintf("%x", sha256.Sum256(data)) + if equal(sha256sum, resource.SHA256) { + return nil + } + c.Logger.Debugf("resources: %s is outdated", fullpath) + } else { + c.Logger.Debugf("resources: can't read %s: %s", fullpath, err.Error()) + } + data, err = (httpx.Client{ + BaseURL: BaseURL, + HTTPClient: c.HTTPClient, + Logger: c.Logger, + UserAgent: c.UserAgent, + }).FetchResourceAndVerify(ctx, resource.URLPath, resource.GzSHA256) + if err != nil { + return err + } + c.Logger.Debugf("resources: uncompress %s", fullpath) + gzreader, err := gzipNewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer gzreader.Close() // we already have a sha256 for it + data, err = ioutilReadAll(gzreader) // small file + if err != nil { + return err + } + sha256sum := fmt.Sprintf("%x", sha256.Sum256(data)) + if equal(sha256sum, resource.SHA256) == false { + return fmt.Errorf("resources: %s sha256 mismatch", fullpath) + } + c.Logger.Debugf("resources: overwrite %s", fullpath) + return ioutil.WriteFile(fullpath, data, 0600) +} diff --git a/internal/engine/resources/resources_test.go b/internal/engine/resources/resources_test.go new file mode 100644 index 0000000..025df30 --- /dev/null +++ b/internal/engine/resources/resources_test.go @@ -0,0 +1,180 @@ +package resources_test + +import ( + "compress/gzip" + "context" + "errors" + "io" + "io/ioutil" + "net/http" + "os" + "strings" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/resources" +) + +func TestEnsureMkdirAllFailure(t *testing.T) { + log.SetLevel(log.DebugLevel) + expected := errors.New("mocked error") + client := resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + OSMkdirAll: func(string, os.FileMode) error { + return expected + }, + UserAgent: "ooniprobe-engine/0.1.0", + WorkDir: "/foobar", + } + err := client.Ensure(context.Background()) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestEnsure(t *testing.T) { + tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test") + if err != nil { + t.Fatal(err) + } + client := resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + WorkDir: tempdir, + } + err = client.Ensure(context.Background()) + if err != nil { + t.Fatal(err) + } + // the second round should be idempotent + err = client.Ensure(context.Background()) + if err != nil { + t.Fatal(err) + } +} + +func TestEnsureFailure(t *testing.T) { + log.SetLevel(log.DebugLevel) + tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test") + if err != nil { + t.Fatal(err) + } + client := resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + WorkDir: tempdir, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = client.Ensure(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } +} + +func TestEnsureFailAllComparisons(t *testing.T) { + log.SetLevel(log.DebugLevel) + tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test") + if err != nil { + t.Fatal(err) + } + client := resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + WorkDir: tempdir, + } + // run once to download the resource once + err = client.EnsureForSingleResource( + context.Background(), "ca-bundle.pem", resources.ResourceInfo{ + URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz", + GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab", + SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a", + }, func(left, right string) bool { + return left == right + }, + gzip.NewReader, ioutil.ReadAll, + ) + if err != nil { + t.Fatal(err) + } + // re-run with broken comparison operator so that we should + // first redownload and then fail for invalid SHA256. + err = client.EnsureForSingleResource( + context.Background(), "ca-bundle.pem", resources.ResourceInfo{ + URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz", + GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab", + SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a", + }, func(left, right string) bool { + return false // comparison for equality always fails + }, + gzip.NewReader, ioutil.ReadAll, + ) + if err == nil || !strings.HasSuffix(err.Error(), "sha256 mismatch") { + t.Fatal("not the error we expected") + } +} + +func TestEnsureFailGzipNewReader(t *testing.T) { + log.SetLevel(log.DebugLevel) + tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test") + if err != nil { + t.Fatal(err) + } + client := resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + WorkDir: tempdir, + } + expected := errors.New("mocked error") + err = client.EnsureForSingleResource( + context.Background(), "ca-bundle.pem", resources.ResourceInfo{ + URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz", + GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab", + SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a", + }, func(left, right string) bool { + return left == right + }, + func(r io.Reader) (*gzip.Reader, error) { + return nil, expected + }, + ioutil.ReadAll, + ) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} + +func TestEnsureFailIoUtilReadAll(t *testing.T) { + log.SetLevel(log.DebugLevel) + tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test") + if err != nil { + t.Fatal(err) + } + client := resources.Client{ + HTTPClient: http.DefaultClient, + Logger: log.Log, + UserAgent: "ooniprobe-engine/0.1.0", + WorkDir: tempdir, + } + expected := errors.New("mocked error") + err = client.EnsureForSingleResource( + context.Background(), "ca-bundle.pem", resources.ResourceInfo{ + URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz", + GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab", + SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a", + }, func(left, right string) bool { + return left == right + }, + gzip.NewReader, func(r io.Reader) ([]byte, error) { + return nil, expected + }, + ) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } +} diff --git a/internal/engine/saver.go b/internal/engine/saver.go new file mode 100644 index 0000000..b939482 --- /dev/null +++ b/internal/engine/saver.go @@ -0,0 +1,69 @@ +package engine + +import ( + "errors" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// Saver saves a measurement on some persistent storage. +type Saver interface { + SaveMeasurement(m *model.Measurement) error +} + +// SaverConfig is the configuration for creating a new Saver. +type SaverConfig struct { + // Enabled is true if saving is enabled. + Enabled bool + + // Experiment is the experiment we're currently running. + Experiment SaverExperiment + + // FilePath is the filepath where to append the measurement as a + // serialized JSON followed by a newline character. + FilePath string + + // Logger is the logger used by the saver. + Logger model.Logger +} + +// SaverExperiment is an experiment according to the Saver. +type SaverExperiment interface { + SaveMeasurement(m *model.Measurement, filepath string) error +} + +// NewSaver creates a new instance of Saver. +func NewSaver(config SaverConfig) (Saver, error) { + if !config.Enabled { + return fakeSaver{}, nil + } + if config.FilePath == "" { + return nil, errors.New("saver: passed an empty filepath") + } + return realSaver{ + Experiment: config.Experiment, + FilePath: config.FilePath, + Logger: config.Logger, + }, nil +} + +type fakeSaver struct{} + +func (fs fakeSaver) SaveMeasurement(m *model.Measurement) error { + return nil +} + +var _ Saver = fakeSaver{} + +type realSaver struct { + Experiment SaverExperiment + FilePath string + Logger model.Logger +} + +func (rs realSaver) SaveMeasurement(m *model.Measurement) error { + rs.Logger.Info("saving measurement to disk") + return rs.Experiment.SaveMeasurement(m, rs.FilePath) +} + +var _ Saver = realSaver{} diff --git a/internal/engine/saver_test.go b/internal/engine/saver_test.go new file mode 100644 index 0000000..95f9307 --- /dev/null +++ b/internal/engine/saver_test.go @@ -0,0 +1,80 @@ +package engine + +import ( + "errors" + "testing" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestNewSaverDisabled(t *testing.T) { + saver, err := NewSaver(SaverConfig{ + Enabled: false, + }) + if err != nil { + t.Fatal(err) + } + if _, ok := saver.(fakeSaver); !ok { + t.Fatal("not the type of Saver we expected") + } + m := new(model.Measurement) + if err := saver.SaveMeasurement(m); err != nil { + t.Fatal(err) + } +} + +func TestNewSaverWithEmptyFilePath(t *testing.T) { + saver, err := NewSaver(SaverConfig{ + Enabled: true, + FilePath: "", + }) + if err == nil || err.Error() != "saver: passed an empty filepath" { + t.Fatalf("not the error we expected: %+v", err) + } + if saver != nil { + t.Fatal("saver should be nil here") + } +} + +type FakeSaverExperiment struct { + M *model.Measurement + Error error + FilePath string +} + +func (fse *FakeSaverExperiment) SaveMeasurement(m *model.Measurement, filepath string) error { + fse.M = m + fse.FilePath = filepath + return fse.Error +} + +var _ SaverExperiment = &FakeSaverExperiment{} + +func TestNewSaverWithFailureWhenSaving(t *testing.T) { + expected := errors.New("mocked error") + fse := &FakeSaverExperiment{Error: expected} + saver, err := NewSaver(SaverConfig{ + Enabled: true, + FilePath: "report.jsonl", + Experiment: fse, + Logger: log.Log, + }) + if err != nil { + t.Fatal(err) + } + if _, ok := saver.(realSaver); !ok { + t.Fatal("not the type of saver we expected") + } + m := &model.Measurement{Input: "www.kernel.org"} + if err := saver.SaveMeasurement(m); !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if diff := cmp.Diff(fse.M, m); diff != "" { + t.Fatal(diff) + } + if fse.FilePath != "report.jsonl" { + t.Fatal("passed invalid filepath") + } +} diff --git a/internal/engine/session.go b/internal/engine/session.go new file mode 100644 index 0000000..1fda2d2 --- /dev/null +++ b/internal/engine/session.go @@ -0,0 +1,514 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "sync" + + "github.com/ooni/probe-cli/v3/internal/engine/atomicx" + "github.com/ooni/probe-cli/v3/internal/engine/geolocate" + "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore" + "github.com/ooni/probe-cli/v3/internal/engine/internal/platform" + "github.com/ooni/probe-cli/v3/internal/engine/internal/sessionresolver" + "github.com/ooni/probe-cli/v3/internal/engine/internal/tunnel" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/resources" + "github.com/ooni/probe-cli/v3/internal/engine/version" +) + +// SessionConfig contains the Session config +type SessionConfig struct { + AssetsDir string + AvailableProbeServices []model.Service + KVStore KVStore + Logger model.Logger + ProxyURL *url.URL + SoftwareName string + SoftwareVersion string + TempDir string + TorArgs []string + TorBinary string +} + +// Session is a measurement session +type Session struct { + assetsDir string + availableProbeServices []model.Service + availableTestHelpers map[string][]model.Service + byteCounter *bytecounter.Counter + httpDefaultTransport netx.HTTPRoundTripper + kvStore model.KeyValueStore + location *geolocate.Results + logger model.Logger + proxyURL *url.URL + queryProbeServicesCount *atomicx.Int64 + resolver *sessionresolver.Resolver + selectedProbeServiceHook func(*model.Service) + selectedProbeService *model.Service + softwareName string + softwareVersion string + tempDir string + torArgs []string + torBinary string + tunnelMu sync.Mutex + tunnelName string + tunnel tunnel.Tunnel +} + +// NewSession creates a new session or returns an error +func NewSession(config SessionConfig) (*Session, error) { + if config.AssetsDir == "" { + return nil, errors.New("AssetsDir is empty") + } + if config.Logger == nil { + return nil, errors.New("Logger is empty") + } + if config.SoftwareName == "" { + return nil, errors.New("SoftwareName is empty") + } + if config.SoftwareVersion == "" { + return nil, errors.New("SoftwareVersion is empty") + } + if config.KVStore == nil { + config.KVStore = kvstore.NewMemoryKeyValueStore() + } + // Implementation note: if config.TempDir is empty, then Go will + // use the temporary directory on the current system. This should + // work on Desktop. We tested that it did also work on iOS, but + // we have also seen on 2020-06-10 that it does not work on Android. + tempDir, err := ioutil.TempDir(config.TempDir, "ooniengine") + if err != nil { + return nil, err + } + sess := &Session{ + assetsDir: config.AssetsDir, + availableProbeServices: config.AvailableProbeServices, + byteCounter: bytecounter.New(), + kvStore: config.KVStore, + logger: config.Logger, + proxyURL: config.ProxyURL, + queryProbeServicesCount: atomicx.NewInt64(), + softwareName: config.SoftwareName, + softwareVersion: config.SoftwareVersion, + tempDir: tempDir, + torArgs: config.TorArgs, + torBinary: config.TorBinary, + } + httpConfig := netx.Config{ + ByteCounter: sess.byteCounter, + BogonIsError: true, + Logger: sess.logger, + } + sess.resolver = sessionresolver.New(httpConfig) + httpConfig.FullResolver = sess.resolver + httpConfig.ProxyURL = config.ProxyURL // no need to proxy the resolver + sess.httpDefaultTransport = netx.NewHTTPTransport(httpConfig) + return sess, nil +} + +// ASNDatabasePath returns the path where the ASN database path should +// be if you have called s.FetchResourcesIdempotent. +func (s *Session) ASNDatabasePath() string { + return filepath.Join(s.assetsDir, resources.ASNDatabaseName) +} + +// KibiBytesReceived accounts for the KibiBytes received by the HTTP clients +// managed by this session so far, including experiments. +func (s *Session) KibiBytesReceived() float64 { + return s.byteCounter.KibiBytesReceived() +} + +// KibiBytesSent is like KibiBytesReceived but for the bytes sent. +func (s *Session) KibiBytesSent() float64 { + return s.byteCounter.KibiBytesSent() +} + +// Close ensures that we close all the idle connections that the HTTP clients +// we are currently using may have created. It will also remove the temp dir +// that contains data from this session. Not calling this function may likely +// cause memory leaks in your application because of open idle connections, +// as well as excessive usage of disk space. +func (s *Session) Close() error { + s.httpDefaultTransport.CloseIdleConnections() + s.resolver.CloseIdleConnections() + s.logger.Infof("%s", s.resolver.Stats()) + if s.tunnel != nil { + s.tunnel.Stop() + } + return os.RemoveAll(s.tempDir) +} + +// CountryDatabasePath is like ASNDatabasePath but for the country DB path. +func (s *Session) CountryDatabasePath() string { + return filepath.Join(s.assetsDir, resources.CountryDatabaseName) +} + +// GetTestHelpersByName returns the available test helpers that +// use the specified name, or false if there's none. +func (s *Session) GetTestHelpersByName(name string) ([]model.Service, bool) { + services, ok := s.availableTestHelpers[name] + return services, ok +} + +// DefaultHTTPClient returns the session's default HTTP client. +func (s *Session) DefaultHTTPClient() *http.Client { + return &http.Client{Transport: s.httpDefaultTransport} +} + +// KeyValueStore returns the configured key-value store. +func (s *Session) KeyValueStore() model.KeyValueStore { + return s.kvStore +} + +// Logger returns the logger used by the session. +func (s *Session) Logger() model.Logger { + return s.logger +} + +// MaybeLookupLocation is a caching location lookup call. +func (s *Session) MaybeLookupLocation() error { + return s.MaybeLookupLocationContext(context.Background()) +} + +// MaybeLookupBackends is a caching OONI backends lookup call. +func (s *Session) MaybeLookupBackends() error { + return s.maybeLookupBackends(context.Background()) +} + +// MaybeLookupBackendsContext is like MaybeLookupBackends but with context. +func (s *Session) MaybeLookupBackendsContext(ctx context.Context) (err error) { + return s.maybeLookupBackends(ctx) +} + +// ErrAlreadyUsingProxy indicates that we cannot create a tunnel with +// a specific name because we already configured a proxy. +var ErrAlreadyUsingProxy = errors.New( + "session: cannot create a new tunnel of this kind: we are already using a proxy", +) + +// MaybeStartTunnel starts the requested tunnel. +// +// This function silently succeeds if we're already using a tunnel with +// the same name or if the requested tunnel name is the empty string. This +// function fails, tho, when we already have a proxy or a tunnel with +// another name and we try to open a tunnel. This function of course also +// fails if we cannot start the requested tunnel. All in all, if you request +// for a tunnel name that is not the empty string and you get a nil error, +// you can be confident that session.ProxyURL() gives you the tunnel URL. +// +// The tunnel will be closed by session.Close(). +func (s *Session) MaybeStartTunnel(ctx context.Context, name string) error { + s.tunnelMu.Lock() + defer s.tunnelMu.Unlock() + if s.tunnel != nil && s.tunnelName == name { + // We've been asked more than once to start the same tunnel. + return nil + } + if s.proxyURL != nil && name == "" { + // The user configured a proxy and here we're not actually trying + // to start any tunnel since `name` is empty. + return nil + } + if s.proxyURL != nil || s.tunnel != nil { + // We already have a proxy or we have a different tunnel. Because a tunnel + // sets a proxy, the second check for s.tunnel is for robustness. + return ErrAlreadyUsingProxy + } + tunnel, err := tunnel.Start(ctx, tunnel.Config{ + Name: name, + Session: s, + }) + if err != nil { + s.logger.Warnf("cannot start tunnel: %+v", err) + return err + } + // Implementation note: tunnel _may_ be NIL here if name is "" + if tunnel == nil { + return nil + } + s.tunnelName = name + s.tunnel = tunnel + s.proxyURL = tunnel.SOCKS5ProxyURL() + return nil +} + +// NewExperimentBuilder returns a new experiment builder +// for the experiment with the given name, or an error if +// there's no such experiment with the given name +func (s *Session) NewExperimentBuilder(name string) (*ExperimentBuilder, error) { + return newExperimentBuilder(s, name) +} + +// NewProbeServicesClient creates a new client for talking with the +// OONI probe services. This function will benchmark the available +// probe services, and select the fastest. In case all probe services +// seem to be down, we try again applying circumvention tactics. +func (s *Session) NewProbeServicesClient(ctx context.Context) (*probeservices.Client, error) { + if err := s.maybeLookupBackends(ctx); err != nil { + return nil, err + } + if err := s.MaybeLookupLocationContext(ctx); err != nil { + return nil, err + } + if s.selectedProbeServiceHook != nil { + s.selectedProbeServiceHook(s.selectedProbeService) + } + return probeservices.NewClient(s, *s.selectedProbeService) +} + +// NewSubmitter creates a new submitter instance. +func (s *Session) NewSubmitter(ctx context.Context) (Submitter, error) { + psc, err := s.NewProbeServicesClient(ctx) + if err != nil { + return nil, err + } + return probeservices.NewSubmitter(psc, s.Logger()), nil +} + +// NewOrchestraClient creates a new orchestra client. This client is registered +// and logged in with the OONI orchestra. An error is returned on failure. +func (s *Session) NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error) { + clnt, err := s.NewProbeServicesClient(ctx) + if err != nil { + return nil, err + } + return s.initOrchestraClient(ctx, clnt, clnt.MaybeLogin) +} + +// Platform returns the current platform. The platform is one of: +// +// - android +// - ios +// - linux +// - macos +// - windows +// - unknown +// +// When running on the iOS simulator, the returned platform is +// macos rather than ios if CGO is disabled. This is a known issue, +// that however should have a very limited impact. +func (s *Session) Platform() string { + return platform.Name() +} + +// ProbeASNString returns the probe ASN as a string. +func (s *Session) ProbeASNString() string { + return fmt.Sprintf("AS%d", s.ProbeASN()) +} + +// ProbeASN returns the probe ASN as an integer. +func (s *Session) ProbeASN() uint { + asn := geolocate.DefaultProbeASN + if s.location != nil { + asn = s.location.ASN + } + return asn +} + +// ProbeCC returns the probe CC. +func (s *Session) ProbeCC() string { + cc := geolocate.DefaultProbeCC + if s.location != nil { + cc = s.location.CountryCode + } + return cc +} + +// ProbeNetworkName returns the probe network name. +func (s *Session) ProbeNetworkName() string { + nn := geolocate.DefaultProbeNetworkName + if s.location != nil { + nn = s.location.NetworkName + } + return nn +} + +// ProbeIP returns the probe IP. +func (s *Session) ProbeIP() string { + ip := geolocate.DefaultProbeIP + if s.location != nil { + ip = s.location.ProbeIP + } + return ip +} + +// ProxyURL returns the Proxy URL, or nil if not set +func (s *Session) ProxyURL() *url.URL { + return s.proxyURL +} + +// ResolverASNString returns the resolver ASN as a string +func (s *Session) ResolverASNString() string { + return fmt.Sprintf("AS%d", s.ResolverASN()) +} + +// ResolverASN returns the resolver ASN +func (s *Session) ResolverASN() uint { + asn := geolocate.DefaultResolverASN + if s.location != nil { + asn = s.location.ResolverASN + } + return asn +} + +// ResolverIP returns the resolver IP +func (s *Session) ResolverIP() string { + ip := geolocate.DefaultResolverIP + if s.location != nil { + ip = s.location.ResolverIP + } + return ip +} + +// ResolverNetworkName returns the resolver network name. +func (s *Session) ResolverNetworkName() string { + nn := geolocate.DefaultResolverNetworkName + if s.location != nil { + nn = s.location.ResolverNetworkName + } + return nn +} + +// SoftwareName returns the application name. +func (s *Session) SoftwareName() string { + return s.softwareName +} + +// SoftwareVersion returns the application version. +func (s *Session) SoftwareVersion() string { + return s.softwareVersion +} + +// TempDir returns the temporary directory. +func (s *Session) TempDir() string { + return s.tempDir +} + +// TorArgs returns the configured extra args for the tor binary. If not set +// we will not pass in any extra arg. Applies to `-OTunnel=tor` mainly. +func (s *Session) TorArgs() []string { + return s.torArgs +} + +// TorBinary returns the configured path to the tor binary. If not set +// we will attempt to use "tor". Applies to `-OTunnel=tor` mainly. +func (s *Session) TorBinary() string { + return s.torBinary +} + +// UserAgent constructs the user agent to be used in this session. +func (s *Session) UserAgent() (useragent string) { + useragent += s.softwareName + "/" + s.softwareVersion + useragent += " ooniprobe-engine/" + version.Version + return +} + +// MaybeUpdateResources updates the resources if needed. +func (s *Session) MaybeUpdateResources(ctx context.Context) error { + return (&resources.Client{ + HTTPClient: s.DefaultHTTPClient(), + Logger: s.logger, + UserAgent: s.UserAgent(), + WorkDir: s.assetsDir, + }).Ensure(ctx) +} + +func (s *Session) getAvailableProbeServices() []model.Service { + if len(s.availableProbeServices) > 0 { + return s.availableProbeServices + } + return probeservices.Default() +} + +func (s *Session) initOrchestraClient( + ctx context.Context, clnt *probeservices.Client, + maybeLogin func(ctx context.Context) error, +) (*probeservices.Client, error) { + // The original implementation has as its only use case that we + // were registering and logging in for sending an update regarding + // the probe whereabouts. Yet here in probe-engine, the orchestra + // is currently only used to fetch inputs. For this purpose, we don't + // need to communicate any specific information. The code that will + // perform an update used to be responsible of doing that. Now, we + // are not using orchestra for this purpose anymore. + meta := probeservices.Metadata{ + Platform: "miniooni", + ProbeASN: "AS0", + ProbeCC: "ZZ", + SoftwareName: "miniooni", + SoftwareVersion: "0.1.0-dev", + SupportedTests: []string{"web_connectivity"}, + } + if err := clnt.MaybeRegister(ctx, meta); err != nil { + return nil, err + } + if err := maybeLogin(ctx); err != nil { + return nil, err + } + return clnt, nil +} + +// LookupASN maps an IP address to its ASN and network name. This method implements +// LocationLookupASNLookupper.LookupASN. +func (s *Session) LookupASN(dbPath, ip string) (uint, string, error) { + return geolocate.LookupASN(dbPath, ip) +} + +// ErrAllProbeServicesFailed indicates all probe services failed. +var ErrAllProbeServicesFailed = errors.New("all available probe services failed") + +func (s *Session) maybeLookupBackends(ctx context.Context) error { + // TODO(bassosimone): do we need a mutex here? + if s.selectedProbeService != nil { + return nil + } + s.queryProbeServicesCount.Add(1) + candidates := probeservices.TryAll(ctx, s, s.getAvailableProbeServices()) + selected := probeservices.SelectBest(candidates) + if selected == nil { + return ErrAllProbeServicesFailed + } + s.logger.Infof("session: using probe services: %+v", selected.Endpoint) + s.selectedProbeService = &selected.Endpoint + s.availableTestHelpers = selected.TestHelpers + return nil +} + +// LookupLocationContext performs a location lookup. If you want memoisation +// of the results, you should use MaybeLookupLocationContext. +func (s *Session) LookupLocationContext(ctx context.Context) (*geolocate.Results, error) { + // Implementation note: we don't perform the lookup of the resolver IP + // when we are using a proxy because that might leak information. + task := geolocate.Must(geolocate.NewTask(geolocate.Config{ + EnableResolverLookup: s.proxyURL == nil, + HTTPClient: s.DefaultHTTPClient(), + Logger: s.Logger(), + ResourcesManager: s, + UserAgent: s.UserAgent(), + })) + return task.Run(ctx) +} + +// MaybeLookupLocationContext is like MaybeLookupLocation but with a context +// that can be used to interrupt this long running operation. +func (s *Session) MaybeLookupLocationContext(ctx context.Context) error { + if s.location == nil { + location, err := s.LookupLocationContext(ctx) + if err != nil { + return err + } + s.location = location + } + return nil +} + +var _ model.ExperimentSession = &Session{} diff --git a/internal/engine/session_integration_test.go b/internal/engine/session_integration_test.go new file mode 100644 index 0000000..18f240d --- /dev/null +++ b/internal/engine/session_integration_test.go @@ -0,0 +1,659 @@ +package engine + +import ( + "context" + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "os" + "syscall" + "testing" + "time" + + "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/engine/geolocate" + "github.com/ooni/probe-cli/v3/internal/engine/model" + "github.com/ooni/probe-cli/v3/internal/engine/netx" + "github.com/ooni/probe-cli/v3/internal/engine/probeservices" + "github.com/ooni/probe-cli/v3/internal/engine/version" +) + +func TestNewSessionBuilderChecks(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + t.Run("with no settings", func(t *testing.T) { + newSessionMustFail(t, SessionConfig{}) + }) + t.Run("with only assets dir", func(t *testing.T) { + newSessionMustFail(t, SessionConfig{ + AssetsDir: "testdata", + }) + }) + t.Run("with also logger", func(t *testing.T) { + newSessionMustFail(t, SessionConfig{ + AssetsDir: "testdata", + Logger: model.DiscardLogger, + }) + }) + t.Run("with also software name", func(t *testing.T) { + newSessionMustFail(t, SessionConfig{ + AssetsDir: "testdata", + Logger: model.DiscardLogger, + SoftwareName: "ooniprobe-engine", + }) + }) + t.Run("with software version and wrong tempdir", func(t *testing.T) { + newSessionMustFail(t, SessionConfig{ + AssetsDir: "testdata", + Logger: model.DiscardLogger, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + TempDir: "./nonexistent", + }) + }) +} + +func TestNewSessionBuilderGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + newSessionForTesting(t) +} + +func newSessionMustFail(t *testing.T, config SessionConfig) { + sess, err := NewSession(config) + if err == nil { + t.Fatal("expected an error here") + } + if sess != nil { + t.Fatal("expected nil session here") + } +} + +func TestSessionTorArgsTorBinary(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession(SessionConfig{ + AssetsDir: "testdata", + AvailableProbeServices: []model.Service{{ + Address: "https://ams-pg-test.ooni.org", + Type: "https", + }}, + Logger: model.DiscardLogger, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + TorArgs: []string{"antani1", "antani2", "antani3"}, + TorBinary: "mascetti", + }) + if err != nil { + t.Fatal(err) + } + if sess.TorBinary() != "mascetti" { + t.Fatal("not the TorBinary we expected") + } + if len(sess.TorArgs()) != 3 { + t.Fatal("not the TorArgs length we expected") + } + if sess.TorArgs()[0] != "antani1" { + t.Fatal("not the TorArgs[0] we expected") + } + if sess.TorArgs()[1] != "antani2" { + t.Fatal("not the TorArgs[1] we expected") + } + if sess.TorArgs()[2] != "antani3" { + t.Fatal("not the TorArgs[2] we expected") + } +} + +func newSessionForTestingNoLookupsWithProxyURL(t *testing.T, URL *url.URL) *Session { + sess, err := NewSession(SessionConfig{ + AssetsDir: "testdata", + AvailableProbeServices: []model.Service{{ + Address: "https://ams-pg-test.ooni.org", + Type: "https", + }}, + Logger: model.DiscardLogger, + ProxyURL: URL, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + return sess +} + +func newSessionForTestingNoLookups(t *testing.T) *Session { + return newSessionForTestingNoLookupsWithProxyURL(t, nil) +} + +func newSessionForTestingNoBackendsLookup(t *testing.T) *Session { + sess := newSessionForTestingNoLookups(t) + if err := sess.MaybeLookupLocation(); err != nil { + t.Fatal(err) + } + log.Infof("Platform: %s", sess.Platform()) + log.Infof("ProbeASN: %d", sess.ProbeASN()) + log.Infof("ProbeASNString: %s", sess.ProbeASNString()) + log.Infof("ProbeCC: %s", sess.ProbeCC()) + log.Infof("ProbeIP: %s", sess.ProbeIP()) + log.Infof("ProbeNetworkName: %s", sess.ProbeNetworkName()) + log.Infof("ResolverASN: %d", sess.ResolverASN()) + log.Infof("ResolverASNString: %s", sess.ResolverASNString()) + log.Infof("ResolverIP: %s", sess.ResolverIP()) + log.Infof("ResolverNetworkName: %s", sess.ResolverNetworkName()) + return sess +} + +func newSessionForTesting(t *testing.T) *Session { + sess := newSessionForTestingNoBackendsLookup(t) + if err := sess.MaybeLookupBackends(); err != nil { + t.Fatal(err) + } + return sess +} + +func TestNewOrchestraClient(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + clnt, err := sess.NewOrchestraClient(context.Background()) + if err != nil { + t.Fatal(err) + } + if clnt == nil { + t.Fatal("expected non nil client here") + } +} + +func TestInitOrchestraClientMaybeRegisterError(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() // so we fail immediately + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + clnt, err := probeservices.NewClient(sess, model.Service{ + Address: "https://ams-pg-test.ooni.org/", + Type: "https", + }) + if err != nil { + t.Fatal(err) + } + outclnt, err := sess.initOrchestraClient( + ctx, clnt, clnt.MaybeLogin, + ) + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } + if outclnt != nil { + t.Fatal("expected a nil client here") + } +} + +func TestInitOrchestraClientMaybeLoginError(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + ctx := context.Background() + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + clnt, err := probeservices.NewClient(sess, model.Service{ + Address: "https://ams-pg-test.ooni.org/", + Type: "https", + }) + if err != nil { + t.Fatal(err) + } + expected := errors.New("mocked error") + outclnt, err := sess.initOrchestraClient( + ctx, clnt, func(context.Context) error { + return expected + }, + ) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected") + } + if outclnt != nil { + t.Fatal("expected a nil client here") + } +} + +func TestBouncerError(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + // Combine proxy testing with a broken proxy with errors + // in reaching out to the bouncer. + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + }, + )) + defer server.Close() + URL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + sess := newSessionForTestingNoLookupsWithProxyURL(t, URL) + defer sess.Close() + if sess.ProxyURL() == nil { + t.Fatal("expected to see explicit proxy here") + } + if err := sess.MaybeLookupBackends(); err == nil { + t.Fatal("expected an error here") + } +} + +func TestMaybeLookupBackendsNewClientError(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + sess.availableProbeServices = []model.Service{{ + Type: "onion", + Address: "httpo://jehhrikjjqrlpufu.onion", + }} + defer sess.Close() + err := sess.MaybeLookupBackends() + if !errors.Is(err, ErrAllProbeServicesFailed) { + t.Fatal("not the error we expected") + } +} + +func TestSessionLocationLookup(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + if err := sess.MaybeLookupLocation(); err != nil { + t.Fatal(err) + } + if sess.ProbeASNString() == geolocate.DefaultProbeASNString { + t.Fatal("unexpected ProbeASNString") + } + if sess.ProbeASN() == geolocate.DefaultProbeASN { + t.Fatal("unexpected ProbeASN") + } + if sess.ProbeCC() == geolocate.DefaultProbeCC { + t.Fatal("unexpected ProbeCC") + } + if sess.ProbeIP() == geolocate.DefaultProbeIP { + t.Fatal("unexpected ProbeIP") + } + if sess.ProbeNetworkName() == geolocate.DefaultProbeNetworkName { + t.Fatal("unexpected ProbeNetworkName") + } + if sess.ResolverASN() == geolocate.DefaultResolverASN { + t.Fatal("unexpected ResolverASN") + } + if sess.ResolverASNString() == geolocate.DefaultResolverASNString { + t.Fatal("unexpected ResolverASNString") + } + if sess.ResolverIP() == geolocate.DefaultResolverIP { + t.Fatal("unexpected ResolverIP") + } + if sess.ResolverNetworkName() == geolocate.DefaultResolverNetworkName { + t.Fatal("unexpected ResolverNetworkName") + } + if sess.KibiBytesSent() <= 0 { + t.Fatal("unexpected KibiBytesSent") + } + if sess.KibiBytesReceived() <= 0 { + t.Fatal("unexpected KibiBytesReceived") + } +} + +func TestSessionCloseCancelsTempDir(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + tempDir := sess.TempDir() + if _, err := os.Stat(tempDir); err != nil { + t.Fatal(err) + } + if err := sess.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(tempDir); !errors.Is(err, syscall.ENOENT) { + t.Fatal("not the error we expected") + } +} + +func TestSessionDownloadResources(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + tmpdir, err := ioutil.TempDir("", "test-download-resources-idempotent") + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + sess.SetAssetsDir(tmpdir) + err = sess.MaybeUpdateResources(ctx) + if err != nil { + t.Fatal(err) + } + readfile := func(path string) (err error) { + _, err = ioutil.ReadFile(path) + return + } + if err := readfile(sess.ASNDatabasePath()); err != nil { + t.Fatal(err) + } + if err := readfile(sess.CountryDatabasePath()); err != nil { + t.Fatal(err) + } +} + +func TestGetAvailableProbeServices(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession(SessionConfig{ + AssetsDir: "testdata", + Logger: model.DiscardLogger, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + defer sess.Close() + all := sess.GetAvailableProbeServices() + diff := cmp.Diff(all, probeservices.Default()) + if diff != "" { + t.Fatal(diff) + } +} + +func TestMaybeLookupBackendsFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession(SessionConfig{ + AssetsDir: "testdata", + Logger: model.DiscardLogger, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + defer sess.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // so we fail immediately + err = sess.MaybeLookupBackendsContext(ctx) + if !errors.Is(err, ErrAllProbeServicesFailed) { + t.Fatal("unexpected error") + } +} + +func TestMaybeLookupTestHelpersIdempotent(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession(SessionConfig{ + AssetsDir: "testdata", + Logger: model.DiscardLogger, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + defer sess.Close() + ctx := context.Background() + if err = sess.MaybeLookupBackendsContext(ctx); err != nil { + t.Fatal(err) + } + if err = sess.MaybeLookupBackendsContext(ctx); err != nil { + t.Fatal(err) + } + if sess.QueryProbeServicesCount() != 1 { + t.Fatal("unexpected number of queries sent to the bouncer") + } +} + +func TestAllProbeServicesUnsupported(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess, err := NewSession(SessionConfig{ + AssetsDir: "testdata", + Logger: model.DiscardLogger, + SoftwareName: "ooniprobe-engine", + SoftwareVersion: "0.0.1", + }) + if err != nil { + t.Fatal(err) + } + defer sess.Close() + sess.AppendAvailableProbeService(model.Service{ + Address: "mascetti", + Type: "antani", + }) + err = sess.MaybeLookupBackends() + if !errors.Is(err, ErrAllProbeServicesFailed) { + t.Fatal("unexpected error") + } +} + +func TestStartTunnelGood(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + ctx := context.Background() + if err := sess.MaybeStartTunnel(ctx, "psiphon"); err != nil { + t.Fatal(err) + } + if err := sess.MaybeStartTunnel(ctx, "psiphon"); err != nil { + t.Fatal(err) // check twice, must be idempotent + } + if sess.ProxyURL() == nil { + t.Fatal("expected non-nil ProxyURL") + } +} + +func TestStartTunnelNonexistent(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + ctx := context.Background() + if err := sess.MaybeStartTunnel(ctx, "antani"); err.Error() != "unsupported tunnel" { + t.Fatal("not the error we expected") + } + if sess.ProxyURL() != nil { + t.Fatal("expected nil ProxyURL") + } +} + +func TestStartTunnelEmptyString(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + ctx := context.Background() + if sess.MaybeStartTunnel(ctx, "") != nil { + t.Fatal("expected no error here") + } + if sess.ProxyURL() != nil { + t.Fatal("expected nil ProxyURL") + } +} + +func TestStartTunnelEmptyStringWithProxy(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + proxyURL := &url.URL{Scheme: "socks5", Host: "127.0.0.1:9050"} + sess := newSessionForTestingNoLookups(t) + sess.proxyURL = proxyURL + defer sess.Close() + ctx := context.Background() + if sess.MaybeStartTunnel(ctx, "") != nil { + t.Fatal("expected no error here") + } + diff := cmp.Diff(proxyURL, sess.ProxyURL()) + if diff != "" { + t.Fatal(diff) + } +} + +func TestStartTunnelWithAlreadyExistingTunnel(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + ctx := context.Background() + if sess.MaybeStartTunnel(ctx, "psiphon") != nil { + t.Fatal("expected no error here") + } + prev := sess.ProxyURL() + err := sess.MaybeStartTunnel(ctx, "tor") + if !errors.Is(err, ErrAlreadyUsingProxy) { + t.Fatal("expected another error here") + } + cur := sess.ProxyURL() + diff := cmp.Diff(prev, cur) + if diff != "" { + t.Fatal(diff) + } +} + +func TestStartTunnelWithAlreadyExistingProxy(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + ctx := context.Background() + orig := &url.URL{Scheme: "socks5", Host: "[::1]:9050"} + sess.proxyURL = orig + err := sess.MaybeStartTunnel(ctx, "psiphon") + if !errors.Is(err, ErrAlreadyUsingProxy) { + t.Fatal("expected another error here") + } + cur := sess.ProxyURL() + diff := cmp.Diff(orig, cur) + if diff != "" { + t.Fatal(diff) + } +} + +func TestStartTunnelCanceledContext(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + defer sess.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel + err := sess.MaybeStartTunnel(ctx, "psiphon") + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected") + } +} + +func TestUserAgentNoProxy(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + expect := "ooniprobe-engine/0.0.1 ooniprobe-engine/" + version.Version + sess := newSessionForTestingNoLookups(t) + ua := sess.UserAgent() + diff := cmp.Diff(expect, ua) + if diff != "" { + t.Fatal(diff) + } +} + +func TestNewOrchestraClientMaybeLookupBackendsFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // fail immediately + client, err := sess.NewOrchestraClient(ctx) + if !errors.Is(err, ErrAllProbeServicesFailed) { + t.Fatal("not the error we expected") + } + if client != nil { + t.Fatal("expected nil client here") + } +} + +type httpTransportThatSleeps struct { + txp netx.HTTPRoundTripper + st time.Duration +} + +func (txp httpTransportThatSleeps) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := txp.txp.RoundTrip(req) + time.Sleep(txp.st) + return resp, err +} + +func (txp httpTransportThatSleeps) CloseIdleConnections() { + txp.txp.CloseIdleConnections() +} + +func TestNewOrchestraClientMaybeLookupLocationFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + sess.httpDefaultTransport = httpTransportThatSleeps{ + txp: sess.httpDefaultTransport, + st: 5 * time.Second, + } + // The transport sleeps for five seconds, so the context should be expired by + // the time in which we attempt at looking up the location. Because the + // implementation performs the round-trip and _then_ sleeps, it means we'll + // see the context expired error when performing the location lookup. + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + client, err := sess.NewOrchestraClient(ctx) + if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) { + t.Fatalf("not the error we expected: %+v", err) + } + if client != nil { + t.Fatal("expected nil client here") + } +} + +func TestNewOrchestraClientProbeServicesNewClientFailure(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + sess := newSessionForTestingNoLookups(t) + sess.selectedProbeServiceHook = func(svc *model.Service) { + svc.Type = "antani" // should really not be supported for a long time + } + client, err := sess.NewOrchestraClient(context.Background()) + if !errors.Is(err, probeservices.ErrUnsupportedEndpoint) { + t.Fatal("not the error we expected") + } + if client != nil { + t.Fatal("expected nil client here") + } +} diff --git a/internal/engine/session_internal_test.go b/internal/engine/session_internal_test.go new file mode 100644 index 0000000..416b3b1 --- /dev/null +++ b/internal/engine/session_internal_test.go @@ -0,0 +1,21 @@ +package engine + +import ( + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func (s *Session) SetAssetsDir(assetsDir string) { + s.assetsDir = assetsDir +} + +func (s *Session) GetAvailableProbeServices() []model.Service { + return s.getAvailableProbeServices() +} + +func (s *Session) AppendAvailableProbeService(svc model.Service) { + s.availableProbeServices = append(s.availableProbeServices, svc) +} + +func (s *Session) QueryProbeServicesCount() int64 { + return s.queryProbeServicesCount.Load() +} diff --git a/internal/engine/submitter.go b/internal/engine/submitter.go new file mode 100644 index 0000000..f5ee834 --- /dev/null +++ b/internal/engine/submitter.go @@ -0,0 +1,67 @@ +package engine + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +// TODO(bassosimone): maybe keep track of which measurements +// could not be submitted by a specific submitter? + +// Submitter submits a measurement to the OONI collector. +type Submitter interface { + // Submit submits the measurement and updates its + // report ID field in case of success. + Submit(ctx context.Context, m *model.Measurement) error +} + +// SubmitterSession is the Submitter's view of the Session. +type SubmitterSession interface { + // NewSubmitter creates a new probeservices Submitter. + NewSubmitter(ctx context.Context) (Submitter, error) +} + +// SubmitterConfig contains settings for NewSubmitter. +type SubmitterConfig struct { + // Enabled is true if measurement submission is enabled. + Enabled bool + + // Session is the current session. + Session SubmitterSession + + // Logger is the logger to be used. + Logger model.Logger +} + +// NewSubmitter creates a new submitter instance. Depending on +// whether submission is enabled or not, the returned submitter +// instance migh just be a stub implementation. +func NewSubmitter(ctx context.Context, config SubmitterConfig) (Submitter, error) { + if !config.Enabled { + return stubSubmitter{}, nil + } + subm, err := config.Session.NewSubmitter(ctx) + if err != nil { + return nil, err + } + return realSubmitter{subm: subm, logger: config.Logger}, nil +} + +type stubSubmitter struct{} + +func (stubSubmitter) Submit(ctx context.Context, m *model.Measurement) error { + return nil +} + +var _ Submitter = stubSubmitter{} + +type realSubmitter struct { + subm Submitter + logger model.Logger +} + +func (rs realSubmitter) Submit(ctx context.Context, m *model.Measurement) error { + rs.logger.Info("submitting measurement to OONI collector; please be patient...") + return rs.subm.Submit(ctx, m) +} diff --git a/internal/engine/submitter_test.go b/internal/engine/submitter_test.go new file mode 100644 index 0000000..d436856 --- /dev/null +++ b/internal/engine/submitter_test.go @@ -0,0 +1,88 @@ +package engine + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/model" +) + +func TestSubmitterNotEnabled(t *testing.T) { + ctx := context.Background() + submitter, err := NewSubmitter(ctx, SubmitterConfig{ + Enabled: false, + }) + if err != nil { + t.Fatal(err) + } + if _, ok := submitter.(stubSubmitter); !ok { + t.Fatal("we did not get a stubSubmitter instance") + } + m := new(model.Measurement) + if err := submitter.Submit(ctx, m); err != nil { + t.Fatal(err) + } +} + +type FakeSubmitter struct { + Calls uint32 + Error error +} + +func (fs *FakeSubmitter) Submit(ctx context.Context, m *model.Measurement) error { + atomic.AddUint32(&fs.Calls, 1) + return fs.Error +} + +var _ Submitter = &FakeSubmitter{} + +type FakeSubmitterSession struct { + Error error + Submitter Submitter +} + +func (fse FakeSubmitterSession) NewSubmitter(ctx context.Context) (Submitter, error) { + return fse.Submitter, fse.Error +} + +var _ SubmitterSession = FakeSubmitterSession{} + +func TestNewSubmitterFails(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + submitter, err := NewSubmitter(ctx, SubmitterConfig{ + Enabled: true, + Session: FakeSubmitterSession{Error: expected}, + }) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if submitter != nil { + t.Fatal("expected nil submitter here") + } +} + +func TestNewSubmitterWithFailedSubmission(t *testing.T) { + expected := errors.New("mocked error") + ctx := context.Background() + fakeSubmitter := &FakeSubmitter{Error: expected} + submitter, err := NewSubmitter(ctx, SubmitterConfig{ + Enabled: true, + Logger: log.Log, + Session: FakeSubmitterSession{Submitter: fakeSubmitter}, + }) + if err != nil { + t.Fatal(err) + } + m := new(model.Measurement) + err = submitter.Submit(context.Background(), m) + if !errors.Is(err, expected) { + t.Fatalf("not the error we expected: %+v", err) + } + if fakeSubmitter.Calls != 1 { + t.Fatal("unexpected number of calls") + } +} diff --git a/internal/engine/testdata/.gitignore b/internal/engine/testdata/.gitignore new file mode 100644 index 0000000..c2dab52 --- /dev/null +++ b/internal/engine/testdata/.gitignore @@ -0,0 +1,10 @@ +/asn.mmdb +/ca-bundle.pem +/country.mmdb +/enginetests*/ +/kvstore2/ +/oonimkall +/oonipsiphon +/psiphon_config.json +/psiphon_unit_tests/ +/test-download-resources-idempotent*/ diff --git a/internal/engine/testdata/inputloader1.txt b/internal/engine/testdata/inputloader1.txt new file mode 100644 index 0000000..b2a875b --- /dev/null +++ b/internal/engine/testdata/inputloader1.txt @@ -0,0 +1,3 @@ +https://www.x.org/ +https://www.slashdot.org/ +https://abc.xyz/ diff --git a/internal/engine/testdata/inputloader2.txt b/internal/engine/testdata/inputloader2.txt new file mode 100644 index 0000000..c2b713e --- /dev/null +++ b/internal/engine/testdata/inputloader2.txt @@ -0,0 +1 @@ +https://run.ooni.io/ diff --git a/internal/engine/testdata/inputloader3.txt b/internal/engine/testdata/inputloader3.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/engine/testdata/inputloader3.txt @@ -0,0 +1 @@ + diff --git a/internal/engine/testjafar.bash b/internal/engine/testjafar.bash new file mode 100755 index 0000000..0c415dc --- /dev/null +++ b/internal/engine/testjafar.bash @@ -0,0 +1,129 @@ +#!/bin/bash + +# +# This script uses cURL to verify that Jafar is able to produce a +# bunch of censorship conditions. It should be noted that this script +# only works on Linux and will never work on other systems. +# + +set -e + +function execute() { + echo "+ $@" 1>&2 + "$@" +} + +function expectexitcode() { + local expect + local exitcode + expect=$1 + shift + set +e + "$@" + exitcode=$? + set -e + echo "expected exitcode $expect, found $exitcode" 1>&2 + if [ $exitcode != $expect ]; then + exit 1 + fi +} + +function runtest() { + echo "=== BEGIN $1 ===" + "$1" + echo "=== END $1 ===" +} + +function http_got_nothing() { + expectexitcode 52 execute ./jafar -iptables-hijack-http-to 127.0.0.1:7117 \ + -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io' +} + +function http_recv_error() { + expectexitcode 56 execute ./jafar -iptables-reset-keyword ooni \ + -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io' +} + +function http_operation_timedout() { + expectexitcode 28 execute ./jafar -iptables-drop-keyword ooni \ + -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io' +} + +function http_couldnt_connect() { + local ip + ip=$(host -tA example.com|cut -f4 -d' ') + expectexitcode 7 execute ./jafar -iptables-reset-ip $ip \ + -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io' +} + +function http_blockpage() { + outfile=$(mktemp) + chown nobody $outfile # curl runs as user nobody + expectexitcode 0 execute ./jafar -http-proxy-block ooni \ + -iptables-hijack-http-to 127.0.0.1:80 \ + -main-command "curl -so $outfile --connect-to ::example.com: http://ooni.io" + if ! grep -q '451 Unavailable For Legal Reasons' $outfile; then + echo "fatal: the blockpage does not contain the expected pattern" 1>&2 + exit 1 + fi +} + +function dns_injection() { + output=$(expectexitcode 0 execute ./jafar \ + -iptables-hijack-dns-to 127.0.0.1:53 \ + -dns-proxy-hijack ooni \ + -main-command 'dig +time=2 +short @example.com ooni.io') + if [ "$output" != "127.0.0.1" ]; then + echo "fatal: the resulting IP is not the expected one" 1>&2 + exit 1 + fi +} + +function dns_timeout() { + expectexitcode 9 execute ./jafar \ + -iptables-hijack-dns-to 127.0.0.1:53 \ + -dns-proxy-ignore ooni \ + -main-command 'dig +time=2 +short @example.com ooni.io' +} + +function dns_nxdomain() { + output=$(expectexitcode 0 execute ./jafar \ + -iptables-hijack-dns-to 127.0.0.1:53 \ + -dns-proxy-block ooni \ + -main-command 'dig +time=2 +short @example.com ooni.io') + if [ "$output" != "" ]; then + echo "fatal: expected no output here" 1>&2 + exit 1 + fi +} + +function sni_man_in_the_middle() { + expectexitcode 60 execute ./jafar -iptables-hijack-https-to 127.0.0.1:4114 \ + -main-command 'curl -sm5 --connect-to ::example.com: https://ooni.io' +} + +function sni_got_nothing() { + expectexitcode 52 execute ./jafar -iptables-hijack-https-to 127.0.0.1:4114 \ + -main-command 'curl -sm5 --cacert badproxy.pem --connect-to ::example.com: https://ooni.io' +} + +function sni_connect_error() { + expectexitcode 35 execute ./jafar -iptables-reset-keyword ooni \ + -main-command 'curl -sm5 --connect-to ::example.com: https://ooni.io' +} + +function main() { + runtest http_got_nothing + runtest http_recv_error + runtest http_operation_timedout + runtest http_couldnt_connect + runtest http_blockpage + runtest dns_injection + runtest dns_timeout + runtest dns_nxdomain + runtest sni_man_in_the_middle + runtest sni_got_nothing + runtest sni_connect_error +} + +main "$@" diff --git a/internal/engine/testusing.bash b/internal/engine/testusing.bash new file mode 100755 index 0000000..d0e5f92 --- /dev/null +++ b/internal/engine/testusing.bash @@ -0,0 +1,23 @@ +#!/bin/bash + +# +# This script simulates a user creating a new project that depends +# on github.com/ooni/probe-engine@GITHUB_SHA. +# + +set -ex +mkdir -p /tmp/example.org/x +cd /tmp/example.org/x +go mod init example.org/x +cat > main.go << EOF +package main + +import "github.com/ooni/probe-engine/libminiooni" + +func main() { + libminiooni.Main() +} +EOF +go get -v github.com/ooni/probe-engine@$GITHUB_SHA +go build -v . +./x --yes -OTunnel=psiphon -ni https://www.example.com urlgetter diff --git a/internal/engine/version/version.go b/internal/engine/version/version.go new file mode 100644 index 0000000..7f384e6 --- /dev/null +++ b/internal/engine/version/version.go @@ -0,0 +1,5 @@ +// Package version contains the probe-engine version. +package version + +// Version is the version of the engine +const Version = "0.23.0"