diff --git a/README.md b/README.md index 089ef95..751e65b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index 2f43241..f7b035d 100644 --- a/TODO.md +++ b/TODO.md @@ -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). diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go index e446bd2..98b3094 100644 --- a/internal/watcher/message_test.go +++ b/internal/watcher/message_test.go @@ -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. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 8c5b17b..9160fbf 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -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 - } - - msg := fmt.Sprintf( - "%s\nNameserver: %s disappeared", - w.nameLine(hostname), ns, - ) - - w.notify.SendNotification( - ctx, - "NS Failure: "+hostname, - msg, - "error", - ) + 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( + "Hostname: %s\nRemoved: %s", + hostname, + strings.Join(removed, ", "), + ) + + w.notify.SendNotification( + ctx, + "NS Change: "+hostname, + msg, + "warning", + ) } // detectNSFailures notifies when a nameserver that answered on the diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 32517d7..aa53b3b 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -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