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:
parent
0735e2018f
commit
88236a4352
|
@ -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 {
|
||||
|
|
180
internal/engine/experiment/quicping/crypto.go
Normal file
180
internal/engine/experiment/quicping/crypto.go
Normal 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
|
||||
}
|
132
internal/engine/experiment/quicping/quic.go
Normal file
132
internal/engine/experiment/quicping/quic.go
Normal 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)
|
||||
}
|
357
internal/engine/experiment/quicping/quicping.go
Normal file
357
internal/engine/experiment/quicping/quicping.go
Normal 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
|
||||
}
|
310
internal/engine/experiment/quicping/quicping_test.go
Normal file
310
internal/engine/experiment/quicping/quicping_test.go
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user