From 67b67b8475b1912fadfc55115bea99f96eec6cdf Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Fri, 2 Oct 2026 12:38:45 +0200
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 | 113 +++++++++++++
15 files changed, 521 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 85263d0..c0c6c2a 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: the refused-query test sends one query to four operators' public
resolvers in turn until one replies, not eight to one operator (closes #251).
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
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 9399a1e..58033b7 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,
) {
@@ -828,8 +876,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()
@@ -839,6 +886,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..32517d7 100644
--- a/internal/watcher/watcher_test.go
+++ b/internal/watcher/watcher_test.go
@@ -482,6 +482,119 @@ 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)
+ }
+ }
+}
+
+// TestDomainWithNoDelegationOfItsOwn checks a domain with no delegation
+// of its own: codeberg.page is on the public suffix list, so
+// docs.codeberg.page is a domain, but the .page servers delegate only
+// codeberg.page, whose servers answer for it. It is saved with no
+// nameservers and without nxdomain, and its records, asked at the
+// codeberg.page servers, are saved. Those are testSmallDomain's two
+// nameservers; github.io, the zone of the README's example, has eight.
+func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
+ t.Parallel()
+
+ const domain = "docs.codeberg.page"
+
+ 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)
+
+ err := checkOnce(ctx, w, deps)
+
+ // A domain saved as not existing has no records to wait for;
+ // the checks below fail on it.
+ if ds, ok := deps.state.GetDomainState(domain); ok && ds.NXDomain {
+ return nil
+ }
+
+ return err
+ })
+
+ ds, _ := deps.state.GetDomainState(domain)
+ if ds.NXDomain || len(ds.Nameservers) != 0 {
+ t.Errorf("saved nxdomain %v and nameservers %v, want false and none",
+ ds.NXDomain, ds.Nameservers)
+ }
+
+ if _, ok := deps.state.GetHostnameState(domain); !ok {
+ t.Errorf("no records saved for %s", domain)
+ }
+}
+
func TestNSAddressChangeDetection(t *testing.T) {
t.Parallel()
|