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