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
+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.