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
This commit is contained in:
2026-10-13 21:16:31 +00:00
parent aaccabad50
commit 817df7fd34
9 changed files with 216 additions and 11 deletions
+42 -4
View File
@@ -465,12 +465,15 @@ func (w *Watcher) detectNSAddressChanges(
}
// checkHostname checks hostname's records and reports whether it saved
// them; it does not when their lookup fails.
// 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(
ctx context.Context,
hostname string,
) bool {
results, err := w.resolver.LookupAllRecords(ctx, hostname)
prev, _ := w.state.GetHostnameState(hostname)
if err != nil {
w.logFailedLookup(
ctx,
@@ -479,11 +482,12 @@ func (w *Watcher) checkHostname(
"error", err,
)
return false
results = failedNameservers(err, prev)
if results == nil {
return false
}
}
prev, _ := w.state.GetHostnameState(hostname)
w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
)
@@ -491,6 +495,40 @@ func (w *Watcher) checkHostname(
return true
}
// failedNameservers returns a failed response for each nameserver in
// prev, a name's state from the previous check, with err as the reason,
// when err says that the walk to the name's nameservers reached their
// zone and none of them answered. For any other error, as when the walk
// got no answer before it reached their zone, or when prev is nil, it
// returns nil.
func failedNameservers(
err error,
prev *state.HostnameState,
) map[string]*resolver.NameserverResponse {
var noAnswer *resolver.NoAnswerError
if !errors.As(err, &noAnswer) || prev == nil {
return nil
}
saved := slices.Sorted(maps.Keys(prev.RecordsByNameserver))
if !slices.Equal(noAnswer.Nameservers, saved) {
return nil
}
results := make(map[string]*resolver.NameserverResponse)
for _, ns := range saved {
results[ns] = &resolver.NameserverResponse{
Nameserver: ns,
Records: make(map[string][]string),
Status: resolver.StatusError,
Error: err.Error(),
}
}
return results
}
// updateHostnameState finishes a check of hostname from newState, built
// from its nameservers' answers: it follows the CNAME in them, notifies
// what changed since the previous check, and saves newState.