2022-02-14 19:21:16 +01:00
|
|
|
// 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/model"
|
2022-06-02 00:50:55 +02:00
|
|
|
"github.com/ooni/probe-cli/v3/internal/tracex"
|
2022-02-14 19:21:16 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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"`
|
|
|
|
|
2022-08-23 11:43:44 +02:00
|
|
|
// netListenUDP allows mocking the real net.ListenUDP call
|
|
|
|
netListenUDP func(network string, laddr *net.UDPAddr) (model.UDPLikeConn, error)
|
2022-02-14 19:21:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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"
|
|
|
|
}
|
|
|
|
|
2022-08-23 11:43:44 +02:00
|
|
|
func (c *Config) doListenUDP(network string, laddr *net.UDPAddr) (model.UDPLikeConn, error) {
|
|
|
|
if c.netListenUDP != nil {
|
|
|
|
return c.netListenUDP(network, laddr)
|
2022-02-14 19:21:16 +01:00
|
|
|
}
|
2022-08-23 11:43:44 +02:00
|
|
|
return net.ListenUDP(network, laddr)
|
2022-02-14 19:21:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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,
|
2022-05-31 21:53:01 +02:00
|
|
|
Failure: tracex.NewFailure(resp.err),
|
2022-02-14 19:21:16 +01:00
|
|
|
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.
|
2022-11-22 10:43:47 +01:00
|
|
|
func (m *Measurer) Run(ctx context.Context, args *model.ExperimentArgs) error {
|
|
|
|
_ = args.Callbacks
|
|
|
|
measurement := args.Measurement
|
|
|
|
sess := args.Session
|
|
|
|
|
2022-02-14 19:21:16 +01:00
|
|
|
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
|
2022-08-23 11:43:44 +02:00
|
|
|
pconn, err := m.config.doListenUDP("udp", &net.UDPAddr{})
|
2022-02-14 19:21:16 +01:00
|
|
|
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,
|
2022-05-31 21:53:01 +02:00
|
|
|
Failure: tracex.NewFailure(req.err),
|
2022-02-14 19:21:16 +01:00
|
|
|
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,
|
2022-05-31 21:53:01 +02:00
|
|
|
Failure: tracex.NewFailure(timeoutErr),
|
2022-02-14 19:21:16 +01:00
|
|
|
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.
|
|
|
|
//
|
2022-05-09 09:33:18 +02:00
|
|
|
// Note that this structure is part of the ABI contract with ooniprobe
|
2022-02-14 19:21:16 +01:00
|
|
|
// 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
|
|
|
|
}
|