Compare commits

...
Author SHA1 Message Date
clawbot 8fd2fadbd8 watcher: send the inconsistency alert once per disagreement (closes #158)
check / check (push) Successful in 57s
detectInconsistencies alerted for every neighbouring pair of nameservers
whose records differed, on every DNS check, for as long as they differed.
It now also takes the previous hostname state and alerts for a pair only
when both nameservers were in that state with equal records. The state
loaded at startup is the previous state for the first check, so a
disagreement saved before a restart is not reported again. The choice of
pairs is in newlyDisagreeingPairs, tested on record data built in the
test. The README describes the new behaviour.

Model: opus-5-5
2026-09-28 23:06:23 +00:00
5 changed files with 170 additions and 17 deletions
+6 -2
View File
@@ -74,8 +74,12 @@ rejected.
This is distinct from "responded with no records."
- **NS recovery**: A previously-unreachable nameserver starts
responding again.
- **Inconsistency detected**: Two nameservers that previously agreed
now return different record sets for the same hostname.
- **Inconsistency detected**: Two nameservers that agreed on the
previous check now return different record sets for the same
hostname. This is sent once, on the check where they start to
disagree, and not again while they keep disagreeing, including
after a restart. If they agree again and later disagree, it is
sent again.
### TCP Port Monitoring
+3
View File
@@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing.
# Completed Steps
- 2026-09-28: the inconsistency alert is sent once, on the check where two
nameservers that agreed start to disagree, instead of on every check while
they disagree, and not again after a restart (closes #158).
- 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are
lower-cased, so nameservers that answer in different letter case no longer
count as inconsistent or as a record change (closes #157).
+11
View File
@@ -0,0 +1,11 @@
package watcher
import "sneak.berlin/go/dnswatcher/internal/state"
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs(
prev *state.HostnameState,
current map[string]map[string][]string,
) [][2]string {
return newlyDisagreeingPairs(prev, current)
}
+109
View File
@@ -0,0 +1,109 @@
package watcher_test
import (
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// hostnameState builds the state a check with these records leaves behind.
func hostnameState(
records map[string]map[string][]string,
) *state.HostnameState {
hs := &state.HostnameState{
RecordsByNameserver: make(map[string]*state.NameserverRecordState),
}
for ns, recs := range records {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: recs,
Status: "ok",
}
}
return hs
}
func TestNewlyDisagreeingPairs(t *testing.T) {
t.Parallel()
const (
nsA = "a.ns.example.net."
nsB = "b.ns.example.net."
ip1 = "192.0.2.1"
ip2 = "192.0.2.2"
)
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
agree := map[string]map[string][]string{
nsA: {"A": {ip1}},
nsB: {"A": {ip1}},
}
disagree := map[string]map[string][]string{
nsA: {"A": {ip1}},
nsB: {"A": {ip2}},
}
alert := [][2]string{{nsA, nsB}}
// Each case starts from the state loaded at startup and runs the
// checks in order; want[i] is what check i alerts for.
tests := []struct {
name string
loaded map[string]map[string][]string
checks []map[string]map[string][]string
want [][][2]string
}{
{
name: "disagreement persisting across checks alerts once",
loaded: agree,
checks: []map[string]map[string][]string{disagree, disagree, disagree},
want: [][][2]string{alert, nil, nil},
},
{
name: "disagreement starting on a later check alerts on it",
loaded: agree,
checks: []map[string]map[string][]string{agree, agree, disagree},
want: [][][2]string{nil, nil, alert},
},
{
name: "disagreement in the loaded state does not alert",
loaded: disagree,
checks: []map[string]map[string][]string{disagree, disagree},
want: [][][2]string{nil, nil},
},
{
name: "nameserver new on this check does not alert",
loaded: onlyA,
checks: []map[string]map[string][]string{disagree},
want: [][][2]string{nil},
},
{
name: "disagreement after agreeing again alerts again",
loaded: agree,
checks: []map[string]map[string][]string{disagree, agree, disagree},
want: [][][2]string{alert, nil, alert},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
prev := hostnameState(tt.loaded)
for i, current := range tt.checks {
got := watcher.NewlyDisagreeingPairs(prev, current)
if !slices.Equal(got, tt.want[i]) {
t.Errorf(
"check %d: alerted for %v, want %v",
i, got, tt.want[i],
)
}
prev = hostnameState(current)
}
})
}
}
+41 -15
View File
@@ -366,7 +366,7 @@ func (w *Watcher) detectHostnameChanges(
) {
w.detectRecordChanges(ctx, hostname, prev, current)
w.detectNSDisappearances(ctx, hostname, prev, current)
w.detectInconsistencies(ctx, hostname, current)
w.detectInconsistencies(ctx, hostname, prev, current)
}
func (w *Watcher) detectRecordChanges(
@@ -448,22 +448,11 @@ func (w *Watcher) detectNSDisappearances(
func (w *Watcher) detectInconsistencies(
ctx context.Context,
hostname string,
prev *state.HostnameState,
current map[string]map[string][]string,
) {
nameservers := make([]string, 0, len(current))
for ns := range current {
nameservers = append(nameservers, ns)
}
sort.Strings(nameservers)
for i := range len(nameservers) - 1 {
ns1 := nameservers[i]
ns2 := nameservers[i+1]
if recordsEqual(current[ns1], current[ns2]) {
continue
}
for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1]
msg := fmt.Sprintf(
"Hostname: %s\n%s: %v\n%s: %v",
@@ -481,6 +470,43 @@ func (w *Watcher) detectInconsistencies(
}
}
// newlyDisagreeingPairs returns the pairs of nameservers, neighbours in
// sorted order, whose records differ in current but were equal in prev.
// A pair with a nameserver missing from prev is not returned.
func newlyDisagreeingPairs(
prev *state.HostnameState,
current map[string]map[string][]string,
) [][2]string {
nameservers := make([]string, 0, len(current))
for ns := range current {
nameservers = append(nameservers, ns)
}
sort.Strings(nameservers)
var pairs [][2]string
for i := range len(nameservers) - 1 {
ns1 := nameservers[i]
ns2 := nameservers[i+1]
if recordsEqual(current[ns1], current[ns2]) {
continue
}
prev1, ok1 := prev.RecordsByNameserver[ns1]
prev2, ok2 := prev.RecordsByNameserver[ns2]
if !ok1 || !ok2 || !recordsEqual(prev1.Records, prev2.Records) {
continue
}
pairs = append(pairs, [2]string{ns1, ns2})
}
return pairs
}
func (w *Watcher) checkAllPorts(ctx context.Context) {
// Phase 1: Build current IP:port → hostname associations
// from fresh DNS data.