From d19b5e0118fdf807e33999aa9e88ed10f83ebcc8 Mon Sep 17 00:00:00 2001 From: frcroth Date: Thu, 19 Dec 2024 12:59:42 +0100 Subject: [PATCH 1/2] Add ddr experiment --- internal/experiment/ddr/ddr.go | 174 ++++++++++++++++++++++++++++ internal/experiment/ddr/ddr_test.go | 15 +++ internal/registry/ddr.go | 28 +++++ 3 files changed, 217 insertions(+) create mode 100644 internal/experiment/ddr/ddr.go create mode 100644 internal/experiment/ddr/ddr_test.go create mode 100644 internal/registry/ddr.go diff --git a/internal/experiment/ddr/ddr.go b/internal/experiment/ddr/ddr.go new file mode 100644 index 000000000..c12ba5ac4 --- /dev/null +++ b/internal/experiment/ddr/ddr.go @@ -0,0 +1,174 @@ +package ddr + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + "github.com/apex/log" + "github.com/miekg/dns" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" +) + +const ( + testName = "ddr" + testVersion = "0.1.0" +) + +type Config struct { +} + +// 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 +} + +type DDRResponse struct { + Priority int `json:"priority"` + Target string `json:"target"` + Keys map[string]string `json:"keys"` +} + +type TestKeys struct { + // DDRResponse is the DDR response. + DDRResponse []DDRResponse `json:"ddr_responses"` + + // SupportsDDR is true if DDR is supported. + SupportsDDR bool `json:"supports_ddr"` + + // Resolver is the resolver used (the system resolver of the host). + Resolver string `json:"resolver"` + + // Failure is the failure that occurred, or nil. + Failure *string `json:"failure"` +} + +func (m *Measurer) Run( + ctx context.Context, + args *model.ExperimentArgs) error { + + log.SetLevel(log.DebugLevel) + measurement := args.Measurement + + tk := &TestKeys{} + measurement.TestKeys = tk + + timeout := 60 * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + systemResolver := getSystemResolverAddress() + if systemResolver == "" { + return errors.New("could not get system resolver") + } + log.Infof("Using system resolver: %s", systemResolver) + tk.Resolver = systemResolver + + // DDR queries are queries of the SVCB type for the _dns.resolver.arpa. domain. + + netx := &netxlite.Netx{} + dialer := netx.NewDialerWithoutResolver(log.Log) + transport := netxlite.NewUnwrappedDNSOverUDPTransport( + dialer, systemResolver) + encoder := &netxlite.DNSEncoderMiekg{} + query := encoder.Encode( + "_dns.resolver.arpa.", // As specified in RFC 9462 + dns.TypeSVCB, + true) + resp, err := transport.RoundTrip(ctx, query) + if err != nil { + failure := err.Error() + tk.Failure = &failure + return nil + } + + reply := &dns.Msg{} + err = reply.Unpack(resp.Bytes()) + if err != nil { + unpackError := err.Error() + tk.Failure = &unpackError + return nil + } + + ddrResponse, err := decodeResponse(reply.Answer) + + if err != nil { + decodingError := err.Error() + tk.Failure = &decodingError + } else { + tk.DDRResponse = ddrResponse + } + + tk.SupportsDDR = len(tk.DDRResponse) > 0 + + log.Infof("Gathered DDR Responses: %+v", tk.DDRResponse) + return nil +} + +// decodeResponse decodes the response from the DNS query. +// DDR is only concerned with SVCB records, so we only decode those. +func decodeResponse(responseFields []dns.RR) ([]DDRResponse, error) { + responses := make([]DDRResponse, 0) + for _, rr := range responseFields { + switch rr := rr.(type) { + case *dns.SVCB: + parsed, err := parseSvcb(rr) + if err != nil { + return nil, err + } + responses = append(responses, parsed) + default: + return nil, fmt.Errorf("unknown RR type: %T", rr) + } + } + return responses, nil +} + +func parseSvcb(rr *dns.SVCB) (DDRResponse, error) { + keys := make(map[string]string) + for _, kv := range rr.Value { + value := kv.String() + key := kv.Key().String() + keys[key] = value + } + + return DDRResponse{ + Priority: int(rr.Priority), + Target: rr.Target, + Keys: keys, + }, nil +} + +// Get the system resolver address from /etc/resolv.conf +// This should also be possible via querying the system resolver and checking the response +func getSystemResolverAddress() string { + resolverConfig, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil { + return "" + } + + if len(resolverConfig.Servers) > 0 { + return net.JoinHostPort(resolverConfig.Servers[0], resolverConfig.Port) + } + + return "" +} + +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{ + config: config, + } +} diff --git a/internal/experiment/ddr/ddr_test.go b/internal/experiment/ddr/ddr_test.go new file mode 100644 index 000000000..ee848efeb --- /dev/null +++ b/internal/experiment/ddr/ddr_test.go @@ -0,0 +1,15 @@ +package ddr + +import ( + "testing" +) + +func TestMeasurerExperimentNameVersion(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "ddr" { + t.Fatal("unexpected ExperimentName") + } + if measurer.ExperimentVersion() != "0.1.0" { + t.Fatal("unexpected ExperimentVersion") + } +} diff --git a/internal/registry/ddr.go b/internal/registry/ddr.go new file mode 100644 index 000000000..9ab7f8454 --- /dev/null +++ b/internal/registry/ddr.go @@ -0,0 +1,28 @@ +package registry + +// +// Registers the `ddr' experiment. +// + +import ( + "github.com/ooni/probe-cli/v3/internal/experiment/ddr" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func init() { + const canonicalName = "ddr" + AllExperiments[canonicalName] = func() *Factory { + return &Factory{ + build: func(config interface{}) model.ExperimentMeasurer { + return ddr.NewExperimentMeasurer( + *config.(*ddr.Config), + ) + }, + canonicalName: canonicalName, + config: &ddr.Config{}, + enabledByDefault: true, + interruptible: true, + inputPolicy: model.InputNone, + } + } +} From 8ddec182caff7c1f9d49aa76a69d2fd10733e613 Mon Sep 17 00:00:00 2001 From: frcroth Date: Thu, 19 Dec 2024 15:59:47 +0100 Subject: [PATCH 2/2] Add tests to DDR experiment --- internal/experiment/ddr/ddr.go | 19 ++++--- internal/experiment/ddr/ddr_test.go | 78 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/internal/experiment/ddr/ddr.go b/internal/experiment/ddr/ddr.go index c12ba5ac4..946d12178 100644 --- a/internal/experiment/ddr/ddr.go +++ b/internal/experiment/ddr/ddr.go @@ -19,6 +19,9 @@ const ( ) type Config struct { + // CustomResolver is the custom resolver to use. + // If empty, the system resolver is used. + CustomResolver *string } // Measurer performs the measurement. @@ -70,19 +73,23 @@ func (m *Measurer) Run( ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - systemResolver := getSystemResolverAddress() - if systemResolver == "" { - return errors.New("could not get system resolver") + if m.config.CustomResolver == nil { + systemResolver := getSystemResolverAddress() + if systemResolver == "" { + return errors.New("could not get system resolver") + } + log.Infof("Using system resolver: %s", systemResolver) + tk.Resolver = systemResolver + } else { + tk.Resolver = *m.config.CustomResolver } - log.Infof("Using system resolver: %s", systemResolver) - tk.Resolver = systemResolver // DDR queries are queries of the SVCB type for the _dns.resolver.arpa. domain. netx := &netxlite.Netx{} dialer := netx.NewDialerWithoutResolver(log.Log) transport := netxlite.NewUnwrappedDNSOverUDPTransport( - dialer, systemResolver) + dialer, tk.Resolver) encoder := &netxlite.DNSEncoderMiekg{} query := encoder.Encode( "_dns.resolver.arpa.", // As specified in RFC 9462 diff --git a/internal/experiment/ddr/ddr_test.go b/internal/experiment/ddr/ddr_test.go index ee848efeb..28d930621 100644 --- a/internal/experiment/ddr/ddr_test.go +++ b/internal/experiment/ddr/ddr_test.go @@ -1,7 +1,12 @@ package ddr import ( + "context" "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/mocks" + "github.com/ooni/probe-cli/v3/internal/model" ) func TestMeasurerExperimentNameVersion(t *testing.T) { @@ -13,3 +18,76 @@ func TestMeasurerExperimentNameVersion(t *testing.T) { t.Fatal("unexpected ExperimentVersion") } } + +func TestMeasurerRun(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + + oneOneOneOneResolver := "1.1.1.1:53" + + measurer := NewExperimentMeasurer(Config{ + CustomResolver: &oneOneOneOneResolver, + }) + args := &model.ExperimentArgs{ + Callbacks: model.NewPrinterCallbacks(log.Log), + Measurement: new(model.Measurement), + Session: &mocks.Session{ + MockLogger: func() model.Logger { + return log.Log + }, + }, + } + if err := measurer.Run(context.Background(), args); err != nil { + t.Fatal(err) + } + tk := args.Measurement.TestKeys.(*TestKeys) + if tk.Failure != nil { + t.Fatal("unexpected Failure") + } + + if tk.Resolver != oneOneOneOneResolver { + t.Fatal("Resolver should be written to TestKeys") + } + + // 1.1.1.1 supports DDR + if tk.SupportsDDR != true { + t.Fatal("unexpected value for Supports DDR") + } +} + +// This test fails because the resolver is a domain name and not an IP address. +func TestMeasurerFailsWithDomainResolver(t *testing.T) { + invalidResolver := "invalid-resolver.example:53" + + tk, _ := runExperiment(invalidResolver) + if tk.Failure == nil { + t.Fatal("expected Failure") + } +} + +func TestMeasurerFailsWithNoPort(t *testing.T) { + invalidResolver := "1.1.1.1" + + tk, _ := runExperiment(invalidResolver) + if tk.Failure == nil { + t.Fatal("expected Failure") + } +} + +func runExperiment(resolver string) (*TestKeys, error) { + measurer := NewExperimentMeasurer(Config{ + CustomResolver: &resolver, + }) + args := &model.ExperimentArgs{ + Callbacks: model.NewPrinterCallbacks(log.Log), + Measurement: new(model.Measurement), + Session: &mocks.Session{ + MockLogger: func() model.Logger { + return log.Log + }, + }, + } + err := measurer.Run(context.Background(), args) + return args.Measurement.TestKeys.(*TestKeys), err +}