watcher: a removed nameserver is an NS Change, not an NS Failure (closes #262)
check / check (push) Waiting to run

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 is contained in:
2026-10-13 17:11:38 +00:00
parent b9686c6d7b
commit 12a60ad36f
5 changed files with 236 additions and 55 deletions
+63 -5
View File
@@ -139,6 +139,22 @@ func (m *mockNotifier) getNotifications() []notification {
return result
}
// cancelOnNotify is a mockNotifier that calls cancel at each
// notification, as if shutdown cut the check short there.
type cancelOnNotify struct {
*mockNotifier
cancel context.CancelFunc
}
func (n cancelOnNotify) SendNotification(
ctx context.Context,
title, message, priority string,
) {
n.mockNotifier.SendNotification(ctx, title, message, priority)
n.cancel()
}
// --- Helpers to build a Watcher and run its checks against live DNS ---
type testDeps struct {
@@ -558,6 +574,11 @@ func TestDomainThatDoesNotExist(t *testing.T) {
// nameservers and without nxdomain, and its records, asked at the
// codeberg.page servers, are saved. Those are testSmallDomain's two
// nameservers; github.io, the zone of the README's example, has eight.
// The previous check saved a delegation to oldNS1, now withdrawn, and
// records asked at oldNS1. A first check is cut short at its first
// notification, as shutdown would, and a second runs in full: across
// them the NS Change of its delegation removes oldNS1, and no other NS
// Change lists it again.
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
@@ -569,9 +590,30 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
deps = newTestDeps(t, cfg)
w, deps = newTestWatcher(t, cfg)
firstCtx, cancel := context.WithCancel(ctx)
defer cancel()
w := watcher.NewForTest(
cfg,
deps.state,
resolver.NewFromLogger(slog.Default()),
deps.portChecker,
deps.tlsChecker,
cancelOnNotify{mockNotifier: deps.notifier, cancel: cancel},
)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: []string{oldNS1},
})
deps.state.SetHostnameState(domain, saved(
map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}),
},
))
w.RunOnce(firstCtx)
err := checkOnce(ctx, w, deps)
@@ -593,6 +635,22 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
if _, ok := deps.state.GetHostnameState(domain); !ok {
t.Errorf("no records saved for %s", domain)
}
title := "NS Change: " + domain
want := "Domain: " + domain + "\nAdded: \nRemoved: " + oldNS1
var changes []notification
for _, n := range deps.notifier.getNotifications() {
if n.Title == title {
changes = append(changes, n)
}
}
if len(changes) != 1 || changes[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s",
changes, title, want)
}
}
func TestNSAddressChangeDetection(t *testing.T) {
@@ -1049,7 +1107,7 @@ func TestSendTestNotification_Disabled(t *testing.T) {
}
}
func TestNSFailureAndRecovery(t *testing.T) {
func TestNSChangeAndRecovery(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
@@ -1059,7 +1117,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 +1129,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