check / check (push) Failing after 2m6s
A hostname's nameservers came from its last two labels, so a name under co.uk was asked at the co.uk servers and a name in a delegated subdomain at the parent's servers; both only refer onward. The hostname now goes through FindAuthoritativeNameservers, which follows delegations for the name and walks up its labels until it finds the zone it is in. followDelegation now stops at an authoritative reply: that server holds the zone, so its reply is not a referral. Without this, a CNAME answer that also lists the zone's NS records in its authority section, as many servers send, was followed as a referral until the delegation limit. Model: opus-5-5
646 lines
15 KiB
Go
646 lines
15 KiB
Go
package resolver_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|