feat: add an experimental quicping experiment (#677)

This experiment pings a QUIC-able host. It can be used to measure QUIC availability independently from TLS.
This is the reference issue: https://github.com/ooni/probe/issues/1994

### A QUIC PING is:
- a QUIC Initial packet with a size of 1200 bytes (minimum datagram size defined in the [RFC 9000](https://www.rfc-editor.org/rfc/rfc9000.html#initial-size)),
- with a random payload (i.e. no TLS ClientHello),
- with the version string 0xbabababa which forces Version Negotiation at the server.

QUIC-able hosts respond to the QUIC PING with a Version Negotiation packet.

The input is a domain name or an IP address. The default port used by quicping is 443, as this is the port used by HTTP/3. The port can be modified with the `-O Port=` option.
The default number of repetitions is 10, it can be changed with `-O Repetitions=`.

### Usage:
```
./miniooni -i google.com quicping
./miniooni -i 142.250.181.206 quicping
./miniooni -i 142.250.181.206 -OPort=443 quicping
./miniooni -i 142.250.181.206 -ORepetitions=2 quicping

```
This commit is contained in:
kelmenhorst 2022-02-14 19:21:16 +01:00 committed by GitHub
parent 0735e2018f
commit 88236a4352
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 992 additions and 0 deletions

View File

@ -12,6 +12,7 @@ import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/httphostheader"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/ndt7"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/psiphon"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/quicping"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/riseupvpn"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/run"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/signal"
@ -142,6 +143,18 @@ var experimentsByName = map[string]func(*Session) *ExperimentBuilder{
}
},
"quicping": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {
return NewExperiment(session, quicping.NewExperimentMeasurer(
*config.(*quicping.Config),
))
},
config: &quicping.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"riseupvpn": func(session *Session) *ExperimentBuilder {
return &ExperimentBuilder{
build: func(config interface{}) *Experiment {

View File

@ -0,0 +1,180 @@
package quicping
import (
"crypto"
"crypto/aes"
"crypto/cipher"
"encoding/binary"
"fmt"
"github.com/ooni/probe-cli/v3/internal/runtimex"
"golang.org/x/crypto/hkdf"
)
// SPDX-License-Identifier: BSD-3-Clause
// This code is borrowed from https://github.com/marten-seemann/qtls-go1-15
// https://github.com/marten-seemann/qtls-go1-15/blob/0d137e9e3594d8e9c864519eff97b323321e5e74/cipher_suites.go#L281
type aead interface {
cipher.AEAD
// explicitNonceLen returns the number of bytes of explicit nonce
// included in each record. This is eight for older AEADs and
// zero for modern ones.
explicitNonceLen() int
}
const (
aeadNonceLength = 12
noncePrefixLength = 4
)
// SPDX-License-Identifier: BSD-3-Clause
// This code is borrowed from https://github.com/marten-seemann/qtls-go1-15
// https://github.com/marten-seemann/qtls-go1-15/blob/0d137e9e3594d8e9c864519eff97b323321e5e74/cipher_suites.go#L375
func aeadAESGCMTLS13(key, nonceMask []byte) aead {
if len(nonceMask) != aeadNonceLength {
panic("tls: internal error: wrong nonce length")
}
aes, err := aes.NewCipher(key)
runtimex.PanicOnError(err, fmt.Sprintf("aes.NewCipher failed: %s", err))
aead, err := cipher.NewGCM(aes)
runtimex.PanicOnError(err, fmt.Sprintf("cipher.NewGCM failed: %s", err))
ret := &xorNonceAEAD{aead: aead}
copy(ret.nonceMask[:], nonceMask)
return ret
}
// SPDX-License-Identifier: MIT
// This code is borrowed from https://github.com/lucas-clemente/quic-go/
// https://github.com/lucas-clemente/quic-go/blob/f3b098775e40f96486c0065204145ddc8675eb7c/internal/handshake/initial_aead.go#L60
// https://www.rfc-editor.org/rfc/rfc9001.html#protection-keys
//
// computeInitialKeyAndIV derives the packet protection key and Initialization Vector (IV) from the initial secret.
func computeInitialKeyAndIV(secret []byte) (key, iv []byte) {
key = hkdfExpandLabel(crypto.SHA256, secret, []byte{}, "quic key", 16)
iv = hkdfExpandLabel(crypto.SHA256, secret, []byte{}, "quic iv", 12)
return
}
// https://www.rfc-editor.org/rfc/rfc9001.html#protection-keys
//
// computeHP derives the header protection key from the initial secret.
func computeHP(secret []byte) (hp []byte) {
hp = hkdfExpandLabel(crypto.SHA256, secret, []byte{}, "quic hp", 16)
return
}
// SPDX-License-Identifier: MIT
// This code is borrowed from https://github.com/lucas-clemente/quic-go/
// https://github.com/lucas-clemente/quic-go/blob/f3b098775e40f96486c0065204145ddc8675eb7c/internal/handshake/initial_aead.go#L53
// https://www.rfc-editor.org/rfc/rfc9001.html#name-initial-secrets
//
// computeSecrets computes the initial secrets based on the destination connection ID.
func computeSecrets(destConnID []byte) (clientSecret, serverSecret []byte) {
initialSalt := []byte{0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a}
initialSecret := hkdf.Extract(crypto.SHA256.New, destConnID, initialSalt)
clientSecret = hkdfExpandLabel(crypto.SHA256, initialSecret, []byte{}, "client in", crypto.SHA256.Size())
serverSecret = hkdfExpandLabel(crypto.SHA256, initialSecret, []byte{}, "server in", crypto.SHA256.Size())
return
}
// https://www.rfc-editor.org/rfc/rfc9001.html#name-client-initial
// https://www.rfc-editor.org/rfc/rfc9001.html#name-header-protection
//
// encryptHeader applies header protection to the packet bytes (raw).
func encryptHeader(raw, hdr, clientSecret []byte) []byte {
hp := computeHP(clientSecret)
block, err := aes.NewCipher(hp)
runtimex.PanicOnError(err, fmt.Sprintf("error creating new AES cipher: %s", err))
hdroffset := 0
payloadOffset := len(hdr)
sample := raw[payloadOffset : payloadOffset+16]
mask := make([]byte, block.BlockSize())
if len(sample) != len(mask) {
panic("invalid sample size")
}
block.Encrypt(mask, sample)
pnOffset := len(hdr) - 4
pnBytes := raw[pnOffset:payloadOffset]
raw[hdroffset] ^= mask[0] & 0xf
for i := range pnBytes {
pnBytes[i] ^= mask[i+1]
}
return raw
}
// https://www.rfc-editor.org/rfc/rfc9001.html#name-packet-protection
//
// encryptPayload encrypts the payload of the packet.
func encryptPayload(payload, destConnID connectionID, clientSecret []byte) []byte {
myKey, myIV := computeInitialKeyAndIV(clientSecret)
encrypter := aeadAESGCMTLS13(myKey, myIV)
nonceBuf := make([]byte, encrypter.NonceSize())
var pn int64 = 2
binary.BigEndian.PutUint64(nonceBuf[len(nonceBuf)-8:], uint64(pn))
encrypted := encrypter.Seal(nil, nonceBuf, payload, nil)
return encrypted
}
// SPDX-License-Identifier: MIT
// This code is borrowed from https://github.com/lucas-clemente/quic-go/
// https://github.com/lucas-clemente/quic-go/blob/master/internal/handshake/hkdf.go
//
// hkdfExpandLabel HKDF expands a label.
func hkdfExpandLabel(hash crypto.Hash, secret, context []byte, label string, length int) []byte {
b := make([]byte, 3, 3+6+len(label)+1+len(context))
binary.BigEndian.PutUint16(b, uint16(length))
b[2] = uint8(6 + len(label))
b = append(b, []byte("tls13 ")...)
b = append(b, []byte(label)...)
b = b[:3+6+len(label)+1]
b[3+6+len(label)] = uint8(len(context))
b = append(b, context...)
out := make([]byte, length)
n, err := hkdf.Expand(hash.New, secret, b).Read(out)
if err != nil || n != length {
panic("quic: HKDF-Expand-Label invocation failed unexpectedly")
}
return out
}
// SPDX-License-Identifier: BSD-3-Clause
// This code is borrowed from https://github.com/marten-seemann/qtls-go1-15
// https://github.com/marten-seemann/qtls-go1-15/blob/0d137e9e3594d8e9c864519eff97b323321e5e74/cipher_suites.go#L319
//
// xoredNonceAEAD wraps an AEAD by XORing in a fixed pattern to the nonce before each call.
type xorNonceAEAD struct {
nonceMask [aeadNonceLength]byte
aead cipher.AEAD
}
func (f *xorNonceAEAD) NonceSize() int { return 8 } // 64-bit sequence number
func (f *xorNonceAEAD) Overhead() int { return f.aead.Overhead() }
func (f *xorNonceAEAD) explicitNonceLen() int { return 0 }
func (f *xorNonceAEAD) Seal(out, nonce, plaintext, additionalData []byte) []byte {
for i, b := range nonce {
f.nonceMask[4+i] ^= b
}
result := f.aead.Seal(out, f.nonceMask[:], plaintext, additionalData)
for i, b := range nonce {
f.nonceMask[4+i] ^= b
}
return result
}
func (f *xorNonceAEAD) Open(out, nonce, ciphertext, additionalData []byte) ([]byte, error) {
for i, b := range nonce {
f.nonceMask[4+i] ^= b
}
result, err := f.aead.Open(out, f.nonceMask[:], ciphertext, additionalData)
for i, b := range nonce {
f.nonceMask[4+i] ^= b
}
return result, err
}

View File

@ -0,0 +1,132 @@
package quicping
import (
"crypto/rand"
"encoding/binary"
"fmt"
"github.com/ooni/probe-cli/v3/internal/runtimex"
)
// buildHeader creates the unprotected QUIC header.
// https://www.rfc-editor.org/rfc/rfc9000.html#name-initial-packet
func buildHeader(destConnID, srcConnID connectionID, payloadLen int) []byte {
hdr := []byte{0xc3} // long header type, fixed
version := make([]byte, 4)
binary.BigEndian.PutUint32(version, uint32(0xbabababa))
hdr = append(hdr, version...) // version
lendID := uint8(len(destConnID))
hdr = append(hdr, lendID) // destination connection ID length
hdr = append(hdr, destConnID...) // destination connection ID
lensID := uint8(len(srcConnID))
hdr = append(hdr, lensID) // source connection ID length
hdr = append(hdr, srcConnID...) // source connection ID
hdr = append(hdr, 0x0) // token length
remainder := 4 + payloadLen
remainder_mask := 0b100000000000000
remainder_mask |= remainder
remainder_b := make([]byte, 2)
binary.BigEndian.PutUint16(remainder_b, uint16(remainder_mask))
hdr = append(hdr, remainder_b...) // remainder length: packet number + encrypted payload
pn := make([]byte, 4)
binary.BigEndian.PutUint32(pn, uint32(2))
hdr = append(hdr, pn...) // packet number
return hdr
}
// buildPacket constructs an Initial QUIC packet
// and applies Initial protection.
// https://www.rfc-editor.org/rfc/rfc9001.html#name-client-initial
func buildPacket() ([]byte, connectionID, connectionID) {
destConnID, srcConnID := generateConnectionIDs()
// generate random payload
minPayloadSize := 1200 - 14 - (len(destConnID) + len(srcConnID))
randomPayload := make([]byte, minPayloadSize)
rand.Read(randomPayload)
clientSecret, _ := computeSecrets(destConnID)
encrypted := encryptPayload(randomPayload, destConnID, clientSecret)
hdr := buildHeader(destConnID, srcConnID, len(encrypted))
raw := append(hdr, encrypted...)
raw = encryptHeader(raw, hdr, clientSecret)
return raw, destConnID, srcConnID
}
// generateConnectionID generates a connection ID using cryptographic random
func generateConnectionID(len int) connectionID {
b := make([]byte, len)
_, err := rand.Read(b)
runtimex.PanicOnError(err, "rand.Read failed")
return connectionID(b)
}
// generateConnectionIDForInitial generates a connection ID for the Initial packet.
// It uses a length randomly chosen between 8 and 18 bytes.
func generateConnectionIDForInitial() connectionID {
r := make([]byte, 1)
_, err := rand.Read(r)
runtimex.PanicOnError(err, "rand.Read failed")
len := minConnectionIDLenInitial + int(r[0])%(maxConnectionIDLen-minConnectionIDLenInitial+1)
return generateConnectionID(len)
}
// generateConnectionIDs generates a destination and source connection ID.
func generateConnectionIDs() ([]byte, []byte) {
destConnID := generateConnectionIDForInitial()
srcConnID := generateConnectionID(defaultConnectionIDLength)
return destConnID, srcConnID
}
// dissectVersionNegotiation dissects the Version Negotiation response.
// It returns the supported versions and the destination connection ID of the response,
// The destination connection ID of the response has to coincide with the source connection ID of the request.
// https://www.rfc-editor.org/rfc/rfc9000.html#name-version-negotiation-packet
func (m *Measurer) dissectVersionNegotiation(i []byte) ([]uint32, connectionID, error) {
firstByte := uint8(i[0])
mask := 0b10000000
mask &= int(firstByte)
if mask == 0 {
return nil, nil, &errUnexpectedResponse{msg: "not a long header packet"}
}
versionBytes := i[1:5]
v := binary.BigEndian.Uint32(versionBytes)
if v != 0 {
return nil, nil, &errUnexpectedResponse{msg: "unexpected Version Negotiation format"}
}
dstLength := i[5]
offset := 6 + uint8(dstLength)
dst := i[6:offset]
srcLength := i[offset]
offset = offset + 1 + srcLength
n := uint8(len(i))
var supportedVersions []uint32
for offset < n {
supportedVersions = append(supportedVersions, binary.BigEndian.Uint32(i[offset:offset+4]))
offset += 4
}
return supportedVersions, dst, nil
}
// errUnexpectedResponse is thrown when the response from the server
// is not a valid Version Negotiation packet
type errUnexpectedResponse struct {
error
msg string
}
// Error implements error.Error()
func (e *errUnexpectedResponse) Error() string {
return fmt.Sprintf("unexptected response: %s", e.msg)
}

View File

@ -0,0 +1,357 @@
// Package quicping implements the quicping network experiment. This
// implements, in particular, v0.1.0 of the spec.
//
// See https://github.com/ooni/spec/blob/master/nettests/ts-031-quicping.md.
package quicping
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"sort"
"strconv"
"time"
_ "crypto/sha256"
"github.com/ooni/probe-cli/v3/internal/engine/netx/archival"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/netxlite"
)
// A connectionID in QUIC
type connectionID []byte
const (
maxConnectionIDLen = 18
minConnectionIDLenInitial = 8
defaultConnectionIDLength = 16
)
const (
testName = "quicping"
testVersion = "0.1.0"
)
// Config contains the experiment configuration.
type Config struct {
// Repetitions is the number of repetitions for each ping.
Repetitions int64 `ooni:"number of times to repeat the measurement"`
// Port is the port to test.
Port int64 `ooni:"port is the port to test"`
// networkLibrary is the underlying network library. Can be used for testing.
networkLib model.UnderlyingNetworkLibrary
}
func (c *Config) repetitions() int64 {
if c.Repetitions > 0 {
return c.Repetitions
}
return 10
}
func (c *Config) port() string {
if c.Port != 0 {
return strconv.FormatInt(c.Port, 10)
}
return "443"
}
func (c *Config) networkLibrary() model.UnderlyingNetworkLibrary {
if c.networkLib != nil {
return c.networkLib
}
return &netxlite.TProxyStdlib{}
}
// TestKeys contains the experiment results.
type TestKeys struct {
Domain string `json:"domain"`
Pings []*SinglePing `json:"pings"`
UnexpectedResponses []*SinglePingResponse `json:"unexpected_responses"`
Repetitions int64 `json:"repetitions"`
}
// SinglePing is a result of a single ping operation.
type SinglePing struct {
ConnIdDst string `json:"conn_id_dst"`
ConnIdSrc string `json:"conn_id_src"`
Failure *string `json:"failure"`
Request *model.ArchivalMaybeBinaryData `json:"request"`
T float64 `json:"t"`
Responses []*SinglePingResponse `json:"responses"`
}
type SinglePingResponse struct {
Data *model.ArchivalMaybeBinaryData `json:"response_data"`
Failure *string `json:"failure"`
T float64 `json:"t"`
SupportedVersions []uint32 `json:"supported_versions"`
}
// makeResponse is a utility function to create a SinglePingResponse
func makeResponse(resp *responseInfo) *SinglePingResponse {
var data *model.ArchivalMaybeBinaryData
if resp.raw != nil {
data = &model.ArchivalMaybeBinaryData{Value: string(resp.raw)}
}
return &SinglePingResponse{
Data: data,
Failure: archival.NewFailure(resp.err),
T: resp.t,
SupportedVersions: resp.versions,
}
}
// Measurer performs the measurement.
type Measurer struct {
config Config
}
// ExperimentName implements ExperimentMeasurer.ExperimentName.
func (m *Measurer) ExperimentName() string {
return testName
}
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion.
func (m *Measurer) ExperimentVersion() string {
return testVersion
}
// pingInfo contains information about a ping request
// and the corresponding ping responses
type pingInfo struct {
request *requestInfo
responses []*responseInfo
}
// requestInfo contains the information of a sent ping request.
type requestInfo struct {
t float64
raw []byte
dstID string
srcID string
err error
}
// responseInfo contains the information of a received ping reponse.
type responseInfo struct {
t float64
raw []byte
dstID string
versions []uint32
err error
}
// sender sends a ping requests to the target hosts every second
func (m *Measurer) sender(
ctx context.Context,
pconn model.UDPLikeConn,
destAddr *net.UDPAddr,
out chan<- requestInfo,
sess model.ExperimentSession,
measurement *model.Measurement,
) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for i := int64(0); i < m.config.repetitions(); i++ {
select {
case <-ctx.Done():
return // user aborted or timeout expired
case stime := <-ticker.C:
sendTime := stime.Sub(measurement.MeasurementStartTimeSaved).Seconds()
packet, dstID, srcID := buildPacket() // build QUIC Initial packet
_, err := pconn.WriteTo(packet, destAddr) // send Initial packet
if errors.Is(err, net.ErrClosed) {
return
}
sess.Logger().Infof("PING %s", destAddr)
// propagate send information, including errors
out <- requestInfo{raw: packet, t: sendTime, dstID: hex.EncodeToString(dstID), srcID: hex.EncodeToString(srcID), err: err}
}
}
}
// receiver receives incoming server responses and
// dissects the payload of the version negotiation response
func (m *Measurer) receiver(
ctx context.Context,
pconn model.UDPLikeConn,
out chan<- responseInfo,
sess model.ExperimentSession,
measurement *model.Measurement,
) {
for ctx.Err() == nil {
// read (timeout was set in Run)
buffer := make([]byte, 1024)
n, addr, err := pconn.ReadFrom(buffer)
respTime := time.Since(measurement.MeasurementStartTimeSaved).Seconds()
if err != nil {
// stop if the connection is already closed
if errors.Is(err, net.ErrClosed) {
break
}
// store read failures and continue receiving
out <- responseInfo{t: respTime, err: err}
continue
}
resp := buffer[:n]
// dissect server response
supportedVersions, dst, err := m.dissectVersionNegotiation(resp)
if err != nil {
// the response was likely not the expected version negotiation response
sess.Logger().Infof(fmt.Sprintf("response dissection failed: %s", err))
out <- responseInfo{raw: resp, t: respTime, err: err}
continue
}
// propagate receive information
out <- responseInfo{raw: resp, t: respTime, dstID: hex.EncodeToString(dst), versions: supportedVersions}
sess.Logger().Infof("PING got response from %s", addr)
}
}
// Run implements ExperimentMeasurer.Run.
func (m *Measurer) Run(
ctx context.Context,
sess model.ExperimentSession,
measurement *model.Measurement,
callbacks model.ExperimentCallbacks,
) error {
host := string(measurement.Input)
// allow URL input
if u, err := url.ParseRequestURI(host); err == nil {
host = u.Host
}
service := net.JoinHostPort(host, m.config.port())
udpAddr, err := net.ResolveUDPAddr("udp4", service)
if err != nil {
return err
}
rep := m.config.repetitions()
tk := &TestKeys{
Domain: host,
Repetitions: rep,
}
measurement.TestKeys = tk
// create UDP socket
pconn, err := m.config.networkLibrary().ListenUDP("udp", &net.UDPAddr{})
if err != nil {
return err
}
defer pconn.Close()
// set context and read timeouts
deadline := time.Duration(rep*2) * time.Second
pconn.SetDeadline(time.Now().Add(deadline))
ctx, cancel := context.WithTimeout(ctx, deadline)
defer cancel()
sendInfoChan := make(chan requestInfo)
recvInfoChan := make(chan responseInfo)
pingMap := make(map[string]*pingInfo)
// start sender and receiver goroutines
go m.sender(ctx, pconn, udpAddr, sendInfoChan, sess, measurement)
go m.receiver(ctx, pconn, recvInfoChan, sess, measurement)
L:
for {
select {
case req := <-sendInfoChan: // a new ping was sent
if req.err != nil {
tk.Pings = append(tk.Pings, &SinglePing{
ConnIdDst: req.dstID,
ConnIdSrc: req.srcID,
Failure: archival.NewFailure(req.err),
Request: &model.ArchivalMaybeBinaryData{Value: string(req.raw)},
T: req.t,
})
continue
}
pingMap[req.srcID] = &pingInfo{request: &req}
case resp := <-recvInfoChan: // a new response has been received
if resp.err != nil {
// resp failure means we cannot assign the response to a request
tk.UnexpectedResponses = append(tk.UnexpectedResponses, makeResponse(&resp))
continue
}
var (
ping *pingInfo
ok bool
)
// match response to request
if ping, ok = pingMap[resp.dstID]; !ok {
// version negotiation response with an unknown destination ID
tk.UnexpectedResponses = append(tk.UnexpectedResponses, makeResponse(&resp))
continue
}
ping.responses = append(ping.responses, &resp)
case <-ctx.Done():
break L
}
}
// transform ping requests into TestKeys.Pings
timeoutErr := errors.New("i/o timeout")
for _, ping := range pingMap {
if ping.request == nil { // this should not happen
return errors.New("internal error: ping.request is nil")
}
if len(ping.responses) <= 0 {
tk.Pings = append(tk.Pings, &SinglePing{
ConnIdDst: ping.request.dstID,
ConnIdSrc: ping.request.srcID,
Failure: archival.NewFailure(timeoutErr),
Request: &model.ArchivalMaybeBinaryData{Value: string(ping.request.raw)},
T: ping.request.t,
})
continue
}
var responses []*SinglePingResponse
for _, resp := range ping.responses {
responses = append(responses, makeResponse(resp))
}
tk.Pings = append(tk.Pings, &SinglePing{
ConnIdDst: ping.request.dstID,
ConnIdSrc: ping.request.srcID,
Failure: nil,
Request: &model.ArchivalMaybeBinaryData{Value: string(ping.request.raw)},
T: ping.request.t,
Responses: responses,
})
}
sort.Slice(tk.Pings, func(i, j int) bool {
return tk.Pings[i].T < tk.Pings[j].T
})
return nil
}
// NewExperimentMeasurer creates a new ExperimentMeasurer.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return &Measurer{config: config}
}
// SummaryKeys contains summary keys for this experiment.
//
// Note that this structure is part of the ABI contract with probe-cli
// therefore we should be careful when changing it.
type SummaryKeys struct {
IsAnomaly bool `json:"-"`
}
// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys.
func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) {
return SummaryKeys{IsAnomaly: false}, nil
}

View File

@ -0,0 +1,310 @@
package quicping
import (
"context"
"encoding/hex"
"errors"
"net"
"strings"
"testing"
"time"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
)
// FailStdLib is a failing model.UnderlyingNetworkLibrary.
type FailStdLib struct {
conn model.UDPLikeConn
err error
writeErr error
readErr error
}
// ListenUDP implements UnderlyingNetworkLibrary.ListenUDP.
func (f *FailStdLib) ListenUDP(network string, laddr *net.UDPAddr) (model.UDPLikeConn, error) {
conn, _ := net.ListenUDP(network, laddr)
f.conn = model.UDPLikeConn(conn)
if f.err != nil {
return nil, f.err
}
if f.writeErr != nil {
return &mocks.UDPLikeConn{
MockWriteTo: func(p []byte, addr net.Addr) (int, error) {
return 0, f.writeErr
},
MockReadFrom: func(p []byte) (int, net.Addr, error) {
return f.conn.ReadFrom(p)
},
MockSetDeadline: func(t time.Time) error {
return f.conn.SetDeadline(t)
},
MockClose: func() error {
return f.conn.Close()
},
}, nil
}
if f.readErr != nil {
return &mocks.UDPLikeConn{
MockWriteTo: func(p []byte, addr net.Addr) (int, error) {
return f.conn.WriteTo(p, addr)
},
MockReadFrom: func(p []byte) (int, net.Addr, error) {
return 0, nil, f.readErr
},
MockSetDeadline: func(t time.Time) error {
return f.conn.SetDeadline(t)
},
MockClose: func() error {
return f.conn.Close()
},
}, nil
}
return &mocks.UDPLikeConn{}, nil
}
// LookupHost implements UnderlyingNetworkLibrary.LookupHost.
func (f *FailStdLib) LookupHost(ctx context.Context, domain string) ([]string, error) {
return nil, f.err
}
// NewSimpleDialer implements UnderlyingNetworkLibrary.NewSimpleDialer.
func (f *FailStdLib) NewSimpleDialer(timeout time.Duration) model.SimpleDialer {
return nil
}
func TestNewExperimentMeasurer(t *testing.T) {
measurer := NewExperimentMeasurer(Config{})
if measurer.ExperimentName() != "quicping" {
t.Fatal("unexpected name")
}
if measurer.ExperimentVersion() != "0.1.0" {
t.Fatal("unexpected version")
}
}
func TestInvalidHost(t *testing.T) {
measurer := NewExperimentMeasurer(Config{
Port: 443,
Repetitions: 1,
})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("a.a.a.a")
sess := &mockable.Session{MockableLogger: log.Log}
err := measurer.Run(context.Background(), sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err == nil {
t.Fatal("expected an error here")
}
if _, ok := err.(*net.DNSError); !ok {
t.Fatal("unexpected error type")
}
}
func TestURLInput(t *testing.T) {
measurer := NewExperimentMeasurer(Config{
Repetitions: 1,
})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("https://google.com/")
sess := &mockable.Session{MockableLogger: log.Log}
err := measurer.Run(context.Background(), sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err != nil {
t.Fatal("unexpected error")
}
tk := measurement.TestKeys.(*TestKeys)
if tk.Domain != "google.com" {
t.Fatal("unexpected domain")
}
}
func TestSuccess(t *testing.T) {
measurer := NewExperimentMeasurer(Config{})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("google.com")
sess := &mockable.Session{MockableLogger: log.Log}
err := measurer.Run(context.Background(), sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err != nil {
t.Fatal("did not expect an error here")
}
tk := measurement.TestKeys.(*TestKeys)
if tk.Domain != "google.com" {
t.Fatal("unexpected domain")
}
if tk.Repetitions != 10 {
t.Fatal("unexpected number of repetitions, default is 10")
}
if tk.Pings == nil || len(tk.Pings) != 10 {
t.Fatal("unexpected number of pings", len(tk.Pings))
}
for i, ping := range tk.Pings {
if ping.Failure != nil {
t.Fatal("ping failed unexpectedly", i, *ping.Failure)
}
for _, resp := range ping.Responses {
if resp.Failure != nil {
t.Fatal("unexepcted response failure")
}
if resp.SupportedVersions == nil || len(resp.SupportedVersions) == 0 {
t.Fatal("server did not respond with supported versions")
}
}
}
sk, err := measurer.GetSummaryKeys(measurement)
if err != nil {
t.Fatal(err)
}
if _, ok := sk.(SummaryKeys); !ok {
t.Fatal("invalid type for summary keys")
}
}
func TestWithCancelledContext(t *testing.T) {
measurer := NewExperimentMeasurer(Config{})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("google.com")
sess := &mockable.Session{MockableLogger: log.Log}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := measurer.Run(ctx, sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err != nil {
t.Fatal("did not expect an error here")
}
tk := measurement.TestKeys.(*TestKeys)
if len(tk.Pings) > 0 {
t.Fatal("there should not be any measurements")
}
}
func TestListenFails(t *testing.T) {
expected := errors.New("expected")
measurer := NewExperimentMeasurer(Config{
networkLib: &FailStdLib{err: expected, readErr: nil, writeErr: nil},
})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("google.com")
sess := &mockable.Session{MockableLogger: log.Log}
err := measurer.Run(context.Background(), sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err == nil {
t.Fatal("expected an error here")
}
if err != expected {
t.Fatal("unexpected error type")
}
}
func TestWriteFails(t *testing.T) {
expected := errors.New("expected")
measurer := NewExperimentMeasurer(Config{
networkLib: &FailStdLib{err: nil, readErr: nil, writeErr: expected},
Repetitions: 1,
})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("google.com")
sess := &mockable.Session{MockableLogger: log.Log}
err := measurer.Run(context.Background(), sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err != nil {
t.Fatal("unexpected error")
}
tk := measurement.TestKeys.(*TestKeys)
if tk.Pings == nil || len(tk.Pings) != 1 {
t.Fatal("unexpected number of pings", len(tk.Pings))
}
for i, ping := range tk.Pings {
if ping.Failure == nil {
t.Fatal("expected an error here, ping", i)
}
if !strings.Contains(*ping.Failure, "expected") {
t.Fatal("ping: unexpected error type", i, *ping.Failure)
}
}
}
func TestReadFails(t *testing.T) {
expected := errors.New("expected")
measurer := NewExperimentMeasurer(Config{
networkLib: &FailStdLib{err: nil, readErr: expected, writeErr: nil},
Repetitions: 1,
})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("google.com")
sess := &mockable.Session{MockableLogger: log.Log}
err := measurer.Run(context.Background(), sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err != nil {
t.Fatal("unexpected error")
}
tk := measurement.TestKeys.(*TestKeys)
if tk.Pings == nil || len(tk.Pings) != 1 {
t.Fatal("unexpected number of pings", len(tk.Pings))
}
for i, ping := range tk.Pings {
if ping.Failure == nil {
t.Fatal("expected an error here, ping", i)
}
}
}
func TestNoResponse(t *testing.T) {
measurer := NewExperimentMeasurer(Config{
Repetitions: 1,
})
measurement := new(model.Measurement)
measurement.Input = model.MeasurementTarget("ooni.org")
sess := &mockable.Session{MockableLogger: log.Log}
err := measurer.Run(context.Background(), sess, measurement,
model.NewPrinterCallbacks(log.Log))
if err != nil {
t.Fatal("did not expect an error here")
}
tk := measurement.TestKeys.(*TestKeys)
if tk.Pings == nil || len(tk.Pings) != 1 {
t.Fatal("unexpected number of pings", len(tk.Pings))
}
if tk.Pings[0].Failure == nil {
t.Fatal("expected an error here")
}
if *tk.Pings[0].Failure != "generic_timeout_error" {
t.Fatal("unexpected error type")
}
}
func TestDissect(t *testing.T) {
// destID--srcID: 040b9649d3fd4c038ab6c073966f3921--44d064031288e97646451f
versionNegotiationResponse, _ := hex.DecodeString("eb0000000010040b9649d3fd4c038ab6c073966f39210b44d064031288e97646451f00000001ff00001dff00001cff00001b")
measurer := NewExperimentMeasurer(Config{})
destID := "040b9649d3fd4c038ab6c073966f3921"
_, dst, err := measurer.(*Measurer).dissectVersionNegotiation(versionNegotiationResponse)
if err != nil {
t.Fatal("unexpected error", err)
}
if hex.EncodeToString(dst) != destID {
t.Fatal("unexpected destination connection ID")
}
versionNegotiationResponse[1] = byte(0xff)
_, _, err = measurer.(*Measurer).dissectVersionNegotiation(versionNegotiationResponse)
if err == nil {
t.Fatal("expected an error here", err)
}
if !strings.HasSuffix(err.Error(), "unexpected Version Negotiation format") {
t.Fatal("unexpected error type", err)
}
versionNegotiationResponse[0] = byte(0x01)
_, _, err = measurer.(*Measurer).dissectVersionNegotiation(versionNegotiationResponse)
if err == nil {
t.Fatal("expected an error here", err)
}
if !strings.HasSuffix(err.Error(), "not a long header packet") {
t.Fatal("unexpected error type", err)
}
}