Compare commits

...

109 Commits

Author SHA1 Message Date
Simone Basso 02ee95d681 [ci skip] 2024-04-17 17:35:40 +02:00
Simone Basso 67ae4d96c4 [ci skip] 2024-04-17 17:31:37 +02:00
Simone Basso da06bbb279 [ci skip] 2024-04-17 17:30:20 +02:00
Simone Basso 28bcd5fdff [ci skip] 2024-04-17 17:25:41 +02:00
Simone Basso f16784cd28 x 2024-04-17 17:16:14 +02:00
Simone Basso e7c75417df x 2024-04-17 17:15:16 +02:00
Simone Basso 32178c6b60 x 2024-04-17 17:13:29 +02:00
Simone Basso 72e1336c33 Merge branch 'master' into issue/2704
Conflicts:
	internal/enginenetx/mix.go
	internal/enginenetx/mix_test.go
2024-04-17 17:11:22 +02:00
Simone Basso 7dab5a2981
feat(enginenetx): implement deterministic+random mixing (#1558)
We need deterministic+random mixing of HTTPS dial tactics to ensure that
we prioritize some tactics coming from the DNS before attempting all the
previous tactics, which would make the bootstrap super slow.

Part of https://github.com/ooni/probe/issues/2704.
2024-04-17 17:08:51 +02:00
Simone Basso 3b0e11026b x 2024-04-17 16:23:17 +02:00
Simone Basso 4e628dba1b x 2024-04-17 16:21:13 +02:00
Simone Basso 7058b7d3be x 2024-04-17 16:19:41 +02:00
Simone Basso d0f721a689 x 2024-04-17 16:18:19 +02:00
Simone Basso fd9c568dce Merge branch 'master' into issue/2704
Conflicts:
	internal/enginenetx/bridgespolicy.go
	internal/enginenetx/httpsdialer.go
	internal/enginenetx/statspolicy.go
2024-04-17 16:17:13 +02:00
Simone Basso 0d4dc93a22
refactor(enginenetx): assign InitialDelay when dialing (#1555)
This diff refactors enginenetx to assign InitialDelay only when dialing.
It is pointless to do that before. Also, take advantage of algorithms
introduced by https://github.com/ooni/probe-cli/pull/1556 to make the
code more compact.

Part of https://github.com/ooni/probe/issues/2704.
2024-04-17 14:26:49 +02:00
Simone Basso 8c4a4f690c
feat(enginenetx): add algorithms to filter, mix, and stream tactics (#1556)
This diff extends the enginenetx package to add algorithms to filter,
mix, and stream tactics.

We will use this algorithms to simplify the implementation and make it
more composable.

This work is part of https://github.com/ooni/probe/issues/2704.
2024-04-17 11:42:22 +02:00
Simone Basso 75eb2df030 Merge branch 'master' into issue/2704
Conflicts:
	internal/enginenetx/bridgespolicy_test.go
	internal/enginenetx/statspolicy.go
2024-04-17 10:30:35 +02:00
Simone Basso 6efffc5a96
feat(enginenetx): test `bridgesPolicy` with DNS success (#1554)
This diff introduces a test case for `bridgesPolicy` where we count
after how many policies we observe a DNS-generated policy. This test has
been crucial to investigate https://github.com/ooni/probe/issues/2704.
Based on this test we can conclude the following:

1. if the bridge IP address gets blocked or stops working, we're still
falling back to using the DNS;
2. however, the current algorithm does that in a too-slow fashion.

Additionally, I manually verified that we're actually falling back to
the DNS and that it really takes a long time by changing the
implementation to use `10.0.0.1` as the bridge address and verifying
that the code behaves as expected (though the "expected" behavior here
is not nice at all and we should improve upon that).

While there, fix naming and comments.
2024-04-17 10:28:52 +02:00
Simone Basso fa1c237d5b [ci skip] 2024-04-17 10:01:23 +02:00
Simone Basso 4baca70d54 [ci skip] 2024-04-17 10:01:04 +02:00
Simone Basso e652147d45 [ci skip] 2024-04-17 09:59:27 +02:00
Simone Basso 697f6b3d66 [ci skip] 2024-04-17 09:57:48 +02:00
Simone Basso 8a3eef7678 [ci skip] 2024-04-17 09:52:19 +02:00
Simone Basso de31fd55d8 [ci skip] 2024-04-17 09:51:39 +02:00
Simone Basso 586c4501cc [ci skip] 2024-04-17 09:51:16 +02:00
Simone Basso f037eb3e69 [ci skip] 2024-04-17 09:49:33 +02:00
Simone Basso dcf4a0300b [ci skip] 2024-04-17 09:48:59 +02:00
Simone Basso d6159d62ad [ci skip] 2024-04-17 09:44:57 +02:00
Simone Basso 43c1e7cf26 [ci skip] 2024-04-17 09:42:05 +02:00
Simone Basso 2944ea020b [ci skip] 2024-04-17 09:39:28 +02:00
Simone Basso 744371080d [ci skip] 2024-04-17 09:38:07 +02:00
Simone Basso 5f7302a834 [ci skip] 2024-04-17 09:31:17 +02:00
Simone Basso be41fa95f0 [ci skip] 2024-04-17 09:30:53 +02:00
Simone Basso dfed510575 [ci skip] 2024-04-17 09:30:13 +02:00
Simone Basso 7a2a360d1d [ci skip] 2024-04-17 09:29:52 +02:00
Simone Basso 8d1458721d [ci skip] 2024-04-17 09:28:39 +02:00
Simone Basso c075625b06 [ci skip] 2024-04-17 09:27:06 +02:00
Simone Basso 2949035e89 remove more code that we probably don't need 2024-04-16 17:33:40 +02:00
Simone Basso 8a9353355e x 2024-04-16 17:12:46 +02:00
Simone Basso fd81cf714c x 2024-04-16 17:07:26 +02:00
Simone Basso 4e3a8afefb the design document should now be good 2024-04-16 17:00:53 +02:00
Simone Basso 8bdbbaf720 x 2024-04-16 16:03:20 +02:00
Simone Basso 4896f68acc [ci skip] 2024-04-16 15:04:17 +02:00
Simone Basso d739ddd642 [ci skip] 2024-04-16 15:03:28 +02:00
Simone Basso 49bbf25301 [ci skip] 2024-04-16 15:00:17 +02:00
Simone Basso 0baaf9b96d [ci skip] 2024-04-16 14:59:37 +02:00
Simone Basso 15d28f2dd6 [ci skip] 2024-04-16 14:56:44 +02:00
Simone Basso baef14ff73 [ci skip] 2024-04-16 14:55:30 +02:00
Simone Basso 3399fec3cf [ci skip] 2024-04-16 14:54:01 +02:00
Simone Basso b7327e2632 [ci skip] 2024-04-16 14:52:54 +02:00
Simone Basso 2b7a881789 [ci skip] 2024-04-16 14:51:41 +02:00
Simone Basso 856d261c9f [ci skip] 2024-04-16 14:50:42 +02:00
Simone Basso 19fc4b53b2 [ci skip] 2024-04-16 14:48:25 +02:00
Simone Basso 6c83c257c6 [ci skip] 2024-04-16 14:47:18 +02:00
Simone Basso 6066a7fee0 [ci skip] 2024-04-16 14:46:41 +02:00
Simone Basso 0b32b47aa8 [ci skip] 2024-04-16 14:45:05 +02:00
Simone Basso ccd26c4db7 [ci skip] 2024-04-16 14:44:21 +02:00
Simone Basso c834599059 [ci skip] 2024-04-16 14:43:50 +02:00
Simone Basso 018fec47dd [ci skip] 2024-04-16 14:42:33 +02:00
Simone Basso 6e47258501 [ci skip] 2024-04-16 14:39:24 +02:00
Simone Basso 8c5bc6016d [ci skip] 2024-04-16 14:39:02 +02:00
Simone Basso dd128d8e6a [ci skip] 2024-04-16 14:38:00 +02:00
Simone Basso e02c5d46a0 [ci skip] 2024-04-16 14:36:52 +02:00
Simone Basso 20f800a8d1 [ci skip] 2024-04-16 14:36:03 +02:00
Simone Basso 86916069b6 [ci skip] 2024-04-16 14:35:27 +02:00
Simone Basso 0c06b53391 [ci skip] 2024-04-16 14:35:10 +02:00
Simone Basso 3b63fbddf0 [ci skip] 2024-04-16 14:34:47 +02:00
Simone Basso 492ab69c25 [ci skip] 2024-04-16 14:33:56 +02:00
Simone Basso f7076166b0 [ci skip] 2024-04-16 14:32:54 +02:00
Simone Basso 02660decad [ci skip] 2024-04-16 14:32:37 +02:00
Simone Basso bfc0a1dfab [ci skip] 2024-04-16 14:32:17 +02:00
Simone Basso cb0dbfc61c [ci skip] 2024-04-16 14:31:27 +02:00
Simone Basso fb651c77b1 [ci skip] 2024-04-16 14:30:34 +02:00
Simone Basso 665b961b89 [ci skip] 2024-04-16 14:30:01 +02:00
Simone Basso 393968de34 [ci skip] 2024-04-16 14:29:53 +02:00
Simone Basso e9eee04186 [ci skip] 2024-04-16 14:29:02 +02:00
Simone Basso f565e68f49 [ci skip] 2024-04-16 14:27:54 +02:00
Simone Basso 77b03bd8c4 [ci skip] 2024-04-16 14:27:04 +02:00
Simone Basso b6aebc2821 [ci skip] 2024-04-16 14:26:22 +02:00
Simone Basso 436fb50e4a [ci skip] 2024-04-16 14:26:03 +02:00
Simone Basso 4f8cf91ba4 [ci skip] 2024-04-16 14:25:08 +02:00
Simone Basso 8e5fee9e68 [ci skip] 2024-04-16 14:24:00 +02:00
Simone Basso 21f9b900f0 [ci skip] 2024-04-16 14:23:07 +02:00
Simone Basso 7edfbb88dd [ci skip] 2024-04-16 14:22:33 +02:00
Simone Basso 1cbc10919a [ci skip] 2024-04-16 14:21:35 +02:00
Simone Basso 7ea130ded0 [ci skip] 2024-04-16 14:21:08 +02:00
Simone Basso 4b0c768122 [ci skip] 2024-04-16 14:18:59 +02:00
Simone Basso 7f165778a1 [ci skip] 2024-04-16 14:18:04 +02:00
Simone Basso c8432411a4 [ci skip] 2024-04-16 12:58:59 +02:00
Simone Basso 7c6ab4bd83 [ci skip] 2024-04-16 12:58:11 +02:00
Simone Basso 08fbf485fd [ci skip] 2024-04-16 12:57:17 +02:00
Simone Basso e2aed07367 x 2024-04-16 12:45:52 +02:00
Simone Basso 8e2a1f372d x 2024-04-16 12:42:40 +02:00
Simone Basso 45e655c6fa x 2024-04-16 12:41:50 +02:00
Simone Basso 4f63b60808 [ci skip] 2024-04-16 12:39:19 +02:00
Simone Basso ce6ec84a7d [ci skip] 2024-04-16 12:37:52 +02:00
Simone Basso f3fb1dd74b [ci skip] 2024-04-16 12:36:38 +02:00
Simone Basso 119e6102d9 [ci skip] 2024-04-16 12:35:54 +02:00
Simone Basso f4522084d7 [ci skip] 2024-04-16 12:35:22 +02:00
Simone Basso 28a6265d83 [ci skip] 2024-04-16 12:34:56 +02:00
Simone Basso aa65cb75b7 [ci skip] 2024-04-16 12:34:25 +02:00
Simone Basso ef8fdfe8ac [ci skip] 2024-04-16 12:33:37 +02:00
Simone Basso 90601c6aa0 [ci skip] 2024-04-16 12:32:48 +02:00
Simone Basso 94eb284cb6 [ci skip] 2024-04-16 12:32:02 +02:00
Simone Basso b5b2e496b3 [ci skip] 2024-04-16 12:31:14 +02:00
Simone Basso 20e71e837c [ci skip] 2024-04-16 12:30:40 +02:00
Simone Basso 089f70b65d x 2024-04-16 12:27:43 +02:00
Simone Basso e5cdbb0247 ongoing work while documenting and clarifying 2024-04-16 12:25:17 +02:00
Simone Basso 2153e3695e feat: start to prepare for filtering endpoints 2024-04-16 09:52:24 +02:00
16 changed files with 1662 additions and 159 deletions

View File

@ -0,0 +1,793 @@
# Engine Network Extensions
This file documents the [./internal/enginenetx](.) package design. The content is current
as of [probe-cli#1552](https://github.com/ooni/probe-cli/pull/1552).
## Design Goals
We define "bridge" an IP address with the following properties:
1. the IP address is not expected to change;
2. the IP address listens on port 443 and accepts _any_ incoming SNI;
3. the webserver on port 443 proxies to the OONI APIs.
We also assume that the Web Connectivity test helpers (TH) could accept any SNIs.
We also define "tactic" a tactic to perform a TLS handshake either with a
bridge or with a TH. We also define "policy" the collection of algorithms for
producing tactics for performing TLS handshakes.
Considering all of this, this package aims to:
1. overcome DNS-based censorship for "api.ooni.io" by hardcoding known-good
bridges IP addresses inside the codebase;
2. overcome SNI-based censorship for "api.ooni.io" and test helpers by choosing
from a pre-defined list of SNIs;
3. remember and use tactics for creating TLS connections that worked previously;
4. recover ~quickly if the conditions change (e.g., if a bridge is discontinued);
5. adopt a censored-users-first approach where the strategy we use by default
should allow for smooth operations _for them_ rather than prioritizing the
non-censored case and using additional tactics as the fallback;
6. try to defer sending the true `SNI` on the wire, therefore trying to
avoid triggering potential residual censorship blocking a given TCP endpoint
for some time regardless of what `SNI` is being used next;
7. allow users to force specific bridges and SNIs by editing `$OONI_HOME/engine/bridges.conf`.
The rest of this document explains how we designed for achieving these goals.
## High-Level API
The purpose of the `enginenetx` package is to provide a `*Network` object from which consumers
can obtain a `model.HTTPTransport` and `*http.Client` for HTTP operations:
```Go
func (n *Network) HTTPTransport() model.HTTPTransport
func (n *Network) NewHTTPClient() *http.Client
```
**Listing 1.** `*enginenetx.Network` HTTP APIs.
The `HTTPTransport` method returns a `*Network` field containing an HTTP transport with
custom TLS connection establishment tactics depending on the configured policies.
The `NewHTTPClient` method wraps such a transport into an `*http.Client`.
## Creating TLS Connections
In [network.go](network.go), `newHTTPSDialerPolicy` configures the dialing policy
depending on the arguments passed to `NewNetwork`:
1. if the `proxyURL` argument is not `nil`, we use the `dnsPolicy` alone;
2. othwerwise, we compose policies as illustrated by the following diagram:
```
+------------+ +-------------+ +--------------+ +-----------+
| userPolicy | --> | statsPolicy | --> | bridgePolicy | --> | dnsPolicy |
+------------+ +-------------+ +--------------+ +-----------+
```
**Diagram 1.** Sequence of policies constructed when not using a proxy.
Policies are described in detail in subsequent sections. On a high-level, here's what each does:
1. `userPolicy`: honours the `bridges.conf` configuration file and, if no entry is found
inside it, then it falls back to the subsequent policy;
2. `statsPolicy`: uses statistics collected from previous runs to select tactics that
worked recently for specific dialing targets, otherwise it falls back to the subsequent policy;
3. `bridgePolicy`: adopts a bridge strategy for `api.ooni.io` (i.e., uses known-in-advance
IP addresses), and otherwise falls back to the subsequent policy, still taking care of
hiding the THs SNIs;
4. `dnsPolicy`: uses the `*engine.Session` DNS resolver to lookup domain names
and produces trivial tactics equivalent to connecting normally using the Go standard library.
While the previous description says "falls back to," the actual semantics of falling
back is more complex than just falling back. For `statsPolicy` and `bridgePolicy`,
we remix the current policy strategy and subsequent policies strategies to strike a
balance between what a policy suggests and what subsequent policies would suggest. In
turn, this reduces the overall bootstrap time in light of issues with policies. (We
added remix as part of [probe-cli#1552](https://github.com/ooni/probe-cli/pull/1552); before,
we implemented strict falling back.)
Also, when using a proxy, we just use `dnsPolicy` assuming the proxy knows how to do circumvention.
## Instructions For Dialing
Each policy implements the following interface (defined in [httpsdialer.go](httpsdialer.go)):
```Go
type httpsDialerPolicy interface {
LookupTactics(ctx context.Context, domain, port string) <-chan *httpsDialerTactic
}
```
**Listing 2.** Interface implemented by policies.
The `LookupTactics` operation is _conceptually_ similar to
[net.Resolver.LookupHost](https://pkg.go.dev/net#Resolver.LookupHost), because
both operations map a domain name to IP addresses to connect to. However,
there are also some key differences, namely:
1. `LookupTactics` is domain _and_ port specific, while `LookupHost`
only takes in input the domain name to resolve;
2. `LookupTactics` returns _a stream_ of viable "tactics", while `LookupHost`
returns a list of IP addresses (we define "stream" a channel where a background
goroutine posts content and which is closed when done).
The second point, in particular, is crucial. The design of `LookupTactics` is
such that we can start attempting to dial as soon as we have some tactics
to try. A composed `httpsDialerPolicy` can, in fact, start multiple child `LookupTactics`
operations and then return tactics to the caller as soon as some are ready, without
blocking dialing until _all_ the child operations are complete.
Also, as you may have guessed, the `dnsPolicy` is a policy that, under the hood,
eventually calls [net.Resolver.LookupHost](https://pkg.go.dev/net#Resolver.LookupHost)
to get IP addresses using the DNS used by the `*engine.Session` type. (Typically, such a
resolver, in turn, composes several DNS-over-HTTPS resolvers with the fallback
`getaddrinfo` resolver, and remembers which resolvers work.)
A "tactic" looks like this:
```Go
type httpsDialerTactic struct {
Address string
InitialDelay time.Duration
Port string
SNI string
VerifyHostname string
}
```
**Listing 3.** Structure describing a tactic.
Here's an explanation of why we have each field in the struct:
- `Address` and `Port` qualify the TCP endpoint;
- `InitialDelay` allows a policy to delay a connect operation to implement
something similar to [happy eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs),
where dialing attempts run in parallel and are staggered in time (the classical
example being: dialing for IPv6 and then attempting dialing for IPv4 after 0.3s);
- `SNI` is the `SNI` to send as part of the TLS ClientHello;
- `VerifyHostname` is the hostname to use for TLS certificate verification.
The separation of `SNI` and `VerifyHostname` is what allows us to send an innocuous
SNI over the network and then verify the certificate using the real SNI after a
`skipVerify=true` TLS handshake has completed. (Obviously, for this trick to work,
the HTTPS server we're using must be okay with receiving unrelated SNIs.)
## HTTPS Dialer
Creating TLS connections is implemented by `(*httpsDialer).DialTLSContext`, also
part of [httpsdialer.go](httpsdialer.go).
This method _morally_ does the following in ~parallel:
```mermaid
stateDiagram-v2
tacticsGenerator --> skipDuplicate
skipDuplicate --> computeHappyEyeballsDelay
computeHappyEyeballsDelay --> tcpConnect
tcpConnect --> tlsHandshake
tlsHandshake --> verifyCertificate
```
**Diagram 2.** Sequence of operations when dialing TLS connections.
Such a diagram roughly corresponds to this Go ~pseudo-code:
```Go
func (hd *httpsDialer) DialTLSContext(
ctx context.Context, network string, endpoint string) (net.Conn, error) {
// map to ensure we don't have duplicate tactics
uniq := make(map[string]int)
// time when we started dialing
t0 := time.Now()
// index of each dialing attempt
idx := 0
// [...] omitting code to get hostname and port from endpoint [...]
// fetch tactics asynchronously
for tx := range hd.policy.LookupTactics(ctx, hostname, port) {
// avoid using the same tactic more than once
summary := tx.tacticSummaryKey()
if uniq[summary] > 0 {
continue
}
uniq[summary]++
// compute the happy eyeballs deadline
deadline := t0.Add(happyEyeballsDelay(idx))
idx++
// dial in a background goroutine so this code runs in parallel
go func(tx *httpsDialerTactic, deadline time.Duration) {
// wait for deadline
if delta := time.Until(deadline); delta > 0 {
time.Sleep(delta)
}
// dial TCP
conn, err := tcpConnect(tx.Address, tx.Port)
// [...] omitting error handling and passing error to DialTLSContext [...]
// handshake
tconn, err := tlsHandshake(conn, tx.SNI, false /* skip verification */)
// [...] omitting error handling and passing error to DialTLSContext [...]
// make sure the hostname's OK
err := verifyHostname(tconn, tx.VerifyHostname)
// [...] omitting error handling and passing error or conn to DialTLSContext [...]
}(tx, deadline)
}
// [...] omitting code to decide whether to return a conn or an error [...]
}
```
**Listing 4.** Algorithm implementing dialing TLS connections.
This simplified algorithm differs for the real implementation in that we
have omitted the following (boring) details:
1. code to obtain `hostname` and `port` from `endpoint` (e.g., code to extract
`"x.org"` and `"443"` from `"x.org:443"`);
2. code to pass back a connection or an error from a background
goroutine to the `DialTLSContext` method;
3. code to decide whether to return a `net.Conn` or an `error`;
4. the fact that `DialTLSContext` uses a goroutine pool rather than creating a
goroutine for each tactic;
5. the fact that, as soon as we successfully have a connection, we
immediately cancel any other parallel attempts.
The `happyEyeballsDelay` function (in [happyeyeballs.go](happyeyeballs.go)) is
such that we generate the following delays:
| idx | delay (s) |
| --- | --------- |
| 1 | 0 |
| 2 | 1 |
| 4 | 2 |
| 4 | 4 |
| 5 | 8 |
| 6 | 16 |
| 7 | 24 |
| 8 | 32 |
| ... | ... |
**Table 1.** Happy-eyeballs-like delays.
That is, we exponentially increase the delay until `8s`, then we linearly increase by `8s`. We
aim to space attempts to accommodate for slow access networks
and/or access network experiencing temporary failures to deliver packets. However,
we also aim to have dialing parallelism, to reduce the overall time to connect
when we're experiencing many timeouts when attempting to dial.
(We chose 1s as the baseline delay because that would be ~three happy-eyeballs delays as
implemented by the Go standard library, and overall a TCP connect followed by a TLS
handshake should roughly amount to three round trips.)
Additionally, the `*httpsDialer` algorithm keeps statistics
using an `httpsDialerEventsHandler` type:
```Go
type httpsDialerEventsHandler interface {
OnStarting(tactic *httpsDialerTactic)
OnTCPConnectError(ctx context.Context, tactic *httpsDialerTactic, err error)
OnTLSHandshakeError(ctx context.Context, tactic *httpsDialerTactic, err error)
OnTLSVerifyError(tactic *httpsDialerTactic, err error)
OnSuccess(tactic *httpsDialerTactic)
}
```
**Listing 5.** Interface for collecting statistics.
These statistics contribute to construct knowledge about the network
conditions and influence the generation of tactics.
## dnsPolicy
The `dnsPolicy` is implemented by [dnspolicy.go](dnspolicy.go).
Its `LookupTactics` algorithm is quite simple:
1. we short circuit the cases in which the `domain` argument
contains an IP address to "resolve" exactly that IP address (thus emulating
what `getaddrinfo` would do when asked to "resolve" an IP address);
2. for each resolved address, we generate tactics where the `SNI` and
`VerifyHostname` equal the `domain`.
If `httpsDialer` uses this policy as its only policy, the operation it
performs are morally equivalent to normally dialing for TLS.
## userPolicy
The `userPolicy` is implemented by [userpolicy.go](userpolicy.go).
When constructing a `userPolicy` with `newUserPolicy` we indicate a fallback
`httpsDialerPolicy` to use as the fallback, when either `$OONI_HOME/engine/bridges.conf`
does not exist or it does not contain actionable dialing rules.
As of 2024-04-16, the structure of `bridges.conf` is like in the following example:
```JavaScript
{
"DomainEndpoints": {
"api.ooni.io:443": [{
"Address": "162.55.247.208",
"Port": "443",
"SNI": "www.example.com",
"VerifyHostname": "api.ooni.io"
}, {
/* omitted */
}]
},
"Version": 3
}
```
**Listing 6.** Sample `bridges.conf` content.
This example instructs to use the given tactic(s) when establishing a TLS connection to
`"api.ooni.io:443"`. Any other destination hostname and port would instead use the
configured "fallback" dialing policy.
The `newUserPolicy` constructor reads this file from disk on startup
and keeps its content in memory.
`LookupTactics` will:
1. check whether there's an entry for the given `domain` and `port`
inside the `DomainEndpoints` map;
2. if there are no entries, fallback to the fallback `httpsDialerPolicy`;
3. otherwise return all the tactic entries.
Because `userPolicy` is user-configured, we _entirely bypass_ the
fallback policy when there's an user-configured entry.
## statsPolicy
The `statsPolicy` is implemented by [statspolicy.go](statspolicy.go).
The general idea of this policy is that it depends on:
1. a `*statsManager` that keeps persistent stats about tactics;
2. a "fallback" policy.
In principle, one would expect `LookupTactics` to first return all
the tactics we can see from the stats and then try tactics obtained
from the fallback policy. However, this simplified algorithm would
lead to suboptimal results in the following case:
1. say there are 10 tactics for "api.ooni.io:443" that are bound
to a specific bridge address that has been discontinued;
2. if we try all these 10 tactics before trying fallback tactics, we
would waste lots of time failing before falling back.
Conversely, a better strategy is to "remix" tactics as implemented
by the [mix.go](mix.go) file:
1. we take the first two tactics from the stats;
2. then we take the first four tactics from the fallback;
3. then we remix the rest, not caring much about whether we're
reading from the stats of from the fallback.
Because we sort tactics from the stats by our understanding of whether
they are working as intended, we'll prioritize what we know to be working,
but then we'll also throw some new tactics into the mix. (We read four
tactics from the fallback because that allows us to include two bridge tactics
and two DNS tactics, as explained below when we discuss the
`bridgePolicy` policy.)
## bridgePolicy
The `bridgePolicy` is implemented by [bridgespolicy.go](bridgespolicy.go) and
rests on the assumptions made explicit above. That is:
1. that there is at least one _bridge_ for "api.ooni.io";
2. that the Web Connectivity Test Helpers accepts any SNI.
Here we're also using the [mix.go](mix.go) algorithm to remix
two different sources of tactics:
1. the `bridgesTacticsForDomain` only returns tactics for "api.ooni.io"
using existing knowledge of bridges and random SNIs;
2. the `maybeRewriteTestHelpersTactics` method filters the results
coming from the fallback tactic such that, if we are connecting
to a known test-helper domain name, we're trying to hide its SNI.
The first two returned tactics will be bridges tactics for "api.ooni.io",
if applicable, followed by two tactics generated using the DNS,
followed by a random remix of all the remaining tactics. This choice of
returning two and two tactics first, is the
reason why in `statsPolicy` we return the first four tactics from
the fallback after getting two tactics from the stats.
## Overall Algorithm
The composed policy is as described in Diagram 1.
Therefore, the compose policy will return the following tactics:
1. if there is a `$OONI_HOME/engine/bridges.conf` with a valid entry,
use it without trying more tactics; otherwise,
2. use the first two tactics coming from stats, if any;
3. then use the first two tactics coming from bridges, if any;
4. then use the first two tactics coming from the DNS, if successful;
5. finally, randomly remix the remaining tactics.
Excluding the case where we have a valid entry in `bridges.conf`, the following
diagram illustrates how we're mixing tactics:
```mermaid
stateDiagram-v2
state statsTacticsChan <<join>>
statsTactics --> statsTacticsChan
state bridgesTacticsChan <<join>>
bridgesTactics --> bridgesTacticsChan
state dnsTacticsChan <<join>>
dnsTactics --> dnsTacticsChan
state "mix(2, 2)" as mix22
bridgesTacticsChan --> mix22
dnsTacticsChan --> mix22
state mix22Chan <<join>>
mix22 --> mix22Chan
state "mix(2, 4)" as mix24
statsTacticsChan --> mix24
mix22Chan --> mix24
state tacticsChan <<join>>
mix24 --> tacticsChan
tacticsChan --> DialTLSContext
```
**Diagram 3.** Tactics generation priorities when not using a proxy.
Here `mix(X, Y)` means taking `X` from the left block, if possible, then `Y` from the
right block, if possible, and then mixing the remainder in random order. Also, the "join"
blocks in the diagram represent Go channels.
Having discussed this, it only remains to discuss managing stats.
## Managing Stats
The [statsmanager.go](statsmanager.go) file implements the `*statsManager`.
We initialize the `*statsManager` by calling `newStatsManager` with a stats-trim
interval of 30 seconds in `NewNetwork` in [network.go](network.go).
The `*statsManager` keeps stats at `$OONI_HOME/engine/httpsdialerstats.state`.
In `newStatsManager`, we attempt to read this file using `loadStatsContainer` and, if
not present, we fall back to create empty stats with `newStatsContainer`.
While creating the `*statsManager` we also spawn a goroutine that trims the stats
at every stats-trimming interval by calling `(*statsManager).trim`. In turn, `trim`
calls `statsContainerPruneEntries`, which eventually:
1. removes entries not modified for more than one week;
2. sorts entries and only keeps the top 10 entries.
More specifically we sort entries using this algorithm:
1. by decreasing success rate; then
2. by decreasing number of successes; then
3. by decreasing last update time.
Likewise, calling `(*statsManager).Close` invokes `statsContainerPruneEntries`, and
then ensures that we write `$OONI_HOME/engine/httpsdialerstats.state`.
This way, subsequent OONI Probe runs could load the stats that are more likely
to work and `statsPolicy` can take advantage of this information.
The overall structure of `httpsdialerstats.state` is roughly the following:
```JavaScript
{
"DomainEndpoints": {
"api.ooni.io:443": {
"Tactics": {
"162.55.247.208:443 sni=api.trademe.co.nz verify=api.ooni.io": {
"CountStarted": 58,
"CountTCPConnectError": 0,
"CountTCPConnectInterrupt": 0,
"CountTCPConnectSuccess": 58,
"CountTLSHandshakeError": 0,
"CountTLSHandshakeInterrupt": 0,
"CountTLSVerificationError": 0,
"CountSuccess": 58,
"HistoTCPConnectError": {},
"HistoTLSHandshakeError": {},
"HistoTLSVerificationError": {},
"LastUpdated": "2024-04-15T10:38:53.575561+02:00",
"Tactic": {
"Address": "162.55.247.208",
"InitialDelay": 0,
"Port": "443",
"SNI": "api.trademe.co.nz",
"VerifyHostname": "api.ooni.io"
}
},
/* ... */
}
}
}
"Version": 5
}
```
**Listing 7.** Content of the stats state as cached on disk.
That is, the `DomainEndpoints` map contains contains an entry for each
TLS endpoint and, in turn, such an entry contains tactics indexed by
a summary string to speed up looking them up.
For each tactic, we keep counters and histograms, the time when the
entry had been updated last, and the tactic itself.
The `*statsManager` implements `httpsDialerEventsHandler`, which means
that it has callbacks invoked by the `*httpsDialer` for interesting
events regarding dialing (e.g., whether TCP connect failed).
These callbacks basically create or update stats by locking a mutex
and updating the relevant counters and histograms.
## Real-World Scenarios
This section illustrates the behavior of this package under specific
network failure conditions, with specific emphasis on what happens if
the bridge IP address becomes, for any reason, unavailable. (We are
doing this because all this work was prompeted by addressing the
[ooni/probe#2704](https://github.com/ooni/probe/issues/2704) issue.)
### Invalid bridge without cached data
In this first scenario, we're showing what happens if the bridge IP address
becomes unavailable without any previous state saved on disk. (To emulate
this scenario, change the bridge IP address in [bridgespolicy.go](bridgespolicy.go)
to become `10.0.0.1`, recompile, and wipe `httpsdialerstats.state`).
Here's an excerpt from the logs:
```
[ 0.001346] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... started
[ 0.002101] <info> sessionresolver: lookup api.ooni.io using https://wikimedia-dns.org/dns-query... started
[ 0.264132] <info> sessionresolver: lookup api.ooni.io using https://wikimedia-dns.org/dns-query... ok
[ 0.501774] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... in progress
[ 1.002330] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... started
[ 1.503687] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... in progress
[ 2.001488] <info> httpsDialer: [#4] TCPConnect 162.55.247.208:443... started
[ 2.046917] <info> httpsDialer: [#4] TCPConnect 162.55.247.208:443... ok
[ 2.047016] <info> httpsDialer: [#4] TLSHandshake with 162.55.247.208:443 SNI=api.ooni.io ALPN=[h2 http/1.1]... started
[ 2.093148] <info> httpsDialer: [#4] TLSHandshake with 162.55.247.208:443 SNI=api.ooni.io ALPN=[h2 http/1.1]... ok
[ 2.093181] <info> httpsDialer: [#4] TLSVerifyCertificateChain api.ooni.io... started
[ 2.095923] <info> httpsDialer: [#4] TLSVerifyCertificateChain api.ooni.io... ok
[ 2.096054] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... interrupted
[ 2.096077] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... interrupted
```
**Listing 8.** Run with no previous cached state and unreachable hardcoded bridge address.
After 2s, we start dialing with the IP addresses obtained through the DNS.
Subsequent runs will cache this information on disk and use it.
### Invalid bridge with cached data
This scenario is like the previous one, however we also assume that we have
a cached `httpsdialerstats.state` containing now-invalid lines. To this
end, we replace the original file with this content:
```JSON
{
"DomainEndpoints": {
"api.ooni.io:443": {
"Tactics": {
"10.0.0.1:443 sni=static-tracking.klaviyo.com verify=api.ooni.io": {
"CountStarted": 1,
"CountTCPConnectError": 0,
"CountTCPConnectInterrupt": 0,
"CountTLSHandshakeError": 0,
"CountTLSHandshakeInterrupt": 0,
"CountTLSVerificationError": 0,
"CountSuccess": 1,
"HistoTCPConnectError": {},
"HistoTLSHandshakeError": {},
"HistoTLSVerificationError": {},
"LastUpdated": "2024-04-16T16:04:34.398778+02:00",
"Tactic": {
"Address": "10.0.0.1",
"InitialDelay": 0,
"Port": "443",
"SNI": "static-tracking.klaviyo.com",
"VerifyHostname": "api.ooni.io"
}
},
"10.0.0.1:443 sni=vidstat.taboola.com verify=api.ooni.io": {
"CountStarted": 1,
"CountTCPConnectError": 0,
"CountTCPConnectInterrupt": 0,
"CountTLSHandshakeError": 0,
"CountTLSHandshakeInterrupt": 0,
"CountTLSVerificationError": 0,
"CountSuccess": 1,
"HistoTCPConnectError": {},
"HistoTLSHandshakeError": {},
"HistoTLSVerificationError": {},
"LastUpdated": "2024-04-16T16:04:34.398795+02:00",
"Tactic": {
"Address": "10.0.0.1",
"InitialDelay": 1000000000,
"Port": "443",
"SNI": "vidstat.taboola.com",
"VerifyHostname": "api.ooni.io"
}
},
"10.0.0.1:443 sni=www.example.com verify=api.ooni.io": {
"CountStarted": 1,
"CountTCPConnectError": 0,
"CountTCPConnectInterrupt": 0,
"CountTLSHandshakeError": 0,
"CountTLSHandshakeInterrupt": 0,
"CountTLSVerificationError": 0,
"CountSuccess": 1,
"HistoTCPConnectError": {},
"HistoTLSHandshakeError": {},
"HistoTLSVerificationError": {},
"LastUpdated": "2024-04-16T16:04:34.398641+02:00",
"Tactic": {
"Address": "10.0.0.1",
"InitialDelay": 2000000000,
"Port": "443",
"SNI": "www.example.com",
"VerifyHostname": "api.ooni.io"
}
}
}
}
},
"Version": 5
}
```
**Listing 9.** Cached state for run with invalid cached state and invalid bridge address.
Here's an excerpt from the logs:
```
[ 0.004017] <info> sessionresolver: lookup api.ooni.io using https://wikimedia-dns.org/dns-query... started
[ 0.003854] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... started
[ 0.108089] <info> sessionresolver: lookup api.ooni.io using https://wikimedia-dns.org/dns-query... ok
[ 0.505472] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... in progress
[ 1.004614] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... started
[ 1.506069] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... in progress
[ 2.003650] <info> httpsDialer: [#3] TCPConnect 10.0.0.1:443... started
[ 2.505130] <info> httpsDialer: [#3] TCPConnect 10.0.0.1:443... in progress
[ 4.004683] <info> httpsDialer: [#4] TCPConnect 10.0.0.1:443... started
[ 4.506176] <info> httpsDialer: [#4] TCPConnect 10.0.0.1:443... in progress
[ 8.004547] <info> httpsDialer: [#5] TCPConnect 162.55.247.208:443... started
[ 8.042946] <info> httpsDialer: [#5] TCPConnect 162.55.247.208:443... ok
[ 8.043015] <info> httpsDialer: [#5] TLSHandshake with 162.55.247.208:443 SNI=api.ooni.io ALPN=[h2 http/1.1]... started
[ 8.088383] <info> httpsDialer: [#5] TLSHandshake with 162.55.247.208:443 SNI=api.ooni.io ALPN=[h2 http/1.1]... ok
[ 8.088417] <info> httpsDialer: [#5] TLSVerifyCertificateChain api.ooni.io... started
[ 8.091007] <info> httpsDialer: [#5] TLSVerifyCertificateChain api.ooni.io... ok
[ 8.091174] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... interrupted
[ 8.091234] <info> httpsDialer: [#3] TCPConnect 10.0.0.1:443... interrupted
[ 8.091258] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... interrupted
[ 8.091324] <info> httpsDialer: [#4] TCPConnect 10.0.0.1:443... interrupted
```
**Listing 10.** Run with invalid cached state and invalid bridge address.
So, here the fifth attempt is using the DNS. This is in line with the mixing algorithm, where
the first four attempt come from the stats or from the bridge policies.
Let's also shows what happens if we repeat the bootstrap:
```
[ 0.000938] <info> httpsDialer: [#2] TCPConnect 162.55.247.208:443... started
[ 0.001014] <info> sessionresolver: lookup api.ooni.io using https://mozilla.cloudflare-dns.com/dns-query... started
[ 0.053325] <info> httpsDialer: [#2] TCPConnect 162.55.247.208:443... ok
[ 0.053355] <info> httpsDialer: [#2] TLSHandshake with 162.55.247.208:443 SNI=api.ooni.io ALPN=[h2 http/1.1]... started
[ 0.080695] <info> sessionresolver: lookup api.ooni.io using https://mozilla.cloudflare-dns.com/dns-query... ok
[ 0.094648] <info> httpsDialer: [#2] TLSHandshake with 162.55.247.208:443 SNI=api.ooni.io ALPN=[h2 http/1.1]... ok
[ 0.094662] <info> httpsDialer: [#2] TLSVerifyCertificateChain api.ooni.io... started
[ 0.096677] <info> httpsDialer: [#2] TLSVerifyCertificateChain api.ooni.io... ok
```
**Listing 11.** Re-run with invalid cached state and bridge address.
You see that now we immediately use the correct address thanks to the stats.
### Valid bridge with invalid cached data
In this scenario, the bridge inside [bridgespolicy.go](bridgespolicy.go) is valid
but we have a cache listing an invalid bridge (I modified my cache to use `10.0.0.1`).
Here's an excerpt from the logs:
```
[ 0.002641] <info> sessionresolver: lookup api.ooni.io using https://mozilla.cloudflare-dns.com/dns-query... started
[ 0.081401] <info> sessionresolver: lookup api.ooni.io using https://mozilla.cloudflare-dns.com/dns-query... ok
[ 0.503518] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... in progress
[ 1.005322] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... started
[ 1.506304] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... in progress
[ 2.002837] <info> httpsDialer: [#4] TCPConnect 162.55.247.208:443... started
[ 2.048721] <info> httpsDialer: [#4] TCPConnect 162.55.247.208:443... ok
[ 2.048760] <info> httpsDialer: [#4] TLSHandshake with 162.55.247.208:443 SNI=player.ex.co ALPN=[h2 http/1.1]... started
[ 2.091016] <info> httpsDialer: [#4] TLSHandshake with 162.55.247.208:443 SNI=player.ex.co ALPN=[h2 http/1.1]... ok
[ 2.091033] <info> httpsDialer: [#4] TLSVerifyCertificateChain api.ooni.io... started
[ 2.093542] <info> httpsDialer: [#4] TLSVerifyCertificateChain api.ooni.io... ok
[ 2.093708] <info> httpsDialer: [#2] TCPConnect 10.0.0.1:443... interrupted
[ 2.093718] <info> httpsDialer: [#1] TCPConnect 10.0.0.1:443... interrupted
```
**Listing 12.** Re with invalid cached state and valid bridge address.
In this case, we pick up the right bridge configuration and successfully
use it after two seconds. This configuration is provided by the `bridgesPolicy`.
## Limitations and Future Work
1. We should integrate the [engineresolver](../engineresolver/) package with this package
more tightly: doing that would allow users to configure the order in which we use DNS-over-HTTPS
resolvers (see [probe#2675](https://github.com/ooni/probe/issues/2675)).
2. We lack a mechanism to dynamically distribute new bridges IP addresses to probes using,
for example, the check-in API and possibly other mechanisms. Lacking this functionality, our
bridge strategy is incomplete since it rests on a single bridge being available. What's
more, if this bridge disappears or is IP blocked, all the probes will have one slow bootstrap
and probes where DNS is not working will stop working (see
[probe#2500](https://github.com/ooni/probe/issues/2500)).
3. We should consider adding TLS ClientHello fragmentation as a tactic.
4. We should add support for HTTP/3 bridges.

View File

@ -25,50 +25,42 @@ type bridgesPolicy struct {
var _ httpsDialerPolicy = &bridgesPolicy{}
// maxInitialBridgeTactics is the number of initial bridge tactics we return.
const maxInitialBridgeTactics = 4
// LookupTactics implements httpsDialerPolicy.
//
// The remix policy of this operation is such that the following happens:
//
// 1. we emit the first two bridge tactics, if any;
//
// 2. we emit the first two fallback (usually DNS) tactics, if any;
//
// 3. we randomly remix the rest.
func (p *bridgesPolicy) LookupTactics(ctx context.Context, domain, port string) <-chan *httpsDialerTactic {
out := make(chan *httpsDialerTactic)
return mixDeterministicThenRandom(
&mixDeterministicThenRandomConfig{
// Prioritize emitting tactics for bridges. Currently we only have bridges
// for "api.ooni.io", therefore, for all other hosts this arm ends up
// returning a channel that will be immediately closed.
C: p.bridgesTacticsForDomain(domain, port),
go func() {
defer close(out) // tell the parent when we're done
index := 0
// This ensures we read the first two bridge tactics.
//
// Note: modifying this field likely indicates you also need to modify the
// corresponding instantiation in statspolicy.go.
N: 2,
},
// Get channel for reading bridge tactics.
bridges := p.bridgesTacticsForDomain(domain, port)
&mixDeterministicThenRandomConfig{
// Mix the above with using the fallback policy and rewriting the SNIs
// used by the test helpers to avoid exposing the real SNIs.
C: p.maybeRewriteTestHelpersTactics(p.Fallback.LookupTactics(ctx, domain, port)),
// Emit the first N bridge tactics. Note that tactics are empty if there
// is no bridge configured for the given domain and port.
for tx := range bridges {
tx.InitialDelay = happyEyeballsDelay(index)
index += 1
out <- tx
if index >= maxInitialBridgeTactics {
break
}
}
// Now fallback to get more tactics (typically via DNS).
//
// We wrap whatever the underlying policy returns us with some
// extra logic for better communicating with test helpers.
for tx := range p.maybeRewriteTestHelpersTactics(p.Fallback.LookupTactics(ctx, domain, port)) {
tx.InitialDelay = happyEyeballsDelay(index)
index += 1
out <- tx
}
// Now finish emitting bridge tactics.
for tx := range bridges {
tx.InitialDelay = happyEyeballsDelay(index)
index += 1
out <- tx
}
}()
return out
// This ensures we read the first two DNS tactics.
//
// Note: modifying this field likely indicates you also need to modify the
// corresponding instantiation in statspolicy.go.
N: 2,
},
)
}
var bridgesPolicyTestHelpersDomains = []string{
@ -96,10 +88,6 @@ func (p *bridgesPolicy) maybeRewriteTestHelpersTactics(input <-chan *httpsDialer
defer close(out) // tell the parent when we're done
for tactic := range input {
// TODO(bassosimone): here we could potentially attempt using tactics
// changing the SNI also for api.ooni.io when we're getting its address
// using a DNS resolver that is working as intended.
// When we're not connecting to a TH, pass the policy down the chain unmodified
if !bridgesPolicySlicesContains(bridgesPolicyTestHelpersDomains, tactic.VerifyHostname) {
out <- tactic
@ -111,7 +99,7 @@ func (p *bridgesPolicy) maybeRewriteTestHelpersTactics(input <-chan *httpsDialer
for _, sni := range p.bridgesDomainsInRandomOrder() {
out <- &httpsDialerTactic{
Address: tactic.Address,
InitialDelay: 0,
InitialDelay: 0, // set when dialing
Port: tactic.Port,
SNI: sni,
VerifyHostname: tactic.VerifyHostname,
@ -138,7 +126,7 @@ func (p *bridgesPolicy) bridgesTacticsForDomain(domain, port string) <-chan *htt
for _, sni := range p.bridgesDomainsInRandomOrder() {
out <- &httpsDialerTactic{
Address: ipAddr,
InitialDelay: 0,
InitialDelay: 0, // set when dialing
Port: port,
SNI: sni,
VerifyHostname: domain,

View File

@ -62,6 +62,10 @@ func TestBridgesPolicy(t *testing.T) {
t.Fatal("the host should always be 93.184.216.34")
}
if tactic.InitialDelay != 0 {
t.Fatal("unexpected InitialDelay")
}
if tactic.SNI != "www.example.com" {
t.Fatal("the SNI field should always be like `www.example.com`")
}
@ -104,6 +108,10 @@ func TestBridgesPolicy(t *testing.T) {
t.Fatal("the host should always be 162.55.247.208")
}
if tactic.InitialDelay != 0 {
t.Fatal("unexpected InitialDelay")
}
if tactic.SNI == "api.ooni.io" {
t.Fatal("we should not see the `api.ooni.io` SNI on the wire")
}
@ -139,6 +147,7 @@ func TestBridgesPolicy(t *testing.T) {
dnsCount int
overallCount int
)
const expectedDNSEntryCount = 153 // yikes!
for tactic := range tactics {
overallCount++
@ -149,29 +158,33 @@ func TestBridgesPolicy(t *testing.T) {
}
switch {
case overallCount == 5:
case overallCount == expectedDNSEntryCount:
if tactic.Address != "130.192.91.211" {
t.Fatal("the host should be 130.192.91.211 for count == 5")
t.Fatal("the host should be 130.192.91.211 for count ==", expectedDNSEntryCount)
}
if tactic.SNI != "api.ooni.io" {
t.Fatal("we should see the `api.ooni.io` SNI on the wire for count == 5")
t.Fatal("we should see the `api.ooni.io` SNI on the wire for count ==", expectedDNSEntryCount)
}
dnsCount++
default:
if tactic.Address != "162.55.247.208" {
t.Fatal("the host should be 162.55.247.208 for count != 5")
t.Fatal("the host should be 162.55.247.208 for count !=", expectedDNSEntryCount)
}
if tactic.SNI == "api.ooni.io" {
t.Fatal("we should not see the `api.ooni.io` SNI on the wire for count != 5")
t.Fatal("we should not see the `api.ooni.io` SNI on the wire for count !=", expectedDNSEntryCount)
}
bridgesCount++
}
if tactic.InitialDelay != 0 {
t.Fatal("unexpected InitialDelay")
}
if tactic.VerifyHostname != "api.ooni.io" {
t.Fatal("the VerifyHostname field should always be like `api.ooni.io`")
}
@ -205,27 +218,25 @@ func TestBridgesPolicy(t *testing.T) {
}
ctx := context.Background()
index := 0
for tactics := range p.LookupTactics(ctx, domain, "443") {
for tactic := range p.LookupTactics(ctx, domain, "443") {
if tactics.Address != "164.92.180.7" {
if tactic.Address != "164.92.180.7" {
t.Fatal("unexpected .Address")
}
if tactics.InitialDelay != happyEyeballsDelay(index) {
if tactic.InitialDelay != 0 {
t.Fatal("unexpected .InitialDelay")
}
index++
if tactics.Port != "443" {
if tactic.Port != "443" {
t.Fatal("unexpected .Port")
}
if tactics.SNI == domain {
if tactic.SNI == domain {
t.Fatal("unexpected .Domain")
}
if tactics.VerifyHostname != domain {
if tactic.VerifyHostname != domain {
t.Fatal("unexpected .VerifyHostname")
}
}

View File

@ -56,10 +56,10 @@ func (p *dnsPolicy) LookupTactics(
}
// The tactics we generate here have SNI == VerifyHostname == domain
for idx, addr := range addrs {
for _, addr := range addrs {
tactic := &httpsDialerTactic{
Address: addr,
InitialDelay: happyEyeballsDelay(idx),
InitialDelay: 0, // set when dialing
Port: port,
SNI: domain,
VerifyHostname: domain,

View File

@ -54,6 +54,9 @@ func TestDNSPolicy(t *testing.T) {
if tactic.Address != "130.192.91.211" {
t.Fatal("invalid endpoint address")
}
if tactic.InitialDelay != 0 {
t.Fatal("unexpected .InitialDelay")
}
if tactic.Port != "443" {
t.Fatal("invalid endpoint port")
}

View File

@ -0,0 +1,73 @@
package enginenetx
// filterOutNilTactics filters out nil tactics.
//
// This function returns a channel where we emit the edited
// tactics, and which we clone when we're done.
func filterOutNilTactics(input <-chan *httpsDialerTactic) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
defer close(output)
for tx := range input {
if tx != nil {
output <- tx
}
}
}()
return output
}
// filterOnlyKeepUniqueTactics only keeps unique tactics.
//
// This function returns a channel where we emit the edited
// tactics, and which we clone when we're done.
func filterOnlyKeepUniqueTactics(input <-chan *httpsDialerTactic) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
// make sure we close output chan
defer close(output)
// useful to make sure we don't emit two equal policy in a single run
uniq := make(map[string]int)
for tx := range input {
// handle the case in which we already emitted a tactic
key := tx.tacticSummaryKey()
if uniq[key] > 0 {
continue
}
uniq[key]++
// emit the tactic
output <- tx
}
}()
return output
}
// filterAssignInitialDelays assigns initial delays to tactics.
//
// This function returns a channel where we emit the edited
// tactics, and which we clone when we're done.
func filterAssignInitialDelays(input <-chan *httpsDialerTactic) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
// make sure we close output chan
defer close(output)
index := 0
for tx := range input {
// rewrite the delays
tx.InitialDelay = happyEyeballsDelay(index)
index++
// emit the tactic
output <- tx
}
}()
return output
}

View File

@ -0,0 +1,111 @@
package enginenetx
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
func TestFilterOutNilTactics(t *testing.T) {
inputs := []*httpsDialerTactic{
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "x.org",
VerifyHostname: "api.ooni.io",
},
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "www.polito.it",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
}
expect := []*httpsDialerTactic{
inputs[2], inputs[4],
}
var output []*httpsDialerTactic
for tx := range filterOutNilTactics(streamTacticsFromSlice(inputs)) {
output = append(output, tx)
}
if diff := cmp.Diff(expect, output); diff != "" {
t.Fatal(diff)
}
}
func TestFilterOnlyKeepUniqueTactics(t *testing.T) {
templates := []*httpsDialerTactic{{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "www.example.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "www.kernel.org",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "x.org",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "www.polito.it",
VerifyHostname: "api.ooni.io",
}}
inputs := []*httpsDialerTactic{
templates[2], templates[1], templates[1],
templates[2], templates[2], templates[1],
templates[0], templates[1], templates[0],
templates[2], templates[1], templates[2],
templates[1], templates[0], templates[1],
templates[3], // only once at the end
}
expect := []*httpsDialerTactic{
templates[2], templates[1], templates[0], templates[3],
}
var output []*httpsDialerTactic
for tx := range filterOnlyKeepUniqueTactics(streamTacticsFromSlice(inputs)) {
output = append(output, tx)
}
if diff := cmp.Diff(expect, output); diff != "" {
t.Fatal(diff)
}
}
func TestFilterAssignInitalDelays(t *testing.T) {
inputs := []*httpsDialerTactic{}
ff := &testingx.FakeFiller{}
ff.Fill(&inputs)
idx := 0
for tx := range filterAssignInitialDelays(streamTacticsFromSlice(inputs)) {
if tx.InitialDelay != happyEyeballsDelay(idx) {
t.Fatal("unexpected .InitialDelay", tx.InitialDelay, "for", idx)
}
idx++
}
if idx < 1 {
t.Fatal("expected to see at least one entry")
}
}

View File

@ -108,7 +108,6 @@ type httpsDialerEventsHandler interface {
// case, obviously, you MUST NOT consider the tactic failed.
OnStarting(tactic *httpsDialerTactic)
OnTCPConnectError(ctx context.Context, tactic *httpsDialerTactic, err error)
OnTCPConnectSuccess(tactic *httpsDialerTactic)
OnTLSHandshakeError(ctx context.Context, tactic *httpsDialerTactic, err error)
OnTLSVerifyError(tactic *httpsDialerTactic, err error)
OnSuccess(tactic *httpsDialerTactic)
@ -210,7 +209,7 @@ func (hd *httpsDialer) DialTLSContext(ctx context.Context, network string, endpo
// The emitter will emit tactics and then close the channel when done. We spawn 16 workers
// that handle tactics in parallel and post results on the collector channel.
emitter := httpsFilterTactics(hd.policy.LookupTactics(ctx, hostname, port))
emitter := httpsDialerFilterTactics(hd.policy.LookupTactics(ctx, hostname, port))
collector := make(chan *httpsDialerErrorOrConn)
joiner := make(chan any)
const parallelism = 16
@ -237,8 +236,10 @@ func (hd *httpsDialer) DialTLSContext(ctx context.Context, network string, endpo
continue
}
// Save the conn and tell goroutines to stop ASAP
// Save the conn
connv = append(connv, result.Conn)
// Interrupt other concurrent dialing attempts
cancel()
}
}
@ -246,26 +247,18 @@ func (hd *httpsDialer) DialTLSContext(ctx context.Context, network string, endpo
return httpsDialerReduceResult(connv, errorv)
}
// httpsFilterTactics filters the tactics and rewrites their InitialDelay.
func httpsFilterTactics(input <-chan *httpsDialerTactic) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
// make sure we close output chan
defer close(output)
index := 0
for tx := range input {
// rewrite the delays
tx.InitialDelay = happyEyeballsDelay(index)
index++
// emit the tactic
output <- tx
}
}()
return output
// httpsDialerFilterTactics filters the tactics to:
//
// 1. be paranoid and filter out nil tactics if any;
//
// 2. avoid emitting duplicate tactics as part of the same run;
//
// 3. rewrite the happy eyeball delays.
//
// This function returns a channel where we emit the edited
// tactics, and which we clone when we're done.
func httpsDialerFilterTactics(input <-chan *httpsDialerTactic) <-chan *httpsDialerTactic {
return filterAssignInitialDelays(filterOnlyKeepUniqueTactics(filterOutNilTactics(input)))
}
// httpsDialerReduceResult returns either an established conn or an error, using [errDNSNoAnswer] in
@ -342,12 +335,6 @@ func (hd *httpsDialer) dialTLS(
return nil, err
}
// track successful TCP connections such that we have stats
// regarding which endpoints work as intended: if we can't dial
// a specific TCP endpoint a couple of times, it doesn't make
// sense to continue trying with different SNIs.
hd.stats.OnTCPConnectSuccess(tactic)
// create TLS configuration
tlsConfig := &tls.Config{
InsecureSkipVerify: true, // Note: we're going to verify at the end of the func!

View File

@ -50,11 +50,6 @@ func (*httpsDialerCancelingContextStatsTracker) OnTCPConnectError(ctx context.Co
// nothing
}
// OnTCPConnectSuccess implements httpsDialerEventsHandler.
func (*httpsDialerCancelingContextStatsTracker) OnTCPConnectSuccess(tactic *httpsDialerTactic) {
// nothing
}
// OnTLSHandshakeError implements httpsDialerEventsHandler.
func (*httpsDialerCancelingContextStatsTracker) OnTLSHandshakeError(ctx context.Context, tactic *httpsDialerTactic, err error) {
// nothing
@ -637,3 +632,231 @@ func TestHTTPSDialerReduceResult(t *testing.T) {
}
})
}
// Make sure that (1) we remove nils; (2) we avoid emitting duplicate tactics; (3) we fill
// the happy-eyeballs delays for each entry we return.
func TestHTTPSDialerFilterTactics(t *testing.T) {
// define the inputs vector including duplicates and nils
inputs := []*httpsDialerTactic{
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "x.org",
VerifyHostname: "api.ooni.io",
},
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "www.polito.it",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "x.org",
VerifyHostname: "api.ooni.io",
},
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "www.polito.it",
VerifyHostname: "api.ooni.io",
},
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "x.com",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "kerneltrap.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "kerneltrap.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "freebsd.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "kerneltrap.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "dragonflybsd.org",
VerifyHostname: "api.ooni.io",
},
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "kerneltrap.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "openbsd.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.231",
InitialDelay: 0,
Port: "443",
SNI: "openbsd.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.231",
InitialDelay: 0,
Port: "443",
SNI: "openbsd.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.231",
InitialDelay: 0,
Port: "443",
SNI: "netbsd.org",
VerifyHostname: "api.ooni.io",
},
nil,
nil,
{
Address: "130.192.91.231",
InitialDelay: 0,
Port: "443",
SNI: "openbsd.org",
VerifyHostname: "api.ooni.io",
},
nil,
}
// define the expectations
expect := []*httpsDialerTactic{
{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "x.org",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.211",
InitialDelay: time.Second,
Port: "443",
SNI: "www.polito.it",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.211",
InitialDelay: 2 * time.Second,
Port: "443",
SNI: "x.com",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.211",
InitialDelay: 4 * time.Second,
Port: "443",
SNI: "kerneltrap.org",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.211",
InitialDelay: 8 * time.Second,
Port: "443",
SNI: "freebsd.org",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.211",
InitialDelay: 16 * time.Second,
Port: "443",
SNI: "dragonflybsd.org",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.211",
InitialDelay: 24 * time.Second,
Port: "443",
SNI: "openbsd.org",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.231",
InitialDelay: 32 * time.Second,
Port: "443",
SNI: "openbsd.org",
VerifyHostname: "api.ooni.io",
},
{
Address: "130.192.91.231",
InitialDelay: 40 * time.Second,
Port: "443",
SNI: "netbsd.org",
VerifyHostname: "api.ooni.io",
},
}
// run the algorithm
var results []*httpsDialerTactic
for tx := range httpsDialerFilterTactics(streamTacticsFromSlice(inputs)) {
results = append(results, tx)
}
// compare the results
if diff := cmp.Diff(expect, results); diff != "" {
t.Fatal(diff)
}
}

View File

@ -0,0 +1,87 @@
package enginenetx
import "sync"
// mixSequentially mixes entries from primary followed by entries from fallback.
//
// This function returns a channel where we emit the edited
// tactics, and which we clone when we're done.
func mixSequentially(primary, fallback <-chan *httpsDialerTactic) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
defer close(output)
for tx := range primary {
output <- tx
}
for tx := range fallback {
output <- tx
}
}()
return output
}
// mixDeterministicThenRandomConfig contains config for [mixDeterministicThenRandom].
type mixDeterministicThenRandomConfig struct {
// C is the channel to mix from.
C <-chan *httpsDialerTactic
// N is the number of entries to read from at the
// beginning before starting random mixing.
N int
}
// mixDeterministicThenRandom reads the first N entries from primary, if any, then the first N
// entries from fallback, if any, and then randomly mixes the entries.
func mixDeterministicThenRandom(primary, fallback *mixDeterministicThenRandomConfig) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
defer close(output)
mixTryEmitN(primary.C, primary.N, output)
mixTryEmitN(fallback.C, fallback.N, output)
for tx := range mixRandomly(primary.C, fallback.C) {
output <- tx
}
}()
return output
}
func mixTryEmitN(input <-chan *httpsDialerTactic, numToRead int, output chan<- *httpsDialerTactic) {
for idx := 0; idx < numToRead; idx++ {
tactic, good := <-input
if !good {
return
}
output <- tactic
}
}
func mixRandomly(left, right <-chan *httpsDialerTactic) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
// read from left
waitg := &sync.WaitGroup{}
waitg.Add(1)
go func() {
defer waitg.Done()
for tx := range left {
output <- tx
}
}()
// read from right
waitg.Add(1)
go func() {
defer waitg.Done()
for tx := range right {
output <- tx
}
}()
// close when done
go func() {
waitg.Wait()
close(output)
}()
}()
return output
}

View File

@ -0,0 +1,227 @@
package enginenetx
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
func TestMixSequentially(t *testing.T) {
primary := []*httpsDialerTactic{}
fallback := []*httpsDialerTactic{}
ff := &testingx.FakeFiller{}
ff.Fill(&primary)
ff.Fill(&fallback)
expect := append([]*httpsDialerTactic{}, primary...)
expect = append(expect, fallback...)
var output []*httpsDialerTactic
for tx := range mixSequentially(streamTacticsFromSlice(primary), streamTacticsFromSlice(fallback)) {
output = append(output, tx)
}
if diff := cmp.Diff(expect, output); diff != "" {
t.Fatal(diff)
}
}
func TestMixDeterministicThenRandom(t *testing.T) {
// define primary data source
primary := []*httpsDialerTactic{{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a1.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a2.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a3.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a4.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a5.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a6.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a7.com",
VerifyHostname: "api.ooni.io",
}}
// define fallback data source
fallback := []*httpsDialerTactic{{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b1.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b2.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b3.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b4.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b5.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b6.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b7.com",
VerifyHostname: "api.ooni.io",
}}
// define the expectations for the beginning of the result
expectBeginning := []*httpsDialerTactic{{
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a1.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "a2.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b1.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b2.com",
VerifyHostname: "api.ooni.io",
}, {
Address: "130.192.91.211",
InitialDelay: 0,
Port: "443",
SNI: "b3.com",
VerifyHostname: "api.ooni.io",
}}
// remix
outch := mixDeterministicThenRandom(
&mixDeterministicThenRandomConfig{
C: streamTacticsFromSlice(primary),
N: 2,
},
&mixDeterministicThenRandomConfig{
C: streamTacticsFromSlice(fallback),
N: 3,
},
)
var output []*httpsDialerTactic
for tx := range outch {
output = append(output, tx)
}
// make sure we have the expected number of entries
if len(output) != 14 {
t.Fatal("we need 14 entries")
}
if diff := cmp.Diff(expectBeginning, output[:5]); diff != "" {
t.Fatal(diff)
}
// make sure each entry is represented
const (
inprimary = 1 << 0
infallback
inoutput
)
mapping := make(map[string]int)
for _, entry := range primary {
mapping[entry.tacticSummaryKey()] |= inprimary
}
for _, entry := range fallback {
mapping[entry.tacticSummaryKey()] |= infallback
}
for _, entry := range output {
mapping[entry.tacticSummaryKey()] |= inoutput
}
for entry, flags := range mapping {
if flags != (inprimary|inoutput) && flags != (infallback|inoutput) {
t.Fatal("unexpected flags", flags, "for entry", entry)
}
}
}
func TestMixTryEmitNWithClosedChannel(t *testing.T) {
// create an already closed channel
inputch := make(chan *httpsDialerTactic)
close(inputch)
// create channel for collecting the results
outputch := make(chan *httpsDialerTactic)
go func() {
// Implementation note: mixTryEmitN does not close the channel
// when done, therefore we need to close it ourselves.
mixTryEmitN(inputch, 10, outputch)
close(outputch)
}()
// read the output channel
var output []*httpsDialerTactic
for tx := range outputch {
output = append(output, tx)
}
// make sure we didn't read anything
if len(output) != 0 {
t.Fatal("expected zero entries")
}
}

View File

@ -39,11 +39,6 @@ func (*nullStatsManager) OnTCPConnectError(ctx context.Context, tactic *httpsDia
// nothing
}
// OnTCPConnectSuccess implements httpsDialerEventsHandler.
func (*nullStatsManager) OnTCPConnectSuccess(tactic *httpsDialerTactic) {
// nothing
}
// OnTLSHandshakeError implements httpsDialerEventsHandler.
func (*nullStatsManager) OnTLSHandshakeError(ctx context.Context, tactic *httpsDialerTactic, err error) {
// nothing
@ -529,11 +524,6 @@ func (mt *statsManager) OnTCPConnectError(ctx context.Context, tactic *httpsDial
statsSafeIncrementMapStringInt64(&record.HistoTCPConnectError, err.Error())
}
// OnTCPConnectSuccess implements httpsDialerEventsHandler.
func (mt *statsManager) OnTCPConnectSuccess(tactic *httpsDialerTactic) {
// TODO(bassosimone): implement this method
}
// OnTLSHandshakeError implements httpsDialerEventsHandler.
func (mt *statsManager) OnTLSHandshakeError(ctx context.Context, tactic *httpsDialerTactic, err error) {
// get exclusive access

View File

@ -30,55 +30,32 @@ var _ httpsDialerPolicy = &statsPolicy{}
// LookupTactics implements HTTPSDialerPolicy.
func (p *statsPolicy) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic {
out := make(chan *httpsDialerTactic)
// avoid emitting nil tactics and duplicate tactics
return filterOnlyKeepUniqueTactics(filterOutNilTactics(mixDeterministicThenRandom(
&mixDeterministicThenRandomConfig{
// Give priority to what we know from stats.
C: streamTacticsFromSlice(statsPolicyFilterStatsTactics(p.Stats.LookupTactics(domain, port))),
go func() {
defer close(out) // make sure the parent knows when we're done
index := 0
// We make sure we emit two stats-based tactics if possible.
N: 2,
},
// useful to make sure we don't emit two equal policy in a single run
uniq := make(map[string]int)
&mixDeterministicThenRandomConfig{
// And remix it with the fallback.
C: p.Fallback.LookupTactics(ctx, domain, port),
// function that emits a given tactic unless we already emitted it
maybeEmitTactic := func(t *httpsDialerTactic) {
// as a safety mechanism let's gracefully handle the
// case in which the tactic is nil
if t == nil {
return
}
// handle the case in which we already emitted a policy
key := t.tacticSummaryKey()
if uniq[key] > 0 {
return
}
uniq[key]++
// 🚀!!!
t.InitialDelay = happyEyeballsDelay(index)
index += 1
out <- t
}
// TODO(bassosimone): as an optimization, here we could mix cached tactics
// and fallback tactics to avoid slow bootstraps in the event in which
// known-to-work cached tactics have become obsolete.
// give priority to what we know from stats
for _, t := range statsPolicyFilterStatsTactics(p.Stats.LookupTactics(domain, port)) {
maybeEmitTactic(t)
}
// fallback to the secondary policy
for t := range p.Fallback.LookupTactics(ctx, domain, port) {
maybeEmitTactic(t)
}
}()
return out
// Under the assumption that below us we have bridgePolicy composed with DNS policy
// and that the stage below emits two bridge tactics, if possible, followed by two
// additional DNS tactics, if possible, we need to allow for four tactics to pass through
// befofe we start remixing from the two channels.
//
// Note: modifying this field likely indicates you also need to modify the
// corresponding instantiation in bridgespolicy.go.
N: 4,
},
)))
}
// statsPolicyFilterStatsTactics filters the tactics generated by consulting the stats.
func statsPolicyFilterStatsTactics(tactics []*statsTactic, good bool) (out []*httpsDialerTactic) {
// when good is false, it means p.Stats.LookupTactics failed
if !good {

View File

@ -169,21 +169,19 @@ func TestStatsPolicyWorkingAsIntended(t *testing.T) {
// compute the list of results we expect to see from the stats data
var expect []*httpsDialerTactic
idx := 0
for _, entry := range expectTacticsStats {
if entry.CountSuccess <= 0 || entry.Tactic == nil {
continue // we SHOULD NOT include entries that systematically failed
}
t := entry.Tactic.Clone()
t.InitialDelay = happyEyeballsDelay(idx)
t.InitialDelay = 0
expect = append(expect, t)
idx++
}
// extend the expected list to include DNS results
expect = append(expect, &httpsDialerTactic{
Address: bridgeAddress,
InitialDelay: 2 * time.Second,
InitialDelay: 0,
Port: "443",
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
@ -234,21 +232,19 @@ func TestStatsPolicyWorkingAsIntended(t *testing.T) {
// compute the list of results we expect to see from the stats data
var expect []*httpsDialerTactic
idx := 0
for _, entry := range expectTacticsStats {
if entry.CountSuccess <= 0 || entry.Tactic == nil {
continue // we SHOULD NOT include entries that systematically failed
}
t := entry.Tactic.Clone()
t.InitialDelay = happyEyeballsDelay(idx)
t.InitialDelay = 0
expect = append(expect, t)
idx++
}
// extend the expected list to include DNS results
expect = append(expect, &httpsDialerTactic{
Address: bridgeAddress,
InitialDelay: 2 * time.Second,
InitialDelay: 0,
Port: "443",
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
@ -290,15 +286,13 @@ func TestStatsPolicyWorkingAsIntended(t *testing.T) {
// compute the list of results we expect to see from the stats data
var expect []*httpsDialerTactic
idx := 0
for _, entry := range expectTacticsStats {
if entry.CountSuccess <= 0 || entry.Tactic == nil {
continue // we SHOULD NOT include entries that systematically failed
}
t := entry.Tactic.Clone()
t.InitialDelay = happyEyeballsDelay(idx)
t.InitialDelay = 0
expect = append(expect, t)
idx++
}
// perform the actual comparison

View File

@ -0,0 +1,16 @@
package enginenetx
// streamTacticsFromSlice streams tactics from a given slice.
//
// This function returns a channel where we emit the edited
// tactics, and which we clone when we're done.
func streamTacticsFromSlice(input []*httpsDialerTactic) <-chan *httpsDialerTactic {
output := make(chan *httpsDialerTactic)
go func() {
defer close(output)
for _, tx := range input {
output <- tx
}
}()
return output
}

View File

@ -0,0 +1,23 @@
package enginenetx
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
func TestStreamTacticsFromSlice(t *testing.T) {
input := []*httpsDialerTactic{}
ff := &testingx.FakeFiller{}
ff.Fill(&input)
var output []*httpsDialerTactic
for tx := range streamTacticsFromSlice(input) {
output = append(output, tx)
}
if diff := cmp.Diff(input, output); diff != "" {
t.Fatal(diff)
}
}