ooni-probe-cli/internal/measurexlite/dns_test.go
Simone Basso c9943dff38
feat(dns): expose more low-level fields (#873)
This pull request started as a draft to enable users to see CNAME answers. It contained several patches which we merged separately (see https://github.com/ooni/probe-cli/pull/873#issuecomment-1222406732 and 2301a30630...60b7d1f87b for details on what has actually changed, which is based on patches originally part of this PR). In its final form, however, this PR only deals with exposing more low-level DNS fields to the archival data format.

Closes: https://github.com/ooni/probe/issues/2228

Related PR spec: https://github.com/ooni/spec/pull/256
2022-08-23 16:12:04 +02:00

695 lines
18 KiB
Go

package measurexlite
import (
"context"
"errors"
"net"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/netxlite"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
func TestNewResolver(t *testing.T) {
t.Run("WrapResolver creates a wrapped resolver with Trace", func(t *testing.T) {
underlying := &mocks.Resolver{}
zeroTime := time.Now()
trace := NewTrace(0, zeroTime)
resolvert := trace.wrapResolver(underlying).(*resolverTrace)
if resolvert.r != underlying {
t.Fatal("invalid parallel resolver")
}
if resolvert.tx != trace {
t.Fatal("invalid trace")
}
})
t.Run("Trace-aware resolver forwards underlying functions", func(t *testing.T) {
var called bool
zeroTime := time.Now()
trace := NewTrace(0, zeroTime)
mockResolver := &mocks.Resolver{
MockAddress: func() string {
return "dns.google"
},
MockNetwork: func() string {
return "udp"
},
MockLookupHost: func(ctx context.Context, domain string) ([]string, error) {
return []string{"1.1.1.1"}, nil
},
MockLookupHTTPS: func(ctx context.Context, domain string) (*model.HTTPSSvc, error) {
return &model.HTTPSSvc{
IPv4: []string{"1.1.1.1"},
}, nil
},
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return []*net.NS{{
Host: "1.1.1.1",
}}, nil
},
MockCloseIdleConnections: func() {
called = true
},
}
resolver := trace.wrapResolver(mockResolver)
t.Run("Address is correctly forwarded", func(t *testing.T) {
got := resolver.Address()
if got != "dns.google" {
t.Fatal("Address not called")
}
})
t.Run("Network is correctly forwarded", func(t *testing.T) {
got := resolver.Network()
if got != "udp" {
t.Fatal("Network not called")
}
})
t.Run("LookupHost is correctly forwarded", func(t *testing.T) {
want := []string{"1.1.1.1"}
ctx := context.Background()
got, err := resolver.LookupHost(ctx, "example.com")
if err != nil {
t.Fatal("expected nil error")
}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
})
t.Run("LookupHTTPS is correctly forwarded", func(t *testing.T) {
want := &model.HTTPSSvc{
IPv4: []string{"1.1.1.1"},
}
ctx := context.Background()
got, err := resolver.LookupHTTPS(ctx, "example.com")
if err != nil {
t.Fatal("expected nil error")
}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
})
t.Run("LookupNS is correctly forwarded", func(t *testing.T) {
want := []*net.NS{{
Host: "1.1.1.1",
}}
ctx := context.Background()
got, err := resolver.LookupNS(ctx, "example.com")
if err != nil {
t.Fatal("expected nil error")
}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
})
t.Run("CloseIdleConnections is correctly forwarded", func(t *testing.T) {
resolver.CloseIdleConnections()
if !called {
t.Fatal("CloseIdleConnections not called")
}
})
})
t.Run("LookupHost saves into trace", func(t *testing.T) {
zeroTime := time.Now()
td := testingx.NewTimeDeterministic(zeroTime)
trace := NewTrace(0, zeroTime)
trace.TimeNowFn = td.Now
txp := &mocks.DNSTransport{
MockRoundTrip: func(ctx context.Context, query model.DNSQuery) (model.DNSResponse, error) {
response := &mocks.DNSResponse{
MockDecodeLookupHost: func() ([]string, error) {
if query.Type() != dns.TypeA {
return []string{"fe80::a00:20ff:feb9:4c54"}, nil
}
return []string{"1.1.1.1"}, nil
},
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
MockRcode: func() int {
return 0
},
MockBytes: func() []byte {
return []byte{}
},
}
return response, nil
},
MockRequiresPadding: func() bool {
return true
},
MockNetwork: func() string {
return "mocked"
},
MockAddress: func() string {
return "dns.google"
},
}
r := netxlite.NewUnwrappedParallelResolver(txp)
resolver := trace.wrapResolver(r)
ctx := context.Background()
addrs, err := resolver.LookupHost(ctx, "example.com")
if err != nil {
t.Fatal("unexpected err", err)
}
if len(addrs) != 2 {
t.Fatal("unexpected array output", addrs)
}
if addrs[0] != "1.1.1.1" && addrs[1] != "1.1.1.1" {
t.Fatal("unexpected array output", addrs)
}
if addrs[0] != "fe80::a00:20ff:feb9:4c54" && addrs[1] != "fe80::a00:20ff:feb9:4c54" {
t.Fatal("unexpected array output", addrs)
}
t.Run("DNSLookup events", func(t *testing.T) {
events := trace.DNSLookupsFromRoundTrip()
if len(events) != 2 {
t.Fatal("unexpected DNS events")
}
for _, ev := range events {
if ev.ResolverAddress != "dns.google" {
t.Fatal("unexpected resolver address")
}
if ev.Engine != "mocked" {
t.Fatal("unexpected engine")
}
if len(ev.Answers) != 2 {
t.Fatal("expected single answer in DNSLookup event")
}
if ev.QueryType == "A" && ev.Answers[0].IPv4 != "1.1.1.1" {
t.Fatal("unexpected A query result")
}
if ev.QueryType == "AAAA" && ev.Answers[0].IPv6 != "fe80::a00:20ff:feb9:4c54" {
t.Fatal("unexpected AAAA query result")
}
if ev.Answers[1].AnswerType != "CNAME " && ev.Answers[1].Hostname != "dns.google." {
t.Fatal("unexpected second answer (expected CNAME)", ev.Answers[1])
}
}
})
})
t.Run("LookupHost discards events when buffers are full", func(t *testing.T) {
zeroTime := time.Now()
td := testingx.NewTimeDeterministic(zeroTime)
trace := NewTrace(0, zeroTime)
trace.dnsLookup = make(chan *model.ArchivalDNSLookupResult) // no buffer
trace.TimeNowFn = td.Now
txp := &mocks.DNSTransport{
MockRoundTrip: func(ctx context.Context, query model.DNSQuery) (model.DNSResponse, error) {
response := &mocks.DNSResponse{
MockDecodeLookupHost: func() ([]string, error) {
if query.Type() != dns.TypeA {
return []string{"fe80::a00:20ff:feb9:4c54"}, nil
}
return []string{"1.1.1.1"}, nil
},
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
MockRcode: func() int {
return 0
},
MockBytes: func() []byte {
return []byte{}
},
}
return response, nil
},
MockRequiresPadding: func() bool {
return true
},
MockNetwork: func() string {
return ""
},
MockAddress: func() string {
return "dns.google"
},
}
r := netxlite.NewUnwrappedParallelResolver(txp)
resolver := trace.wrapResolver(r)
ctx := context.Background()
addrs, err := resolver.LookupHost(ctx, "example.com")
if err != nil {
t.Fatal("unexpected err", err)
}
if len(addrs) != 2 {
t.Fatal("unexpected array output", addrs)
}
if addrs[0] != "1.1.1.1" && addrs[1] != "1.1.1.1" {
t.Fatal("unexpected array output", addrs)
}
if addrs[0] != "fe80::a00:20ff:feb9:4c54" && addrs[1] != "fe80::a00:20ff:feb9:4c54" {
t.Fatal("unexpected array output", addrs)
}
t.Run("DNSLookup Events", func(t *testing.T) {
events := trace.DNSLookupsFromRoundTrip()
if len(events) != 0 {
t.Fatal("expected to see no DNSLookup events")
}
})
})
}
func TestNewWrappedResolvers(t *testing.T) {
t.Run("NewParallelDNSOverHTTPSResolver works as intended", func(t *testing.T) {
zeroTime := time.Now()
trace := NewTrace(0, zeroTime)
resolver := trace.NewParallelDNSOverHTTPSResolver(model.DiscardLogger, "https://dns.google.com")
resolvert := resolver.(*resolverTrace)
if resolvert.tx != trace {
t.Fatal("invalid trace")
}
if resolver.Network() != "doh" {
t.Fatal("unexpected resolver network")
}
})
t.Run("NewParallelUDPResolver works as intended", func(t *testing.T) {
zeroTime := time.Now()
trace := NewTrace(0, zeroTime)
dialer := netxlite.NewDialerWithStdlibResolver(model.DiscardLogger)
resolver := trace.NewParallelUDPResolver(model.DiscardLogger, dialer, "1.1.1.1:53")
resolvert := resolver.(*resolverTrace)
if resolvert.tx != trace {
t.Fatal("invalid trace")
}
if resolver.Network() != "udp" {
t.Fatal("unexpected resolver network")
}
})
t.Run("NewStdlibResolver works as intended", func(t *testing.T) {
zeroTime := time.Now()
trace := NewTrace(0, zeroTime)
resolver := trace.NewStdlibResolver(model.DiscardLogger)
resolvert := resolver.(*resolverTrace)
if resolvert.tx != trace {
t.Fatal("invalid trace")
}
if resolver.Network() != "system" {
t.Fatal("unexpected resolver network")
}
})
}
func TestFirstDNSLookup(t *testing.T) {
t.Run("returns nil when buffer is empty", func(t *testing.T) {
zeroTime := time.Now()
trace := NewTrace(0, zeroTime)
got := trace.FirstDNSLookup()
if got != nil {
t.Fatal("expected nil event")
}
})
t.Run("return first non-nil DNSLookup", func(t *testing.T) {
filler := func(tx *Trace, events []*model.ArchivalDNSLookupResult) {
for _, ev := range events {
tx.dnsLookup <- ev
}
}
zeroTime := time.Now()
trace := NewTrace(0, zeroTime)
expect := []*model.ArchivalDNSLookupResult{{
Engine: "doh",
Failure: nil,
Hostname: "example.com",
QueryType: "A",
}, {
Engine: "doh",
Failure: nil,
Hostname: "example.com",
QueryType: "AAAA",
}}
filler(trace, expect)
got := trace.FirstDNSLookup()
if diff := cmp.Diff(got, expect[0]); diff != "" {
t.Fatal(diff)
}
})
}
func TestDelayedDNSResponseWithTimeout(t *testing.T) {
t.Run("OnDelayedDNSResponse saves into the trace", func(t *testing.T) {
t.Run("when buffer is not full", func(t *testing.T) {
zeroTime := time.Now()
td := testingx.NewTimeDeterministic(zeroTime)
trace := NewTrace(0, zeroTime)
trace.TimeNowFn = td.Now
txp := &mocks.DNSTransport{
MockNetwork: func() string {
return "udp"
},
MockAddress: func() string {
return "1.1.1.1"
},
}
started := trace.TimeNow()
query := &mocks.DNSQuery{
MockType: func() uint16 {
return dns.TypeA
},
MockDomain: func() string {
return "dns.google.com"
},
}
addrs := []string{"1.1.1.1"}
finished := trace.TimeNow()
// 1. fill the trace
dnsResponse := &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
MockRcode: func() int {
return 0
},
MockBytes: func() []byte {
return []byte{}
},
}
err := trace.OnDelayedDNSResponse(started, txp, query, dnsResponse, addrs, nil, finished)
// 2. read the trace
got := trace.DelayedDNSResponseWithTimeout(context.Background(), time.Second)
if err != nil {
t.Fatal("unexpected error", err)
}
if len(got) != 1 {
t.Fatal("unexpected output from trace")
}
})
t.Run("when buffer is full", func(t *testing.T) {
zeroTime := time.Now()
td := testingx.NewTimeDeterministic(zeroTime)
trace := NewTrace(0, zeroTime)
trace.TimeNowFn = td.Now
trace.delayedDNSResponse = make(chan *model.ArchivalDNSLookupResult) // no buffer
txp := &mocks.DNSTransport{
MockNetwork: func() string {
return "udp"
},
MockAddress: func() string {
return "1.1.1.1"
},
}
started := trace.TimeNow()
query := &mocks.DNSQuery{
MockType: func() uint16 {
return dns.TypeA
},
MockDomain: func() string {
return "dns.google.com"
},
}
addrs := []string{"1.1.1.1"}
finished := trace.TimeNow()
// 1. attempt to write into the trace
dnsResponse := &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
MockRcode: func() int {
return 0
},
MockBytes: func() []byte {
return []byte{}
},
}
err := trace.OnDelayedDNSResponse(started, txp, query, dnsResponse, addrs, nil, finished)
if !errors.Is(err, ErrDelayedDNSResponseBufferFull) {
t.Fatal("unexpected error", err)
}
// 2. confirm we didn't write anything
got := trace.DelayedDNSResponseWithTimeout(context.Background(), time.Second)
if len(got) != 0 {
t.Fatal("unexpected output from trace")
}
})
})
t.Run("DelayedDNSResponseWithTimeout drains the trace", func(t *testing.T) {
t.Run("context is already cancelled and we still drain the trace", func(t *testing.T) {
zeroTime := time.Now()
td := testingx.NewTimeDeterministic(zeroTime)
trace := NewTrace(0, zeroTime)
trace.TimeNowFn = td.Now
txp := &mocks.DNSTransport{
MockNetwork: func() string {
return "udp"
},
MockAddress: func() string {
return "1.1.1.1"
},
}
started := trace.TimeNow()
query := &mocks.DNSQuery{
MockType: func() uint16 {
return dns.TypeA
},
MockDomain: func() string {
return "dns.google.com"
},
}
addrs := []string{"1.1.1.1"}
finished := trace.TimeNow()
events := 4
dnsResponse := &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
MockRcode: func() int {
return 0
},
MockBytes: func() []byte {
return []byte{}
},
}
for i := 0; i < events; i++ {
// fill the trace
trace.delayedDNSResponse <- NewArchivalDNSLookupResultFromRoundTrip(trace.Index, started.Sub(trace.ZeroTime),
txp, query, dnsResponse, addrs, nil, finished.Sub(trace.ZeroTime))
}
ctx, cancel := context.WithCancel(context.Background())
cancel() // we ensure that the context cancels before draining all the events
// drain the trace
got := trace.DelayedDNSResponseWithTimeout(ctx, 10*time.Second)
if len(got) != 4 {
t.Fatal("unexpected output from trace", len(got))
}
})
t.Run("normal case where the context times out after we start draining", func(t *testing.T) {
zeroTime := time.Now()
td := testingx.NewTimeDeterministic(zeroTime)
trace := NewTrace(0, zeroTime)
trace.TimeNowFn = td.Now
txp := &mocks.DNSTransport{
MockNetwork: func() string {
return "udp"
},
MockAddress: func() string {
return "1.1.1.1"
},
}
started := trace.TimeNow()
query := &mocks.DNSQuery{
MockType: func() uint16 {
return dns.TypeA
},
MockDomain: func() string {
return "dns.google.com"
},
}
addrs := []string{"1.1.1.1"}
finished := trace.TimeNow()
dnsResponse := &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", netxlite.ErrOODNSNoAnswer
},
MockRcode: func() int {
return 0
},
MockBytes: func() []byte {
return []byte{}
},
}
trace.delayedDNSResponse <- NewArchivalDNSLookupResultFromRoundTrip(trace.Index, started.Sub(trace.ZeroTime),
txp, query, dnsResponse, addrs, nil, finished.Sub(trace.ZeroTime))
got := trace.DelayedDNSResponseWithTimeout(context.Background(), time.Second)
if len(got) != 1 {
t.Fatal("unexpected output from trace")
}
})
})
}
func TestNewArchivalDNSAnswers(t *testing.T) {
tests := []struct {
name string
addrs []string
resp model.DNSResponse
expected []model.ArchivalDNSAnswer
}{{
name: "with valid input",
addrs: []string{
"8.8.4.4",
"2001:4860:4860::8844",
},
resp: nil,
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "A",
Hostname: "",
IPv4: "8.8.4.4",
IPv6: "",
TTL: nil,
}, {
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "AAAA",
Hostname: "",
IPv4: "",
IPv6: "2001:4860:4860::8844",
TTL: nil,
}},
}, {
name: "with invalid IPv4 address",
addrs: []string{
"1.1.1.1.1", // invalid because it has five dots
"2001:4860:4860::8844",
},
resp: nil,
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "AAAA",
Hostname: "",
IPv4: "",
IPv6: "2001:4860:4860::8844",
TTL: nil,
}},
}, {
name: "with invalid IPv6 address",
addrs: []string{
"8.8.4.4",
"fe80::a00:20ff:feb9:::4c54", // invalid because it has :::
},
resp: nil,
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "A",
Hostname: "",
IPv4: "8.8.4.4",
IPv6: "",
TTL: nil,
}},
}, {
name: "with empty input",
addrs: []string{},
resp: nil,
expected: nil,
}, {
name: "with nil input",
addrs: nil,
resp: nil,
expected: nil,
}, {
name: "with valid IPv4 address and CNAME",
addrs: []string{
"8.8.8.8",
},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
},
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "A",
Hostname: "",
IPv4: "8.8.8.8",
IPv6: "",
TTL: nil,
}, {
ASN: 0,
ASOrgName: "",
AnswerType: "CNAME",
Hostname: "dns.google.",
IPv4: "",
IPv6: "",
TTL: nil,
}},
}, {
name: "with valid IPv6 address and CNAME",
addrs: []string{
"2001:4860:4860::8844",
},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "dns.google.", nil
},
},
expected: []model.ArchivalDNSAnswer{{
ASN: 15169,
ASOrgName: "Google LLC",
AnswerType: "AAAA",
Hostname: "",
IPv4: "",
IPv6: "2001:4860:4860::8844",
TTL: nil,
}, {
ASN: 0,
ASOrgName: "",
AnswerType: "CNAME",
Hostname: "dns.google.",
IPv4: "",
IPv6: "",
TTL: nil,
}},
}, {
name: "with DecodeCNAME error",
addrs: []string{},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", errors.New("mocked errorr")
},
},
expected: nil,
}, {
name: "with DecodeCNAME success and no CNAME",
addrs: []string{},
resp: &mocks.DNSResponse{
MockDecodeCNAME: func() (string, error) {
return "", nil
},
},
expected: nil,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := newArchivalDNSAnswers(tt.addrs, tt.resp)
if diff := cmp.Diff(tt.expected, got); diff != "" {
t.Fatal(diff)
}
})
}
}