From 01879aaa2db05572484f9683a65902dc1b6b569b Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 2 Oct 2026 08:38:47 +0000 Subject: [PATCH] resolver, watcher: a domain's nameservers are only its own delegation (closes #222) When a domain's parent zone's servers answer NXDOMAIN, LookupNS returns ErrNXDomain. The watcher then saves the domain with no nameservers and nxdomain set, shown on the dashboard and in /api/v1/status, asks for none of its records and removes those saved, so its old nameservers go in one NS Change. A domain with no delegation of its own gets an empty set and its records are still asked at the zone it is in. FindAuthoritativeNameservers moves to a parent name only on one of those two answers; when the servers do not answer, it returns the error. After an upgrade, a domain without its own delegation that was saved with its parent zone's nameservers gets one NS Change; the README says so. Model: opus-5-5 --- README.md | 45 ++++-- TODO.md | 2 + internal/handlers/dashboard_test.go | 24 ++- internal/handlers/status.go | 5 +- internal/handlers/status_test.go | 42 +++++ internal/handlers/templates/dashboard.html | 4 + internal/resolver/errors.go | 4 + internal/resolver/export_test.go | 37 +++++ internal/resolver/iterative.go | 80 +++++++--- internal/resolver/livedns_test.go | 4 +- internal/resolver/resolver_test.go | 176 ++++++++++++++++++++- internal/state/state.go | 5 +- internal/watcher/interfaces.go | 4 +- internal/watcher/watcher.go | 16 ++ internal/watcher/watcher_test.go | 69 ++++++++ 15 files changed, 477 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index b943c4e..c1988ed 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,21 @@ 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 whose parent zone's servers + answer NXDOMAIN, that it does not exist, has no nameservers and is shown as + not existing (see Web Dashboard and HTTP API). A domain that exists but has no + delegation of its own, such as `octocat.github.io`, has no nameservers either. + When the parent zone's servers 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. + After an upgrade, a domain with no delegation of its own, for which an + earlier version saved its parent zone's nameservers, also gets one with + all of them removed, on its first check. That one does not mean the domain + stopped existing: it is not shown as not existing, and its records are + still watched. - 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 @@ -87,7 +99,10 @@ 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 delegation of its own has these records asked at + the servers of the zone it is in, as a hostname has. A domain that does not + exist has none: they are not asked for, and those saved by an earlier check + are removed without a notification. ### DNS Hostname Monitoring (Subdomains) @@ -95,7 +110,11 @@ 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, or does not exist. 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. - Each record type is a query of its own. When a nameserver answers some types @@ -260,8 +279,9 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL (`/`). It displays: - **Summary counts** for monitored domains, hostnames, ports, and certificates. -- **Domains** with their discovered nameservers, and each domain's own records - per nameserver and status, shown as a hostname's are. +- **Domains** with their discovered nameservers, or "does not exist" for a + domain whose parent zone's servers answered NXDOMAIN, and each domain's own + records per nameserver and status, shown as a hostname's are. - **Hostnames** with per-nameserver DNS records and status. For a nameserver whose query failed, the reason is shown in place of the records. - **Ports** with open/closed state and the domains and hostnames that resolve to @@ -298,9 +318,11 @@ In `/api/v1/status`, each nameserver entry and certificate entry whose `status` is `error` also has `error`, the reason, as in the state file (see State File Format). A domain's own records are in its entry in `domains`, under `recordsByNameserver`, in the form a hostname's entry in `hostnames` has them -under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A port -entry lists the domains that resolve to its address in `domains`, and the -hostnames in `hostnames`. +under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A domain +entry's `nxdomain` is `true` when the domain's parent zone's servers answered +NXDOMAIN, that it does not exist; its `nameservers` and `recordsByNameserver` +are then empty. A port entry lists the domains that resolve to its address in +`domains`, and the hostnames in `hostnames`. `/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime, @@ -588,6 +610,11 @@ nothing for it. Both lists are left out when empty. resolves to. A state file without it loads, and the next check fills it in without a notification. +A domain entry has `"nxdomain": true` when the domain's parent zone's servers +answered NXDOMAIN, that it does not exist. Its `nameservers` and +`nameserverAddresses` are then empty, and `hostnames` holds no entry for it. +`nxdomain` is left out when false. + `cnameAddresses` lists the sorted addresses at the end of the chain of every CNAME target a hostname's nameservers gave, found when they answered with a CNAME and no address; it is empty when they answered with an address. When a diff --git a/TODO.md b/TODO.md index dbc8924..d2344e8 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 is shown so, with no nameservers; no + name gets a parent's nameservers when its own did not answer (closes #222). - 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so the dashboard and API, at startup, before the first check (closes #223). - 2026-10-02: a record type whose query to a nameserver fails keeps its previous diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index d49278f..57d4f9c 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -191,21 +191,39 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) { words := strings.Join(strings.Fields(page), " ") - footer := "monitoring 1 domains + 1 hostnames" + footer := "monitoring 2 domains + 1 hostnames" if !strings.Contains(words, footer) { t.Errorf("dashboard does not say %q", footer) } - // With the tags taken out, the summary bar starts "Domains 1 + // With the tags taken out, the summary bar starts "Domains 2 // Hostnames 1". text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ") - summary := "Domains 1 Hostnames 1" + summary := "Domains 2 Hostnames 1" if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) { t.Errorf("summary bar does not say %q", summary) } } +// TestDashboardMarksDomainThatDoesNotExist checks that the Domains +// section says a domain that does not exist does not exist, and does +// not say so of a domain that exists. +func TestDashboardMarksDomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + page := get(t, newHandlersWithFailures(t).HandleDashboard()) + domains := dashboardSection(t, page, "Domains") + + if !strings.Contains(dashboardRow(t, domains, missingDomain), "does not exist") { + t.Errorf("row of %s does not say it does not exist", missingDomain) + } + + if strings.Contains(dashboardRow(t, domains, testDomain), "does not exist") { + t.Errorf("row of %s says it does not exist", testDomain) + } +} + // rowCells returns the text of each cell of a dashboard table row // whose cells start with tag, " + {{ if $ds.NXDomain }} + does not exist + {{ else }} {{ joinStrings $ds.Nameservers ", " }} + {{ end }} {{ relTime $ds.LastChecked }} diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index 61ca007..b628dd9 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -10,6 +10,10 @@ var ( "no authoritative nameservers found", ) + // ErrNXDomain is returned when the servers of the zone a domain + // is in answer NXDOMAIN: the domain does not exist. + ErrNXDomain = errors.New("domain does not exist") + // ErrNoNameserverAnswered is returned when every nameserver // asked about a name timed out, failed or returned a referral, // so whether the name has addresses is unknown. diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index a0e4103..0ea1a1f 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -17,6 +17,43 @@ func NewWithFailingTCP(log *slog.Logger) *Resolver { return r } +// NewWithQueryTimeout returns a Resolver whose queries over UDP give up +// after timeout, so a test that asks an address where nothing answers +// does not wait out the usual timeout. +func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver { + r := NewFromLogger(log) + r.client = &udpClient{timeout: timeout} + + return r +} + +// FollowDelegation exports followDelegation for testing. +func (r *Resolver) FollowDelegation( + ctx context.Context, + domain string, + servers []string, +) ([]string, error) { + return r.followDelegation(ctx, domain, servers) +} + +// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers +// for testing. +func (r *Resolver) FindAuthoritativeNameserversFrom( + ctx context.Context, + domain string, + servers []string, +) ([]string, error) { + return r.findAuthoritativeNameservers(ctx, domain, servers) +} + +// ResolveNSIterative exports resolveNSIterative for testing. +func (r *Resolver) ResolveNSIterative( + ctx context.Context, + domain string, +) ([]string, error) { + return r.resolveNSIterative(ctx, domain) +} + // ExtractRecordValue exports extractRecordValue for testing. func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 4b15543..b275a2f 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -204,6 +204,12 @@ 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 domain does not exist, the error +// is ErrNXDomain. When they answer that it has no delegation of its own, +// because it is not the zone's apex, the set is empty and there is no +// error. Any other error means that no such answer came. func (r *Resolver) followDelegation( ctx context.Context, domain string, @@ -234,10 +240,15 @@ 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 has no nameservers of its own: it does + // not exist, when the reply is NXDOMAIN, or else it is not the + // zone's apex. + if resp.Authoritative && resp.Rcode == dns.RcodeNameError { + return nil, ErrNXDomain + } + if resp.Authoritative { - return nil, ErrNoNameservers + return []string{}, nil } authNS := extractNSSet(resp.Ns) @@ -486,7 +497,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, @@ -516,6 +528,16 @@ func (r *Resolver) resolveNSIterative( return nsNames, nil } + // As in followDelegation: domain has no nameservers of its + // own. + if resp.Authoritative && resp.Rcode == dns.RcodeNameError { + return nil, ErrNXDomain + } + + if resp.Authoritative { + return []string{}, nil + } + // Follow delegation. authNS := extractNSSet(resp.Ns) if len(authNS) == 0 { @@ -601,12 +623,23 @@ 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, or does not exist, 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, +) ([]string, error) { + return r.findAuthoritativeNameservers(ctx, domain, rootServerList()) +} + +// findAuthoritativeNameservers is FindAuthoritativeNameservers with each +// walk starting at servers, the root servers. +func (r *Resolver) findAuthoritativeNameservers( + ctx context.Context, + domain string, + servers []string, ) ([]string, error) { if checkCtx(ctx) != nil { return nil, ErrContextCanceled @@ -622,19 +655,16 @@ func (r *Resolver) FindAuthoritativeNameservers( candidate := strings.Join(labels[i:], ".") + "." - nsNames, err := r.followDelegation( - ctx, candidate, rootServerList(), - ) - if err == nil && len(nsNames) > 0 { + nsNames, err := r.followDelegation(ctx, candidate, servers) + if err != nil && !errors.Is(err, ErrNXDomain) { + 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 @@ -852,9 +882,7 @@ func readReply( // 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 @@ -1022,12 +1050,22 @@ 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 does not exist, the error is ErrNXDomain. +// When they answer that it has no delegation of its own, 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 94a2fa0..bd1b9b5 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -25,6 +25,13 @@ import ( // Test helpers // ---------------------------------------------------------------- +// nonexistentDomain is a .com domain that does not exist. +const nonexistentDomain = "dnswatcher-test-does-not-exist.com" + +// noAnswerAddress is 192.0.2.1, a documentation address: nothing +// answers there. +const noAnswerAddress = "192.0.2.1" + func newTestResolver(t *testing.T) *resolver.Resolver { t.Helper() @@ -88,6 +95,47 @@ func TestFindAuthoritativeNameservers_Subdomain( assert.Equal(t, fromZone, fromHost) } +// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the +// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that +// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer +// that the name has no delegation of its own, so it gets their names, +// not those of the cmu.edu servers. Every referral on the way gives the +// nameservers' addresses, so the walk sends few queries. +func TestFindAuthoritativeNameservers_DelegatedSubdomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu") + fromZone := liveLookupNS(t, r, "cs.cmu.edu") + fromParent := liveLookupNS(t, r, "cmu.edu") + + assert.Equal(t, fromZone, fromHost) + assert.NotEqual(t, fromParent, fromHost) +} + +// TestFindAuthoritativeNameservers_NoAnswer starts each walk for +// www.google.com at 192.0.2.1, a documentation address where nothing +// answers. A walk that got no answer does not say that the name has no +// delegation of its own, so the lookup returns that walk's error, about +// www.google.com, and tries no parent name: trying google.com and com +// would end in ErrNoNameservers, or in the error of a walk for one of +// them. +func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) { + t.Parallel() + + r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) + + nameservers, err := r.FindAuthoritativeNameserversFrom( + t.Context(), "www.google.com", []string{noAnswerAddress}, + ) + require.Error(t, err) + require.NotErrorIs(t, err, resolver.ErrNoNameservers) + assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress) + assert.Empty(t, nameservers) +} + func TestFindAuthoritativeNameservers_ReturnsSorted( t *testing.T, ) { @@ -831,8 +879,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() @@ -842,6 +889,131 @@ 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 NXDOMAIN, so the +// error is ErrNXDomain, and the domain does not get their names. +func TestLookupNS_DomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var ( + nameservers []string + err error + ) + + livednstest.Retry( + t, + "LookupNS("+nonexistentDomain+")", + func(ctx context.Context) error { + nameservers, err = r.LookupNS(ctx, nonexistentDomain) + if errors.Is(err, resolver.ErrNXDomain) { + return nil + } + + return err + }, + ) + + require.ErrorIs(t, err, resolver.ErrNXDomain) + assert.Empty(t, nameservers) +} + +// TestLookupNS_NoDelegationOfItsOwn looks up the nameservers of +// www.google.com, a name in the google.com zone with no delegation of +// its own, as a domain such as octocat.github.io is. The google.com +// servers answer with no NS records for it: the set is empty, and it is +// not ErrNXDomain. +func TestLookupNS_NoDelegationOfItsOwn(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var nameservers []string + + livednstest.Retry( + t, + "LookupNS(www.google.com)", + func(ctx context.Context) error { + var err error + + nameservers, err = r.LookupNS(ctx, "www.google.com") + + return err + }, + ) + + assert.Empty(t, nameservers) +} + +// TestFollowDelegation_NoAnswer starts the walk LookupNS uses, for +// google.com, at 192.0.2.1, a documentation address where nothing +// answers. A walk that got no answer is an error, not an empty set, +// which the watcher would report as an NS Change with every nameserver +// removed. +func TestFollowDelegation_NoAnswer(t *testing.T) { + t.Parallel() + + r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) + + nameservers, err := r.FollowDelegation( + t.Context(), "google.com.", []string{noAnswerAddress}, + ) + require.Error(t, err) + assert.Empty(t, nameservers) +} + +// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers +// of www.google.com as the fallback walk does. As in +// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error. +func TestResolveNSIterative_NoDelegationOfItsOwn(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var nameservers []string + + livednstest.Retry( + t, + "ResolveNSIterative(www.google.com)", + func(ctx context.Context) error { + var err error + + nameservers, err = r.ResolveNSIterative(ctx, "www.google.com") + + return err + }, + ) + + assert.Empty(t, nameservers) +} + +// TestResolveNSIterative_DomainThatDoesNotExist walks to the nameservers +// of a .com domain that does not exist as the fallback walk does. As in +// TestLookupNS_DomainThatDoesNotExist, the error is ErrNXDomain. +func TestResolveNSIterative_DomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var err error + + livednstest.Retry( + t, + "ResolveNSIterative("+nonexistentDomain+")", + func(ctx context.Context) error { + _, err = r.ResolveNSIterative(ctx, nonexistentDomain) + if errors.Is(err, resolver.ErrNXDomain) { + return nil + } + + return err + }, + ) + + require.ErrorIs(t, err, resolver.ErrNXDomain) +} + // ---------------------------------------------------------------- // ResolveIPAddresses tests // ---------------------------------------------------------------- diff --git a/internal/state/state.go b/internal/state/state.go index 5bd9aef..39da3cb 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -38,10 +38,13 @@ type Params struct { // DomainState holds the monitoring state for an apex domain. // NameserverAddresses holds the sorted addresses each nameserver's name // resolves to, by nameserver name. A state file written before it -// existed loads with it nil. +// existed loads with it nil. NXDomain is true when the domain's parent +// zone's servers answered that it does not exist; it then has no +// nameservers. type DomainState struct { Nameservers []string `json:"nameservers"` NameserverAddresses map[string][]string `json:"nameserverAddresses"` + NXDomain bool `json:"nxdomain,omitempty"` LastChecked time.Time `json:"lastChecked"` } diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index 41f53bc..166ada4 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -11,7 +11,9 @@ 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, and + // resolver.ErrNXDomain when they answer that it does not exist. LookupNS( ctx context.Context, domain string, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2825055..7368dab 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -289,6 +289,13 @@ func (w *Watcher) checkDomain( domain string, ) { nameservers, err := w.resolver.LookupNS(ctx, domain) + + // A domain that does not exist has no nameservers. + nxdomain := errors.Is(err, resolver.ErrNXDomain) + if nxdomain { + nameservers, err = []string{}, nil + } + if err != nil { w.logFailedLookup( ctx, @@ -323,9 +330,18 @@ func (w *Watcher) checkDomain( w.state.SetDomainState(domain, &state.DomainState{ Nameservers: nameservers, NameserverAddresses: addresses, + NXDomain: nxdomain, LastChecked: now, }) + // A domain that does not exist has no records of its own: none are + // asked for, and those saved by an earlier check are removed. + if nxdomain { + w.state.DeleteHostnameState(domain) + + return + } + // The apex domain's records are also checked and saved as a // hostname's, so that the port and TLS checks find its addresses. // Notifications about them name it as a domain (see nameLine). diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 46b40cc..9d60cdf 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -482,6 +482,75 @@ func TestNSChangeDetection(t *testing.T) { } } +// TestDomainThatDoesNotExist checks a .com domain that does not exist, +// with nameservers and records saved by an earlier check. The .com +// servers answer that it does not exist, so it is saved with nxdomain +// set and no nameservers, an NS Change removes them all, and its saved +// records are removed rather than asked for at the .com servers. +func TestDomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + const domain = "dnswatcher-test-does-not-exist.com" + + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + + var deps *testDeps + + livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { + var w *watcher.Watcher + + w, deps = newTestWatcher(t, cfg) + + deps.state.SetDomainState(domain, &state.DomainState{ + Nameservers: []string{oldNS1, oldNS2}, + }) + deps.state.SetHostnameState(domain, &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + oldNS1: { + Records: map[string][]string{"A": {oldIP}}, + Status: "ok", + }, + }, + }) + + started := time.Now() + + w.RunOnce(ctx) + + // When no server answered, the domain's state is not saved. + ds, _ := deps.state.GetDomainState(domain) + if ds.LastChecked.Before(started) { + return fmt.Errorf("%s: %w", domain, livednstest.ErrNoAnswer) + } + + return nil + }) + + ds, _ := deps.state.GetDomainState(domain) + if !ds.NXDomain || len(ds.Nameservers) != 0 { + t.Errorf("saved nxdomain %v and nameservers %v, want true and none", + ds.NXDomain, ds.Nameservers) + } + + if hs, ok := deps.state.GetHostnameState(domain); ok { + t.Errorf("records saved for %s: %v", domain, hs.RecordsByNameserver) + } + + assertNotified(t, deps, "NS Change: "+domain, "warning") + + // That is the only notification, and it removes both nameservers, + // in either order. + for _, n := range deps.notifier.getNotifications() { + removed := strings.TrimPrefix( + n.Message, "Domain: "+domain+"\nAdded: \nRemoved: ", + ) + if removed != oldNS1+", "+oldNS2 && removed != oldNS2+", "+oldNS1 { + t.Errorf("unexpected notification: %v", n) + } + } +} + func TestNSAddressChangeDetection(t *testing.T) { t.Parallel()