package resolver_test import ( "context" "fmt" "log/slog" "net" "os" "sort" "strings" "testing" "time" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/dnswatcher/internal/livednstest" "sneak.berlin/go/dnswatcher/internal/resolver" ) // ---------------------------------------------------------------- // Test helpers // ---------------------------------------------------------------- func newTestResolver(t *testing.T) *resolver.Resolver { t.Helper() log := slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}, )) return resolver.NewFromLogger(log) } // findOneNSForDomain picks one authoritative nameserver to aim a // test at. Quorum handling lives in livedns_test.go, and the live-DNS // retry and concurrency limit in package livednstest. func findOneNSForDomain( t *testing.T, r *resolver.Resolver, domain string, ) string { t.Helper() return liveFindAuthoritative(t, r, domain)[0] } // ---------------------------------------------------------------- // FindAuthoritativeNameservers tests // ---------------------------------------------------------------- func TestFindAuthoritativeNameservers_ValidDomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) nameservers := liveFindAuthoritative(t, r, "google.com") hasGoogleNS := false for _, ns := range nameservers { if strings.Contains(ns, "google") { hasGoogleNS = true break } } assert.True(t, hasGoogleNS, "expected google nameservers, got: %v", nameservers, ) } func TestFindAuthoritativeNameservers_Subdomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) fromHost := liveFindAuthoritative(t, r, "www.google.com") fromZone := liveFindAuthoritative(t, r, "google.com") assert.Equal(t, fromZone, fromHost) } func TestFindAuthoritativeNameservers_ReturnsSorted( t *testing.T, ) { t.Parallel() r := newTestResolver(t) nameservers := liveFindAuthoritative(t, r, "google.com") assert.True( t, sort.StringsAreSorted(nameservers), "nameservers should be sorted, got: %v", nameservers, ) } func TestFindAuthoritativeNameservers_Deterministic( t *testing.T, ) { t.Parallel() r := newTestResolver(t) first := liveFindAuthoritative(t, r, "google.com") second := liveFindAuthoritative(t, r, "google.com") assert.Equal(t, first, second) } func TestFindAuthoritativeNameservers_TrailingDot( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ns1 := liveFindAuthoritative(t, r, "google.com") ns2 := liveFindAuthoritative(t, r, "google.com.") assert.Equal(t, ns1, ns2) } func TestFindAuthoritativeNameservers_CloudflareDomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) nameservers := liveFindAuthoritative(t, r, "cloudflare.com") for _, ns := range nameservers { assert.True(t, strings.HasSuffix(ns, "."), "NS should be FQDN with trailing dot: %s", ns, ) } } // TestResolveNSIPs_EveryNameserver looks up the addresses of two of // google.com's nameservers together, as the walk does when a referral // names a zone's nameservers without their addresses, and compares them // with each looked up alone. Together they must give the addresses of // both, not only of the first that resolves, so that when one gives no // usable reply the walk goes on to the other. func TestResolveNSIPs_EveryNameserver(t *testing.T) { t.Parallel() r := newTestResolver(t) names := []string{"ns3.google.com.", "ns4.google.com."} want := make([]string, 0, len(names)) for _, name := range names { want = append(want, liveResolveNSIPs(t, r, []string{name}, 1)...) } got := liveResolveNSIPs(t, r, names, len(want)) assert.ElementsMatch(t, want, got) } // ---------------------------------------------------------------- // QueryNameserver tests // ---------------------------------------------------------------- func TestQueryNameserver_BasicA(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "www.google.com", "A", "CNAME") require.NotNil(t, resp) assert.Equal(t, resolver.StatusOK, resp.Status) assert.Equal(t, ns, resp.Nameserver) hasRecords := len(resp.Records["A"]) > 0 || len(resp.Records["CNAME"]) > 0 assert.True(t, hasRecords, "expected A or CNAME records for www.google.com", ) } func TestQueryNameserver_AAAA(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "cloudflare.com") resp := liveQueryNameserver(t, r, ns, "cloudflare.com", "AAAA") aaaaRecords := resp.Records["AAAA"] require.NotEmpty(t, aaaaRecords, "cloudflare.com should have AAAA records", ) for _, ip := range aaaaRecords { parsed := net.ParseIP(ip) require.NotNil(t, parsed, "should be valid IP: %s", ip, ) } } func TestQueryNameserver_MX(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com", "MX") mxRecords := resp.Records["MX"] require.NotEmpty(t, mxRecords, "google.com should have MX records", ) } func TestQueryNameserver_TXT(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com", "TXT") txtRecords := resp.Records["TXT"] require.NotEmpty(t, txtRecords, "google.com should have TXT records", ) hasSPF := false for _, txt := range txtRecords { if strings.Contains(txt, "v=spf1") { hasSPF = true break } } assert.True(t, hasSPF, "google.com should have SPF TXT record", ) } func TestQueryNameserver_NXDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver( t, r, ns, "this-surely-does-not-exist-xyz.google.com", ) assert.Equal(t, resolver.StatusNXDomain, resp.Status) } // TestQueryNameserver_Refused asks a google.com nameserver about // cloudflare.com, a zone it does not serve, which it refuses. Refusing // is a failure to answer, not an answer with no records. func TestQueryNameserver_Refused(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") var resp *resolver.NameserverResponse livednstest.Retry( t, "QueryNameserver("+ns+", cloudflare.com)", func(ctx context.Context) error { var err error resp, err = r.QueryNameserver(ctx, ns, "cloudflare.com") if err != nil { return err } // A timeout or a network error is no reply at all. if resp.Status == resolver.StatusTimeout || strings.HasPrefix(resp.Error, "network error") { return fmt.Errorf( "%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error, ) } return nil }, ) assert.Equal(t, resolver.StatusError, resp.Status) assert.Equal(t, "server returned REFUSED", resp.Error) } // TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public // recursive resolver, about google.com at both of its addresses. Quad9 // refuses a query that does not ask for recursion and answers one that // does. The resolver never asks for recursion, so it must be reported // as refusing, never as answering. func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) { t.Parallel() r := newTestResolver(t) for _, ip := range []string{"9.9.9.9", "149.112.112.112"} { var resp *resolver.NameserverResponse livednstest.Retry( t, "QueryNameserverIP("+ip+", google.com)", func(ctx context.Context) error { var err error resp, err = r.QueryNameserverIP( ctx, ip, ip, "google.com", ) if err != nil { return err } // A timeout or a network error is no reply at all. if resp.Status == resolver.StatusTimeout || strings.HasPrefix(resp.Error, "network error") { return fmt.Errorf( "%w: %s: %s", livednstest.ErrNoAnswer, ip, resp.Error, ) } return nil }, ) assert.Equal(t, resolver.StatusError, resp.Status, ip) assert.Equal(t, "server returned REFUSED", resp.Error, ip) } } // googleNameserverIPv4s returns the IPv4 addresses of google.com's // nameservers, the only addresses the resolver asks servers at. func googleNameserverIPv4s(t *testing.T, r *resolver.Resolver) []string { t.Helper() names := liveFindAuthoritative(t, r, "google.com") return liveResolveNSIPs(t, r, names, len(names)) } // TestQueryServers_EveryServerRefused asks all of google.com's // nameservers about cloudflare.com, a zone they do not serve, which // they all refuse. The error says every server refused; it is not // ErrIntercepted, which only the root servers refusing shows. func TestQueryServers_EveryServerRefused(t *testing.T) { t.Parallel() r := newTestResolver(t) servers := googleNameserverIPv4s(t, r) var err error livednstest.Retry( t, "QueryServers(google.com servers, cloudflare.com)", func(ctx context.Context) error { _, err = r.QueryServers( ctx, servers, "google.com.", "cloudflare.com.", dns.TypeNS, ) // When not every server refused, one may have given no // reply at all, so the attempt is tried again. if err != nil && !strings.HasPrefix(err.Error(), "every server of") { return fmt.Errorf( "%w: %w", livednstest.ErrNoAnswer, err, ) } return nil }, ) require.ErrorIs(t, err, resolver.ErrRefused) require.NotErrorIs(t, err, resolver.ErrIntercepted) require.EqualError( t, err, "every server of google.com. refused a query for "+ "cloudflare.com.: dns query refused", ) } // TestQueryServers_EveryRootServerRefused passes google.com's // nameservers to QueryServers as the servers of the root zone. They // refuse a query about cloudflare.com, as root servers would if // something on the network answered in their place, so the error is // ErrIntercepted. func TestQueryServers_EveryRootServerRefused(t *testing.T) { t.Parallel() r := newTestResolver(t) servers := googleNameserverIPv4s(t, r) var err error livednstest.Retry( t, "QueryServers(google.com servers as root servers, cloudflare.com)", func(ctx context.Context) error { _, err = r.QueryServers( ctx, servers, ".", "cloudflare.com.", dns.TypeNS, ) // When not every server refused, one may have given no // reply at all, so the attempt is tried again. Both errors // for every server refusing say "refused a query for". if err != nil && !strings.Contains(err.Error(), "refused a query for") { return fmt.Errorf( "%w: %w", livednstest.ErrNoAnswer, err, ) } return nil }, ) require.ErrorIs(t, err, resolver.ErrIntercepted) require.EqualError( t, err, "every root server refused a query for cloudflare.com.: "+ "this network intercepts DNS queries", ) } // TestQueryServers_NotEveryRootServerRefused passes google.com's // nameservers and 192.0.2.1 to QueryServers as the servers of the root // zone. The google.com nameservers refuse a query about cloudflare.com, // but nothing answers at 192.0.2.1, a documentation address, so not // every server refused, wherever 192.0.2.1 falls in the random order: // the error is not ErrIntercepted and does not say every server refused. func TestQueryServers_NotEveryRootServerRefused(t *testing.T) { t.Parallel() r := newTestResolver(t) servers := googleNameserverIPv4s(t, r) servers = append(servers, "192.0.2.1") var err error livednstest.Retry( t, "QueryServers(google.com servers and 192.0.2.1, cloudflare.com)", func(ctx context.Context) error { _, err = r.QueryServers( ctx, servers, ".", "cloudflare.com.", dns.TypeNS, ) // An attempt that ran out of time may not have asked every // server, so it is tried again. if ctx.Err() != nil { return fmt.Errorf( "%w: %w", livednstest.ErrNoAnswer, err, ) } return nil }, ) require.Error(t, err) require.NotErrorIs(t, err, resolver.ErrIntercepted) // Both errors for every server refusing say "refused a query for". require.NotContains(t, err.Error(), "refused a query for") } func TestQueryNameserver_RecordsSorted(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com") for recordType, values := range resp.Records { assert.True( t, sort.StringsAreSorted(values), "%s records should be sorted", recordType, ) } } func TestQueryNameserver_ResponseIncludesNameserver( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "cloudflare.com") resp := liveQueryNameserver(t, r, ns, "cloudflare.com") assert.Equal(t, ns, resp.Nameserver) } func TestQueryNameserver_EmptyRecordsOnNXDomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver( t, r, ns, "this-surely-does-not-exist-xyz.google.com", ) totalRecords := 0 for _, values := range resp.Records { totalRecords += len(values) } assert.Zero(t, totalRecords) } func TestQueryNameserver_TrailingDotHandling(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp1 := liveQueryNameserver(t, r, ns, "google.com") resp2 := liveQueryNameserver(t, r, ns, "google.com.") assert.Equal(t, resp1.Status, resp2.Status) } // ---------------------------------------------------------------- // QueryAllNameservers tests // ---------------------------------------------------------------- func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { t.Parallel() r := newTestResolver(t) results := liveQueryAllNameservers(t, r, "google.com") assert.GreaterOrEqual(t, len(results), minNameservers) for ns, resp := range results { assert.Equal(t, ns, resp.Nameserver) } } func TestQueryAllNameservers_AllReturnOK(t *testing.T) { t.Parallel() // The last two names are in zones other than their last two // labels: google.co.uk, under the two-label suffix co.uk, and // compute-1.amazonaws.com, which amazonaws.com delegates to other // servers and which has a host name for each of its addresses. // Servers above a name's zone only refer onward, which gives // nodata, so ok shows the name was asked at its own zone's // servers. hostnames := []string{ "google.com", "www.google.co.uk", "ec2-3-80-0-1.compute-1.amazonaws.com", } for _, hostname := range hostnames { t.Run(hostname, func(t *testing.T) { t.Parallel() r := newTestResolver(t) results := liveQueryAllNameservers(t, r, hostname) // A quorum, not unanimity: one authoritative server // being slow or rate-limiting us is a property of the // live internet, not a resolver defect. assert.GreaterOrEqual( t, countStatus(results, resolver.StatusOK), liveQuorum(len(results)), "a quorum of nameservers should answer OK: %s", describeStatuses(results), ) // Quorum tolerates SILENCE only. Every individual // result must be either the expected answer or a // non-answer: ok, timeout or error, and nothing else. // Stated as a closed allowlist so that a wrong answer // no one thought to ban — nxdomain and nodata today, // any status added later — fails here rather than // sliding through under the quorum. assert.Empty( t, unsanctionedStatuses( results, resolver.StatusOK, resolver.StatusTimeout, resolver.StatusError, ), "every nameserver must answer OK or not answer "+ "at all: %s", describeStatuses(results), ) }) } } func TestQueryAllNameservers_NXDomainFromAllNS( t *testing.T, ) { t.Parallel() r := newTestResolver(t) results := liveQueryAllNameservers( t, r, "this-surely-does-not-exist-xyz.google.com", ) assert.GreaterOrEqual( t, countStatus(results, resolver.StatusNXDomain), liveQuorum(len(results)), "a quorum of nameservers should report NXDOMAIN: %s", describeStatuses(results), ) // Silence is tolerated; any actual answer other than NXDOMAIN // is not. Closed allowlist for the same reason as above: a // server answering `ok` or `nodata` for a name that must not // exist is a wrong answer, not a slow one. assert.Empty( t, unsanctionedStatuses( results, resolver.StatusNXDomain, resolver.StatusTimeout, resolver.StatusError, ), "every nameserver must report NXDOMAIN or not answer "+ "at all: %s", describeStatuses(results), ) } // ---------------------------------------------------------------- // LookupNS tests // ---------------------------------------------------------------- func TestLookupNS_ValidDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) nameservers := liveLookupNS(t, r, "google.com") for _, ns := range nameservers { assert.True(t, strings.HasSuffix(ns, "."), "NS should have trailing dot: %s", ns, ) } } func TestLookupNS_Sorted(t *testing.T) { t.Parallel() r := newTestResolver(t) nameservers := liveLookupNS(t, r, "google.com") assert.True(t, sort.StringsAreSorted(nameservers)) } func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { t.Parallel() r := newTestResolver(t) fromLookup := liveLookupNS(t, r, "google.com") fromFind := liveFindAuthoritative(t, r, "google.com") assert.Equal(t, fromFind, fromLookup) } // ---------------------------------------------------------------- // ResolveIPAddresses tests // ---------------------------------------------------------------- func TestResolveIPAddresses_ReturnsIPs(t *testing.T) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPs(t, r, "google.com") for _, ip := range ips { parsed := net.ParseIP(ip) assert.NotNil(t, parsed, "should be valid IP: %s", ip, ) } } func TestResolveIPAddresses_Deduplicated(t *testing.T) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPs(t, r, "google.com") seen := make(map[string]bool) for _, ip := range ips { assert.False(t, seen[ip], "duplicate IP: %s", ip) seen[ip] = true } } func TestResolveIPAddresses_Sorted(t *testing.T) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPs(t, r, "google.com") assert.True(t, sort.StringsAreSorted(ips)) } func TestResolveIPAddresses_NXDomainReturnsEmpty( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPsAllowingEmpty( t, r, "this-surely-does-not-exist-xyz.google.com", ) assert.Empty(t, ips) } func TestResolveIPAddresses_CloudflareDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPs(t, r, "cloudflare.com") assert.NotEmpty(t, ips) } // TestResolveIPAddresses_NameserverIPv4AndIPv6 looks up the addresses of // one of cloudflare.com's nameservers, as a domain check does for each // nameserver. That name has A and AAAA records, so both kinds of address // come back. func TestResolveIPAddresses_NameserverIPv4AndIPv6(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "cloudflare.com") ips := liveResolveIPs(t, r, ns) var ipv4, ipv6 int for _, ip := range ips { parsed := net.ParseIP(ip) require.NotNil(t, parsed, "should be valid IP: %s", ip) if parsed.To4() != nil { ipv4++ } else { ipv6++ } } assert.Positive(t, ipv4, "no IPv4 address for %s: %v", ns, ips) assert.Positive(t, ipv6, "no IPv6 address for %s: %v", ns, ips) } // ---------------------------------------------------------------- // Context cancellation tests // ---------------------------------------------------------------- func TestFindAuthoritativeNameservers_ContextCanceled( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := r.FindAuthoritativeNameservers(ctx, "google.com") assert.Error(t, err) } func TestQueryNameserver_ContextCanceled(t *testing.T) { t.Parallel() r := newTestResolver(t) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := r.QueryNameserver( ctx, "ns1.google.com.", "google.com", ) assert.Error(t, err) } func TestQueryAllNameservers_ContextCanceled(t *testing.T) { t.Parallel() r := newTestResolver(t) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := r.QueryAllNameservers(ctx, "google.com") assert.Error(t, err) } // TestQueryEachNS_CanceledDuringQuery cancels the context while a // nameserver is being queried, as shutdown does. A lookup cut short // says nothing about the nameserver, so it must return an error, not a // failed response for it. func TestQueryEachNS_CanceledDuringQuery(t *testing.T) { t.Parallel() r := newTestResolver(t) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) // Finding the nameserver's address alone starts at the root // servers and takes several round trips, so a cancel a few // milliseconds in lands during the query. time.AfterFunc(5*time.Millisecond, cancel) results, err := r.QueryEachNS( ctx, []string{"ns1.google.com."}, "google.com", ) require.ErrorIs(t, err, resolver.ErrContextCanceled) assert.Nil(t, results) } // ---------------------------------------------------------------- // Timeout tests // ---------------------------------------------------------------- func TestQueryNameserverIP_Timeout(t *testing.T) { t.Parallel() r := newTestResolver(t) // Nothing answers at 192.0.2.1, a documentation address. The // resolver tries each query twice, and the first try gives up // after two seconds. A deadline that ends during the first try // makes the status vary from run to run between error and // timeout, so the deadline must outlast the first try. ctx, cancel := context.WithTimeout( context.Background(), 3*time.Second, ) t.Cleanup(cancel) resp, err := r.QueryNameserverIP( ctx, "unreachable.test.", "192.0.2.1", "example.com", ) require.NoError(t, err) assert.Equal(t, resolver.StatusTimeout, resp.Status) assert.NotEmpty(t, resp.Error) } // TestCollectIPs_NoNameserverAnswered takes the response of a // nameserver at 192.0.2.1, where nothing answers, as // TestQueryNameserverIP_Timeout does. Addresses collected from // nameservers that all failed to answer are an error, not none. func TestCollectIPs_NoNameserverAnswered(t *testing.T) { t.Parallel() r := newTestResolver(t) // The deadline outlasts the first try, as in // TestQueryNameserverIP_Timeout. ctx, cancel := context.WithTimeout( context.Background(), 3*time.Second, ) t.Cleanup(cancel) resp, err := r.QueryNameserverIP( ctx, "unreachable.test.", "192.0.2.1", "example.com", ) require.NoError(t, err) ips, _, err := resolver.CollectIPs( map[string]*resolver.NameserverResponse{resp.Nameserver: resp}, ) require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered) assert.Empty(t, ips) } // TestCollectIPs_ReferralIsNoAnswer asks a root server about // example.com, which the root zone does not hold, so it only refers the // query to the com servers. That reply is no answer, as is a parent // zone's when every server of the name's own zone failed. func TestCollectIPs_ReferralIsNoAnswer(t *testing.T) { t.Parallel() r := newTestResolver(t) var resp *resolver.NameserverResponse livednstest.Retry( t, "QueryNameserverIP(a.root-servers.net, example.com)", func(ctx context.Context) error { var err error resp, err = r.QueryNameserverIP( ctx, "a.root-servers.net.", "198.41.0.4", "example.com", ) if err != nil { return err } // A timeout or a network error is no reply at all. if resp.Status == resolver.StatusTimeout || strings.HasPrefix(resp.Error, "network error") { return fmt.Errorf( "%w: %s", livednstest.ErrNoAnswer, resp.Error, ) } return nil }, ) assert.Equal(t, resolver.StatusError, resp.Status) assert.Equal(t, "server returned a referral", resp.Error) ips, _, err := resolver.CollectIPs( map[string]*resolver.NameserverResponse{resp.Nameserver: resp}, ) require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered) assert.Empty(t, ips) } func TestResolveIPAddresses_ContextCanceled(t *testing.T) { t.Parallel() r := newTestResolver(t) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := r.ResolveIPAddresses(ctx, "google.com") assert.Error(t, err) }