From ac5248067deb41a72fc72d4cecad406b98c7bef3 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 2 Oct 2026 06:11:36 +0000 Subject: [PATCH] watcher: change messages name only the record types that differ (closes #219) A Record Change notification printed the nameserver's whole old and new record sets in Go map syntax, and an Inconsistency notification the two nameservers' whole sets, so a one-address change had to be found by eye among kilobytes of unchanged TXT, CAA, MX and NS values. Both now list, in sorted order of type, only the record types whose values differ: a line naming the type, then each side's values separated by commas, or none when that side has no records of that type. The dashboard's Recent alerts shows the same text. Model: opus-5-5 --- TODO.md | 2 + internal/watcher/message_test.go | 66 ++++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 64 ++++++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 internal/watcher/message_test.go diff --git a/TODO.md b/TODO.md index 2910b41..41521ae 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-02: Record Change and Inconsistency notifications list only the record + types that differ, each with its values as plain text (closes #219). - 2026-10-02: the startup notification no longer says every notification endpoint works; it says it is a test sent to each of them (closes #230). - 2026-10-02: a Mattermost webhook that answers an HTTP error is logged as diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go new file mode 100644 index 0000000..2716ba0 --- /dev/null +++ b/internal/watcher/message_test.go @@ -0,0 +1,66 @@ +package watcher_test + +import ( + "testing" + + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// When one nameserver's A record changes and its TXT record does not, +// the record change and the inconsistency it starts name the A record +// alone, with its values written as plain text. +func TestChangeMessagesNameTheChangedType(t *testing.T) { + t.Parallel() + + // A nameserver's records: this A address and the same TXT record. + records := func(address string) map[string][]string { + return map[string][]string{ + "A": {address}, + "TXT": {"v=spf1 -all"}, + } + } + + before := hostnameState(map[string]map[string][]string{ + nsA: records(ip1), + nsB: records(ip1), + }) + after := hostnameState(map[string]map[string][]string{ + nsA: records(ip1), + nsB: records(ip2), + }) + + // The hostname change detection uses only the notifier. + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectHostnameChanges(t.Context(), host, before, after) + + want := map[string]string{ + "Record Change: " + host: `Hostname: www.example.net +Nameserver: b.ns.example.net. +Type: A +Old: 192.0.2.1 +New: 192.0.2.2`, + "Inconsistency: " + host: `Hostname: www.example.net +Type: A +a.ns.example.net.: 192.0.2.1 +b.ns.example.net.: 192.0.2.2`, + } + + notifications := notifier.getNotifications() + if len(notifications) != len(want) { + t.Fatalf( + "sent %d notifications, want %d: %v", + len(notifications), len(want), notifications, + ) + } + + for _, n := range notifications { + if n.Message != want[n.Title] { + t.Errorf( + "%s message:\n%s\nwant:\n%s", + n.Title, n.Message, want[n.Title], + ) + } + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 50fe163..001ee95 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -570,10 +570,12 @@ func (w *Watcher) detectRecordChanges( } msg := fmt.Sprintf( - "Hostname: %s\nNameserver: %s\n"+ - "Old: %v\nNew: %v", + "Hostname: %s\nNameserver: %s\n%s", hostname, ns, - prevNS.Records, cur.Records, + recordDifferences( + "Old", prevNS.Records, + "New", cur.Records, + ), ) w.notify.SendNotification( @@ -661,10 +663,12 @@ func (w *Watcher) detectInconsistencies( ns1, ns2 := pair[0], pair[1] msg := fmt.Sprintf( - "Hostname: %s\n%s: %v\n%s: %v", + "Hostname: %s\n%s", hostname, - ns1, current.RecordsByNameserver[ns1].Records, - ns2, current.RecordsByNameserver[ns2].Records, + recordDifferences( + ns1, current.RecordsByNameserver[ns1].Records, + ns2, current.RecordsByNameserver[ns2].Records, + ), ) w.notify.SendNotification( @@ -1214,6 +1218,54 @@ func recordsEqual( return true } +// recordDifferences describes, in sorted order of type, each record +// type whose values differ between a and b: a line naming the type, +// then a line with a's values after labelA and one with b's after +// labelB. Types with the same values in both are left out. +func recordDifferences( + labelA string, a map[string][]string, + labelB string, b map[string][]string, +) string { + types := make([]string, 0, len(a)+len(b)) + + for recordType := range a { + types = append(types, recordType) + } + + for recordType := range b { + if _, ok := a[recordType]; !ok { + types = append(types, recordType) + } + } + + sort.Strings(types) + + var lines []string + + for _, recordType := range types { + if sliceEqual(a[recordType], b[recordType]) { + continue + } + + lines = append(lines, + "Type: "+recordType, + labelA+": "+joinValues(a[recordType]), + labelB+": "+joinValues(b[recordType]), + ) + } + + return strings.Join(lines, "\n") +} + +// joinValues lists record values separated by commas, or says none. +func joinValues(values []string) string { + if len(values) == 0 { + return "none" + } + + return strings.Join(values, ", ") +} + func sliceEqual(a, b []string) bool { if len(a) != len(b) { return false