1 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 36 additions and 117 deletions
+4 -4
View File
@@ -76,10 +76,10 @@ rejected.
responding again. responding again.
- **Inconsistency detected**: Two nameservers that agreed on the - **Inconsistency detected**: Two nameservers that agreed on the
previous check now return different record sets for the same previous check now return different record sets for the same
hostname. Every pair of nameservers is compared. The alert is sent hostname. This is sent once, on the check where they start to
once for each such pair, on the check where they start to disagree, disagree, and not again while they keep disagreeing, including
and not again while they keep disagreeing, including after a restart. after a restart. If they agree again and later disagree, it is
If they agree again and later disagree, it is sent again. sent again.
### TCP Port Monitoring ### TCP Port Monitoring
+1 -2
View File
@@ -25,8 +25,7 @@ Rationale, Design, TODO, License, Author) if any are still missing.
- 2026-09-28: the inconsistency alert is sent once, on the check where two - 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 nameservers that agreed start to disagree, instead of on every check while
they disagree, and not again after a restart. Every pair of nameservers is they disagree, and not again after a restart (closes #158).
compared, not only neighbours in sorted order of name (closes #158).
- 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are - 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 lower-cased, so nameservers that answer in different letter case no longer
count as inconsistent or as a record change (closes #157). count as inconsistent or as a record change (closes #157).
+1 -15
View File
@@ -1,10 +1,6 @@
package watcher package watcher
import ( import "sneak.berlin/go/dnswatcher/internal/state"
"context"
"sneak.berlin/go/dnswatcher/internal/state"
)
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing. // NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs( func NewlyDisagreeingPairs(
@@ -13,13 +9,3 @@ func NewlyDisagreeingPairs(
) [][2]string { ) [][2]string {
return newlyDisagreeingPairs(prev, current) return newlyDisagreeingPairs(prev, current)
} }
// DetectHostnameChanges exports detectHostnameChanges for testing.
func (w *Watcher) DetectHostnameChanges(
ctx context.Context,
hostname string,
prev *state.HostnameState,
current map[string]map[string][]string,
) {
w.detectHostnameChanges(ctx, hostname, prev, current)
}
+14 -81
View File
@@ -8,16 +8,6 @@ import (
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
const (
host = "www.example.net"
nsA = "a.ns.example.net."
nsB = "b.ns.example.net."
nsC = "c.ns.example.net."
ip1 = "192.0.2.1"
ip2 = "192.0.2.2"
ip3 = "192.0.2.3"
)
// hostnameState builds the state a check with these records leaves behind. // hostnameState builds the state a check with these records leaves behind.
func hostnameState( func hostnameState(
records map[string]map[string][]string, records map[string]map[string][]string,
@@ -39,19 +29,23 @@ func hostnameState(
func TestNewlyDisagreeingPairs(t *testing.T) { func TestNewlyDisagreeingPairs(t *testing.T) {
t.Parallel() t.Parallel()
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}} const (
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}} nsA = "a.ns.example.net."
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}} nsB = "b.ns.example.net."
alert := [][2]string{{nsA, nsB}} ip1 = "192.0.2.1"
ip2 = "192.0.2.2"
)
// b already disagrees with a and c; then c changes, so a and c, onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
// which agreed, now differ. agree := map[string]map[string][]string{
bDiffers := map[string]map[string][]string{ nsA: {"A": {ip1}},
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip1}}, nsB: {"A": {ip1}},
} }
cChanges := map[string]map[string][]string{ disagree := map[string]map[string][]string{
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip3}}, nsA: {"A": {ip1}},
nsB: {"A": {ip2}},
} }
alert := [][2]string{{nsA, nsB}}
// Each case starts from the state loaded at startup and runs the // Each case starts from the state loaded at startup and runs the
// checks in order; want[i] is what check i alerts for. // checks in order; want[i] is what check i alerts for.
@@ -91,12 +85,6 @@ func TestNewlyDisagreeingPairs(t *testing.T) {
checks: []map[string]map[string][]string{disagree, agree, disagree}, checks: []map[string]map[string][]string{disagree, agree, disagree},
want: [][][2]string{alert, nil, alert}, want: [][][2]string{alert, nil, alert},
}, },
{
name: "new disagreement while another nameserver differs alerts",
loaded: bDiffers,
checks: []map[string]map[string][]string{cChanges, cChanges},
want: [][][2]string{{{nsA, nsC}}, nil},
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -119,58 +107,3 @@ func TestNewlyDisagreeingPairs(t *testing.T) {
}) })
} }
} }
func TestInconsistencyAlert(t *testing.T) {
t.Parallel()
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
// Each case starts from the state loaded at startup and then sees
// the nameservers disagree on three checks in a row.
tests := []struct {
name string
loaded map[string]map[string][]string
want int
}{
{
name: "disagreement lasting several checks alerts once",
loaded: agree,
want: 1,
},
{
name: "disagreement in the loaded state does not alert",
loaded: disagree,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// The hostname change detection uses only the notifier.
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
prev := hostnameState(tt.loaded)
for range 3 {
w.DetectHostnameChanges(t.Context(), host, prev, disagree)
prev = hostnameState(disagree)
}
got := 0
for _, n := range notifier.getNotifications() {
if n.Title == "Inconsistency: "+host {
got++
}
}
if got != tt.want {
t.Errorf("sent %d inconsistency alerts, want %d", got, tt.want)
}
})
}
}
+16 -15
View File
@@ -470,8 +470,8 @@ func (w *Watcher) detectInconsistencies(
} }
} }
// newlyDisagreeingPairs returns every pair of nameservers whose records // newlyDisagreeingPairs returns the pairs of nameservers, neighbours in
// differ in current but were equal in prev, in sorted order of name. // sorted order, whose records differ in current but were equal in prev.
// A pair with a nameserver missing from prev is not returned. // A pair with a nameserver missing from prev is not returned.
func newlyDisagreeingPairs( func newlyDisagreeingPairs(
prev *state.HostnameState, prev *state.HostnameState,
@@ -486,21 +486,22 @@ func newlyDisagreeingPairs(
var pairs [][2]string var pairs [][2]string
for i, ns1 := range nameservers { for i := range len(nameservers) - 1 {
for _, ns2 := range nameservers[i+1:] { ns1 := nameservers[i]
if recordsEqual(current[ns1], current[ns2]) { ns2 := nameservers[i+1]
continue
}
prev1, ok1 := prev.RecordsByNameserver[ns1] if recordsEqual(current[ns1], current[ns2]) {
prev2, ok2 := prev.RecordsByNameserver[ns2] continue
if !ok1 || !ok2 || !recordsEqual(prev1.Records, prev2.Records) {
continue
}
pairs = append(pairs, [2]string{ns1, ns2})
} }
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 return pairs