watcher: a removed nameserver is an NS Change, not an NS Failure (closes #262)
check / check (push) Canceled after 0s

A nameserver that left a domain's delegation was reported twice: as the
domain's NS Change, and by the check of the domain's own records as an
error-level NS Failure saying it disappeared. The check of a name's
records now sends one NS Change at warning level, starting Hostname: or
Domain:, listing the nameservers it was asked at before and not now.
For a domain it leaves out those of its saved delegation, which the
domain's own NS Change lists, so a domain's new delegation is now
notified and saved only after, and only together with, its records.
Nothing is sent for a nameserver that joins.

A nameserver that was failing before it left is now listed too; the
NS Failure skipped it.

Model: opus-5-5
This commit was merged in pull request #273.
This commit is contained in:
2026-10-13 19:27:36 +02:00
parent b9686c6d7b
commit 79761c29e1
5 changed files with 236 additions and 55 deletions
+60 -30
View File
@@ -322,6 +322,20 @@ func (w *Watcher) checkDomain(
ctx, nameservers, prevAddresses,
)
// 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.
// Otherwise 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). When
// their lookup fails, the check fails as when the NS lookup does, so
// the saved delegation and the saved records come from the same
// check, as detectNSDisappearances needs.
if nxdomain {
w.state.DeleteHostnameState(domain)
} else if !w.checkHostname(ctx, domain) {
return
}
if hasPrev && !w.firstRun {
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
@@ -333,19 +347,6 @@ func (w *Watcher) checkDomain(
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).
w.checkHostname(ctx, domain)
}
func (w *Watcher) detectNSChanges(
@@ -456,10 +457,12 @@ func (w *Watcher) detectNSAddressChanges(
}
}
// checkHostname checks hostname's records and reports whether it saved
// them; it does not when their lookup fails.
func (w *Watcher) checkHostname(
ctx context.Context,
hostname string,
) {
) bool {
results, err := w.resolver.LookupAllRecords(ctx, hostname)
if err != nil {
w.logFailedLookup(
@@ -469,7 +472,7 @@ func (w *Watcher) checkHostname(
"error", err,
)
return
return false
}
prev, _ := w.state.GetHostnameState(hostname)
@@ -477,6 +480,8 @@ func (w *Watcher) checkHostname(
w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
)
return true
}
// updateHostnameState finishes a check of hostname from newState, built
@@ -772,28 +777,53 @@ func (w *Watcher) detectRecordChanges(
}
}
// detectNSDisappearances sends one NS Change listing the nameservers of
// the previous check that are no longer those of the zone hostname is
// in. For a configured apex domain it leaves out those of the domain's
// saved delegation, which detectNSChanges notifies when they are removed
// from it. checkDomain saves a new delegation only after this runs, and
// only together with the records, so the saved delegation and the
// records in prev come from the same check.
func (w *Watcher) detectNSDisappearances(
ctx context.Context,
hostname string,
prev, current *state.HostnameState,
) {
for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK {
continue
var delegation []string
if w.isDomain(hostname) {
if domainState, ok := w.state.GetDomainState(hostname); ok {
delegation = domainState.Nameservers
}
msg := fmt.Sprintf(
"%s\nNameserver: %s disappeared",
w.nameLine(hostname), ns,
)
w.notify.SendNotification(
ctx,
"NS Failure: "+hostname,
msg,
"error",
)
}
var removed []string
for ns := range prev.RecordsByNameserver {
_, ok := current.RecordsByNameserver[ns]
if !ok && !slices.Contains(delegation, ns) {
removed = append(removed, ns)
}
}
if len(removed) == 0 {
return
}
slices.Sort(removed)
msg := fmt.Sprintf(
"%s\nRemoved: %s",
w.nameLine(hostname),
strings.Join(removed, ", "),
)
w.notify.SendNotification(
ctx,
"NS Change: "+hostname,
msg,
"warning",
)
}
// detectNSFailures notifies when a nameserver that answered on the