Compare commits

1 Commits
Author SHA1 Message Date
sneak 817df7fd34 watcher: a hostname whose zone's nameservers all stop answering gets NS Failure (closes #284)
check / check (push) Canceled after 0s
When none of the nameservers of a hostname's zone answered, the walk to
them failed and the check was only logged. The walk now returns a
NoAnswerError naming the nameservers of the zone it reached when none
of them answers. When those are the nameservers the previous check
saved for the hostname, each is saved as failed with the reason, as a
domain's are, so it gets an NS Failure and later an NS Recovery. A walk
that fails before that zone, as when the host loses its network, is
still only logged.

Not tested live: the resolver naming a zone's nameservers that never
answer, as no live zone has such nameservers; the watcher test builds
that error.

Model: opus-5-5
2026-10-13 21:16:31 +00:00
8 changed files with 73 additions and 172 deletions
+3 -5
View File
@@ -104,11 +104,9 @@ notification endpoint set, changes show only on the dashboard; see
NS change notification above. A domain listed in NS change notification above. A domain listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
notification. A domain with no delegation of its own has these records asked notification. 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. When none of those at the servers of the zone it is in, as a hostname has. A nameserver the
servers answers, the lookup of its NS record set fails, and they are saved as domain's records were asked at on the previous check but not on this one gets
failed and notified as a hostname's are (see below). A nameserver the domain's a hostname's NS change notification (see below), unless it was in the domain's
records were asked at on the previous check but not on this one gets a
hostname's NS change notification (see below), unless it was in the domain's
delegation on the previous check, as when that delegation is withdrawn. A delegation on the previous check, as when that delegation is withdrawn. A
domain that does not exist has none: they are not asked for, and those saved domain that does not exist has none: they are not asked for, and those saved
by an earlier check are removed without a notification. by an earlier check are removed without a notification.
+2 -2
View File
@@ -19,8 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Completed Steps # Completed Steps
- 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering - 2026-10-13: a hostname whose zone's nameservers all stop answering gets an NS
gets an NS Failure for each, then an NS Recovery (closes #284). Failure for each, and an NS Recovery when they answer again (closes #284).
- 2026-10-13: an IPv6 address is written in brackets before its port, as in - 2026-10-13: an IPv6 address is written in brackets before its port, as in
`[2001:db8::1]:443`, in keys and Port Change notifications (closes #270). `[2001:db8::1]:443`, in keys and Port Change notifications (closes #270).
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its - 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
+1 -3
View File
@@ -31,11 +31,9 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
func (r *Resolver) FollowDelegation( func (r *Resolver) FollowDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
return r.followDelegation(ctx, domain, zone, nameservers, servers) return r.followDelegation(ctx, domain, servers)
} }
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers // FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
+11 -12
View File
@@ -204,13 +204,10 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips return ips
} }
// followDelegation follows referrals to domain from servers, the // followDelegation follows referrals from servers, the root servers, to
// addresses of the servers of zone, and returns the NS set of domain's // domain and returns the NS set of domain's delegation. When the servers
// delegation. nameservers are the names a referral gave the servers of // of the zone domain is in answer that domain does not exist, the error
// zone; the walk to a name's nameservers starts at the root servers, the // is ErrNXDomain. When they answer that it has no delegation of its own,
// servers of zone ".", which no referral names. 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 // 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; it is a // error. Any other error means that no such answer came; it is a
// *NoAnswerError when the servers that did not answer are those of a // *NoAnswerError when the servers that did not answer are those of a
@@ -218,11 +215,13 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
func (r *Resolver) followDelegation( func (r *Resolver) followDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
var withoutAddresses []string // servers are the root servers, the servers of zone ".". After a
// referral, nameservers are the names of the servers of zone.
zone := "."
var nameservers, withoutAddresses []string
for range maxDelegation { for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
@@ -667,7 +666,7 @@ func (r *Resolver) findAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "." candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers) nsNames, err := r.followDelegation(ctx, candidate, servers)
if err != nil && !errors.Is(err, ErrNXDomain) { if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err return nil, err
} }
@@ -1079,7 +1078,7 @@ func (r *Resolver) LookupNS(
} }
return r.followDelegation( return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(), ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
) )
} }
+1 -23
View File
@@ -959,34 +959,12 @@ func TestFollowDelegation_NoAnswer(t *testing.T) {
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FollowDelegation( nameservers, err := r.FollowDelegation(
t.Context(), "google.com.", ".", nil, []string{noAnswerAddress}, t.Context(), "google.com.", []string{noAnswerAddress},
) )
require.Error(t, err) require.Error(t, err)
assert.Empty(t, nameservers) assert.Empty(t, nameservers)
} }
// TestFollowDelegation_NoAnswerAfterReferral starts the walk for
// www.example.net at the servers of example.net as a referral names
// them, ns1.example.net and ns2.example.net, at 192.0.2.1, where nothing
// answers. The error is a NoAnswerError naming them, which the watcher
// compares with the nameservers it saved for the name.
func TestFollowDelegation_NoAnswerAfterReferral(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers := []string{"ns1.example.net.", "ns2.example.net."}
_, err := r.FollowDelegation(
t.Context(), "www.example.net.", "example.net.", nameservers,
[]string{noAnswerAddress},
)
var noAnswer *resolver.NoAnswerError
require.ErrorAs(t, err, &noAnswer)
assert.Equal(t, nameservers, noAnswer.Nameservers)
assert.Contains(t, err.Error(), "query www.example.net. @"+noAnswerAddress)
}
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers // TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
// of www.google.com as the fallback walk does. As in // of www.google.com as the fallback walk does. As in
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error. // TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
-20
View File
@@ -122,26 +122,6 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
w.runTLSChecks(ctx) w.runTLSChecks(ctx)
} }
// CheckHostnameResult exports checkHostnameResult for testing.
func (w *Watcher) CheckHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
return w.checkHostnameResult(ctx, hostname, results, err)
}
// CheckDomainResult exports checkDomainResult for testing.
func (w *Watcher) CheckDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
w.checkDomainResult(ctx, domain, nameservers, err)
}
// FailedNameservers exports failedNameservers for testing. // FailedNameservers exports failedNameservers for testing.
func FailedNameservers( func FailedNameservers(
err error, err error,
+30 -48
View File
@@ -188,48 +188,20 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
} }
} }
// TestZoneNameserversAllStopAnswering runs three checks of host from // TestHostnameWhoseNameserversAllStopAnswering starts from a check of
// the point where its lookup has returned. On the first, nsA and nsB, // host in which nsA and nsB answered, then gives the watcher the error
// the nameservers of its zone, answer. On the second, the lookup fails // the walk to host's nameservers returns when it reached their zone and
// with the error the walk to them returns when neither answers (see // neither answered. No live zone has nameservers that never answer, so
// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given // the error is built here. Both are saved as failed, with the reason,
// here because no live zone has nameservers that never answer. For a // and each gets an NS Failure. On the next check both answer, and each
// hostname it is the lookup of its records that fails, and for a domain // gets an NS Recovery.
// with no delegation of its own the lookup of its delegation. Both are func TestHostnameWhoseNameserversAllStopAnswering(t *testing.T) {
// saved as failed, with the reason, and each gets an NS Failure. On the
// third, both answer, and each gets an NS Recovery. A domain's records
// are checked as a hostname's, so its first and third checks are too.
func TestZoneNameserversAllStopAnswering(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(records),
}
noAnswer := &resolver.NoAnswerError{
Nameservers: []string{nsA, nsB}, Err: errNoReply,
}
tests := []struct {
name string
isDomain bool
}{
{"hostname", false},
{"domain with no delegation of its own", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
if tt.isDomain {
cfg.Domains = []string{host}
} else {
cfg.Hostnames = []string{host} cfg.Hostnames = []string{host}
}
// The watcher is given each lookup's result, so it has no // The watcher is given each check's lookup result, so it has no
// resolver. // resolver.
deps := newTestDeps(t, cfg) deps := newTestDeps(t, cfg)
w := watcher.NewForTest( w := watcher.NewForTest(
@@ -238,22 +210,32 @@ func TestZoneNameserversAllStopAnswering(t *testing.T) {
) )
w.SetFirstRun(false) w.SetFirstRun(false)
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil) records := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{
if tt.isDomain { nsA: response(records), nsB: response(records),
w.CheckDomainResult(t.Context(), host, nil, noAnswer)
} else {
w.CheckHostnameResult(t.Context(), host, nil, noAnswer)
} }
deps.state.SetHostnameState(
host, watcher.BuildHostnameState(bothAnswer, nil, time.Now()),
)
err := &resolver.NoAnswerError{
Nameservers: []string{nsA, nsB}, Err: errNoReply,
}
prev, _ := deps.state.GetHostnameState(host)
w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState(
watcher.FailedNameservers(err, prev), prev, time.Now(),
))
current, _ := deps.state.GetHostnameState(host) current, _ := deps.state.GetHostnameState(host)
for _, ns := range []string{nsA, nsB} { for _, ns := range []string{nsA, nsB} {
got := current.RecordsByNameserver[ns] got := current.RecordsByNameserver[ns]
if got.Status != failed().Status || got.Error != errNoReply.Error() { if got.Status != failed().Status || got.Error != err.Error() {
t.Errorf( t.Errorf(
"%s saved with status %q, error %q; want %q, %q", "%s saved with status %q, error %q; want %q, %q",
ns, got.Status, got.Error, failed().Status, errNoReply, ns, got.Status, got.Error, failed().Status, err.Error(),
) )
} }
} }
@@ -262,13 +244,13 @@ func TestZoneNameserversAllStopAnswering(t *testing.T) {
t.Errorf("sent %d NS Failure notifications, want 2", n) t.Errorf("sent %d NS Failure notifications, want 2", n)
} }
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil) w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState(
bothAnswer, current, time.Now(),
))
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 { if n := countNotifications(deps, "NS Recovery: "+host); n != 2 {
t.Errorf("sent %d NS Recovery notifications, want 2", n) t.Errorf("sent %d NS Recovery notifications, want 2", n)
} }
})
}
} }
// TestFailedNameserversOnlyForThoseOfThePreviousCheck gives // TestFailedNameserversOnlyForThoseOfThePreviousCheck gives
+6 -40
View File
@@ -291,25 +291,12 @@ func (w *Watcher) logFailedLookup(
w.log.Error(msg, args...) w.log.Error(msg, args...)
} }
// checkDomain looks up domain's delegation and checks domain with it
// (see checkDomainResult).
func (w *Watcher) checkDomain( func (w *Watcher) checkDomain(
ctx context.Context, ctx context.Context,
domain string, domain string,
) { ) {
nameservers, err := w.resolver.LookupNS(ctx, domain) nameservers, err := w.resolver.LookupNS(ctx, domain)
w.checkDomainResult(ctx, domain, nameservers, err)
}
// checkDomainResult finishes a check of domain from nameservers and err,
// what the lookup of its delegation returned.
func (w *Watcher) checkDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
// A domain that does not exist has no nameservers. // A domain that does not exist has no nameservers.
nxdomain := errors.Is(err, resolver.ErrNXDomain) nxdomain := errors.Is(err, resolver.ErrNXDomain)
if nxdomain { if nxdomain {
@@ -324,12 +311,6 @@ func (w *Watcher) checkDomainResult(
"error", err, "error", err,
) )
// For a domain with no delegation of its own, the servers that
// did not answer can be those of the zone it is in, at which
// its records are asked. They are then saved as failed, as a
// hostname's are.
w.checkHostnameResult(ctx, domain, nil, err)
return return
} }
@@ -483,13 +464,16 @@ func (w *Watcher) detectNSAddressChanges(
} }
} }
// checkHostname looks up hostname's records and checks them (see // checkHostname checks hostname's records and reports whether it saved
// checkHostnameResult). It reports whether it saved them. // them; it does not when their lookup fails, unless the nameservers that
// did not answer are those of the previous check (see failedNameservers).
func (w *Watcher) checkHostname( func (w *Watcher) checkHostname(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) bool { ) bool {
results, err := w.resolver.LookupAllRecords(ctx, hostname) results, err := w.resolver.LookupAllRecords(ctx, hostname)
prev, _ := w.state.GetHostnameState(hostname)
if err != nil { if err != nil {
w.logFailedLookup( w.logFailedLookup(
ctx, ctx,
@@ -497,25 +481,7 @@ func (w *Watcher) checkHostname(
"hostname", hostname, "hostname", hostname,
"error", err, "error", err,
) )
}
return w.checkHostnameResult(ctx, hostname, results, err)
}
// checkHostnameResult finishes a check of hostname from results and err,
// what the lookup of its records returned, and reports whether it saved
// them. When the lookup failed, it saves nothing, unless the nameservers
// that did not answer are those of the previous check (see
// failedNameservers).
func (w *Watcher) checkHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
prev, _ := w.state.GetHostnameState(hostname)
if err != nil {
results = failedNameservers(err, prev) results = failedNameservers(err, prev)
if results == nil { if results == nil {
return false return false
@@ -862,7 +828,7 @@ func (w *Watcher) detectRecordChanges(
// saved delegation, which detectNSChanges notifies when they are removed // saved delegation, which detectNSChanges notifies when they are removed
// from it. checkDomain saves a new delegation only after this runs, and // from it. checkDomain saves a new delegation only after this runs, and
// only together with the records, so the saved delegation and the // only together with the records, so the saved delegation and the
// nameservers in prev come from the same check. // records in prev come from the same check.
func (w *Watcher) detectNSDisappearances( func (w *Watcher) detectNSDisappearances(
ctx context.Context, ctx context.Context,
hostname string, hostname string,