From 2c19a2ca35653314a83396affe974c6eec42e4ee Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 2 Oct 2026 08:38:47 +0000 Subject: [PATCH] resolver: a domain's nameservers are only its own delegation (closes #222) LookupNS now follows the delegation for the domain alone. When the parent zone's servers answer that it has no delegation, as for a domain that does not exist, the set is empty, and the watcher's NS comparison reports every nameserver removed. FindAuthoritativeNameservers, used for hostnames, still moves to a parent name when the servers answer that the name has no delegation of its own, but returns the error when they do not answer, where it used to take a parent zone's nameservers. The fallback walk treats an authoritative answer the same way. A domain with no delegation still has its own records asked at the servers of the zone it is in. Model: opus-5-5 --- README.md | 17 ++++++--- TODO.md | 2 ++ internal/resolver/iterative.go | 55 ++++++++++++++++++++---------- internal/resolver/livedns_test.go | 4 +-- internal/resolver/resolver_test.go | 51 +++++++++++++++++++++++++-- internal/watcher/interfaces.go | 3 +- 6 files changed, 105 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5939483..e3bf6ff 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,13 @@ notification endpoint set, changes show only on the dashboard; see to discover all authoritative nameservers (NS records) for each domain. - Queries **every** discovered authoritative nameserver independently. - Stores the domain's NS record set, as its parent zone's servers delegate it, - and the IPv4 and IPv6 addresses each nameserver's name resolves to. + and the IPv4 and IPv6 addresses each nameserver's name resolves to. The set is + only ever the domain's own delegation: a domain that its parent zone's servers + answer does not exist, or has no delegation, has no nameservers. When they do + not answer, the check fails and the set from the previous check is kept. - Any change triggers a notification: - - NS added to or removed from that set. + - NS added to or removed from that set. A domain that had nameservers on the + previous check and no longer exists gets one with all of them removed. - NS address change: a nameserver that stays in the set resolves to different addresses than on the previous check. A nameserver added or removed gets only the NS change notification. When the lookup of a @@ -86,7 +90,9 @@ notification endpoint set, changes show only on the dashboard; see records, stored per nameserver. Their changes are notified as a hostname's are, as a record change, NS query failure, NS recovery, inconsistency or CNAME address change, in a message that starts `Domain:` where a hostname's starts - `Hostname:`. + `Hostname:`. A domain with no nameservers of its own has these records asked + at the servers of the zone it is in, as a hostname has: for a `.com` domain + that does not exist, the `.com` servers, which answer that it does not exist. ### DNS Hostname Monitoring (Subdomains) @@ -94,7 +100,10 @@ notification endpoint set, changes show only on the dashboard; see via the Public Suffix List). - Every **1 hour** by default, performs a full iterative trace to discover the 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). + its last two labels (a name under `co.uk`, or in a delegated subdomain). The + trace moves from a name to its parent only when the servers asked answer that + the name has no delegation of its own. When they do not answer, the check + fails and the hostname's records from the previous check are kept. - 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 merged diff --git a/TODO.md b/TODO.md index 397b53c..8d1b9ac 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a domain that does not exist has no nameservers, not its parent + zone's; no name gets a parent's when its servers did not answer (closes #222). - 2026-10-02: nameservers a referral names without addresses are looked up, three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221). - 2026-10-02: an apex domain is not counted or listed as a hostname; its records diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 5686622..873a8a0 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -200,6 +200,11 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string { return ips } +// followDelegation follows referrals from servers, the root servers, to +// domain and returns the NS set of domain's delegation. When the servers +// of the zone domain is in answer that it has no delegation of its own, +// because it is not the zone's apex or does not exist, the set is empty +// and there is no error. An error means that no such answer came. func (r *Resolver) followDelegation( ctx context.Context, domain string, @@ -230,10 +235,10 @@ func (r *Resolver) followDelegation( // 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. + // the answer, domain is not the zone's apex, or does not + // exist, and has no nameservers of its own. if resp.Authoritative { - return nil, ErrNoNameservers + return []string{}, nil } authNS := extractNSSet(resp.Ns) @@ -475,7 +480,8 @@ func (r *Resolver) resolveNSIPs( // resolveNSIterative queries for NS records using iterative // resolution as a fallback when followDelegation finds no -// authoritative answer in the delegation chain. +// authoritative answer in the delegation chain. Its result means what +// followDelegation's does. func (r *Resolver) resolveNSIterative( ctx context.Context, domain string, @@ -505,6 +511,12 @@ func (r *Resolver) resolveNSIterative( return nsNames, nil } + // As in followDelegation: domain has no nameservers of its + // own. + if resp.Authoritative { + return []string{}, nil + } + // Follow delegation. authNS := extractNSSet(resp.Ns) if len(authNS) == 0 { @@ -590,9 +602,10 @@ func (r *Resolver) resolveARecord( // FindAuthoritativeNameservers traces the delegation chain from // root servers to discover all authoritative nameservers for the // given domain, as the delegation from its parent zone's servers lists -// them. 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. +// them. When the servers asked answer that the name has no delegation +// of its own, it tries each parent name in turn, so it returns the +// nameservers of the zone the name is in. When they do not answer, it +// returns the error and tries no parent name. func (r *Resolver) FindAuthoritativeNameservers( ctx context.Context, domain string, @@ -614,16 +627,15 @@ func (r *Resolver) FindAuthoritativeNameservers( nsNames, err := r.followDelegation( ctx, candidate, rootServerList(), ) - if err == nil && len(nsNames) > 0 { + if err != nil { + return nil, err + } + + if len(nsNames) > 0 { sort.Strings(nsNames) return nsNames, nil } - - // The root servers would refuse every parent name too. - if errors.Is(err, ErrIntercepted) { - return nil, err - } } return nil, ErrNoNameservers @@ -784,9 +796,7 @@ func (r *Resolver) querySingleType( // A reply with no answer that lists other nameservers, from a server // that does not hold the name's zone, is a referral and says nothing // about the name's records. A server named in the delegation that - // does not hold the zone may send one, as do a parent zone's servers - // when FindAuthoritativeNameservers found no delegation for the - // name's zone and moved on to a parent name. + // does not hold the zone may send one. if !msg.Authoritative && len(msg.Answer) == 0 && len(extractNSSet(msg.Ns)) > 0 { state.gotReferral = true @@ -941,12 +951,21 @@ func (r *Resolver) queryEachNS( return results, nil } -// LookupNS returns the NS record set for a domain. +// LookupNS returns the NS record set of a domain, as the delegation from +// its parent zone's servers lists it, and never a parent name's. When +// they answer that the domain has no delegation of its own, as when it +// does not exist, the set is empty and there is no error. func (r *Resolver) LookupNS( ctx context.Context, domain string, ) ([]string, error) { - return r.FindAuthoritativeNameservers(ctx, domain) + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + return r.followDelegation( + ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(), + ) } // LookupAllRecords performs iterative resolution to find all DNS diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go index 59f95ca..53946d6 100644 --- a/internal/resolver/livedns_test.go +++ b/internal/resolver/livedns_test.go @@ -187,8 +187,8 @@ func liveFindAuthoritative( return out } -// liveLookupNS is liveFindAuthoritative through the LookupNS entry -// point, so that both entry points stay independently exercised. +// liveLookupNS looks up the NS record set of domain, a domain that has +// one, retrying until the delegation chain can be walked. func liveLookupNS( t *testing.T, r *resolver.Resolver, diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index ac45b89..03a27e4 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -87,6 +87,27 @@ func TestFindAuthoritativeNameservers_Subdomain( assert.Equal(t, fromZone, fromHost) } +// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the +// nameservers of a name in compute-1.amazonaws.com, a zone that +// amazonaws.com delegates to other servers. The servers of +// compute-1.amazonaws.com answer that the name has no delegation of its +// own, so it gets their names, not those of the amazonaws.com servers. +func TestFindAuthoritativeNameservers_DelegatedSubdomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + fromHost := liveFindAuthoritative( + t, r, "ec2-3-80-0-1.compute-1.amazonaws.com", + ) + fromZone := liveLookupNS(t, r, "compute-1.amazonaws.com") + fromParent := liveLookupNS(t, r, "amazonaws.com") + + assert.Equal(t, fromZone, fromHost) + assert.NotEqual(t, fromParent, fromHost) +} + func TestFindAuthoritativeNameservers_ReturnsSorted( t *testing.T, ) { @@ -806,8 +827,7 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { // nameservers of g.ntpns.org. The org servers delegate its parent zone, // ntpns.org, without the addresses of its nameservers, so the walk has // to look them up to ask them. If it did not, the walk for g.ntpns.org -// would fail and LookupNS would return the nameservers of ntpns.org, -// which a.ntpns.org is not one of. +// would fail. func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) { t.Parallel() @@ -817,6 +837,33 @@ func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) { assert.Contains(t, nameservers, "a.ntpns.org.") } +// TestLookupNS_DomainThatDoesNotExist looks up the nameservers of a .com +// domain that does not exist. The .com servers answer that it does not +// exist, so it has none, and does not get theirs. +func TestLookupNS_DomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + const domain = "dnswatcher-test-does-not-exist.com" + + r := newTestResolver(t) + + var nameservers []string + + livednstest.Retry( + t, + "LookupNS("+domain+")", + func(ctx context.Context) error { + var err error + + nameservers, err = r.LookupNS(ctx, domain) + + return err + }, + ) + + assert.Empty(t, nameservers) +} + // ---------------------------------------------------------------- // ResolveIPAddresses tests // ---------------------------------------------------------------- diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index 41f53bc..4a12fda 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -11,7 +11,8 @@ import ( // DNSResolver performs iterative DNS resolution. type DNSResolver interface { - // LookupNS discovers authoritative nameservers for a domain. + // LookupNS returns a domain's NS record set, as its parent zone's + // servers delegate it: empty when they answer that it has none. LookupNS( ctx context.Context, domain string,