chore: merge probe-engine into probe-cli (#201)
This is how I did it: 1. `git clone https://github.com/ooni/probe-engine internal/engine` 2. ``` (cd internal/engine && git describe --tags) v0.23.0 ``` 3. `nvim go.mod` (merging `go.mod` with `internal/engine/go.mod` 4. `rm -rf internal/.git internal/engine/go.{mod,sum}` 5. `git add internal/engine` 6. `find . -type f -name \*.go -exec sed -i 's@/ooni/probe-engine@/ooni/probe-cli/v3/internal/engine@g' {} \;` 7. `go build ./...` (passes) 8. `go test -race ./...` (temporary failure on RiseupVPN) 9. `go mod tidy` 10. this commit message Once this piece of work is done, we can build a new version of `ooniprobe` that is using `internal/engine` directly. We need to do more work to ensure all the other functionality in `probe-engine` (e.g. making mobile packages) are still WAI. Part of https://github.com/ooni/probe/issues/1335
This commit is contained in:
@@ -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) {}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)<title>([^<]{1,128})</title>`) // 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
|
||||
}
|
||||
@@ -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: "<HTML/>"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
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: "<HTML/>"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
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: "<HTML><TITLE>La community di MSN</TITLE></HTML>"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
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: "<HTML><TITLE>La communità di MSN</TITLE></HTML>"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
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: "<HTML><TITLE>" + randx.Letters(1024) + "</TITLE></HTML>"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
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: "<HTML><TiTLe>La commUNity di MSN</tITLE></HTML>"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <https://git.io/JJHOl>.
|
||||
//
|
||||
// We eventually managed to link such a change with the 0.3.4 release of
|
||||
// Measurement Kit <https://git.io/JJHOS>. This led us to find out the
|
||||
// related issue #867 <https://git.io/JJHOH>. From this issue it become
|
||||
// clear that the change on Measurement Kit was applied to mirror a change
|
||||
// implemented in OONI Probe Legacy <https://git.io/JJH3T>. 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 <https://git.io/JJH3C>.
|
||||
//
|
||||
// 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 <https://github.com/ooni/probe-cli/v3/internal/engine/issues/579>.
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user