feat(webconnectivity): long-term-evolution prototype (#882)
See https://github.com/ooni/probe/issues/2237
This commit is contained in:
parent
9ba6f8dcbb
commit
1a1d3126ae
109
internal/experiment/webconnectivity/README.md
Normal file
109
internal/experiment/webconnectivity/README.md
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
# webconnectivity
|
||||||
|
|
||||||
|
This directory contains a new implementation of [Web Connectivity](
|
||||||
|
https://github.com/ooni/spec/blob/master/nettests/ts-017-web-connectivity.md).
|
||||||
|
|
||||||
|
As of 2022-08-26, this code is experimental and is not selected
|
||||||
|
by default when you run the `websites` group. You can select this
|
||||||
|
implementation with `miniooni` using `miniooni web_connectivity@v0.5`
|
||||||
|
from the command line.
|
||||||
|
|
||||||
|
Issue [#2237](https://github.com/ooni/probe/issues/2237) explains the rationale
|
||||||
|
behind writing this new implementation.
|
||||||
|
|
||||||
|
## Implementation overview
|
||||||
|
|
||||||
|
The experiment measures a single URL at a time. The OONI Engine invokes the
|
||||||
|
`Run` method inside the [measurer.go](measurer.go) file.
|
||||||
|
|
||||||
|
This code starts a number of background tasks, waits for them to complete, and
|
||||||
|
finally calls `TestKeys.finalize` to finalize the content of the JSON measurement.
|
||||||
|
|
||||||
|
The first task that is started deals with DNS and lives in the
|
||||||
|
[dnsresolvers.go](dnsresolvers.go) file. This task is responsible for
|
||||||
|
resolving the domain inside the URL into `0..N` IP addresses.
|
||||||
|
|
||||||
|
The domain resolution includes the system resolver and a DNS-over-UDP
|
||||||
|
resolver. The implementaion _may_ do more than that, but this is the
|
||||||
|
bare minimum we're feeling like documenting right now. (We need to
|
||||||
|
experiment a bit more to understand what else we can do there, hence
|
||||||
|
the code is _probably_ doing more than just that.)
|
||||||
|
|
||||||
|
Once we know the `0..N` IP addresses for the domain we do the following:
|
||||||
|
|
||||||
|
1. start a background task to communicate with the Web Connectivity
|
||||||
|
test helper, using code inside [control.go](control.go);
|
||||||
|
|
||||||
|
2. start an endpoint measurement task for each IP adddress (which of
|
||||||
|
course only happens when we know _at least_ one addr).
|
||||||
|
|
||||||
|
Regarding starting endpoint measurements, we follow this policy:
|
||||||
|
|
||||||
|
1. if the original URL is `http://...` then we start a cleartext task
|
||||||
|
and an encrypted task for each address using ports `80` and `443`
|
||||||
|
respectively.
|
||||||
|
|
||||||
|
2. if it's `https://...`, then we only start encrypted tasks.
|
||||||
|
|
||||||
|
Cleartext tasks are implemented by [cleartextflow.go](cleartextflow.go) while
|
||||||
|
the encrypted tasks live in [secureflow.go](secureflow.go).
|
||||||
|
|
||||||
|
A cleartext task does the following:
|
||||||
|
|
||||||
|
1. TCP connect;
|
||||||
|
|
||||||
|
2. additionally, the first task to establish a connection also performs
|
||||||
|
a GET request to fetch a webpage (we cannot GET for all connections, because
|
||||||
|
that would be `websteps` and would require a different data format).
|
||||||
|
|
||||||
|
An encrypted task does the following:
|
||||||
|
|
||||||
|
1. TCP connect;
|
||||||
|
|
||||||
|
2. TLS handshake;
|
||||||
|
|
||||||
|
3. additionally, the first task to handshake also performs
|
||||||
|
a GET request to fetch a webpage _iff_ the input URL was `https://...` (we cannot GET
|
||||||
|
for all connections, because that would be `websteps` and would require a
|
||||||
|
different data format).
|
||||||
|
|
||||||
|
If fetching the webpage returns a redirect, we start a new DNS task passing it
|
||||||
|
the redirect URL as the new URL to measure. We do not call the test helper again
|
||||||
|
when this happens, though. The Web Connectivity test helper already follows the whole
|
||||||
|
redirect chain, so we would need to change the test helper to get information on
|
||||||
|
each flow. When this will happen, this experiment will probably not be Web Connectivity
|
||||||
|
anymore, but rather some form of [websteps](https://github.com/bassosimone/websteps-illustrated/).
|
||||||
|
|
||||||
|
Additionally, when the test helper terminates, we run TCP connect and TLS handshake
|
||||||
|
(when applicable) for new IP addresses discovered using the test helper that were
|
||||||
|
previously unknown to the probe, thus collecting extra information. This logic lives
|
||||||
|
inside the [control.go](control.go) file.
|
||||||
|
|
||||||
|
As previously mentioned, when all tasks complete, we call `TestKeys.finalize`.
|
||||||
|
|
||||||
|
In turn, this function analyzes the collected data by calling code implemented
|
||||||
|
inside the following files:
|
||||||
|
|
||||||
|
- [analysiscore.go](analysiscore.go) contains the core analysis algorithm;
|
||||||
|
|
||||||
|
- [analysisdns.go](analysisdns.go) contains DNS specific analysis;
|
||||||
|
|
||||||
|
- [analysishttpcore.go](analysishttpcore.go) contains the bulk of the HTTP
|
||||||
|
analysis, where we mainly determine TLS blocking;
|
||||||
|
|
||||||
|
- [analysishttpdiff.go](analysishttpdiff.go) contains the HTTP diff algorithm;
|
||||||
|
|
||||||
|
- [analysistcpip.go](analysistcpip.go) checks for TCP/IP blocking.
|
||||||
|
|
||||||
|
We emit the `blocking` and `accessible` keys we emitted before as well as new
|
||||||
|
keys, prefixed by `x_` to indicate that they're experimental.
|
||||||
|
|
||||||
|
## Limitations and next steps
|
||||||
|
|
||||||
|
We need to extend the Web Connectivity test helper to return us information
|
||||||
|
about TLS handshakes with IP addresses discovered by the probe. This information
|
||||||
|
would allow us to make more precise TLS blocking statements.
|
||||||
|
|
||||||
|
Further changes are probably possible. Departing too radically from the Web
|
||||||
|
Connectivity model, though, will lead us to have a `websteps` implementation (but
|
||||||
|
then the data model would most likely be different).
|
144
internal/experiment/webconnectivity/analysiscore.go
Normal file
144
internal/experiment/webconnectivity/analysiscore.go
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
import "github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
|
||||||
|
//
|
||||||
|
// Core analysis
|
||||||
|
//
|
||||||
|
|
||||||
|
// These flags determine the context of TestKeys.Blocking. However, while .Blocking
|
||||||
|
// is an enumeration, these flags allow to describe multiple blocking methods.
|
||||||
|
const (
|
||||||
|
// analysisFlagDNSBlocking indicates there's blocking at the DNS level.
|
||||||
|
analysisFlagDNSBlocking = 1 << iota
|
||||||
|
|
||||||
|
// analysisFlagTCPIPBlocking indicates there's blocking at the TCP/IP level.
|
||||||
|
analysisFlagTCPIPBlocking
|
||||||
|
|
||||||
|
// analysisFlagTLSBlocking indicates there were TLS issues.
|
||||||
|
analysisFlagTLSBlocking
|
||||||
|
|
||||||
|
// analysisFlagHTTPBlocking indicates there was an HTTP failure.
|
||||||
|
analysisFlagHTTPBlocking
|
||||||
|
|
||||||
|
// analysisFlagHTTPDiff indicates there's an HTTP diff.
|
||||||
|
analysisFlagHTTPDiff
|
||||||
|
|
||||||
|
// analysisFlagSuccess indicates we did not detect any blocking.
|
||||||
|
analysisFlagSuccess
|
||||||
|
)
|
||||||
|
|
||||||
|
// analysisToplevel is the toplevel function that analyses the results
|
||||||
|
// of the experiment once all network tasks have completed.
|
||||||
|
//
|
||||||
|
// The ultimate objective of this function is to set the toplevel flags
|
||||||
|
// used by the backend to score results. These flags are:
|
||||||
|
//
|
||||||
|
// - blocking (and x_blocking_flags) which contain information about
|
||||||
|
// the detected blocking method (or methods);
|
||||||
|
//
|
||||||
|
// - accessible which contains information on whether we think we
|
||||||
|
// could access the resource somehow.
|
||||||
|
//
|
||||||
|
// Originally, Web Connectivity only had a blocking scalar value so
|
||||||
|
// we could see ourselves in one of the following cases:
|
||||||
|
//
|
||||||
|
// +----------+------------+--------------------------+
|
||||||
|
// | Blocking | Accessible | Meaning |
|
||||||
|
// +----------+------------+--------------------------+
|
||||||
|
// | null | null | Probe analysis error |
|
||||||
|
// +----------+------------+--------------------------+
|
||||||
|
// | false | true | We detected no blocking |
|
||||||
|
// +----------+------------+--------------------------+
|
||||||
|
// | "..." | false | We detected blocking |
|
||||||
|
// +----------+------------+--------------------------+
|
||||||
|
//
|
||||||
|
// While it would be possible in this implementation, which has a granular
|
||||||
|
// definition of blocking (x_blocking_flags), to set accessible to mean
|
||||||
|
// whether we could access the resource in some conditions, it seems quite
|
||||||
|
// dangerous to deviate from the original behavior.
|
||||||
|
//
|
||||||
|
// Our code will NEVER set .Blocking or .Accessible outside of this function
|
||||||
|
// and we'll instead rely on XBlockingFlags. This function's job is to call
|
||||||
|
// other functions that compute the .XBlockingFlags and then to assign the value
|
||||||
|
// of .Blocking and .Accessible from the .XBlockingFlags value.
|
||||||
|
//
|
||||||
|
// Accordingly, this is how we map the value of the .XBlockingFlags to the
|
||||||
|
// values of .Blocking and .Accessible:
|
||||||
|
//
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
// | XBlockingFlags | .Blocking | .Accessible |
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
// | (& DNSBlocking) != 0 | "dns" | false |
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
// | (& TCPIPBlocking) != 0 | "tcp_ip" | false |
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
// | (& (TLSBlocking|HTTPBlocking)) != 0 | "http-failure" | false |
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
// | (& HTTPDiff) != 0 | "http-diff" | false |
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
// | == FlagSuccess | false | true |
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
// | otherwise | null | null |
|
||||||
|
// +--------------------------------------+----------------+-------------+
|
||||||
|
//
|
||||||
|
// It's a very simple rule, that should preserve previous semantics.
|
||||||
|
func (tk *TestKeys) analysisToplevel(logger model.Logger) {
|
||||||
|
// Since we run after all tasks have completed (or so we assume) we're
|
||||||
|
// not going to use any form of locking here.
|
||||||
|
|
||||||
|
// these functions compute the value of XBlockingFlags
|
||||||
|
tk.analysisDNSToplevel(logger)
|
||||||
|
tk.analysisTCPIPToplevel(logger)
|
||||||
|
tk.analysisHTTPToplevel(logger)
|
||||||
|
|
||||||
|
// now, let's determine .Accessible and .Blocking
|
||||||
|
switch {
|
||||||
|
case (tk.BlockingFlags & analysisFlagDNSBlocking) != 0:
|
||||||
|
tk.Blocking = "dns"
|
||||||
|
tk.Accessible = false
|
||||||
|
logger.Warnf(
|
||||||
|
"ANOMALY: flags=%d accessible=%+v, blocking=%+v",
|
||||||
|
tk.BlockingFlags, tk.Accessible, tk.Blocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
case (tk.BlockingFlags & analysisFlagTCPIPBlocking) != 0:
|
||||||
|
tk.Blocking = "tcp_ip"
|
||||||
|
tk.Accessible = false
|
||||||
|
logger.Warnf(
|
||||||
|
"ANOMALY: flags=%d accessible=%+v, blocking=%+v",
|
||||||
|
tk.BlockingFlags, tk.Accessible, tk.Blocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
case (tk.BlockingFlags & (analysisFlagTLSBlocking | analysisFlagHTTPBlocking)) != 0:
|
||||||
|
tk.Blocking = "http-failure"
|
||||||
|
tk.Accessible = false
|
||||||
|
logger.Warnf("ANOMALY: flags=%d accessible=%+v, blocking=%+v",
|
||||||
|
tk.BlockingFlags, tk.Accessible, tk.Blocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
case (tk.BlockingFlags & analysisFlagHTTPDiff) != 0:
|
||||||
|
tk.Blocking = "http-diff"
|
||||||
|
tk.Accessible = false
|
||||||
|
logger.Warnf(
|
||||||
|
"ANOMALY: flags=%d accessible=%+v, blocking=%+v",
|
||||||
|
tk.BlockingFlags, tk.Accessible, tk.Blocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
case tk.BlockingFlags == analysisFlagSuccess:
|
||||||
|
tk.Blocking = false
|
||||||
|
tk.Accessible = true
|
||||||
|
logger.Infof(
|
||||||
|
"SUCCESS: flags=%d accessible=%+v, blocking=%+v",
|
||||||
|
tk.BlockingFlags, tk.Accessible, tk.Blocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
tk.Blocking = nil
|
||||||
|
tk.Accessible = nil
|
||||||
|
logger.Warnf(
|
||||||
|
"UNKNOWN: flags=%d, accessible=%+v, blocking=%+v",
|
||||||
|
tk.BlockingFlags, tk.Accessible, tk.Blocking,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
368
internal/experiment/webconnectivity/analysisdns.go
Normal file
368
internal/experiment/webconnectivity/analysisdns.go
Normal file
|
@ -0,0 +1,368 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// DNS analysis
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/engine/geolocate"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AnalysisDNSBogon indicates we got any bogon reply
|
||||||
|
AnalysisDNSBogon = 1 << iota
|
||||||
|
|
||||||
|
// AnalysisDNSUnexpectedFailure indicates the TH could
|
||||||
|
// resolve a domain while the probe couldn't
|
||||||
|
AnalysisDNSUnexpectedFailure
|
||||||
|
|
||||||
|
// AnalysisDNSUnexpectedAddrs indicates the TH resolved
|
||||||
|
// different addresses from the probe
|
||||||
|
AnalysisDNSUnexpectedAddrs
|
||||||
|
)
|
||||||
|
|
||||||
|
// analysisDNSToplevel is the toplevel analysis function for DNS results.
|
||||||
|
//
|
||||||
|
// The goals of this function are the following:
|
||||||
|
//
|
||||||
|
// 1. Set the legacy .DNSExperimentFailure field to the failure value of the
|
||||||
|
// first DNS query that failed among the ones we actually tried. Because we
|
||||||
|
// have multiple queries, unfortunately we are forced to pick one error among
|
||||||
|
// possibly many to assign to this field. This is why I consider it legacy.
|
||||||
|
//
|
||||||
|
// 2. Compute the XDNSFlags value.
|
||||||
|
//
|
||||||
|
// From the XDNSFlags value, we determine, in turn DNSConsistency and
|
||||||
|
// XBlockingFlags according to the following decision table:
|
||||||
|
//
|
||||||
|
// +-----------+----------------+---------------------+
|
||||||
|
// | XDNSFlags | DNSConsistency | XBlockingFlags |
|
||||||
|
// +-----------+----------------+---------------------+
|
||||||
|
// | 0 | "consistent" | no change |
|
||||||
|
// +-----------+----------------+---------------------+
|
||||||
|
// | nonzero | "inconsistent" | set FlagDNSBlocking |
|
||||||
|
// +-----------+----------------+---------------------+
|
||||||
|
//
|
||||||
|
// We explain how XDNSFlags is determined in the documentation of
|
||||||
|
// the functions that this function calls to do its job.
|
||||||
|
func (tk *TestKeys) analysisDNSToplevel(logger model.Logger) {
|
||||||
|
tk.analysisDNSExperimentFailure()
|
||||||
|
tk.analysisDNSBogon(logger)
|
||||||
|
tk.analysisDNSUnexpectedFailure(logger)
|
||||||
|
tk.analysisDNSUnexpectedAddrs(logger)
|
||||||
|
if tk.DNSFlags != 0 {
|
||||||
|
logger.Warn("DNSConsistency: inconsistent")
|
||||||
|
tk.DNSConsistency = "inconsistent"
|
||||||
|
tk.BlockingFlags |= analysisFlagDNSBlocking
|
||||||
|
} else {
|
||||||
|
logger.Info("DNSConsistency: consistent")
|
||||||
|
tk.DNSConsistency = "consistent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisDNSExperimentFailure sets the legacy DNSExperimentFailure field.
|
||||||
|
func (tk *TestKeys) analysisDNSExperimentFailure() {
|
||||||
|
for _, query := range tk.Queries {
|
||||||
|
if fail := query.Failure; fail != nil {
|
||||||
|
if query.QueryType == "AAAA" && *query.Failure == netxlite.FailureDNSNoAnswer {
|
||||||
|
// maybe this heuristic could be further improved by checking
|
||||||
|
// whether the TH did actually see any IPv6 address?
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tk.DNSExperimentFailure = fail
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisDNSBogon computes the AnalysisDNSBogon flag. We set this flag if
|
||||||
|
// we dectect any bogon in the .Queries field of the TestKeys.
|
||||||
|
func (tk *TestKeys) analysisDNSBogon(logger model.Logger) {
|
||||||
|
for _, query := range tk.Queries {
|
||||||
|
for _, answer := range query.Answers {
|
||||||
|
switch answer.AnswerType {
|
||||||
|
case "A":
|
||||||
|
if net.ParseIP(answer.IPv4) != nil && netxlite.IsBogon(answer.IPv4) {
|
||||||
|
logger.Warnf("DNS: BOGON %s in #%d", answer.IPv4, query.TransactionID)
|
||||||
|
tk.DNSFlags |= AnalysisDNSBogon
|
||||||
|
// continue processing so we print all the bogons we have
|
||||||
|
}
|
||||||
|
case "AAAA":
|
||||||
|
if net.ParseIP(answer.IPv6) != nil && netxlite.IsBogon(answer.IPv6) {
|
||||||
|
logger.Warnf("DNS: BOGON %s in #%d", answer.IPv6, query.TransactionID)
|
||||||
|
tk.DNSFlags |= AnalysisDNSBogon
|
||||||
|
// continue processing so we print all the bogons we have
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisDNSUnexpectedFailure computes the AnalysisDNSUnexpectedFailure flags. We say
|
||||||
|
// a failure is unexpected when the TH could resolve a domain and the probe couldn't.
|
||||||
|
func (tk *TestKeys) analysisDNSUnexpectedFailure(logger model.Logger) {
|
||||||
|
// make sure we have control before proceeding futher
|
||||||
|
if tk.Control == nil || tk.ControlRequest == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtain thRequest and thResponse as shortcuts
|
||||||
|
thRequest := tk.ControlRequest
|
||||||
|
thResponse := tk.Control
|
||||||
|
|
||||||
|
// obtain the domain that the TH has queried for
|
||||||
|
URL, err := url.Parse(thRequest.HTTPRequest)
|
||||||
|
if err != nil {
|
||||||
|
return // this looks like a bug
|
||||||
|
}
|
||||||
|
domain := URL.Hostname()
|
||||||
|
|
||||||
|
// we obviously don't care if the domain was an IP adddress
|
||||||
|
if net.ParseIP(domain) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the control didn't lookup any IP addresses our job here is done
|
||||||
|
// because we can't say whether we have unexpected failures
|
||||||
|
hasAddrs := len(thResponse.DNS.Addrs) > 0
|
||||||
|
if !hasAddrs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// with TH-resolved addrs, any local query _for the same domain_ queried
|
||||||
|
// by the probe that contains an error is suspicious
|
||||||
|
for _, query := range tk.Queries {
|
||||||
|
if domain != query.Hostname {
|
||||||
|
continue // not the domain queried by the test helper
|
||||||
|
}
|
||||||
|
hasAddrs := false
|
||||||
|
Loop:
|
||||||
|
for _, answer := range query.Answers {
|
||||||
|
switch answer.AnswerType {
|
||||||
|
case "A", "AAA":
|
||||||
|
hasAddrs = true
|
||||||
|
break Loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasAddrs {
|
||||||
|
// if the lookup returned any IP address, we are
|
||||||
|
// not dealing with unexpected failures
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if query.Failure == nil {
|
||||||
|
// we expect to see a failure if we don't see
|
||||||
|
// answers, so this seems a bug?
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if query.QueryType == "AAAA" && *query.Failure == netxlite.FailureDNSNoAnswer {
|
||||||
|
// maybe this heuristic could be further improved by checking
|
||||||
|
// whether the TH did actually see any IPv6 address?
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Warnf("DNS: unexpected failure %s in #%d", *query.Failure, query.TransactionID)
|
||||||
|
tk.DNSFlags |= AnalysisDNSUnexpectedFailure
|
||||||
|
// continue processing so we print all the unexpected failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisDNSUnexpectedAddrs computes the AnalysisDNSUnexpectedAddrs flags. This
|
||||||
|
// algorithm builds upon the original DNSDiff algorithm by introducing an additional
|
||||||
|
// TLS based heuristic for determining whether an IP address was legit.
|
||||||
|
func (tk *TestKeys) analysisDNSUnexpectedAddrs(logger model.Logger) {
|
||||||
|
// make sure we have control before proceeding futher
|
||||||
|
if tk.Control == nil || tk.ControlRequest == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtain thRequest and thResponse as shortcuts
|
||||||
|
thRequest := tk.ControlRequest
|
||||||
|
thResponse := tk.Control
|
||||||
|
|
||||||
|
// obtain the domain that the TH has queried for
|
||||||
|
URL, err := url.Parse(thRequest.HTTPRequest)
|
||||||
|
if err != nil {
|
||||||
|
return // this looks like a bug
|
||||||
|
}
|
||||||
|
domain := URL.Hostname()
|
||||||
|
|
||||||
|
// we obviously don't care if the domain was an IP adddress
|
||||||
|
if net.ParseIP(domain) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the control didn't resolve any IP address, then we basically
|
||||||
|
// cannot run this algorithm at all
|
||||||
|
thAddrs := thResponse.DNS.Addrs
|
||||||
|
if len(thAddrs) <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// gather all the IP addresses queried by the probe
|
||||||
|
// for the same domain for which the TH queried.
|
||||||
|
var probeAddrs []string
|
||||||
|
for _, query := range tk.Queries {
|
||||||
|
if domain != query.Hostname {
|
||||||
|
continue // not the domain the TH queried for
|
||||||
|
}
|
||||||
|
for _, answer := range query.Answers {
|
||||||
|
switch answer.AnswerType {
|
||||||
|
case "A":
|
||||||
|
probeAddrs = append(probeAddrs, answer.IPv4)
|
||||||
|
case "AAAA":
|
||||||
|
probeAddrs = append(probeAddrs, answer.IPv6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the probe has not collected any addr for the same domain, it's
|
||||||
|
// definitely suspicious and counts as a difference
|
||||||
|
if len(probeAddrs) <= 0 {
|
||||||
|
logger.Warnf("DNS: no IP address resolved by the probe")
|
||||||
|
tk.DNSFlags |= AnalysisDNSUnexpectedAddrs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there are no different addresses between the probe and the TH then
|
||||||
|
// our job here is done and we can just stop searching
|
||||||
|
differentAddrs := tk.analysisDNSDiffAddrs(probeAddrs, thAddrs)
|
||||||
|
if len(differentAddrs) <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// now, let's exclude the differentAddrs for which we successfully
|
||||||
|
// completed a TLS handshake: those should be good addrs
|
||||||
|
withoutHandshake := tk.findAddrsWithoutTLSHandshake(domain, differentAddrs)
|
||||||
|
if len(withoutHandshake) <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// as a last resort, accept the addresses without an handshake whose
|
||||||
|
// ASN overlaps with ASNs resolved by the TH
|
||||||
|
differentASNs := tk.analysisDNSDiffASN(withoutHandshake, thAddrs)
|
||||||
|
if len(differentASNs) <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, conclude we have unexpected probe addrs
|
||||||
|
logger.Warnf(
|
||||||
|
"DNSDiff: differentAddrs: %+v; withoutHandshake: %+v; differentASNs: %+v",
|
||||||
|
differentAddrs, withoutHandshake, differentASNs,
|
||||||
|
)
|
||||||
|
tk.DNSFlags |= AnalysisDNSUnexpectedAddrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisDNSDiffAddrs returns all the IP addresses that are
|
||||||
|
// resolved by the probe but not by the test helper.
|
||||||
|
func (tk *TestKeys) analysisDNSDiffAddrs(probeAddrs, thAddrs []string) (diff []string) {
|
||||||
|
const (
|
||||||
|
inProbe = 1 << iota
|
||||||
|
inTH
|
||||||
|
)
|
||||||
|
mapping := make(map[string]int)
|
||||||
|
for _, addr := range probeAddrs {
|
||||||
|
mapping[addr] |= inProbe
|
||||||
|
}
|
||||||
|
for _, addr := range thAddrs {
|
||||||
|
mapping[addr] = inTH
|
||||||
|
}
|
||||||
|
for addr, where := range mapping {
|
||||||
|
if (where & inTH) == 0 {
|
||||||
|
diff = append(diff, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// analysisDNSDiffASN returns whether there are IP addresses in the probe's
|
||||||
|
// list with different ASNs from the ones in the TH's list.
|
||||||
|
func (tk *TestKeys) analysisDNSDiffASN(probeAddrs, thAddrs []string) (asns []uint) {
|
||||||
|
const (
|
||||||
|
inProbe = 1 << iota
|
||||||
|
inTH
|
||||||
|
)
|
||||||
|
mapping := make(map[uint]int)
|
||||||
|
for _, addr := range probeAddrs {
|
||||||
|
asn, _, _ := geolocate.LookupASN(addr)
|
||||||
|
mapping[asn] |= inProbe // including the zero ASN that means unknown
|
||||||
|
}
|
||||||
|
for _, addr := range thAddrs {
|
||||||
|
asn, _, _ := geolocate.LookupASN(addr)
|
||||||
|
mapping[asn] |= inTH // including the zero ASN that means unknown
|
||||||
|
}
|
||||||
|
for asn, where := range mapping {
|
||||||
|
if (where & inTH) == 0 {
|
||||||
|
asns = append(asns, asn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAddrsWithoutTLSHandshake computes the list of probe discovered [addresses]
|
||||||
|
// for which we couldn't successfully perform a TLS handshake for the given [domain].
|
||||||
|
func (tk *TestKeys) findAddrsWithoutTLSHandshake(domain string, addresses []string) (output []string) {
|
||||||
|
const (
|
||||||
|
resolved = 1 << iota
|
||||||
|
handshakeOK
|
||||||
|
)
|
||||||
|
mapping := make(map[string]int)
|
||||||
|
|
||||||
|
// fill the input map with the addresses we're interested to analyze
|
||||||
|
for _, addr := range addresses {
|
||||||
|
mapping[addr] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag the subset of addresses resolved by the probe
|
||||||
|
for _, query := range tk.Queries {
|
||||||
|
for _, answer := range query.Answers {
|
||||||
|
var addr string
|
||||||
|
switch answer.AnswerType {
|
||||||
|
case "A":
|
||||||
|
addr = answer.IPv4
|
||||||
|
case "AAAA":
|
||||||
|
addr = answer.IPv6
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, found := mapping[addr]; !found {
|
||||||
|
continue // we're not interested into this addr
|
||||||
|
}
|
||||||
|
mapping[addr] |= resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag the subset of addrs with successful handshake for the right SNI
|
||||||
|
for _, thx := range tk.TLSHandshakes {
|
||||||
|
addr, _, err := net.SplitHostPort(thx.Address)
|
||||||
|
if err != nil {
|
||||||
|
continue // looks like a bug
|
||||||
|
}
|
||||||
|
if thx.Failure != nil {
|
||||||
|
continue // this handshake failed
|
||||||
|
}
|
||||||
|
if _, found := mapping[addr]; !found {
|
||||||
|
continue // we're not interested into this addr
|
||||||
|
}
|
||||||
|
if thx.ServerName != domain {
|
||||||
|
continue // the SNI is different, so...
|
||||||
|
}
|
||||||
|
mapping[addr] |= handshakeOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute the list of addresses without the handshakeOK flag
|
||||||
|
for addr, flags := range mapping {
|
||||||
|
if flags == 0 {
|
||||||
|
continue // this looks like a bug
|
||||||
|
}
|
||||||
|
if (flags & (resolved | handshakeOK)) == resolved {
|
||||||
|
output = append(output, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
145
internal/experiment/webconnectivity/analysishttpcore.go
Normal file
145
internal/experiment/webconnectivity/analysishttpcore.go
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// HTTP core analysis
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// analysisHTTPToplevel is the toplevel analysis function for HTTP results.
|
||||||
|
//
|
||||||
|
// This function's job is to determine whether there were unexpected TLS
|
||||||
|
// handshake results (compared to what the TH observed), or unexpected
|
||||||
|
// failures during HTTP round trips (using the TH as benchmark), or whether
|
||||||
|
// the obtained body differs from the one obtained by the TH.
|
||||||
|
//
|
||||||
|
// This results in possibly setting these XBlockingFlags:
|
||||||
|
//
|
||||||
|
// - analysisFlagTLSBlocking
|
||||||
|
//
|
||||||
|
// - analysisFlagHTTPBlocking
|
||||||
|
//
|
||||||
|
// - analysisFlagHTTPDiff
|
||||||
|
//
|
||||||
|
// In websteps fashion, we don't stop at the first failure, rather we
|
||||||
|
// process all the available data and evaluate all possible errors.
|
||||||
|
func (tk *TestKeys) analysisHTTPToplevel(logger model.Logger) {
|
||||||
|
// don't perform any analysis without TH data
|
||||||
|
if tk.Control == nil || tk.ControlRequest == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctrl := tk.Control.HTTPRequest
|
||||||
|
|
||||||
|
// don't perform any analysis if the TH's HTTP measurement failed
|
||||||
|
if ctrl.Failure != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine whether the original URL was HTTPS
|
||||||
|
origURL, err := url.Parse(tk.ControlRequest.HTTPRequest)
|
||||||
|
if err != nil {
|
||||||
|
return // this seeems like a bug?
|
||||||
|
}
|
||||||
|
isHTTPS := origURL.Scheme == "https"
|
||||||
|
|
||||||
|
// determine whether we had any TLS handshake issue and, in such a case,
|
||||||
|
// declare that we had a case of "http-failure" through TLS.
|
||||||
|
//
|
||||||
|
// Note that this would eventually count as an "http-failure" for .Blocking
|
||||||
|
// because Web Connectivity did not have a concept of TLS based blocking.
|
||||||
|
if tk.hasWellKnownTLSHandshakeIssues(isHTTPS, logger) {
|
||||||
|
tk.BlockingFlags |= analysisFlagTLSBlocking
|
||||||
|
// continue processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine whether we had well known cleartext HTTP round trip issues
|
||||||
|
// and, in such a case, declare we had an "http-failure".
|
||||||
|
if tk.hasWellKnownHTTPRoundTripIssues(logger) {
|
||||||
|
tk.BlockingFlags |= analysisFlagHTTPBlocking
|
||||||
|
// continue processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we don't have any request to check, there's not much more we
|
||||||
|
// can actually do here, so let's just return.
|
||||||
|
if len(tk.Requests) <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the request has failed in any other way, we don't know. By convention, the first
|
||||||
|
// entry in the tk.Requests array is the last entry that was measured.
|
||||||
|
finalRequest := tk.Requests[0]
|
||||||
|
if finalRequest.Failure != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to the HTTP diff algo.
|
||||||
|
tk.analysisHTTPDiff(logger, finalRequest, &ctrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasWellKnownTLSHandshakeIssues returns true in case we observed
|
||||||
|
// a set of well-known issues during the TLS handshake.
|
||||||
|
func (tk *TestKeys) hasWellKnownTLSHandshakeIssues(isHTTPS bool, logger model.Logger) (result bool) {
|
||||||
|
// TODO(bassosimone): we should return TLS information in the TH
|
||||||
|
// such that we can perform a TCP-like check. For now, instead, we
|
||||||
|
// only perform comparison when the initial URL was HTTPS. Given
|
||||||
|
// that we unconditionally check for HTTPS even when the URL is HTTP,
|
||||||
|
// we cannot blindly treat all TLS errors as blocking. A website
|
||||||
|
// may just not have HTTPS. While in the obvious cases we will see
|
||||||
|
// certificate errors, in some cases it may actually timeout.
|
||||||
|
if isHTTPS {
|
||||||
|
for _, thx := range tk.TLSHandshakes {
|
||||||
|
fail := thx.Failure
|
||||||
|
if fail == nil {
|
||||||
|
continue // this handshake succeded, so skip it
|
||||||
|
}
|
||||||
|
switch *fail {
|
||||||
|
case netxlite.FailureConnectionReset,
|
||||||
|
netxlite.FailureGenericTimeoutError,
|
||||||
|
netxlite.FailureEOFError,
|
||||||
|
netxlite.FailureSSLInvalidHostname,
|
||||||
|
netxlite.FailureSSLInvalidCertificate,
|
||||||
|
netxlite.FailureSSLUnknownAuthority:
|
||||||
|
logger.Warnf(
|
||||||
|
"TLS: endpoint %s fails with %s (see #%d)",
|
||||||
|
thx.Address, *fail, thx.TransactionID,
|
||||||
|
)
|
||||||
|
result = true // flip the result but continue looping so we print them all
|
||||||
|
default:
|
||||||
|
// check next handshake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasWellKnownHTTPRoundTripIssues checks whether any HTTP round
|
||||||
|
// trip failed in a well-known suspicious way
|
||||||
|
func (tk *TestKeys) hasWellKnownHTTPRoundTripIssues(logger model.Logger) (result bool) {
|
||||||
|
for _, rtx := range tk.Requests {
|
||||||
|
fail := rtx.Failure
|
||||||
|
if fail == nil {
|
||||||
|
// This one succeded, so skip it. Note that, in principle, we know
|
||||||
|
// the fist entry is the last request occurred, but I really do not
|
||||||
|
// want to embed this bad assumption in one extra place!
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch *fail {
|
||||||
|
case netxlite.FailureConnectionReset,
|
||||||
|
netxlite.FailureGenericTimeoutError,
|
||||||
|
netxlite.FailureEOFError:
|
||||||
|
logger.Warnf(
|
||||||
|
"TLS: endpoint %s fails with %s (see #%d)",
|
||||||
|
"N/A", *fail, rtx.TransactionID, // TODO(bassosimone): implement
|
||||||
|
)
|
||||||
|
result = true // flip the result but continue looping so we print them all
|
||||||
|
default:
|
||||||
|
// check next round trip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
246
internal/experiment/webconnectivity/analysishttpdiff.go
Normal file
246
internal/experiment/webconnectivity/analysishttpdiff.go
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// HTTP diff analysis
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/runtimex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// analysisHTTPDiff computes the HTTP diff between the final request-response
|
||||||
|
// observed by the probe and the TH's result. The caller is responsible of passing
|
||||||
|
// us a valid probe observation and a valid TH observation with nil failure.
|
||||||
|
func (tk *TestKeys) analysisHTTPDiff(logger model.Logger,
|
||||||
|
probe *model.ArchivalHTTPRequestResult, th *webconnectivity.ControlHTTPRequestResult) {
|
||||||
|
// make sure the caller respected the contract
|
||||||
|
runtimex.PanicIfTrue(
|
||||||
|
probe.Failure != nil || th.Failure != nil,
|
||||||
|
"the caller should have passed us successful HTTP observations",
|
||||||
|
)
|
||||||
|
|
||||||
|
// if we're dealing with an HTTPS request, don't perform any comparison
|
||||||
|
// under the assumption that we're good if we're using TLS
|
||||||
|
URL, err := url.Parse(probe.Request.URL)
|
||||||
|
if err != nil {
|
||||||
|
return // looks like a bug
|
||||||
|
}
|
||||||
|
if URL.Scheme == "https" {
|
||||||
|
logger.Infof("HTTP: HTTPS && no error => #%d is successful", probe.TransactionID)
|
||||||
|
tk.BlockingFlags |= analysisFlagSuccess
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// original HTTP diff algorithm adapted for this implementation
|
||||||
|
tk.httpDiffBodyLengthChecks(probe, th)
|
||||||
|
tk.httpDiffStatusCodeMatch(probe, th)
|
||||||
|
tk.httpDiffHeadersMatch(probe, th)
|
||||||
|
tk.httpDiffTitleMatch(probe, th)
|
||||||
|
|
||||||
|
if tk.StatusCodeMatch != nil && *tk.StatusCodeMatch {
|
||||||
|
if tk.BodyLengthMatch != nil && *tk.BodyLengthMatch {
|
||||||
|
logger.Infof(
|
||||||
|
"HTTP: statusCodeMatch && bodyLengthMatch => #%d is successful",
|
||||||
|
probe.TransactionID,
|
||||||
|
)
|
||||||
|
tk.BlockingFlags |= analysisFlagSuccess
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tk.HeadersMatch != nil && *tk.HeadersMatch {
|
||||||
|
logger.Infof(
|
||||||
|
"HTTP: statusCodeMatch && headersMatch => #%d is successful",
|
||||||
|
probe.TransactionID,
|
||||||
|
)
|
||||||
|
tk.BlockingFlags |= analysisFlagSuccess
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tk.TitleMatch != nil && *tk.TitleMatch {
|
||||||
|
logger.Infof(
|
||||||
|
"HTTP: statusCodeMatch && titleMatch => #%d is successful",
|
||||||
|
probe.TransactionID,
|
||||||
|
)
|
||||||
|
tk.BlockingFlags |= analysisFlagSuccess
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tk.BlockingFlags |= analysisFlagHTTPDiff
|
||||||
|
logger.Warnf("HTTP: it seems #%d is a case of httpDiff", probe.TransactionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpDiffBodyLengthChecks compares the bodies lengths.
|
||||||
|
func (tk *TestKeys) httpDiffBodyLengthChecks(
|
||||||
|
probe *model.ArchivalHTTPRequestResult, ctrl *webconnectivity.ControlHTTPRequestResult) {
|
||||||
|
control := ctrl.BodyLength
|
||||||
|
if control <= 0 {
|
||||||
|
return // no actual length
|
||||||
|
}
|
||||||
|
response := probe.Response
|
||||||
|
if response.BodyIsTruncated {
|
||||||
|
return // cannot trust body length in this case
|
||||||
|
}
|
||||||
|
measurement := int64(len(response.Body.Value))
|
||||||
|
if measurement <= 0 {
|
||||||
|
return // no actual length
|
||||||
|
}
|
||||||
|
const bodyProportionFactor = 0.7
|
||||||
|
var proportion float64
|
||||||
|
if measurement >= control {
|
||||||
|
proportion = float64(control) / float64(measurement)
|
||||||
|
} else {
|
||||||
|
proportion = float64(measurement) / float64(control)
|
||||||
|
}
|
||||||
|
good := proportion > bodyProportionFactor
|
||||||
|
tk.BodyLengthMatch = &good
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpDiffStatusCodeMatch compares the status codes.
|
||||||
|
func (tk *TestKeys) httpDiffStatusCodeMatch(
|
||||||
|
probe *model.ArchivalHTTPRequestResult, ctrl *webconnectivity.ControlHTTPRequestResult) {
|
||||||
|
control := ctrl.StatusCode
|
||||||
|
measurement := probe.Response.Code
|
||||||
|
if control <= 0 {
|
||||||
|
return // no real status code
|
||||||
|
}
|
||||||
|
if measurement <= 0 {
|
||||||
|
return // no real status code
|
||||||
|
}
|
||||||
|
if control/100 != 2 {
|
||||||
|
return // avoid comparison if it seems the TH failed
|
||||||
|
}
|
||||||
|
good := control == measurement
|
||||||
|
tk.StatusCodeMatch = &good
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpDiffHeadersMatch compares the uncommon headers.
|
||||||
|
func (tk *TestKeys) httpDiffHeadersMatch(
|
||||||
|
probe *model.ArchivalHTTPRequestResult, ctrl *webconnectivity.ControlHTTPRequestResult) {
|
||||||
|
control := ctrl.Headers
|
||||||
|
measurement := probe.Response.Headers
|
||||||
|
if len(control) <= 0 || len(measurement) <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Implementation note: using map because we only care about the
|
||||||
|
// keys being different and we ignore the values.
|
||||||
|
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 {
|
||||||
|
tk.HeadersMatch = &good
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// compute the intersection of uncommon headers
|
||||||
|
found := false
|
||||||
|
for _, value := range matching {
|
||||||
|
if (value & inBoth) == inBoth {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tk.HeadersMatch = &found
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpDiffTitleMatch compares the titles.
|
||||||
|
func (tk *TestKeys) httpDiffTitleMatch(
|
||||||
|
probe *model.ArchivalHTTPRequestResult, ctrl *webconnectivity.ControlHTTPRequestResult) {
|
||||||
|
response := probe.Response
|
||||||
|
if response.Code <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response.BodyIsTruncated {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctrl.StatusCode <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
control := ctrl.Title
|
||||||
|
measurementBody := response.Body.Value
|
||||||
|
measurement := webconnectivity.GetTitle(measurementBody)
|
||||||
|
if control == "" || 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tk.TitleMatch = &good
|
||||||
|
}
|
82
internal/experiment/webconnectivity/analysistcpip.go
Normal file
82
internal/experiment/webconnectivity/analysistcpip.go
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// TCP/IP analysis
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// analysisTCPIPToplevel is the toplevel analysis function for TCP/IP results.
|
||||||
|
//
|
||||||
|
// This algorithm has two objectives:
|
||||||
|
//
|
||||||
|
// 1. walk the list of TCP connect attempts and mark each of them as
|
||||||
|
// Status.Blocked = true | false | null depending on what the TH observed
|
||||||
|
// for the same set of IP addresses (it's ugly to modify a data struct
|
||||||
|
// in place, but this algorithm is defined by the spec);
|
||||||
|
//
|
||||||
|
// 2. assign the analysisFlagTCPIPBlocking flag to XBlockingFlags if
|
||||||
|
// we see any TCP endpoint for which Status.Blocked is true.
|
||||||
|
func (tk *TestKeys) analysisTCPIPToplevel(logger model.Logger) {
|
||||||
|
// if we don't have a control result, do nothing.
|
||||||
|
if tk.Control == nil || len(tk.Control.TCPConnect) <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
istrue = true
|
||||||
|
isfalse = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO(bassosimone): the TH should measure also some of the IP addrs it discovered
|
||||||
|
// and the probe did not discover to improve the analysis. Otherwise, the probe
|
||||||
|
// is fooled by the TH also failing for countries that return random IP addresses
|
||||||
|
// that are actually not working. Yet, ooni/data would definitely see this.
|
||||||
|
|
||||||
|
// walk the list of probe results and compare with TH results
|
||||||
|
for _, entry := range tk.TCPConnect {
|
||||||
|
// skip successful entries
|
||||||
|
failure := entry.Status.Failure
|
||||||
|
if failure == nil {
|
||||||
|
entry.Status.Blocked = &isfalse
|
||||||
|
continue // did not fail
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure we exclude the IPv6 failures caused by lack of
|
||||||
|
// proper IPv6 support by the probe
|
||||||
|
ipv6, err := netxlite.IsIPv6(entry.IP)
|
||||||
|
if err != nil {
|
||||||
|
continue // looks like a bug
|
||||||
|
}
|
||||||
|
if ipv6 {
|
||||||
|
ignore := (*failure == netxlite.FailureNetworkUnreachable ||
|
||||||
|
*failure == netxlite.FailureHostUnreachable)
|
||||||
|
if ignore {
|
||||||
|
// this occurs when we don't have IPv6 on the probe
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtain the corresponding endpoint
|
||||||
|
epnt := net.JoinHostPort(entry.IP, fmt.Sprintf("%d", entry.Port))
|
||||||
|
ctrl, found := tk.Control.TCPConnect[epnt]
|
||||||
|
if !found {
|
||||||
|
continue // only the probe tested this, so hard to say anything...
|
||||||
|
}
|
||||||
|
if ctrl.Failure != nil {
|
||||||
|
// If the TH failed as well, don't set XBlockingFlags and
|
||||||
|
// also don't bother with setting .Status.Blocked thus leaving
|
||||||
|
// it null. Performing precise error mapping should be a job
|
||||||
|
// for the pipeline rather than for the probe.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Warnf("TCP/IP: endpoint %s is blocked (see #%d)", epnt, entry.TransactionID)
|
||||||
|
entry.Status.Blocked = &istrue
|
||||||
|
tk.BlockingFlags |= analysisFlagTCPIPBlocking
|
||||||
|
}
|
||||||
|
}
|
287
internal/experiment/webconnectivity/cleartextflow.go
Normal file
287
internal/experiment/webconnectivity/cleartextflow.go
Normal file
|
@ -0,0 +1,287 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// CleartextFlow
|
||||||
|
//
|
||||||
|
// Generated by `boilerplate' using the http template.
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/atomicx"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/measurexlite"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Measures HTTP endpoints.
|
||||||
|
//
|
||||||
|
// The zero value of this structure IS NOT valid and you MUST initialize
|
||||||
|
// all the fields marked as MANDATORY before using this structure.
|
||||||
|
type CleartextFlow struct {
|
||||||
|
// Address is the MANDATORY address to connect to.
|
||||||
|
Address string
|
||||||
|
|
||||||
|
// DNSCache is the MANDATORY DNS cache.
|
||||||
|
DNSCache *DNSCache
|
||||||
|
|
||||||
|
// IDGenerator is the MANDATORY atomic int64 to generate task IDs.
|
||||||
|
IDGenerator *atomicx.Int64
|
||||||
|
|
||||||
|
// Logger is the MANDATORY logger to use.
|
||||||
|
Logger model.Logger
|
||||||
|
|
||||||
|
// Sema is the MANDATORY semaphore to allow just a single
|
||||||
|
// connection to perform the HTTP transaction.
|
||||||
|
Sema <-chan any
|
||||||
|
|
||||||
|
// TestKeys is MANDATORY and contains the TestKeys.
|
||||||
|
TestKeys *TestKeys
|
||||||
|
|
||||||
|
// ZeroTime is the MANDATORY measurement's zero time.
|
||||||
|
ZeroTime time.Time
|
||||||
|
|
||||||
|
// WaitGroup is the MANDATORY wait group this task belongs to.
|
||||||
|
WaitGroup *sync.WaitGroup
|
||||||
|
|
||||||
|
// CookieJar contains the OPTIONAL cookie jar, used for redirects.
|
||||||
|
CookieJar http.CookieJar
|
||||||
|
|
||||||
|
// FollowRedirects is OPTIONAL and instructs this flow
|
||||||
|
// to follow HTTP redirects (if any).
|
||||||
|
FollowRedirects bool
|
||||||
|
|
||||||
|
// HostHeader is the OPTIONAL host header to use.
|
||||||
|
HostHeader string
|
||||||
|
|
||||||
|
// Referer contains the OPTIONAL referer, used for redirects.
|
||||||
|
Referer string
|
||||||
|
|
||||||
|
// UDPAddress is the OPTIONAL address of the UDP resolver to use. If this
|
||||||
|
// field is not set we use a default one (e.g., `8.8.8.8:53`).
|
||||||
|
UDPAddress string
|
||||||
|
|
||||||
|
// URLPath is the OPTIONAL URL path.
|
||||||
|
URLPath string
|
||||||
|
|
||||||
|
// URLRawQuery is the OPTIONAL URL raw query.
|
||||||
|
URLRawQuery string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts this task in a background goroutine.
|
||||||
|
func (t *CleartextFlow) Start(ctx context.Context) {
|
||||||
|
t.WaitGroup.Add(1)
|
||||||
|
index := t.IDGenerator.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer t.WaitGroup.Done() // synchronize with the parent
|
||||||
|
t.Run(ctx, index)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs this task in the current goroutine.
|
||||||
|
func (t *CleartextFlow) Run(parentCtx context.Context, index int64) {
|
||||||
|
// create trace
|
||||||
|
trace := measurexlite.NewTrace(index, t.ZeroTime)
|
||||||
|
|
||||||
|
// start the operation logger
|
||||||
|
ol := measurexlite.NewOperationLogger(
|
||||||
|
t.Logger, "[#%d] GET http://%s using %s", index, t.HostHeader, t.Address,
|
||||||
|
)
|
||||||
|
|
||||||
|
// perform the TCP connect
|
||||||
|
const tcpTimeout = 10 * time.Second
|
||||||
|
tcpCtx, tcpCancel := context.WithTimeout(parentCtx, tcpTimeout)
|
||||||
|
defer tcpCancel()
|
||||||
|
tcpDialer := trace.NewDialerWithoutResolver(t.Logger)
|
||||||
|
tcpConn, err := tcpDialer.DialContext(tcpCtx, "tcp", t.Address)
|
||||||
|
t.TestKeys.AppendTCPConnectResults(trace.TCPConnects()...)
|
||||||
|
if err != nil {
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
t.TestKeys.AppendNetworkEvents(trace.NetworkEvents()...)
|
||||||
|
tcpConn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
alpn := "" // no ALPN because we're not using TLS
|
||||||
|
|
||||||
|
// Only allow N flows to _use_ the connection
|
||||||
|
select {
|
||||||
|
case <-t.Sema:
|
||||||
|
default:
|
||||||
|
ol.Stop(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// create HTTP transport
|
||||||
|
httpTransport := netxlite.NewHTTPTransport(
|
||||||
|
t.Logger,
|
||||||
|
netxlite.NewSingleUseDialer(tcpConn),
|
||||||
|
netxlite.NewNullTLSDialer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// create HTTP request
|
||||||
|
const httpTimeout = 10 * time.Second
|
||||||
|
httpCtx, httpCancel := context.WithTimeout(parentCtx, httpTimeout)
|
||||||
|
defer httpCancel()
|
||||||
|
httpReq, err := t.newHTTPRequest(httpCtx)
|
||||||
|
if err != nil {
|
||||||
|
if t.Referer == "" {
|
||||||
|
// when the referer is empty, the failing URL comes from our backend
|
||||||
|
// or from the user, so it's a fundamental failure. After that, we
|
||||||
|
// are dealing with websites provided URLs, so we should not flag a
|
||||||
|
// fundamental failure, because we want to see the measurement submitted.
|
||||||
|
t.TestKeys.SetFundamentalFailure(err)
|
||||||
|
}
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform HTTP transaction
|
||||||
|
httpResp, httpRespBody, err := t.httpTransaction(
|
||||||
|
httpCtx,
|
||||||
|
"tcp",
|
||||||
|
t.Address,
|
||||||
|
alpn,
|
||||||
|
httpTransport,
|
||||||
|
httpReq,
|
||||||
|
trace,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if enabled, follow possible redirects
|
||||||
|
t.maybeFollowRedirects(parentCtx, httpResp)
|
||||||
|
|
||||||
|
// TODO: insert here additional code if needed
|
||||||
|
_ = httpRespBody
|
||||||
|
|
||||||
|
// completed successfully
|
||||||
|
ol.Stop(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlHost computes the host to include into the URL
|
||||||
|
func (t *CleartextFlow) urlHost(scheme string) (string, error) {
|
||||||
|
addr, port, err := net.SplitHostPort(t.Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Logger.Warnf("BUG: net.SplitHostPort failed for %s: %s", t.Address, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
urlHost := t.HostHeader
|
||||||
|
if urlHost == "" {
|
||||||
|
urlHost = addr
|
||||||
|
}
|
||||||
|
if port == "80" && scheme == "http" {
|
||||||
|
return urlHost, nil
|
||||||
|
}
|
||||||
|
urlHost = net.JoinHostPort(urlHost, port)
|
||||||
|
return urlHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHTTPRequest creates a new HTTP request.
|
||||||
|
func (t *CleartextFlow) newHTTPRequest(ctx context.Context) (*http.Request, error) {
|
||||||
|
const urlScheme = "http"
|
||||||
|
urlHost, err := t.urlHost(urlScheme)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpURL := &url.URL{
|
||||||
|
Scheme: urlScheme,
|
||||||
|
Host: urlHost,
|
||||||
|
Path: t.URLPath,
|
||||||
|
RawQuery: t.URLRawQuery,
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "GET", httpURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Host", t.HostHeader)
|
||||||
|
httpReq.Header.Set("Accept", model.HTTPHeaderAccept)
|
||||||
|
httpReq.Header.Set("Accept-Language", model.HTTPHeaderAcceptLanguage)
|
||||||
|
httpReq.Header.Set("Referer", t.Referer)
|
||||||
|
httpReq.Header.Set("User-Agent", model.HTTPHeaderUserAgent)
|
||||||
|
httpReq.Host = t.HostHeader
|
||||||
|
if t.CookieJar != nil {
|
||||||
|
for _, cookie := range t.CookieJar.Cookies(httpURL) {
|
||||||
|
httpReq.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httpReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpTransaction runs the HTTP transaction and saves the results.
|
||||||
|
func (t *CleartextFlow) httpTransaction(ctx context.Context, network, address, alpn string,
|
||||||
|
txp model.HTTPTransport, req *http.Request, trace *measurexlite.Trace) (*http.Response, []byte, error) {
|
||||||
|
const maxbody = 1 << 19
|
||||||
|
started := trace.TimeSince(trace.ZeroTime)
|
||||||
|
resp, err := txp.RoundTrip(req)
|
||||||
|
var body []byte
|
||||||
|
if err == nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if cookies := resp.Cookies(); t.CookieJar != nil && len(cookies) > 0 {
|
||||||
|
t.CookieJar.SetCookies(req.URL, cookies)
|
||||||
|
}
|
||||||
|
reader := io.LimitReader(resp.Body, maxbody)
|
||||||
|
body, err = netxlite.ReadAllContext(ctx, reader)
|
||||||
|
}
|
||||||
|
finished := trace.TimeSince(trace.ZeroTime)
|
||||||
|
ev := measurexlite.NewArchivalHTTPRequestResult(
|
||||||
|
trace.Index,
|
||||||
|
started,
|
||||||
|
network,
|
||||||
|
address,
|
||||||
|
alpn,
|
||||||
|
txp.Network(),
|
||||||
|
req,
|
||||||
|
resp,
|
||||||
|
maxbody,
|
||||||
|
body,
|
||||||
|
err,
|
||||||
|
finished,
|
||||||
|
)
|
||||||
|
t.TestKeys.AppendRequests(ev)
|
||||||
|
return resp, body, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeFollowRedirects follows redirects if configured and needed
|
||||||
|
func (t *CleartextFlow) maybeFollowRedirects(ctx context.Context, resp *http.Response) {
|
||||||
|
if !t.FollowRedirects {
|
||||||
|
return // not configured
|
||||||
|
}
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case 301, 302, 307, 308:
|
||||||
|
location, err := resp.Location()
|
||||||
|
if err != nil {
|
||||||
|
return // broken response from server
|
||||||
|
}
|
||||||
|
t.Logger.Infof("redirect to: %s", location.String())
|
||||||
|
resolvers := &DNSResolvers{
|
||||||
|
CookieJar: t.CookieJar,
|
||||||
|
DNSCache: t.DNSCache,
|
||||||
|
Domain: location.Hostname(),
|
||||||
|
IDGenerator: t.IDGenerator,
|
||||||
|
Logger: t.Logger,
|
||||||
|
TestKeys: t.TestKeys,
|
||||||
|
URL: location,
|
||||||
|
ZeroTime: t.ZeroTime,
|
||||||
|
WaitGroup: t.WaitGroup,
|
||||||
|
Referer: resp.Request.URL.String(),
|
||||||
|
Session: nil, // no need to issue another control request
|
||||||
|
THAddr: "", // ditto
|
||||||
|
UDPAddress: t.UDPAddress,
|
||||||
|
}
|
||||||
|
resolvers.Start(ctx)
|
||||||
|
default:
|
||||||
|
// no redirect to follow
|
||||||
|
}
|
||||||
|
}
|
8
internal/experiment/webconnectivity/config.go
Normal file
8
internal/experiment/webconnectivity/config.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// Config
|
||||||
|
//
|
||||||
|
|
||||||
|
// Config contains webconnectivity experiment configuration.
|
||||||
|
type Config struct{}
|
177
internal/experiment/webconnectivity/control.go
Normal file
177
internal/experiment/webconnectivity/control.go
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/httpx"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/measurexlite"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EndpointMeasurementsStarter is used by Control to start extra
|
||||||
|
// measurements using new IP addrs discovered by the TH.
|
||||||
|
type EndpointMeasurementsStarter interface {
|
||||||
|
// startCleartextFlowsWithSema starts a TCP measurement flow for each IP addr. The [sema]
|
||||||
|
// argument allows to control how many flows are allowed to perform HTTP measurements. Every
|
||||||
|
// flow will attempt to read from [sema] and won't perform HTTP measurements if a
|
||||||
|
// nonblocking read fails. Hence, you must create a [sema] channel with buffer equal
|
||||||
|
// to N and N elements inside it to allow N flows to perform HTTP measurements. Passing
|
||||||
|
// a nil [sema] causes no flow to attempt HTTP measurements.
|
||||||
|
startCleartextFlowsWithSema(ctx context.Context, sema <-chan any, addresses []string)
|
||||||
|
|
||||||
|
// startSecureFlowsWithSema starts a TCP+TLS measurement flow for each IP addr. See
|
||||||
|
// the docs of startCleartextFlowsWithSema for more info on the [sema] arg.
|
||||||
|
startSecureFlowsWithSema(ctx context.Context, sema <-chan any, addresses []string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control issues a Control request and saves the results
|
||||||
|
// inside of the experiment's TestKeys.
|
||||||
|
//
|
||||||
|
// The zero value of this structure IS NOT valid and you MUST initialize
|
||||||
|
// all the fields marked as MANDATORY before using this structure.
|
||||||
|
type Control struct {
|
||||||
|
// Addresses contains the MANDATORY addresses we've looked up.
|
||||||
|
Addresses []string
|
||||||
|
|
||||||
|
// ExtraMeasurementsStarter is MANDATORY and allows this struct to
|
||||||
|
// start additional measurements using new TH-discovered addrs.
|
||||||
|
ExtraMeasurementsStarter EndpointMeasurementsStarter
|
||||||
|
|
||||||
|
// Logger is the MANDATORY logger to use.
|
||||||
|
Logger model.Logger
|
||||||
|
|
||||||
|
// TestKeys is MANDATORY and contains the TestKeys.
|
||||||
|
TestKeys *TestKeys
|
||||||
|
|
||||||
|
// Session is the MANDATORY session to use.
|
||||||
|
Session model.ExperimentSession
|
||||||
|
|
||||||
|
// THAddr is the MANDATORY TH's URL.
|
||||||
|
THAddr string
|
||||||
|
|
||||||
|
// URL is the MANDATORY URL we are measuring.
|
||||||
|
URL *url.URL
|
||||||
|
|
||||||
|
// WaitGroup is the MANDATORY wait group this task belongs to.
|
||||||
|
WaitGroup *sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts this task in a background goroutine.
|
||||||
|
func (c *Control) Start(ctx context.Context) {
|
||||||
|
c.WaitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer c.WaitGroup.Done() // synchronize with the parent
|
||||||
|
c.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs this task until completion.
|
||||||
|
func (c *Control) Run(parentCtx context.Context) {
|
||||||
|
// create a subcontext attached to a maximum timeout
|
||||||
|
const timeout = 30 * time.Second
|
||||||
|
opCtx, cancel := context.WithTimeout(parentCtx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// create control request
|
||||||
|
var endpoints []string
|
||||||
|
for _, address := range c.Addresses {
|
||||||
|
if port := c.URL.Port(); port != "" { // handle the case of a custom port
|
||||||
|
endpoints = append(endpoints, net.JoinHostPort(address, port))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// otherwise, always attempt to measure both 443 and 80 endpoints
|
||||||
|
endpoints = append(endpoints, net.JoinHostPort(address, "443"))
|
||||||
|
endpoints = append(endpoints, net.JoinHostPort(address, "80"))
|
||||||
|
}
|
||||||
|
creq := &webconnectivity.ControlRequest{
|
||||||
|
HTTPRequest: c.URL.String(),
|
||||||
|
HTTPRequestHeaders: map[string][]string{
|
||||||
|
"Accept": {model.HTTPHeaderAccept},
|
||||||
|
"Accept-Language": {model.HTTPHeaderAcceptLanguage},
|
||||||
|
"User-Agent": {model.HTTPHeaderUserAgent},
|
||||||
|
},
|
||||||
|
TCPConnect: endpoints,
|
||||||
|
}
|
||||||
|
c.TestKeys.SetControlRequest(creq)
|
||||||
|
|
||||||
|
// TODO(bassosimone): the current TH will not perform TLS measurements for
|
||||||
|
// 443 endpoints. However, we should modify the TH to do that, such that we're
|
||||||
|
// able to be more confident about TLS measurements results.
|
||||||
|
|
||||||
|
// create logger for this operation
|
||||||
|
ol := measurexlite.NewOperationLogger(c.Logger, "control for %s", creq.HTTPRequest)
|
||||||
|
|
||||||
|
// create an API client
|
||||||
|
clnt := (&httpx.APIClientTemplate{
|
||||||
|
Accept: "",
|
||||||
|
Authorization: "",
|
||||||
|
BaseURL: c.THAddr,
|
||||||
|
HTTPClient: c.Session.DefaultHTTPClient(),
|
||||||
|
Host: "", // use the one inside the URL
|
||||||
|
LogBody: true,
|
||||||
|
Logger: c.Logger,
|
||||||
|
UserAgent: c.Session.UserAgent(),
|
||||||
|
}).Build()
|
||||||
|
|
||||||
|
// issue the control request and wait for the response
|
||||||
|
var cresp webconnectivity.ControlResponse
|
||||||
|
err := clnt.PostJSON(opCtx, "/", creq, &cresp)
|
||||||
|
if err != nil {
|
||||||
|
// make sure error is wrapped
|
||||||
|
err = netxlite.NewTopLevelGenericErrWrapper(err)
|
||||||
|
c.TestKeys.SetControlFailure(err)
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the TH returned us addresses we did not previously were
|
||||||
|
// aware of, make sure we also measure them
|
||||||
|
c.maybeStartExtraMeasurements(parentCtx, cresp.DNS.Addrs)
|
||||||
|
|
||||||
|
// on success, save the control response
|
||||||
|
c.TestKeys.SetControl(&cresp)
|
||||||
|
ol.Stop(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function determines whether we should start new
|
||||||
|
// background measurements for previously unknown IP addrs.
|
||||||
|
func (c *Control) maybeStartExtraMeasurements(ctx context.Context, thAddrs []string) {
|
||||||
|
// classify addeesses by who discovered them
|
||||||
|
const (
|
||||||
|
inProbe = 1 << iota
|
||||||
|
inTH
|
||||||
|
)
|
||||||
|
mapping := make(map[string]int)
|
||||||
|
for _, addr := range c.Addresses {
|
||||||
|
mapping[addr] |= inProbe
|
||||||
|
}
|
||||||
|
for _, addr := range thAddrs {
|
||||||
|
mapping[addr] |= inTH
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtain the TH-only addresses
|
||||||
|
var thOnly []string
|
||||||
|
for addr, flags := range mapping {
|
||||||
|
if (flags & inProbe) != 0 {
|
||||||
|
continue // discovered by the probe => already tested
|
||||||
|
}
|
||||||
|
thOnly = append(thOnly, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start extra measurements for TH-only addresses. Because we already
|
||||||
|
// measured HTTP(S) using IP addrs discovered by the resolvers, we are not
|
||||||
|
// going to do that again now. I am not sure this is the right policy
|
||||||
|
// but I think we can just try it and then change if needed...
|
||||||
|
//
|
||||||
|
// Also, let's remember that reading from a nil chan blocks forever, so
|
||||||
|
// we're basically forcing the goroutines to avoid HTTP(S).
|
||||||
|
var nohttp chan any = nil
|
||||||
|
c.ExtraMeasurementsStarter.startCleartextFlowsWithSema(ctx, nohttp, thOnly)
|
||||||
|
c.ExtraMeasurementsStarter.startSecureFlowsWithSema(ctx, nohttp, thOnly)
|
||||||
|
}
|
37
internal/experiment/webconnectivity/dnscache.go
Normal file
37
internal/experiment/webconnectivity/dnscache.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// DNSCache wraps a model.Resolver to provide DNS caching.
|
||||||
|
//
|
||||||
|
// The zero value is invalid; please, use NewDNSCache to construct.
|
||||||
|
type DNSCache struct {
|
||||||
|
// mu provides mutual exclusion.
|
||||||
|
mu *sync.Mutex
|
||||||
|
|
||||||
|
// values contains already resolved values.
|
||||||
|
values map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets values from the cache
|
||||||
|
func (c *DNSCache) Get(domain string) ([]string, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
values, found := c.values[domain]
|
||||||
|
c.mu.Unlock()
|
||||||
|
return values, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set inserts into the cache
|
||||||
|
func (c *DNSCache) Set(domain string, values []string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.values[domain] = values
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDNSCache creates a new DNSCache instance.
|
||||||
|
func NewDNSCache() *DNSCache {
|
||||||
|
return &DNSCache{
|
||||||
|
mu: &sync.Mutex{},
|
||||||
|
values: map[string][]string{},
|
||||||
|
}
|
||||||
|
}
|
498
internal/experiment/webconnectivity/dnsresolvers.go
Normal file
498
internal/experiment/webconnectivity/dnsresolvers.go
Normal file
|
@ -0,0 +1,498 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// DNSResolvers
|
||||||
|
//
|
||||||
|
// Generated by `boilerplate' using the multi-resolver template.
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/apex/log"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/atomicx"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/measurexlite"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolves the URL's domain using several resolvers.
|
||||||
|
//
|
||||||
|
// The zero value of this structure IS NOT valid and you MUST initialize
|
||||||
|
// all the fields marked as MANDATORY before using this structure.
|
||||||
|
type DNSResolvers struct {
|
||||||
|
// DNSCache is the MANDATORY DNS cache.
|
||||||
|
DNSCache *DNSCache
|
||||||
|
|
||||||
|
// Domain is the MANDATORY domain to resolve.
|
||||||
|
Domain string
|
||||||
|
|
||||||
|
// IDGenerator is the MANDATORY atomic int64 to generate task IDs.
|
||||||
|
IDGenerator *atomicx.Int64
|
||||||
|
|
||||||
|
// Logger is the MANDATORY logger to use.
|
||||||
|
Logger model.Logger
|
||||||
|
|
||||||
|
// TestKeys is MANDATORY and contains the TestKeys.
|
||||||
|
TestKeys *TestKeys
|
||||||
|
|
||||||
|
// URL is the MANDATORY URL we're measuring.
|
||||||
|
URL *url.URL
|
||||||
|
|
||||||
|
// ZeroTime is the MANDATORY zero time of the measurement.
|
||||||
|
ZeroTime time.Time
|
||||||
|
|
||||||
|
// WaitGroup is the MANDATORY wait group this task belongs to.
|
||||||
|
WaitGroup *sync.WaitGroup
|
||||||
|
|
||||||
|
// CookieJar contains the OPTIONAL cookie jar, used for redirects.
|
||||||
|
CookieJar http.CookieJar
|
||||||
|
|
||||||
|
// Referer contains the OPTIONAL referer, used for redirects.
|
||||||
|
Referer string
|
||||||
|
|
||||||
|
// Session is the OPTIONAL session. If the session is set, we will use
|
||||||
|
// it to start the task that issues the control request. This request must
|
||||||
|
// only be sent during the first iteration. It would be pointless to
|
||||||
|
// issue such a request for subsequent redirects, because the TH will
|
||||||
|
// always follow the redirect chain caused by the provided URL.
|
||||||
|
Session model.ExperimentSession
|
||||||
|
|
||||||
|
// THAddr is the OPTIONAL test helper address.
|
||||||
|
THAddr string
|
||||||
|
|
||||||
|
// UDPAddress is the OPTIONAL address of the UDP resolver to use. If this
|
||||||
|
// field is not set we use a default one (e.g., `8.8.8.8:53`).
|
||||||
|
UDPAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts this task in a background goroutine.
|
||||||
|
func (t *DNSResolvers) Start(ctx context.Context) {
|
||||||
|
t.WaitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer t.WaitGroup.Done() // synchronize with the parent
|
||||||
|
t.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// run performs a DNS lookup and returns the looked up addrs
|
||||||
|
func (t *DNSResolvers) run(parentCtx context.Context) []string {
|
||||||
|
// create output channels for the lookup
|
||||||
|
systemOut := make(chan []string)
|
||||||
|
udpOut := make(chan []string)
|
||||||
|
httpsOut := make(chan []string)
|
||||||
|
whoamiSystemV4Out := make(chan []DNSWhoamiInfoEntry)
|
||||||
|
whoamiUDPv4Out := make(chan []DNSWhoamiInfoEntry)
|
||||||
|
|
||||||
|
// TODO(bassosimone): add opportunistic support for detecting
|
||||||
|
// whether DNS queries are answered regardless of dest addr by
|
||||||
|
// sending a few queries to root DNS servers
|
||||||
|
|
||||||
|
udpAddress := t.udpAddress()
|
||||||
|
|
||||||
|
// start asynchronous lookups
|
||||||
|
go t.lookupHostSystem(parentCtx, systemOut)
|
||||||
|
go t.lookupHostUDP(parentCtx, udpAddress, udpOut)
|
||||||
|
go t.lookupHostDNSOverHTTPS(parentCtx, httpsOut)
|
||||||
|
go t.whoamiSystemV4(parentCtx, whoamiSystemV4Out)
|
||||||
|
go t.whoamiUDPv4(parentCtx, udpAddress, whoamiUDPv4Out)
|
||||||
|
|
||||||
|
// collect resulting IP addresses (which may be nil/empty lists)
|
||||||
|
systemAddrs := <-systemOut
|
||||||
|
udpAddrs := <-udpOut
|
||||||
|
httpsAddrs := <-httpsOut
|
||||||
|
|
||||||
|
// collect whoami results (which also may be nil/empty)
|
||||||
|
whoamiSystemV4 := <-whoamiSystemV4Out
|
||||||
|
whoamiUDPv4 := <-whoamiUDPv4Out
|
||||||
|
t.TestKeys.WithDNSWhoami(func(di *DNSWhoamiInfo) {
|
||||||
|
di.SystemV4 = whoamiSystemV4
|
||||||
|
di.UDPv4[udpAddress] = whoamiUDPv4
|
||||||
|
})
|
||||||
|
|
||||||
|
// merge the resolved IP addresses
|
||||||
|
merged := map[string]bool{}
|
||||||
|
for _, addr := range systemAddrs {
|
||||||
|
merged[addr] = true
|
||||||
|
}
|
||||||
|
for _, addr := range udpAddrs {
|
||||||
|
merged[addr] = true
|
||||||
|
}
|
||||||
|
for _, addr := range httpsAddrs {
|
||||||
|
merged[addr] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rearrange addresses to have IPv4 first
|
||||||
|
sorted := []string{}
|
||||||
|
for addr := range merged {
|
||||||
|
if v6, err := netxlite.IsIPv6(addr); err == nil && !v6 {
|
||||||
|
sorted = append(sorted, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for addr := range merged {
|
||||||
|
if v6, err := netxlite.IsIPv6(addr); err == nil && v6 {
|
||||||
|
sorted = append(sorted, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(bassosimone): remove bogons
|
||||||
|
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs this task in the current goroutine.
|
||||||
|
func (t *DNSResolvers) Run(parentCtx context.Context) {
|
||||||
|
var (
|
||||||
|
addresses []string
|
||||||
|
found bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// attempt to use the dns cache
|
||||||
|
addresses, found = t.DNSCache.Get(t.Domain)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
// fall back to performing a real dns lookup
|
||||||
|
addresses = t.run(parentCtx)
|
||||||
|
|
||||||
|
// insert the addresses we just looked us into the cache
|
||||||
|
t.DNSCache.Set(t.Domain, addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("using resolved addrs: %+v", addresses)
|
||||||
|
|
||||||
|
// fan out a number of child async tasks to use the IP addrs
|
||||||
|
t.startCleartextFlows(parentCtx, addresses)
|
||||||
|
t.startSecureFlows(parentCtx, addresses)
|
||||||
|
t.maybeStartControlFlow(parentCtx, addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// whoamiSystemV4 performs a DNS whoami lookup for the system resolver. This function must
|
||||||
|
// always emit an ouput on the [out] channel to synchronize with the caller func.
|
||||||
|
func (t *DNSResolvers) whoamiSystemV4(parentCtx context.Context, out chan<- []DNSWhoamiInfoEntry) {
|
||||||
|
value, _ := DNSWhoamiSingleton.SystemV4(parentCtx)
|
||||||
|
t.Logger.Infof("DNS whoami for system resolver: %+v", value)
|
||||||
|
out <- value
|
||||||
|
}
|
||||||
|
|
||||||
|
// whoamiUDPv4 performs a DNS whoami lookup for the given UDP resolver. This function must
|
||||||
|
// always emit an ouput on the [out] channel to synchronize with the caller func.
|
||||||
|
func (t *DNSResolvers) whoamiUDPv4(parentCtx context.Context, udpAddress string, out chan<- []DNSWhoamiInfoEntry) {
|
||||||
|
value, _ := DNSWhoamiSingleton.UDPv4(parentCtx, udpAddress)
|
||||||
|
t.Logger.Infof("DNS whoami for %s/udp resolver: %+v", udpAddress, value)
|
||||||
|
out <- value
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupHostSystem performs a DNS lookup using the system resolver. This function must
|
||||||
|
// always emit an ouput on the [out] channel to synchronize with the caller func.
|
||||||
|
func (t *DNSResolvers) lookupHostSystem(parentCtx context.Context, out chan<- []string) {
|
||||||
|
// create context with attached a timeout
|
||||||
|
const timeout = 4 * time.Second
|
||||||
|
lookupCtx, lookpCancel := context.WithTimeout(parentCtx, timeout)
|
||||||
|
defer lookpCancel()
|
||||||
|
|
||||||
|
// create trace's index
|
||||||
|
index := t.IDGenerator.Add(1)
|
||||||
|
|
||||||
|
// create trace
|
||||||
|
trace := measurexlite.NewTrace(index, t.ZeroTime)
|
||||||
|
|
||||||
|
// start the operation logger
|
||||||
|
ol := measurexlite.NewOperationLogger(
|
||||||
|
t.Logger, "[#%d] lookup %s using system", index, t.Domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
// runs the lookup
|
||||||
|
reso := trace.NewStdlibResolver(t.Logger)
|
||||||
|
addrs, err := reso.LookupHost(lookupCtx, t.Domain)
|
||||||
|
t.TestKeys.AppendQueries(trace.DNSLookupsFromRoundTrip()...)
|
||||||
|
ol.Stop(err)
|
||||||
|
out <- addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupHostUDP performs a DNS lookup using an UDP resolver. This function must always
|
||||||
|
// emit an ouput on the [out] channel to synchronize with the caller func.
|
||||||
|
func (t *DNSResolvers) lookupHostUDP(parentCtx context.Context, udpAddress string, out chan<- []string) {
|
||||||
|
// create context with attached a timeout
|
||||||
|
const timeout = 4 * time.Second
|
||||||
|
lookupCtx, lookpCancel := context.WithTimeout(parentCtx, timeout)
|
||||||
|
defer lookpCancel()
|
||||||
|
|
||||||
|
// create trace's index
|
||||||
|
index := t.IDGenerator.Add(1)
|
||||||
|
|
||||||
|
// create trace
|
||||||
|
trace := measurexlite.NewTrace(index, t.ZeroTime)
|
||||||
|
|
||||||
|
// start the operation logger
|
||||||
|
ol := measurexlite.NewOperationLogger(
|
||||||
|
t.Logger, "[#%d] lookup %s using %s", index, t.Domain, udpAddress,
|
||||||
|
)
|
||||||
|
|
||||||
|
// runs the lookup
|
||||||
|
dialer := netxlite.NewDialerWithoutResolver(t.Logger)
|
||||||
|
reso := trace.NewParallelUDPResolver(t.Logger, dialer, udpAddress)
|
||||||
|
addrs, err := reso.LookupHost(lookupCtx, t.Domain)
|
||||||
|
|
||||||
|
// saves the results making sure we split Do53 queries from other queries
|
||||||
|
do53, other := t.do53SplitQueries(trace.DNSLookupsFromRoundTrip())
|
||||||
|
t.TestKeys.AppendQueries(do53...)
|
||||||
|
t.TestKeys.WithTestKeysDo53(func(tkd *TestKeysDo53) {
|
||||||
|
tkd.Queries = append(tkd.Queries, other...)
|
||||||
|
tkd.NetworkEvents = append(tkd.NetworkEvents, trace.NetworkEvents()...)
|
||||||
|
})
|
||||||
|
|
||||||
|
ol.Stop(err)
|
||||||
|
out <- addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divides queries generated by Do53 in Do53-proper queries and other queries.
|
||||||
|
func (t *DNSResolvers) do53SplitQueries(
|
||||||
|
input []*model.ArchivalDNSLookupResult) (do53, other []*model.ArchivalDNSLookupResult) {
|
||||||
|
for _, query := range input {
|
||||||
|
switch query.Engine {
|
||||||
|
case "udp", "tcp":
|
||||||
|
do53 = append(do53, query)
|
||||||
|
default:
|
||||||
|
other = append(other, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(bassosimone): maybe cycle through a bunch of well known addresses
|
||||||
|
|
||||||
|
// Returns the UDP resolver we should be using by default.
|
||||||
|
func (t *DNSResolvers) udpAddress() string {
|
||||||
|
if t.UDPAddress != "" {
|
||||||
|
return t.UDPAddress
|
||||||
|
}
|
||||||
|
return "8.8.4.4:53"
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpportunisticDNSOverHTTPS allows to perform opportunistic DNS-over-HTTPS
|
||||||
|
// measurements as part of Web Connectivity.
|
||||||
|
type OpportunisticDNSOverHTTPS struct {
|
||||||
|
// interval is the next interval after which to measure.
|
||||||
|
interval time.Duration
|
||||||
|
|
||||||
|
// mu provides mutual exclusion
|
||||||
|
mu *sync.Mutex
|
||||||
|
|
||||||
|
// rnd is the random number generator to use.
|
||||||
|
rnd *rand.Rand
|
||||||
|
|
||||||
|
// t is when we last run an opportunistic measurement.
|
||||||
|
t time.Time
|
||||||
|
|
||||||
|
// urls contains the urls of known DoH services.
|
||||||
|
urls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaybeNextURL returns the next URL to measure, if any. Our aim is to perform
|
||||||
|
// periodic, opportunistic DoH measurements as part of Web Connectivity.
|
||||||
|
func (o *OpportunisticDNSOverHTTPS) MaybeNextURL() (string, bool) {
|
||||||
|
now := time.Now()
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
if o.t.IsZero() || now.Sub(o.t) > o.interval {
|
||||||
|
o.rnd.Shuffle(len(o.urls), func(i, j int) {
|
||||||
|
o.urls[i], o.urls[j] = o.urls[j], o.urls[i]
|
||||||
|
})
|
||||||
|
o.t = now
|
||||||
|
o.interval = time.Duration(20+o.rnd.Uint32()%20) * time.Second
|
||||||
|
return o.urls[0], true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(bassosimone): consider whether factoring out this code
|
||||||
|
// and storing the state on disk instead of using memory
|
||||||
|
|
||||||
|
// TODO(bassosimone): consider unifying somehow this code and
|
||||||
|
// the systemresolver code (or maybe just the list of resolvers)
|
||||||
|
|
||||||
|
// OpportunisticDNSOverHTTPSSingleton is the singleton used to keep
|
||||||
|
// track of the opportunistic DNS-over-HTTPS measurements state.
|
||||||
|
var OpportunisticDNSOverHTTPSSingleton = &OpportunisticDNSOverHTTPS{
|
||||||
|
interval: 0,
|
||||||
|
mu: &sync.Mutex{},
|
||||||
|
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
|
t: time.Time{},
|
||||||
|
urls: []string{
|
||||||
|
"https://mozilla.cloudflare-dns.com/dns-query",
|
||||||
|
"https://dns.nextdns.io/dns-query",
|
||||||
|
"https://dns.google/dns-query",
|
||||||
|
"https://dns.quad9.net/dns-query",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupHostDNSOverHTTPS performs a DNS lookup using a DoH resolver. This function must
|
||||||
|
// always emit an ouput on the [out] channel to synchronize with the caller func.
|
||||||
|
func (t *DNSResolvers) lookupHostDNSOverHTTPS(parentCtx context.Context, out chan<- []string) {
|
||||||
|
// obtain an opportunistic DoH URL
|
||||||
|
URL, good := OpportunisticDNSOverHTTPSSingleton.MaybeNextURL()
|
||||||
|
if !good {
|
||||||
|
// no need to perform opportunistic DoH at this time but we still
|
||||||
|
// need to fake out a lookup to please our caller
|
||||||
|
out <- []string{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// create context with attached a timeout
|
||||||
|
const timeout = 4 * time.Second
|
||||||
|
lookupCtx, lookpCancel := context.WithTimeout(parentCtx, timeout)
|
||||||
|
defer lookpCancel()
|
||||||
|
|
||||||
|
// create trace's index
|
||||||
|
index := t.IDGenerator.Add(1)
|
||||||
|
|
||||||
|
// create trace
|
||||||
|
trace := measurexlite.NewTrace(index, t.ZeroTime)
|
||||||
|
|
||||||
|
// start the operation logger
|
||||||
|
ol := measurexlite.NewOperationLogger(
|
||||||
|
t.Logger, "[#%d] lookup %s using %s", index, t.Domain, URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
// runs the lookup
|
||||||
|
reso := trace.NewParallelDNSOverHTTPSResolver(t.Logger, URL)
|
||||||
|
addrs, err := reso.LookupHost(lookupCtx, t.Domain)
|
||||||
|
reso.CloseIdleConnections()
|
||||||
|
|
||||||
|
// save results making sure we properly split DoH queries from other queries
|
||||||
|
doh, other := t.dohSplitQueries(trace.DNSLookupsFromRoundTrip())
|
||||||
|
t.TestKeys.AppendQueries(doh...)
|
||||||
|
t.TestKeys.WithTestKeysDoH(func(tkdh *TestKeysDoH) {
|
||||||
|
tkdh.Queries = append(tkdh.Queries, other...)
|
||||||
|
tkdh.NetworkEvents = append(tkdh.NetworkEvents, trace.NetworkEvents()...)
|
||||||
|
tkdh.TCPConnect = append(tkdh.TCPConnect, trace.TCPConnects()...)
|
||||||
|
tkdh.TLSHandshakes = append(tkdh.TLSHandshakes, trace.TLSHandshakes()...)
|
||||||
|
})
|
||||||
|
|
||||||
|
ol.Stop(err)
|
||||||
|
out <- addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divides queries generated by DoH in DoH-proper queries and other queries.
|
||||||
|
func (t *DNSResolvers) dohSplitQueries(
|
||||||
|
input []*model.ArchivalDNSLookupResult) (doh, other []*model.ArchivalDNSLookupResult) {
|
||||||
|
for _, query := range input {
|
||||||
|
switch query.Engine {
|
||||||
|
case "doh":
|
||||||
|
doh = append(doh, query)
|
||||||
|
default:
|
||||||
|
other = append(other, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCleartextFlows starts a TCP measurement flow for each IP addr.
|
||||||
|
func (t *DNSResolvers) startCleartextFlows(ctx context.Context, addresses []string) {
|
||||||
|
sema := make(chan any, 1)
|
||||||
|
sema <- true // allow a single flow to fetch the HTTP body
|
||||||
|
t.startCleartextFlowsWithSema(ctx, sema, addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCleartextFlowsWithSema implements EndpointMeasurementsStarter.
|
||||||
|
func (t *DNSResolvers) startCleartextFlowsWithSema(ctx context.Context, sema <-chan any, addresses []string) {
|
||||||
|
if t.URL.Scheme != "http" {
|
||||||
|
// Do not bother with measuring HTTP when the user
|
||||||
|
// has asked us to measure an HTTPS URL.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port := "80"
|
||||||
|
if urlPort := t.URL.Port(); urlPort != "" {
|
||||||
|
port = urlPort
|
||||||
|
}
|
||||||
|
for _, addr := range addresses {
|
||||||
|
task := &CleartextFlow{
|
||||||
|
Address: net.JoinHostPort(addr, port),
|
||||||
|
DNSCache: t.DNSCache,
|
||||||
|
IDGenerator: t.IDGenerator,
|
||||||
|
Logger: t.Logger,
|
||||||
|
Sema: sema,
|
||||||
|
TestKeys: t.TestKeys,
|
||||||
|
ZeroTime: t.ZeroTime,
|
||||||
|
WaitGroup: t.WaitGroup,
|
||||||
|
CookieJar: t.CookieJar,
|
||||||
|
FollowRedirects: t.URL.Scheme == "http",
|
||||||
|
HostHeader: t.URL.Host,
|
||||||
|
Referer: t.Referer,
|
||||||
|
UDPAddress: t.UDPAddress,
|
||||||
|
URLPath: t.URL.Path,
|
||||||
|
URLRawQuery: t.URL.RawQuery,
|
||||||
|
}
|
||||||
|
task.Start(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSecureFlows starts a TCP+TLS measurement flow for each IP addr.
|
||||||
|
func (t *DNSResolvers) startSecureFlows(ctx context.Context, addresses []string) {
|
||||||
|
sema := make(chan any, 1)
|
||||||
|
if t.URL.Scheme == "https" {
|
||||||
|
// Allows just a single worker to fetch the response body but do that
|
||||||
|
// only if the test-lists URL uses "https" as the scheme. Otherwise, just
|
||||||
|
// validate IPs by performing a TLS handshake.
|
||||||
|
sema <- true
|
||||||
|
}
|
||||||
|
t.startSecureFlowsWithSema(ctx, sema, addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSecureFlowsWithSema implements EndpointMeasurementsStarter.
|
||||||
|
func (t *DNSResolvers) startSecureFlowsWithSema(ctx context.Context, sema <-chan any, addresses []string) {
|
||||||
|
port := "443"
|
||||||
|
if urlPort := t.URL.Port(); urlPort != "" {
|
||||||
|
if t.URL.Scheme != "https" {
|
||||||
|
// If the URL is like http://example.com:8080/, we don't know
|
||||||
|
// which would be the correct port where to use HTTPS.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port = urlPort
|
||||||
|
}
|
||||||
|
for _, addr := range addresses {
|
||||||
|
task := &SecureFlow{
|
||||||
|
Address: net.JoinHostPort(addr, port),
|
||||||
|
DNSCache: t.DNSCache,
|
||||||
|
IDGenerator: t.IDGenerator,
|
||||||
|
Logger: t.Logger,
|
||||||
|
Sema: sema,
|
||||||
|
TestKeys: t.TestKeys,
|
||||||
|
ZeroTime: t.ZeroTime,
|
||||||
|
WaitGroup: t.WaitGroup,
|
||||||
|
ALPN: []string{"h2", "http/1.1"},
|
||||||
|
CookieJar: t.CookieJar,
|
||||||
|
FollowRedirects: t.URL.Scheme == "https",
|
||||||
|
SNI: t.URL.Hostname(),
|
||||||
|
HostHeader: t.URL.Host,
|
||||||
|
Referer: t.Referer,
|
||||||
|
UDPAddress: t.UDPAddress,
|
||||||
|
URLPath: t.URL.Path,
|
||||||
|
URLRawQuery: t.URL.RawQuery,
|
||||||
|
}
|
||||||
|
task.Start(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeStartControlFlow starts the control flow iff .Session and .THAddr are set.
|
||||||
|
func (t *DNSResolvers) maybeStartControlFlow(ctx context.Context, addresses []string) {
|
||||||
|
if t.Session != nil && t.THAddr != "" {
|
||||||
|
ctrl := &Control{
|
||||||
|
Addresses: addresses,
|
||||||
|
ExtraMeasurementsStarter: t, // allows starting follow-up measurement flows
|
||||||
|
Logger: t.Logger,
|
||||||
|
TestKeys: t.TestKeys,
|
||||||
|
Session: t.Session,
|
||||||
|
THAddr: t.THAddr,
|
||||||
|
URL: t.URL,
|
||||||
|
WaitGroup: t.WaitGroup,
|
||||||
|
}
|
||||||
|
ctrl.Start(ctx)
|
||||||
|
}
|
||||||
|
}
|
79
internal/experiment/webconnectivity/dnswhoami.go
Normal file
79
internal/experiment/webconnectivity/dnswhoami.go
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO(bassosimone): this code needs refining before we can merge it inside
|
||||||
|
// master. For one, we already have systemv4 info. Additionally, it would
|
||||||
|
// be neat to avoid additional AAAA queries. Furthermore, we should also see
|
||||||
|
// to implement support for IPv6 only clients as well.
|
||||||
|
|
||||||
|
// DNSWhoamiService is a service that performs DNS whoami lookups.
|
||||||
|
type DNSWhoamiService struct {
|
||||||
|
// mu provides mutual exclusion
|
||||||
|
mu *sync.Mutex
|
||||||
|
|
||||||
|
// systemv4 contains systemv4 results
|
||||||
|
systemv4 []DNSWhoamiInfoEntry
|
||||||
|
|
||||||
|
// udpv4 contains udpv4 results
|
||||||
|
udpv4 map[string][]DNSWhoamiInfoEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemV4 returns the results of querying using the system resolver and IPv4.
|
||||||
|
func (svc *DNSWhoamiService) SystemV4(ctx context.Context) ([]DNSWhoamiInfoEntry, bool) {
|
||||||
|
svc.mu.Lock()
|
||||||
|
defer svc.mu.Unlock()
|
||||||
|
if len(svc.systemv4) <= 0 {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
reso := netxlite.NewStdlibResolver(model.DiscardLogger)
|
||||||
|
addrs, err := reso.LookupHost(ctx, "whoami.v4.powerdns.org")
|
||||||
|
if err != nil || len(addrs) < 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
svc.systemv4 = []DNSWhoamiInfoEntry{{
|
||||||
|
Address: addrs[0],
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return svc.systemv4, len(svc.systemv4) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPv4 returns the results of querying a given UDP resolver and IPv4.
|
||||||
|
func (svc *DNSWhoamiService) UDPv4(ctx context.Context, address string) ([]DNSWhoamiInfoEntry, bool) {
|
||||||
|
svc.mu.Lock()
|
||||||
|
defer svc.mu.Unlock()
|
||||||
|
if len(svc.udpv4[address]) <= 0 {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
dialer := netxlite.NewDialerWithStdlibResolver(model.DiscardLogger)
|
||||||
|
reso := netxlite.NewParallelUDPResolver(model.DiscardLogger, dialer, address)
|
||||||
|
// TODO(bassosimone): this should actually only send an A query. Sending an AAAA
|
||||||
|
// query is _way_ unnecessary since we know that only A is going to work.
|
||||||
|
addrs, err := reso.LookupHost(ctx, "whoami.v4.powerdns.org")
|
||||||
|
if err != nil || len(addrs) < 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
svc.udpv4[address] = []DNSWhoamiInfoEntry{{
|
||||||
|
Address: addrs[0],
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
value := svc.udpv4[address]
|
||||||
|
return value, len(value) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(bassosimone): consider factoring this code and keeping state
|
||||||
|
// on disk rather than on memory.
|
||||||
|
|
||||||
|
// DNSWhoamiSingleton is the DNSWhoamiService singleton.
|
||||||
|
var DNSWhoamiSingleton = &DNSWhoamiService{
|
||||||
|
mu: &sync.Mutex{},
|
||||||
|
systemv4: []DNSWhoamiInfoEntry{},
|
||||||
|
udpv4: map[string][]DNSWhoamiInfoEntry{},
|
||||||
|
}
|
7
internal/experiment/webconnectivity/doc.go
Normal file
7
internal/experiment/webconnectivity/doc.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// Package webconnectivity implements the web_connectivity experiment.
|
||||||
|
//
|
||||||
|
// Spec: https://github.com/ooni/spec/blob/master/nettests/ts-017-web-connectivity.md.
|
||||||
|
//
|
||||||
|
// This implementation, in particular, contains extensions over the original model,
|
||||||
|
// which we document at https://github.com/ooni/probe/issues/2237.
|
||||||
|
package webconnectivity
|
63
internal/experiment/webconnectivity/inputparser.go
Normal file
63
internal/experiment/webconnectivity/inputparser.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// Input parsing
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/runtimex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputParser helps to print the experiment's input.
|
||||||
|
type InputParser struct {
|
||||||
|
// List of accepted URL schemes.
|
||||||
|
AcceptedSchemes []string
|
||||||
|
|
||||||
|
// Whether to allow endpoints in input.
|
||||||
|
AllowEndpoints bool
|
||||||
|
|
||||||
|
// The default scheme to use if AllowEndpoints == true.
|
||||||
|
DefaultScheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parses the experiment input and returns the resulting URL.
|
||||||
|
func (ip *InputParser) Parse(input string) (*url.URL, error) {
|
||||||
|
// put this check at top-level such that we always see the crash if needed
|
||||||
|
runtimex.PanicIfTrue(
|
||||||
|
ip.AllowEndpoints && ip.DefaultScheme == "",
|
||||||
|
"invalid configuration for InputParser.AllowEndpoints == true",
|
||||||
|
)
|
||||||
|
URL, err := url.Parse(input)
|
||||||
|
if err != nil {
|
||||||
|
return ip.maybeAllowEndpoints(URL, err)
|
||||||
|
}
|
||||||
|
for _, scheme := range ip.AcceptedSchemes {
|
||||||
|
if URL.Scheme == scheme {
|
||||||
|
// TODO: here you may want to perform additional parsing
|
||||||
|
return URL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("cannot parse input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditionally allows endpoints when ip.AllowEndpoints is true.
|
||||||
|
func (ip *InputParser) maybeAllowEndpoints(URL *url.URL, err error) (*url.URL, error) {
|
||||||
|
runtimex.PanicIfNil(err, "expected to be called with a non-nil error")
|
||||||
|
if ip.AllowEndpoints && URL.Scheme != "" && URL.Opaque != "" && URL.User == nil &&
|
||||||
|
URL.Host == "" && URL.Path == "" && URL.RawPath == "" &&
|
||||||
|
URL.RawQuery == "" && URL.Fragment == "" && URL.RawFragment == "" {
|
||||||
|
// See https://go.dev/play/p/Rk5pS_zGY5U
|
||||||
|
//
|
||||||
|
// Note that we know that `ip.DefaultScheme != ""` from the above runtime check.
|
||||||
|
out := &url.URL{
|
||||||
|
Scheme: ip.DefaultScheme,
|
||||||
|
Host: net.JoinHostPort(URL.Scheme, URL.Opaque),
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
135
internal/experiment/webconnectivity/measurer.go
Normal file
135
internal/experiment/webconnectivity/measurer.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// Measurer
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/atomicx"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Measurer for the web_connectivity experiment.
|
||||||
|
type Measurer struct {
|
||||||
|
// Contains the experiment's config.
|
||||||
|
Config *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExperimentMeasurer creates a new model.ExperimentMeasurer.
|
||||||
|
func NewExperimentMeasurer(config *Config) model.ExperimentMeasurer {
|
||||||
|
return &Measurer{
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExperimentName implements model.ExperimentMeasurer.
|
||||||
|
func (m *Measurer) ExperimentName() string {
|
||||||
|
return "web_connectivity"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExperimentVersion implements model.ExperimentMeasurer.
|
||||||
|
func (m *Measurer) ExperimentVersion() string {
|
||||||
|
return "0.5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run implements model.ExperimentMeasurer.
|
||||||
|
func (m *Measurer) Run(ctx context.Context, sess model.ExperimentSession,
|
||||||
|
measurement *model.Measurement, callbacks model.ExperimentCallbacks) error {
|
||||||
|
// Reminder: when this function returns an error, the measurement result
|
||||||
|
// WILL NOT be submitted to the OONI backend. You SHOULD only return an error
|
||||||
|
// for fundamental errors (e.g., the input is invalid or missing).
|
||||||
|
|
||||||
|
// honour InputOrQueryBackend
|
||||||
|
input := measurement.Input
|
||||||
|
if input == "" {
|
||||||
|
return errors.New("no input provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert the input string to a URL
|
||||||
|
inputParser := &InputParser{
|
||||||
|
AcceptedSchemes: []string{
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
},
|
||||||
|
AllowEndpoints: false,
|
||||||
|
DefaultScheme: "",
|
||||||
|
}
|
||||||
|
URL, err := inputParser.Parse(string(measurement.Input))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize the experiment's test keys
|
||||||
|
tk := NewTestKeys()
|
||||||
|
measurement.TestKeys = tk
|
||||||
|
|
||||||
|
// create variables required to run parallel tasks
|
||||||
|
idGenerator := &atomicx.Int64{}
|
||||||
|
wg := &sync.WaitGroup{}
|
||||||
|
|
||||||
|
// create cookiejar
|
||||||
|
jar, err := cookiejar.New(&cookiejar.Options{
|
||||||
|
PublicSuffixList: publicsuffix.List,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtain the test helper's address
|
||||||
|
testhelpers, _ := sess.GetTestHelpersByName("web-connectivity")
|
||||||
|
var thAddr string
|
||||||
|
for _, th := range testhelpers {
|
||||||
|
if th.Type == "https" {
|
||||||
|
thAddr = th.Address
|
||||||
|
measurement.TestHelpers = map[string]any{
|
||||||
|
"backend": &th,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if thAddr == "" {
|
||||||
|
sess.Logger().Warnf("continuing without a valid TH address")
|
||||||
|
tk.SetControlFailure(webconnectivity.ErrNoAvailableTestHelpers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start background tasks
|
||||||
|
resos := &DNSResolvers{
|
||||||
|
DNSCache: NewDNSCache(),
|
||||||
|
Domain: URL.Hostname(),
|
||||||
|
IDGenerator: idGenerator,
|
||||||
|
Logger: sess.Logger(),
|
||||||
|
TestKeys: tk,
|
||||||
|
URL: URL,
|
||||||
|
ZeroTime: measurement.MeasurementStartTimeSaved,
|
||||||
|
WaitGroup: wg,
|
||||||
|
CookieJar: jar,
|
||||||
|
Referer: "",
|
||||||
|
Session: sess,
|
||||||
|
THAddr: thAddr,
|
||||||
|
UDPAddress: "",
|
||||||
|
}
|
||||||
|
resos.Start(ctx)
|
||||||
|
|
||||||
|
// wait for background tasks to join
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// If the context passed to us has been cancelled, we cannot
|
||||||
|
// trust this experiment's results to be okay.
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform any deferred computation on the test keys
|
||||||
|
tk.Finalize(sess.Logger())
|
||||||
|
|
||||||
|
// return whether there was a fundamental failure, which would prevent
|
||||||
|
// the measurement from being submitted to the OONI collector.
|
||||||
|
return tk.fundamentalFailure
|
||||||
|
}
|
339
internal/experiment/webconnectivity/secureflow.go
Normal file
339
internal/experiment/webconnectivity/secureflow.go
Normal file
|
@ -0,0 +1,339 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// SecureFlow
|
||||||
|
//
|
||||||
|
// Generated by `boilerplate' using the https template.
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/atomicx"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/measurexlite"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Measures HTTPS endpoints.
|
||||||
|
//
|
||||||
|
// The zero value of this structure IS NOT valid and you MUST initialize
|
||||||
|
// all the fields marked as MANDATORY before using this structure.
|
||||||
|
type SecureFlow struct {
|
||||||
|
// Address is the MANDATORY address to connect to.
|
||||||
|
Address string
|
||||||
|
|
||||||
|
// DNSCache is the MANDATORY DNS cache.
|
||||||
|
DNSCache *DNSCache
|
||||||
|
|
||||||
|
// IDGenerator is the MANDATORY atomic int64 to generate task IDs.
|
||||||
|
IDGenerator *atomicx.Int64
|
||||||
|
|
||||||
|
// Logger is the MANDATORY logger to use.
|
||||||
|
Logger model.Logger
|
||||||
|
|
||||||
|
// Sema is the MANDATORY semaphore to allow just a single
|
||||||
|
// connection to perform the HTTP transaction.
|
||||||
|
Sema <-chan any
|
||||||
|
|
||||||
|
// TestKeys is MANDATORY and contains the TestKeys.
|
||||||
|
TestKeys *TestKeys
|
||||||
|
|
||||||
|
// ZeroTime is the MANDATORY measurement's zero time.
|
||||||
|
ZeroTime time.Time
|
||||||
|
|
||||||
|
// WaitGroup is the MANDATORY wait group this task belongs to.
|
||||||
|
WaitGroup *sync.WaitGroup
|
||||||
|
|
||||||
|
// ALPN is the OPTIONAL ALPN to use.
|
||||||
|
ALPN []string
|
||||||
|
|
||||||
|
// CookieJar contains the OPTIONAL cookie jar, used for redirects.
|
||||||
|
CookieJar http.CookieJar
|
||||||
|
|
||||||
|
// FollowRedirects is OPTIONAL and instructs this flow
|
||||||
|
// to follow HTTP redirects (if any).
|
||||||
|
FollowRedirects bool
|
||||||
|
|
||||||
|
// HostHeader is the OPTIONAL host header to use.
|
||||||
|
HostHeader string
|
||||||
|
|
||||||
|
// Referer contains the OPTIONAL referer, used for redirects.
|
||||||
|
Referer string
|
||||||
|
|
||||||
|
// SNI is the OPTIONAL SNI to use.
|
||||||
|
SNI string
|
||||||
|
|
||||||
|
// UDPAddress is the OPTIONAL address of the UDP resolver to use. If this
|
||||||
|
// field is not set we use a default one (e.g., `8.8.8.8:53`).
|
||||||
|
UDPAddress string
|
||||||
|
|
||||||
|
// URLPath is the OPTIONAL URL path.
|
||||||
|
URLPath string
|
||||||
|
|
||||||
|
// URLRawQuery is the OPTIONAL URL raw query.
|
||||||
|
URLRawQuery string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts this task in a background goroutine.
|
||||||
|
func (t *SecureFlow) Start(ctx context.Context) {
|
||||||
|
t.WaitGroup.Add(1)
|
||||||
|
index := t.IDGenerator.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer t.WaitGroup.Done() // synchronize with the parent
|
||||||
|
t.Run(ctx, index)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs this task in the current goroutine.
|
||||||
|
func (t *SecureFlow) Run(parentCtx context.Context, index int64) {
|
||||||
|
// create trace
|
||||||
|
trace := measurexlite.NewTrace(index, t.ZeroTime)
|
||||||
|
|
||||||
|
// start the operation logger
|
||||||
|
ol := measurexlite.NewOperationLogger(
|
||||||
|
t.Logger, "[#%d] GET https://%s using %s", index, t.HostHeader, t.Address,
|
||||||
|
)
|
||||||
|
|
||||||
|
// perform the TCP connect
|
||||||
|
const tcpTimeout = 10 * time.Second
|
||||||
|
tcpCtx, tcpCancel := context.WithTimeout(parentCtx, tcpTimeout)
|
||||||
|
defer tcpCancel()
|
||||||
|
tcpDialer := trace.NewDialerWithoutResolver(t.Logger)
|
||||||
|
tcpConn, err := tcpDialer.DialContext(tcpCtx, "tcp", t.Address)
|
||||||
|
t.TestKeys.AppendTCPConnectResults(trace.TCPConnects()...)
|
||||||
|
if err != nil {
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
t.TestKeys.AppendNetworkEvents(trace.NetworkEvents()...)
|
||||||
|
tcpConn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// perform TLS handshake
|
||||||
|
tlsSNI, err := t.sni()
|
||||||
|
if err != nil {
|
||||||
|
t.TestKeys.SetFundamentalFailure(err)
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tlsHandshaker := trace.NewTLSHandshakerStdlib(t.Logger)
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
NextProtos: t.alpn(),
|
||||||
|
RootCAs: netxlite.NewDefaultCertPool(),
|
||||||
|
ServerName: tlsSNI,
|
||||||
|
}
|
||||||
|
const tlsTimeout = 10 * time.Second
|
||||||
|
tlsCtx, tlsCancel := context.WithTimeout(parentCtx, tlsTimeout)
|
||||||
|
defer tlsCancel()
|
||||||
|
tlsConn, tlsConnState, err := tlsHandshaker.Handshake(tlsCtx, tcpConn, tlsConfig)
|
||||||
|
t.TestKeys.AppendTLSHandshakes(trace.TLSHandshakes()...)
|
||||||
|
if err != nil {
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tlsConn.Close()
|
||||||
|
|
||||||
|
alpn := tlsConnState.NegotiatedProtocol
|
||||||
|
|
||||||
|
// Only allow N flows to _use_ the connection
|
||||||
|
select {
|
||||||
|
case <-t.Sema:
|
||||||
|
default:
|
||||||
|
ol.Stop(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// create HTTP transport
|
||||||
|
httpTransport := netxlite.NewHTTPTransport(
|
||||||
|
t.Logger,
|
||||||
|
netxlite.NewNullDialer(),
|
||||||
|
// note: netxlite guarantees that here tlsConn is a netxlite.TLSConn
|
||||||
|
netxlite.NewSingleUseTLSDialer(tlsConn.(netxlite.TLSConn)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// create HTTP request
|
||||||
|
const httpTimeout = 10 * time.Second
|
||||||
|
httpCtx, httpCancel := context.WithTimeout(parentCtx, httpTimeout)
|
||||||
|
defer httpCancel()
|
||||||
|
httpReq, err := t.newHTTPRequest(httpCtx)
|
||||||
|
if err != nil {
|
||||||
|
if t.Referer == "" {
|
||||||
|
// when the referer is empty, the failing URL comes from our backend
|
||||||
|
// or from the user, so it's a fundamental failure. After that, we
|
||||||
|
// are dealing with websites provided URLs, so we should not flag a
|
||||||
|
// fundamental failure, because we want to see the measurement submitted.
|
||||||
|
t.TestKeys.SetFundamentalFailure(err)
|
||||||
|
}
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform HTTP transaction
|
||||||
|
httpResp, httpRespBody, err := t.httpTransaction(
|
||||||
|
httpCtx,
|
||||||
|
"tcp",
|
||||||
|
t.Address,
|
||||||
|
alpn,
|
||||||
|
httpTransport,
|
||||||
|
httpReq,
|
||||||
|
trace,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ol.Stop(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if enabled, follow possible redirects
|
||||||
|
t.maybeFollowRedirects(parentCtx, httpResp)
|
||||||
|
|
||||||
|
// TODO: insert here additional code if needed
|
||||||
|
_ = httpRespBody
|
||||||
|
|
||||||
|
// completed successfully
|
||||||
|
ol.Stop(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alpn returns the user-configured ALPN or a reasonable default
|
||||||
|
func (t *SecureFlow) alpn() []string {
|
||||||
|
if len(t.ALPN) > 0 {
|
||||||
|
return t.ALPN
|
||||||
|
}
|
||||||
|
return []string{"h2", "http/1.1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sni returns the user-configured SNI or a reasonable default
|
||||||
|
func (t *SecureFlow) sni() (string, error) {
|
||||||
|
if t.SNI != "" {
|
||||||
|
return t.SNI, nil
|
||||||
|
}
|
||||||
|
addr, _, err := net.SplitHostPort(t.Address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlHost computes the host to include into the URL
|
||||||
|
func (t *SecureFlow) urlHost(scheme string) (string, error) {
|
||||||
|
addr, port, err := net.SplitHostPort(t.Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Logger.Warnf("BUG: net.SplitHostPort failed for %s: %s", t.Address, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
urlHost := t.HostHeader
|
||||||
|
if urlHost == "" {
|
||||||
|
urlHost = addr
|
||||||
|
}
|
||||||
|
if port == "443" && scheme == "https" {
|
||||||
|
return urlHost, nil
|
||||||
|
}
|
||||||
|
urlHost = net.JoinHostPort(urlHost, port)
|
||||||
|
return urlHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHTTPRequest creates a new HTTP request.
|
||||||
|
func (t *SecureFlow) newHTTPRequest(ctx context.Context) (*http.Request, error) {
|
||||||
|
const urlScheme = "https"
|
||||||
|
urlHost, err := t.urlHost(urlScheme)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpURL := &url.URL{
|
||||||
|
Scheme: urlScheme,
|
||||||
|
Host: urlHost,
|
||||||
|
Path: t.URLPath,
|
||||||
|
RawQuery: t.URLRawQuery,
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "GET", httpURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Host", t.HostHeader)
|
||||||
|
httpReq.Header.Set("Accept", model.HTTPHeaderAccept)
|
||||||
|
httpReq.Header.Set("Accept-Language", model.HTTPHeaderAcceptLanguage)
|
||||||
|
httpReq.Header.Set("Referer", t.Referer)
|
||||||
|
httpReq.Header.Set("User-Agent", model.HTTPHeaderUserAgent)
|
||||||
|
httpReq.Host = t.HostHeader
|
||||||
|
if t.CookieJar != nil {
|
||||||
|
for _, cookie := range t.CookieJar.Cookies(httpURL) {
|
||||||
|
httpReq.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httpReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpTransaction runs the HTTP transaction and saves the results.
|
||||||
|
func (t *SecureFlow) httpTransaction(ctx context.Context, network, address, alpn string,
|
||||||
|
txp model.HTTPTransport, req *http.Request, trace *measurexlite.Trace) (*http.Response, []byte, error) {
|
||||||
|
const maxbody = 1 << 19
|
||||||
|
started := trace.TimeSince(trace.ZeroTime)
|
||||||
|
resp, err := txp.RoundTrip(req)
|
||||||
|
var body []byte
|
||||||
|
if err == nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if cookies := resp.Cookies(); t.CookieJar != nil && len(cookies) > 0 {
|
||||||
|
t.CookieJar.SetCookies(req.URL, cookies)
|
||||||
|
}
|
||||||
|
reader := io.LimitReader(resp.Body, maxbody)
|
||||||
|
body, err = netxlite.ReadAllContext(ctx, reader)
|
||||||
|
}
|
||||||
|
finished := trace.TimeSince(trace.ZeroTime)
|
||||||
|
ev := measurexlite.NewArchivalHTTPRequestResult(
|
||||||
|
trace.Index,
|
||||||
|
started,
|
||||||
|
network,
|
||||||
|
address,
|
||||||
|
alpn,
|
||||||
|
txp.Network(),
|
||||||
|
req,
|
||||||
|
resp,
|
||||||
|
maxbody,
|
||||||
|
body,
|
||||||
|
err,
|
||||||
|
finished,
|
||||||
|
)
|
||||||
|
t.TestKeys.AppendRequests(ev)
|
||||||
|
return resp, body, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeFollowRedirects follows redirects if configured and needed
|
||||||
|
func (t *SecureFlow) maybeFollowRedirects(ctx context.Context, resp *http.Response) {
|
||||||
|
if !t.FollowRedirects {
|
||||||
|
return // not configured
|
||||||
|
}
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case 301, 302, 307, 308:
|
||||||
|
location, err := resp.Location()
|
||||||
|
if err != nil {
|
||||||
|
return // broken response from server
|
||||||
|
}
|
||||||
|
t.Logger.Infof("redirect to: %s", location.String())
|
||||||
|
resolvers := &DNSResolvers{
|
||||||
|
CookieJar: t.CookieJar,
|
||||||
|
DNSCache: t.DNSCache,
|
||||||
|
Domain: location.Hostname(),
|
||||||
|
IDGenerator: t.IDGenerator,
|
||||||
|
Logger: t.Logger,
|
||||||
|
TestKeys: t.TestKeys,
|
||||||
|
URL: location,
|
||||||
|
ZeroTime: t.ZeroTime,
|
||||||
|
WaitGroup: t.WaitGroup,
|
||||||
|
Referer: resp.Request.URL.String(),
|
||||||
|
Session: nil, // no need to issue another control request
|
||||||
|
THAddr: "", // ditto
|
||||||
|
UDPAddress: t.UDPAddress,
|
||||||
|
}
|
||||||
|
resolvers.Start(ctx)
|
||||||
|
default:
|
||||||
|
// no redirect to follow
|
||||||
|
}
|
||||||
|
}
|
23
internal/experiment/webconnectivity/summary.go
Normal file
23
internal/experiment/webconnectivity/summary.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// Summary
|
||||||
|
//
|
||||||
|
|
||||||
|
import "github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
|
||||||
|
// Summary contains the summary results.
|
||||||
|
//
|
||||||
|
// Note that this structure is part of the ABI contract with ooniprobe
|
||||||
|
// therefore we should be careful when changing it.
|
||||||
|
type SummaryKeys struct {
|
||||||
|
// TODO: add here additional summary fields.
|
||||||
|
isAnomaly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys.
|
||||||
|
func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (any, error) {
|
||||||
|
// TODO(bassosimone): fill all the SummaryKeys
|
||||||
|
sk := SummaryKeys{isAnomaly: false}
|
||||||
|
return sk, nil
|
||||||
|
}
|
291
internal/experiment/webconnectivity/testkeys.go
Normal file
291
internal/experiment/webconnectivity/testkeys.go
Normal file
|
@ -0,0 +1,291 @@
|
||||||
|
package webconnectivity
|
||||||
|
|
||||||
|
//
|
||||||
|
// TestKeys for web_connectivity.
|
||||||
|
//
|
||||||
|
// Note: for historical reasons, we call TestKeys the JSON object
|
||||||
|
// containing the results produced by OONI experiments.
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/tracex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestKeys contains the results produced by web_connectivity.
|
||||||
|
type TestKeys struct {
|
||||||
|
// NetworkEvents contains network events.
|
||||||
|
NetworkEvents []*model.ArchivalNetworkEvent `json:"network_events"`
|
||||||
|
|
||||||
|
// DNSWhoami contains results of using the DNS whoami functionality for the
|
||||||
|
// possibly cleartext resolvers that we're using.
|
||||||
|
DNSWoami *DNSWhoamiInfo `json:"x_dns_whoami"`
|
||||||
|
|
||||||
|
// DoH contains ancillary observations collected by DoH resolvers.
|
||||||
|
DoH *TestKeysDoH `json:"x_doh"`
|
||||||
|
|
||||||
|
// Do53 contains ancillary observations collected by Do53 resolvers.
|
||||||
|
Do53 *TestKeysDo53 `json:"x_do53"`
|
||||||
|
|
||||||
|
// Queries contains DNS queries.
|
||||||
|
Queries []*model.ArchivalDNSLookupResult `json:"queries"`
|
||||||
|
|
||||||
|
// Requests contains HTTP results.
|
||||||
|
Requests []*model.ArchivalHTTPRequestResult `json:"requests"`
|
||||||
|
|
||||||
|
// TCPConnect contains TCP connect results.
|
||||||
|
TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"`
|
||||||
|
|
||||||
|
// TLSHandshakes contains TLS handshakes results.
|
||||||
|
TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"`
|
||||||
|
|
||||||
|
// ControlRequest is the control request we sent.
|
||||||
|
ControlRequest *webconnectivity.ControlRequest `json:"x_control_request"`
|
||||||
|
|
||||||
|
// Control contains the TH's response.
|
||||||
|
Control *webconnectivity.ControlResponse `json:"control"`
|
||||||
|
|
||||||
|
// ControlFailure contains the failure of the control experiment.
|
||||||
|
ControlFailure *string `json:"control_failure"`
|
||||||
|
|
||||||
|
// DNSFlags contains DNS analysis flags.
|
||||||
|
DNSFlags int64 `json:"x_dns_flags"`
|
||||||
|
|
||||||
|
// DNSExperimentFailure indicates whether there was a failure in any
|
||||||
|
// of the DNS experiments we performed.
|
||||||
|
DNSExperimentFailure *string `json:"dns_experiment_failure"`
|
||||||
|
|
||||||
|
// DNSConsistency indicates whether there is consistency between
|
||||||
|
// the TH's DNS results and the probe's DNS results.
|
||||||
|
DNSConsistency string `json:"dns_consistency"`
|
||||||
|
|
||||||
|
// BlockingFlags contains blocking flags.
|
||||||
|
BlockingFlags int64 `json:"x_blocking_flags"`
|
||||||
|
|
||||||
|
// BodyLength match tells us whether the body length matches.
|
||||||
|
BodyLengthMatch *bool `json:"body_length_match"`
|
||||||
|
|
||||||
|
// HeadersMatch tells us whether the headers match.
|
||||||
|
HeadersMatch *bool `json:"headers_match"`
|
||||||
|
|
||||||
|
// StatusCodeMatch tells us whether the status code matches.
|
||||||
|
StatusCodeMatch *bool `json:"status_code_match"`
|
||||||
|
|
||||||
|
// TitleMatch tells us whether the title matches.
|
||||||
|
TitleMatch *bool `json:"title_match"`
|
||||||
|
|
||||||
|
// Blocking indicates the reason for blocking. This is notoriously a bad
|
||||||
|
// type because it can be one of the following values:
|
||||||
|
//
|
||||||
|
// - "tcp_ip"
|
||||||
|
// - "dns"
|
||||||
|
// - "http-diff"
|
||||||
|
// - "http-failure"
|
||||||
|
// - false
|
||||||
|
// - null
|
||||||
|
//
|
||||||
|
// In addition to having a ~bad type, this field has the issue that it
|
||||||
|
// reduces the reason for blocking to an enum, whereas it's a set of flags,
|
||||||
|
// hence we introduced the x_blocking_flags field.
|
||||||
|
Blocking any `json:"blocking"`
|
||||||
|
|
||||||
|
// Accessible indicates whether the resource is accessible. Possible
|
||||||
|
// values for this field are: nil, true, and false.
|
||||||
|
Accessible any `json:"accessible"`
|
||||||
|
|
||||||
|
// fundamentalFailure indicates that some fundamental error occurred
|
||||||
|
// in a background task. A fundamental error is something like a programmer
|
||||||
|
// such as a failure to parse a URL that was hardcoded in the codebase. When
|
||||||
|
// this class of errors happens, you certainly don't want to submit the
|
||||||
|
// resulting measurement to the OONI collector.
|
||||||
|
fundamentalFailure error
|
||||||
|
|
||||||
|
// mu provides mutual exclusion for accessing the test keys.
|
||||||
|
mu *sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSWhoamiInfoEntry contains an entry for DNSWhoamiInfo.
|
||||||
|
type DNSWhoamiInfoEntry struct {
|
||||||
|
// Address is the IP address
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSWhoamiInfo contains info about DNS whoami.
|
||||||
|
type DNSWhoamiInfo struct {
|
||||||
|
// SystemV4 contains results related to the system resolver using IPv4.
|
||||||
|
SystemV4 []DNSWhoamiInfoEntry `json:"system_v4"`
|
||||||
|
|
||||||
|
// UDPv4 contains results related to an UDP resolver using IPv4.
|
||||||
|
UDPv4 map[string][]DNSWhoamiInfoEntry `json:"udp_v4"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeysDoH contains ancillary observations collected using DoH (e.g., the
|
||||||
|
// DNS lookups, TCP connects, TLS handshakes caused by given DoH lookups).
|
||||||
|
//
|
||||||
|
// They are on a separate hierarchy to simplify processing.
|
||||||
|
type TestKeysDoH struct {
|
||||||
|
// NetworkEvents contains network events.
|
||||||
|
NetworkEvents []*model.ArchivalNetworkEvent `json:"network_events"`
|
||||||
|
|
||||||
|
// Queries contains DNS queries.
|
||||||
|
Queries []*model.ArchivalDNSLookupResult `json:"queries"`
|
||||||
|
|
||||||
|
// Requests contains HTTP results.
|
||||||
|
Requests []*model.ArchivalHTTPRequestResult `json:"requests"`
|
||||||
|
|
||||||
|
// TCPConnect contains TCP connect results.
|
||||||
|
TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"`
|
||||||
|
|
||||||
|
// TLSHandshakes contains TLS handshakes results.
|
||||||
|
TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeysDo53 contains ancillary observations collected using Do53.
|
||||||
|
//
|
||||||
|
// They are on a separate hierarchy to simplify processing.
|
||||||
|
type TestKeysDo53 struct {
|
||||||
|
// NetworkEvents contains network events.
|
||||||
|
NetworkEvents []*model.ArchivalNetworkEvent `json:"network_events"`
|
||||||
|
|
||||||
|
// Queries contains DNS queries.
|
||||||
|
Queries []*model.ArchivalDNSLookupResult `json:"queries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendNetworkEvents appends to NetworkEvents.
|
||||||
|
func (tk *TestKeys) AppendNetworkEvents(v ...*model.ArchivalNetworkEvent) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.NetworkEvents = append(tk.NetworkEvents, v...)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendQueries appends to Queries.
|
||||||
|
func (tk *TestKeys) AppendQueries(v ...*model.ArchivalDNSLookupResult) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.Queries = append(tk.Queries, v...)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendRequests appends to Requests.
|
||||||
|
func (tk *TestKeys) AppendRequests(v ...*model.ArchivalHTTPRequestResult) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
// Implementation note: append at the front since the most recent
|
||||||
|
// request must be at the beginning of the list.
|
||||||
|
tk.Requests = append(v, tk.Requests...)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendTCPConnectResults appends to TCPConnect.
|
||||||
|
func (tk *TestKeys) AppendTCPConnectResults(v ...*model.ArchivalTCPConnectResult) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.TCPConnect = append(tk.TCPConnect, v...)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendTLSHandshakes appends to TLSHandshakes.
|
||||||
|
func (tk *TestKeys) AppendTLSHandshakes(v ...*model.ArchivalTLSOrQUICHandshakeResult) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.TLSHandshakes = append(tk.TLSHandshakes, v...)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetControlRequest sets the value of controlRequest.
|
||||||
|
func (tk *TestKeys) SetControlRequest(v *webconnectivity.ControlRequest) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.ControlRequest = v
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetControl sets the value of Control.
|
||||||
|
func (tk *TestKeys) SetControl(v *webconnectivity.ControlResponse) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.Control = v
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetControlFailure sets the value of controlFailure.
|
||||||
|
func (tk *TestKeys) SetControlFailure(err error) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.ControlFailure = tracex.NewFailure(err)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFundamentalFailure sets the value of fundamentalFailure.
|
||||||
|
func (tk *TestKeys) SetFundamentalFailure(err error) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
tk.fundamentalFailure = err
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTestKeysDoH calls the given function with the mutex locked passing to
|
||||||
|
// it as argument the pointer to the DoH field.
|
||||||
|
func (tk *TestKeys) WithTestKeysDoH(f func(*TestKeysDoH)) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
f(tk.DoH)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTestKeysDo53 calls the given function with the mutex locked passing to
|
||||||
|
// it as argument the pointer to the Do53 field.
|
||||||
|
func (tk *TestKeys) WithTestKeysDo53(f func(*TestKeysDo53)) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
f(tk.Do53)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDNSWhoami calls the given function with the mutex locked passing to
|
||||||
|
// it as argument the pointer to the DNSWhoami field.
|
||||||
|
func (tk *TestKeys) WithDNSWhoami(fun func(*DNSWhoamiInfo)) {
|
||||||
|
tk.mu.Lock()
|
||||||
|
fun(tk.DNSWoami)
|
||||||
|
tk.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestKeys creates a new instance of TestKeys.
|
||||||
|
func NewTestKeys() *TestKeys {
|
||||||
|
return &TestKeys{
|
||||||
|
NetworkEvents: []*model.ArchivalNetworkEvent{},
|
||||||
|
DNSWoami: &DNSWhoamiInfo{
|
||||||
|
SystemV4: []DNSWhoamiInfoEntry{},
|
||||||
|
UDPv4: map[string][]DNSWhoamiInfoEntry{},
|
||||||
|
},
|
||||||
|
DoH: &TestKeysDoH{
|
||||||
|
NetworkEvents: []*model.ArchivalNetworkEvent{},
|
||||||
|
Queries: []*model.ArchivalDNSLookupResult{},
|
||||||
|
Requests: []*model.ArchivalHTTPRequestResult{},
|
||||||
|
TCPConnect: []*model.ArchivalTCPConnectResult{},
|
||||||
|
TLSHandshakes: []*model.ArchivalTLSOrQUICHandshakeResult{},
|
||||||
|
},
|
||||||
|
Do53: &TestKeysDo53{
|
||||||
|
NetworkEvents: []*model.ArchivalNetworkEvent{},
|
||||||
|
Queries: []*model.ArchivalDNSLookupResult{},
|
||||||
|
},
|
||||||
|
Queries: []*model.ArchivalDNSLookupResult{},
|
||||||
|
Requests: []*model.ArchivalHTTPRequestResult{},
|
||||||
|
TCPConnect: []*model.ArchivalTCPConnectResult{},
|
||||||
|
TLSHandshakes: []*model.ArchivalTLSOrQUICHandshakeResult{},
|
||||||
|
Control: nil,
|
||||||
|
ControlFailure: nil,
|
||||||
|
DNSFlags: 0,
|
||||||
|
DNSExperimentFailure: nil,
|
||||||
|
DNSConsistency: "",
|
||||||
|
BlockingFlags: 0,
|
||||||
|
BodyLengthMatch: nil,
|
||||||
|
HeadersMatch: nil,
|
||||||
|
StatusCodeMatch: nil,
|
||||||
|
TitleMatch: nil,
|
||||||
|
Blocking: nil,
|
||||||
|
Accessible: nil,
|
||||||
|
ControlRequest: nil,
|
||||||
|
fundamentalFailure: nil,
|
||||||
|
mu: &sync.Mutex{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize performs any delayed computation on the test keys. This function
|
||||||
|
// must be called from the measurer after all the tasks have completed.
|
||||||
|
func (tk *TestKeys) Finalize(logger model.Logger) {
|
||||||
|
tk.analysisToplevel(logger)
|
||||||
|
}
|
|
@ -215,7 +215,8 @@ func CanonicalizeExperimentName(name string) string {
|
||||||
|
|
||||||
// NewFactory creates a new Factory instance.
|
// NewFactory creates a new Factory instance.
|
||||||
func NewFactory(name string) (*Factory, error) {
|
func NewFactory(name string) (*Factory, error) {
|
||||||
factory := allexperiments[CanonicalizeExperimentName(name)]
|
name = CanonicalizeExperimentName(name)
|
||||||
|
factory := allexperiments[name]
|
||||||
if factory == nil {
|
if factory == nil {
|
||||||
return nil, fmt.Errorf("no such experiment: %s", name)
|
return nil, fmt.Errorf("no such experiment: %s", name)
|
||||||
}
|
}
|
||||||
|
|
27
internal/registry/webconnectivityv05.go
Normal file
27
internal/registry/webconnectivityv05.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package registry
|
||||||
|
|
||||||
|
//
|
||||||
|
// Registers the `web_connectivity@v0.5' experiment.
|
||||||
|
//
|
||||||
|
// See https://github.com/ooni/probe/issues/2237
|
||||||
|
//
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/experiment/webconnectivity"
|
||||||
|
"github.com/ooni/probe-cli/v3/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Note: the name inserted into the table is the canonicalized experiment
|
||||||
|
// name though we advertise using `web_connectivity@v0.5`.
|
||||||
|
allexperiments["web_connectivity@v_0_5"] = &Factory{
|
||||||
|
build: func(config any) model.ExperimentMeasurer {
|
||||||
|
return webconnectivity.NewExperimentMeasurer(
|
||||||
|
config.(*webconnectivity.Config),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
config: &webconnectivity.Config{},
|
||||||
|
interruptible: false,
|
||||||
|
inputPolicy: model.InputOrQueryBackend,
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user