From 88236a4352e1329dab14541ee5ae4e2b9a7f0bc3 Mon Sep 17 00:00:00 2001 From: kelmenhorst <45046038+kelmenhorst@users.noreply.github.com> Date: Mon, 14 Feb 2022 19:21:16 +0100 Subject: [PATCH] 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 ``` --- internal/engine/allexperiments.go | 13 + internal/engine/experiment/quicping/crypto.go | 180 +++++++++ internal/engine/experiment/quicping/quic.go | 132 +++++++ .../engine/experiment/quicping/quicping.go | 357 ++++++++++++++++++ .../experiment/quicping/quicping_test.go | 310 +++++++++++++++ 5 files changed, 992 insertions(+) create mode 100644 internal/engine/experiment/quicping/crypto.go create mode 100644 internal/engine/experiment/quicping/quic.go create mode 100644 internal/engine/experiment/quicping/quicping.go create mode 100644 internal/engine/experiment/quicping/quicping_test.go diff --git a/internal/engine/allexperiments.go b/internal/engine/allexperiments.go index 25750c4..e2ea968 100644 --- a/internal/engine/allexperiments.go +++ b/internal/engine/allexperiments.go @@ -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 { diff --git a/internal/engine/experiment/quicping/crypto.go b/internal/engine/experiment/quicping/crypto.go new file mode 100644 index 0000000..e41b830 --- /dev/null +++ b/internal/engine/experiment/quicping/crypto.go @@ -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 +} diff --git a/internal/engine/experiment/quicping/quic.go b/internal/engine/experiment/quicping/quic.go new file mode 100644 index 0000000..1601204 --- /dev/null +++ b/internal/engine/experiment/quicping/quic.go @@ -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) +} diff --git a/internal/engine/experiment/quicping/quicping.go b/internal/engine/experiment/quicping/quicping.go new file mode 100644 index 0000000..bed3ab7 --- /dev/null +++ b/internal/engine/experiment/quicping/quicping.go @@ -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 +} diff --git a/internal/engine/experiment/quicping/quicping_test.go b/internal/engine/experiment/quicping/quicping_test.go new file mode 100644 index 0000000..fd65db5 --- /dev/null +++ b/internal/engine/experiment/quicping/quicping_test.go @@ -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) + } +}