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. That check now sends
nothing for a domain. For a hostname, the nameservers that leave its
zone are sent as one NS Change at warning level, with a Hostname: line
and a Removed: line, as decided on the issue; nothing is sent for one
that joins. The README says what each case gets.

A nameserver that was failing before it left is now listed too; the
NS Failure skipped it.
A domain with no delegation of its own gets nothing when a nameserver
leaves the zone it is in.

Model: opus-5-5
This commit is contained in:
2026-10-13 11:27:04 +00:00
parent e8b40062da
commit 79ab40a87b
5 changed files with 89 additions and 30 deletions
+14 -6
View File
@@ -99,11 +99,14 @@ 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:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
record change or inconsistency 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. 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.
`Hostname:`. A nameserver removed from the domain's delegation gets only the
NS change notification above. A domain listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
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, and nothing is sent
when a nameserver leaves that zone. 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)
@@ -156,6 +159,10 @@ notification endpoint set, changes show only on the dashboard; see
- **NS recovery**: A previously-unreachable nameserver starts responding
again. Its records are not compared with those from before it failed, so a
change made while it was failing is not reported as a record change.
- **NS change**: A nameserver of the zone the hostname is in on the previous
check is not one now, whether or not it answered. One notification lists
every such nameserver on a `Removed:` line. Nothing is sent for a
nameserver that joins the zone.
- **Inconsistency detected**: Two nameservers return different record sets
for the same hostname and did not already differ on the previous check.
Every pair of nameservers is compared. The alert is sent once for each
@@ -237,7 +244,8 @@ includes:
- **DNS record changes**: Which hostname or domain, which nameserver, what
record type, old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added/removed.
- **DNS NS changes**: Which domain, which nameservers were added to or removed
from its delegation. For a hostname, which nameservers left the zone it is in.
- **NS address changes**: Which domain, which nameserver, its old and new
addresses.
- **CNAME address changes**: Which hostname or domain, the old and new addresses
+2
View File
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps
- 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change;
one leaving a hostname's zone sends NS Change, not NS Failure (closes #262).
- 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint
and test are phases of the `Dockerfile`, every scripted build uncached (closes
#257).
+37 -5
View File
@@ -81,14 +81,12 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
)
// nsA's address changes, which also makes it differ from nsC; nsB
// fails; nsC answers again; nsD is gone.
nsD := "d.ns.example.net."
// fails; nsC answers again.
w.DetectHostnameChanges(t.Context(), domain,
saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
nsD: answered(map[string][]string{"A": {ip1}}),
}),
saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip2}}),
@@ -102,11 +100,10 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Context(), domain, cnameState(ip1), cnameState(ip2),
)
// NS Failure is sent for nsB failing and for nsD being gone.
want := map[string]int{
"Record Change": 1,
"Inconsistency": 1,
"NS Failure": 2,
"NS Failure": 1,
"NS Recovery": 1,
"CNAME Address Change": 1,
}
@@ -128,6 +125,41 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
}
}
// nsB, which answered, and nsC, which did not, are no longer among the
// nameservers. For the hostname one NS Change lists both; for the
// configured apex domain nothing is sent, because the check of its
// delegation sends its NS Change.
func TestRemovedNameserversAreAnNSChange(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(
&config.Config{Domains: []string{domain}},
nil, nil, nil, nil, notifier,
)
prev := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
})
current := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
})
w.DetectHostnameChanges(t.Context(), domain, prev, current)
w.DetectHostnameChanges(t.Context(), host, prev, current)
title := "NS Change: " + host
want := `Hostname: www.example.net
Removed: b.ns.example.net., c.ns.example.net.`
got := notifier.getNotifications()
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
}
}
// The startup notification counts the configured domains and hostnames,
// although the state's hostnames also hold the apex domain's own
// records. Nothing is looked up: the watcher has no resolver.
+25 -8
View File
@@ -772,28 +772,45 @@ func (w *Watcher) detectRecordChanges(
}
}
// detectNSDisappearances sends one NS Change for a hostname, listing
// the nameservers of the previous check that are no longer those of its
// zone. Nothing is sent for a configured apex domain, whose removed
// nameservers detectNSChanges notifies.
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
if w.isDomain(hostname) {
return
}
var removed []string
for ns := range prev.RecordsByNameserver {
if _, ok := current.RecordsByNameserver[ns]; !ok {
removed = append(removed, ns)
}
}
if len(removed) == 0 {
return
}
slices.Sort(removed)
msg := fmt.Sprintf(
"%s\nNameserver: %s disappeared",
w.nameLine(hostname), ns,
"Hostname: %s\nRemoved: %s",
hostname,
strings.Join(removed, ", "),
)
w.notify.SendNotification(
ctx,
"NS Failure: "+hostname,
"NS Change: "+hostname,
msg,
"error",
"warning",
)
}
}
// detectNSFailures notifies when a nameserver that answered on the
+3 -3
View File
@@ -1049,7 +1049,7 @@ func TestSendTestNotification_Disabled(t *testing.T) {
}
}
func TestNSFailureAndRecovery(t *testing.T) {
func TestNSChangeAndRecovery(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
@@ -1059,7 +1059,7 @@ func TestNSFailureAndRecovery(t *testing.T) {
// The saved state has every nameserver live DNS lists as one that
// did not answer, and, as answering, one that live DNS does not
// list, which then disappears.
// list, which the check then finds removed.
_, deps := runChecks(t, cfg, func(deps *testDeps) {
byNameserver := map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}),
@@ -1071,7 +1071,7 @@ func TestNSFailureAndRecovery(t *testing.T) {
deps.state.SetHostnameState(testHost, saved(byNameserver))
})
assertNotified(t, deps, "NS Failure: "+testHost, "error")
assertNotified(t, deps, "NS Change: "+testHost, "warning")
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
// A nameserver that did not answer has no records to compare, so