diff --git a/README.md b/README.md index 35454d2..94fe5a7 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ rejected. - Accepts a list of DNS hostnames (subdomains, distinguished from apex domains via the Public Suffix List). - Every **1 hour**, performs a full iterative trace to discover the - authoritative nameservers for the hostname's parent domain. + authoritative nameservers of the zone the hostname is in, which is not + always its last two labels (a name under `co.uk`, or in a delegated + subdomain). - Queries **each** authoritative nameserver independently for **all** record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. - Stores results **per nameserver**. The state for a hostname is not a diff --git a/TODO.md b/TODO.md index 6043a58..c3e8285 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Completed Steps +- 2026-10-01: a hostname is queried at the servers of the zone it is in, found + by following delegations for the name, not its last two labels (closes #189). - 2026-10-01: `DNSWATCHER_SENTRY_DSN` reports panics in HTTP handlers to Sentry, and a DSN Sentry cannot parse stops startup (closes #107). - 2026-10-01: a port or TLS check that shutdown cuts short saves nothing and diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 1e0b494..93f6b9b 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -17,7 +17,6 @@ const ( maxRetries = 2 maxDelegation = 20 timeoutMultiplier = 2 - minDomainLabels = 2 ) // ErrRefused is returned when a DNS server refuses a query. @@ -225,6 +224,15 @@ func (r *Resolver) followDelegation( return ansNS, nil } + // An authoritative reply comes from the servers of the zone + // domain is in; it is not a referral, even when its authority + // section lists that zone's NS records. Without NS records in + // the answer, domain is not the zone's apex and has no + // nameservers of its own. + if resp.Authoritative { + return nil, ErrNoNameservers + } + authNS := extractNSSet(resp.Ns) if len(authNS) == 0 { return r.resolveNSIterative(ctx, domain) @@ -407,7 +415,9 @@ func (r *Resolver) resolveARecord( // FindAuthoritativeNameservers traces the delegation chain from // root servers to discover all authoritative nameservers for the -// given domain. Walks up the label hierarchy for subdomains. +// given domain. For a name that is not a zone apex it tries each +// parent name in turn, so it returns the nameservers of the zone the +// name is in. func (r *Resolver) FindAuthoritativeNameservers( ctx context.Context, domain string, @@ -653,22 +663,8 @@ func extractRecordValue(rr dns.RR) string { } } -// parentDomain returns the registerable parent domain. -func parentDomain(hostname string) string { - hostname = dns.Fqdn(strings.ToLower(hostname)) - labels := dns.SplitDomainName(hostname) - - if len(labels) <= minDomainLabels { - return strings.Join(labels, ".") + "." - } - - return strings.Join( - labels[len(labels)-minDomainLabels:], ".", - ) + "." -} - -// QueryAllNameservers discovers auth NSes for the hostname's -// parent domain, then queries each one independently. +// QueryAllNameservers discovers the auth NSes of the zone the +// hostname is in, then queries each one independently. func (r *Resolver) QueryAllNameservers( ctx context.Context, hostname string, @@ -677,9 +673,7 @@ func (r *Resolver) QueryAllNameservers( return nil, ErrContextCanceled } - parent := parentDomain(hostname) - - nameservers, err := r.FindAuthoritativeNameservers(ctx, parent) + nameservers, err := r.FindAuthoritativeNameservers(ctx, hostname) if err != nil { return nil, err } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 2946519..15a18c5 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -79,9 +79,10 @@ func TestFindAuthoritativeNameservers_Subdomain( t.Parallel() r := newTestResolver(t) - nameservers := liveFindAuthoritative(t, r, "www.google.com") + fromHost := liveFindAuthoritative(t, r, "www.google.com") + fromZone := liveFindAuthoritative(t, r, "google.com") - assert.NotEmpty(t, nameservers) + assert.Equal(t, fromZone, fromHost) } func TestFindAuthoritativeNameservers_ReturnsSorted( @@ -350,37 +351,58 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { func TestQueryAllNameservers_AllReturnOK(t *testing.T) { t.Parallel() - r := newTestResolver(t) - results := liveQueryAllNameservers(t, r, "google.com") + // 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", + } - // 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), - ) + for _, hostname := range hostnames { + t.Run(hostname, func(t *testing.T) { + t.Parallel() - // 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), - ) + 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(