resolver, watcher: a record type whose query fails keeps its previous records (closes #231)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The resolver lists in FailedTypes each record type whose query to a nameserver got no usable reply (no reply, a code other than NOERROR or NXDOMAIN, a referral, or a truncated reply whose TCP retry failed) and logs it unless shutdown cut it short. A nameserver that answered no type has failed. The watcher saves such a type in failedTypes with the previous check's records, leaves it out of the comparison with other nameservers on that check, and compares it with the next answer. When the previous check did not know its records either, it is also in unknownTypes and not compared until it answers. A nameserver whose A, AAAA or CNAME query failed is no answer when following a CNAME or resolving addresses. Model: opus-5-5
This commit is contained in:
@@ -202,6 +202,42 @@ func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCNAMEWhoseAddressQueryFailedKeepsPrevious checks a name whose
|
||||
// nameserver answered, but whose query for A, AAAA or CNAME failed with
|
||||
// nothing kept for it. That is not an answer with no address: the
|
||||
// addresses the previous check saved from following its CNAME are kept,
|
||||
// and nothing is looked up, the watcher having no resolver.
|
||||
func TestCNAMEWhoseAddressQueryFailedKeepsPrevious(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, rtype := range []string{"A", "AAAA", "CNAME"} {
|
||||
t.Run(rtype, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := watcher.NewForTest(nil, nil, nil, nil, nil, nil)
|
||||
|
||||
current := saved(map[string]*state.NameserverRecordState{
|
||||
nsA: {
|
||||
Records: map[string][]string{},
|
||||
FailedTypes: []string{rtype},
|
||||
UnknownTypes: []string{rtype},
|
||||
Status: "ok",
|
||||
},
|
||||
})
|
||||
prev := cnameState(oldIP)
|
||||
|
||||
w.ResolveCNAMEAddresses(t.Context(), host, current, prev)
|
||||
|
||||
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
|
||||
t.Errorf(
|
||||
"saved %v, want %v",
|
||||
current.CNAMEAddresses, prev.CNAMEAddresses,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// cnameTo builds the records of a nameserver that answered with a CNAME
|
||||
// to target and no address.
|
||||
func cnameTo(target string) map[string][]string {
|
||||
|
||||
@@ -109,7 +109,8 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
|
||||
// BuildHostnameState exports buildHostnameState for testing.
|
||||
func BuildHostnameState(
|
||||
results map[string]*resolver.NameserverResponse,
|
||||
prev *state.HostnameState,
|
||||
now time.Time,
|
||||
) *state.HostnameState {
|
||||
return buildHostnameState(results, now)
|
||||
return buildHostnameState(results, prev, now)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package watcher_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||
)
|
||||
|
||||
const (
|
||||
// txt is the record type whose query fails in these tests.
|
||||
txt = "TXT"
|
||||
spf1 = "v=spf1 -all"
|
||||
spf2 = "v=spf1 include:example.net -all"
|
||||
)
|
||||
|
||||
// response is a nameserver's response with these records, whose queries
|
||||
// for failedTypes failed.
|
||||
func response(
|
||||
records map[string][]string,
|
||||
failedTypes ...string,
|
||||
) *resolver.NameserverResponse {
|
||||
return &resolver.NameserverResponse{
|
||||
Records: records,
|
||||
FailedTypes: failedTypes,
|
||||
Status: resolver.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
// savedChecks saves the state of each check in turn from the
|
||||
// nameservers' responses, each from the state the check before saved.
|
||||
func savedChecks(
|
||||
checks ...map[string]*resolver.NameserverResponse,
|
||||
) []*state.HostnameState {
|
||||
states := make([]*state.HostnameState, 0, len(checks))
|
||||
|
||||
var prev *state.HostnameState
|
||||
|
||||
for _, results := range checks {
|
||||
prev = watcher.BuildHostnameState(results, prev, time.Now())
|
||||
states = append(states, prev)
|
||||
}
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
// TestFailedTypeKeepsPreviousRecords saves a check in which nsA's query
|
||||
// for TXT failed, after previous checks of several kinds. TXT is always
|
||||
// saved in FailedTypes, and in UnknownTypes when there was nothing to
|
||||
// keep.
|
||||
func TestFailedTypeKeepsPreviousRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aOnly := map[string][]string{"A": {ip1}}
|
||||
withTXT := map[string][]string{"A": {ip1}, txt: {spf1}}
|
||||
txtKept := &state.NameserverRecordState{
|
||||
Records: withTXT, FailedTypes: []string{txt}, Status: "ok",
|
||||
}
|
||||
txtNotKnown := &state.NameserverRecordState{
|
||||
Records: aOnly,
|
||||
FailedTypes: []string{txt},
|
||||
UnknownTypes: []string{txt},
|
||||
Status: "ok",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prev *state.HostnameState
|
||||
wantRecords map[string][]string
|
||||
wantUnknown []string
|
||||
}{
|
||||
{
|
||||
"previous TXT records are kept",
|
||||
saved(map[string]*state.NameserverRecordState{nsA: answered(withTXT)}),
|
||||
withTXT, nil,
|
||||
},
|
||||
{
|
||||
"previous check had no TXT records",
|
||||
saved(map[string]*state.NameserverRecordState{nsA: answered(aOnly)}),
|
||||
aOnly, nil,
|
||||
},
|
||||
{
|
||||
"TXT failed on the previous check, which kept its records",
|
||||
saved(map[string]*state.NameserverRecordState{nsA: txtKept}),
|
||||
withTXT, nil,
|
||||
},
|
||||
{"first check", nil, aOnly, []string{txt}},
|
||||
{
|
||||
"nameserver new on this check",
|
||||
saved(map[string]*state.NameserverRecordState{nsB: answered(withTXT)}),
|
||||
aOnly, []string{txt},
|
||||
},
|
||||
{
|
||||
"nameserver failed on the previous check",
|
||||
saved(map[string]*state.NameserverRecordState{nsA: failed()}),
|
||||
aOnly, []string{txt},
|
||||
},
|
||||
{
|
||||
"TXT failed on the previous check with nothing to keep",
|
||||
saved(map[string]*state.NameserverRecordState{nsA: txtNotKnown}),
|
||||
aOnly, []string{txt},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hs := watcher.BuildHostnameState(
|
||||
map[string]*resolver.NameserverResponse{
|
||||
nsA: response(map[string][]string{"A": {ip1}}, txt),
|
||||
},
|
||||
tt.prev, time.Now(),
|
||||
)
|
||||
|
||||
got := hs.RecordsByNameserver[nsA]
|
||||
if got.Status != "ok" ||
|
||||
!maps.EqualFunc(got.Records, tt.wantRecords, slices.Equal) ||
|
||||
!slices.Equal(got.FailedTypes, []string{txt}) ||
|
||||
!slices.Equal(got.UnknownTypes, tt.wantUnknown) {
|
||||
t.Errorf(
|
||||
"saved status %q, records %v, failed types %v, "+
|
||||
"unknown types %v; want ok, %v, [%s], %v",
|
||||
got.Status, got.Records, got.FailedTypes,
|
||||
got.UnknownTypes, tt.wantRecords, txt, tt.wantUnknown,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedTypeAlerts saves the checks of each case in turn from the
|
||||
// nameservers' responses, the first being the state loaded at startup,
|
||||
// and counts the alerts sent. nsB's TXT query fails on one check, and
|
||||
// nothing changes.
|
||||
func TestFailedTypeAlerts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := map[string][]string{"A": {ip1}, txt: {spf1}}
|
||||
aOnly := map[string][]string{"A": {ip1}}
|
||||
|
||||
bothAnswer := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(records), nsB: response(records),
|
||||
}
|
||||
bTXTFails := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(records), nsB: response(aOnly, txt),
|
||||
}
|
||||
onlyA := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(records),
|
||||
}
|
||||
bFails := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(records),
|
||||
nsB: {
|
||||
Records: map[string][]string{},
|
||||
Status: resolver.StatusTimeout,
|
||||
Error: "all queries timed out",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
checks []map[string]*resolver.NameserverResponse
|
||||
want alertCounts
|
||||
}{
|
||||
{
|
||||
"type failing at one nameserver alerts nothing, nor its next answer",
|
||||
[]map[string]*resolver.NameserverResponse{
|
||||
bothAnswer, bTXTFails, bothAnswer,
|
||||
},
|
||||
alertCounts{},
|
||||
},
|
||||
{
|
||||
"type failing on the first check alerts nothing on the next",
|
||||
[]map[string]*resolver.NameserverResponse{bTXTFails, bothAnswer},
|
||||
alertCounts{},
|
||||
},
|
||||
{
|
||||
"type failing at a nameserver new on that check alerts nothing",
|
||||
[]map[string]*resolver.NameserverResponse{
|
||||
onlyA, bTXTFails, bothAnswer,
|
||||
},
|
||||
alertCounts{},
|
||||
},
|
||||
{
|
||||
"type failing at a recovering nameserver alerts the recovery",
|
||||
[]map[string]*resolver.NameserverResponse{
|
||||
bFails, bTXTFails, bothAnswer,
|
||||
},
|
||||
alertCounts{recoveries: 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
states := savedChecks(tt.checks...)
|
||||
|
||||
got := countAlerts(t, states[0], states[1:])
|
||||
if got != tt.want {
|
||||
t.Errorf("sent %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedTypeComparedOnceItAnswers saves the checks of each case in
|
||||
// turn as TestFailedTypeAlerts does. nsB's TXT query fails on one check,
|
||||
// and the TXT record changes: the change is sent as a Record Change for
|
||||
// each nameserver on the check where it answers it, and an Inconsistency
|
||||
// only when nsB still answers the old record.
|
||||
func TestFailedTypeComparedOnceItAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := map[string][]string{"A": {ip1}, txt: {spf1}}
|
||||
changed := map[string][]string{"A": {ip1}, txt: {spf2}}
|
||||
aOnly := map[string][]string{"A": {ip1}}
|
||||
|
||||
bothAnswer := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(records), nsB: response(records),
|
||||
}
|
||||
bTXTFails := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(records), nsB: response(aOnly, txt),
|
||||
}
|
||||
bothChange := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(changed), nsB: response(changed),
|
||||
}
|
||||
aChangesBTXTFails := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(changed), nsB: response(aOnly, txt),
|
||||
}
|
||||
bStillOld := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(changed), nsB: response(records),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
checks []map[string]*resolver.NameserverResponse
|
||||
want alertCounts
|
||||
}{
|
||||
{
|
||||
"change made while the type failed",
|
||||
[]map[string]*resolver.NameserverResponse{
|
||||
bothAnswer, bTXTFails, bothChange,
|
||||
},
|
||||
alertCounts{recordChanges: 2},
|
||||
},
|
||||
{
|
||||
"change seen at one nameserver while the other's type failed",
|
||||
[]map[string]*resolver.NameserverResponse{
|
||||
bothAnswer, aChangesBTXTFails, bothChange,
|
||||
},
|
||||
alertCounts{recordChanges: 2},
|
||||
},
|
||||
{
|
||||
"old record answered after the type failed",
|
||||
[]map[string]*resolver.NameserverResponse{
|
||||
bothAnswer, aChangesBTXTFails, bStillOld,
|
||||
},
|
||||
alertCounts{recordChanges: 1, inconsistencies: 1},
|
||||
},
|
||||
{
|
||||
"change after the type failed on the first check and answered",
|
||||
[]map[string]*resolver.NameserverResponse{
|
||||
bTXTFails, bothAnswer, bothChange,
|
||||
},
|
||||
alertCounts{recordChanges: 2},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
states := savedChecks(tt.checks...)
|
||||
|
||||
got := countAlerts(t, states[0], states[1:])
|
||||
if got != tt.want {
|
||||
t.Errorf("sent %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedTypeLeftOutOfMessages checks that a Record Change and an
|
||||
// Inconsistency name only the record types they compared. nsB's TXT
|
||||
// records are not known on the first check, and on the second either
|
||||
// answered or still not known; nsB's A record changes, so both alerts
|
||||
// are sent and name the A record alone.
|
||||
func TestFailedTypeLeftOutOfMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
withTXT := map[string][]string{"A": {ip1}, txt: {spf1}}
|
||||
txtNotKnown := func(address string) *state.NameserverRecordState {
|
||||
return &state.NameserverRecordState{
|
||||
Records: map[string][]string{"A": {address}},
|
||||
FailedTypes: []string{txt},
|
||||
UnknownTypes: []string{txt},
|
||||
Status: "ok",
|
||||
}
|
||||
}
|
||||
|
||||
before := saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(withTXT), nsB: txtNotKnown(ip1),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
after *state.HostnameState
|
||||
}{
|
||||
{
|
||||
"TXT answers",
|
||||
saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(withTXT),
|
||||
nsB: answered(map[string][]string{"A": {ip2}, txt: {spf1}}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"TXT still not known",
|
||||
saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(withTXT), nsB: txtNotKnown(ip2),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"Record Change: " + host: "Hostname: " + host +
|
||||
"\nNameserver: " + nsB + "\nType: A\nOld: " + ip1 + "\nNew: " + ip2,
|
||||
"Inconsistency: " + host: "Hostname: " + host +
|
||||
"\nType: A\n" + nsA + ": " + ip1 + "\n" + nsB + ": " + ip2,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
w.DetectHostnameChanges(t.Context(), host, before, tt.after)
|
||||
|
||||
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],
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestNameserverThatNeverAnswers(t *testing.T) {
|
||||
}
|
||||
|
||||
hs := watcher.BuildHostnameState(
|
||||
map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(),
|
||||
map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(),
|
||||
)
|
||||
|
||||
got := hs.RecordsByNameserver[nsA]
|
||||
@@ -256,7 +256,7 @@ func TestNameserverThatAnswersNXDOMAIN(t *testing.T) {
|
||||
}
|
||||
|
||||
hs := watcher.BuildHostnameState(
|
||||
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
|
||||
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
|
||||
)
|
||||
|
||||
got := hs.RecordsByNameserver[ns]
|
||||
@@ -320,7 +320,7 @@ func TestNameserverThatRefuses(t *testing.T) {
|
||||
}
|
||||
|
||||
hs := watcher.BuildHostnameState(
|
||||
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
|
||||
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
|
||||
)
|
||||
|
||||
got := hs.RecordsByNameserver[ns]
|
||||
|
||||
+100
-19
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -400,8 +401,10 @@ func (w *Watcher) checkHostname(
|
||||
return
|
||||
}
|
||||
|
||||
prev, _ := w.state.GetHostnameState(hostname)
|
||||
|
||||
w.updateHostnameState(
|
||||
ctx, hostname, buildHostnameState(results, time.Now().UTC()),
|
||||
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -431,8 +434,9 @@ func (w *Watcher) updateHostnameState(
|
||||
// the addresses found for all of them are saved, so nameservers that
|
||||
// disagree on the target do not change the result from check to check.
|
||||
// The addresses saved in prev, which may be nil, are kept when none of
|
||||
// the name's nameservers answered, and when a target cannot be
|
||||
// followed, as when no nameserver of a zone in its chain answers.
|
||||
// the name's nameservers answered its queries for A, AAAA and CNAME,
|
||||
// and when a target cannot be followed, as when no nameserver of a zone
|
||||
// in its chain answers.
|
||||
func (w *Watcher) resolveCNAMEAddresses(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
@@ -450,7 +454,10 @@ func (w *Watcher) resolveCNAMEAddresses(
|
||||
targets := make(map[string]bool)
|
||||
|
||||
for _, nsState := range current.RecordsByNameserver {
|
||||
if nsState.Status != statusOK {
|
||||
if nsState.Status != statusOK ||
|
||||
slices.Contains(nsState.FailedTypes, "A") ||
|
||||
slices.Contains(nsState.FailedTypes, "AAAA") ||
|
||||
slices.Contains(nsState.FailedTypes, "CNAME") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -496,11 +503,13 @@ func (w *Watcher) resolveCNAMEAddresses(
|
||||
}
|
||||
|
||||
// buildHostnameState saves each nameserver's response. A nameserver
|
||||
// that answered, even with NXDOMAIN or no records, is saved as ok; one
|
||||
// that timed out or failed is saved as error with the reason, and its
|
||||
// empty record set is not an answer.
|
||||
// that answered, even with NXDOMAIN or no records, is saved as ok, with
|
||||
// the record types whose query failed; one that timed out or failed is
|
||||
// saved as error with the reason, and its empty record set is not an
|
||||
// answer. prev is the hostname's state from the previous check, or nil.
|
||||
func buildHostnameState(
|
||||
results map[string]*resolver.NameserverResponse,
|
||||
prev *state.HostnameState,
|
||||
now time.Time,
|
||||
) *state.HostnameState {
|
||||
hs := &state.HostnameState{
|
||||
@@ -512,7 +521,7 @@ func buildHostnameState(
|
||||
|
||||
for ns, resp := range results {
|
||||
nsState := &state.NameserverRecordState{
|
||||
Records: resp.Records,
|
||||
Records: maps.Clone(resp.Records),
|
||||
Status: statusOK,
|
||||
LastChecked: now,
|
||||
}
|
||||
@@ -521,6 +530,15 @@ func buildHostnameState(
|
||||
resp.Status == resolver.StatusError {
|
||||
nsState.Status = statusError
|
||||
nsState.Error = resp.Error
|
||||
} else {
|
||||
nsState.FailedTypes = resp.FailedTypes
|
||||
|
||||
var prevNS *state.NameserverRecordState
|
||||
if prev != nil {
|
||||
prevNS = prev.RecordsByNameserver[ns]
|
||||
}
|
||||
|
||||
keepFailedTypes(nsState, prevNS)
|
||||
}
|
||||
|
||||
hs.RecordsByNameserver[ns] = nsState
|
||||
@@ -529,6 +547,27 @@ func buildHostnameState(
|
||||
return hs
|
||||
}
|
||||
|
||||
// keepFailedTypes copies into nsState, for each record type in its
|
||||
// FailedTypes, the records prevNS, the nameserver's state from the
|
||||
// previous check, holds for that type, which may be none. When prevNS
|
||||
// does not know them either, because the nameserver was new or failing
|
||||
// then or the type was in its UnknownTypes, the type goes in
|
||||
// nsState.UnknownTypes instead.
|
||||
func keepFailedTypes(nsState, prevNS *state.NameserverRecordState) {
|
||||
for _, rtype := range nsState.FailedTypes {
|
||||
if prevNS == nil || prevNS.Status != statusOK ||
|
||||
slices.Contains(prevNS.UnknownTypes, rtype) {
|
||||
nsState.UnknownTypes = append(nsState.UnknownTypes, rtype)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if records, ok := prevNS.Records[rtype]; ok {
|
||||
nsState.Records[rtype] = records
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) detectHostnameChanges(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
@@ -573,7 +612,10 @@ func (w *Watcher) detectCNAMEAddressChanges(
|
||||
|
||||
// detectRecordChanges compares each nameserver's records with those of
|
||||
// the previous check. Only answers are compared: a nameserver that
|
||||
// failed on either check has no records to compare.
|
||||
// failed on either check has no records to compare. The records kept
|
||||
// for a record type whose query failed are compared too, but not those
|
||||
// of a type in UnknownTypes on either check, which the message leaves
|
||||
// out as well.
|
||||
func (w *Watcher) detectRecordChanges(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
@@ -585,7 +627,11 @@ func (w *Watcher) detectRecordChanges(
|
||||
continue
|
||||
}
|
||||
|
||||
if recordsEqual(prevNS.Records, cur.Records) {
|
||||
unknown := slices.Concat(prevNS.UnknownTypes, cur.UnknownTypes)
|
||||
oldRecords := withoutTypes(prevNS.Records, unknown)
|
||||
newRecords := withoutTypes(cur.Records, unknown)
|
||||
|
||||
if recordsEqual(oldRecords, newRecords) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -593,8 +639,8 @@ func (w *Watcher) detectRecordChanges(
|
||||
"Hostname: %s\nNameserver: %s\n%s",
|
||||
hostname, ns,
|
||||
recordDifferences(
|
||||
"Old", prevNS.Records,
|
||||
"New", cur.Records,
|
||||
"Old", oldRecords,
|
||||
"New", newRecords,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -681,13 +727,19 @@ func (w *Watcher) detectInconsistencies(
|
||||
) {
|
||||
for _, pair := range newlyDisagreeingPairs(prev, current) {
|
||||
ns1, ns2 := pair[0], pair[1]
|
||||
state1 := current.RecordsByNameserver[ns1]
|
||||
state2 := current.RecordsByNameserver[ns2]
|
||||
|
||||
// The record types left out of the comparison are left out of
|
||||
// the message too.
|
||||
failed := slices.Concat(state1.FailedTypes, state2.FailedTypes)
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\n%s",
|
||||
hostname,
|
||||
recordDifferences(
|
||||
ns1, current.RecordsByNameserver[ns1].Records,
|
||||
ns2, current.RecordsByNameserver[ns2].Records,
|
||||
ns1, withoutTypes(state1.Records, failed),
|
||||
ns2, withoutTypes(state2.Records, failed),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -705,7 +757,9 @@ func (w *Watcher) detectInconsistencies(
|
||||
// except pairs where both nameservers answered in prev and already
|
||||
// differed there. A nameserver missing from prev, or that failed there,
|
||||
// is paired with every nameserver it differs from. A nameserver that
|
||||
// failed in current has no records to compare and is in no pair.
|
||||
// failed in current has no records to compare and is in no pair. In
|
||||
// both checks, a record type whose query failed at either nameserver is
|
||||
// not compared.
|
||||
func newlyDisagreeingPairs(
|
||||
prev, current *state.HostnameState,
|
||||
) [][2]string {
|
||||
@@ -722,9 +776,9 @@ func newlyDisagreeingPairs(
|
||||
|
||||
for i, ns1 := range nameservers {
|
||||
for _, ns2 := range nameservers[i+1:] {
|
||||
if recordsEqual(
|
||||
current.RecordsByNameserver[ns1].Records,
|
||||
current.RecordsByNameserver[ns2].Records,
|
||||
if nameserversAgree(
|
||||
current.RecordsByNameserver[ns1],
|
||||
current.RecordsByNameserver[ns2],
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -734,7 +788,7 @@ func newlyDisagreeingPairs(
|
||||
|
||||
if ok1 && ok2 &&
|
||||
prev1.Status == statusOK && prev2.Status == statusOK &&
|
||||
!recordsEqual(prev1.Records, prev2.Records) {
|
||||
!nameserversAgree(prev1, prev2) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1221,6 +1275,33 @@ func toSet(items []string) map[string]bool {
|
||||
return set
|
||||
}
|
||||
|
||||
// nameserversAgree reports whether two nameservers' states from the same
|
||||
// check hold the same records, leaving out the record types either lists
|
||||
// in FailedTypes: the records held for those are kept from an earlier
|
||||
// check, or not known.
|
||||
func nameserversAgree(a, b *state.NameserverRecordState) bool {
|
||||
failed := slices.Concat(a.FailedTypes, b.FailedTypes)
|
||||
|
||||
return recordsEqual(
|
||||
withoutTypes(a.Records, failed), withoutTypes(b.Records, failed),
|
||||
)
|
||||
}
|
||||
|
||||
// withoutTypes returns a copy of records without the record types in
|
||||
// types.
|
||||
func withoutTypes(
|
||||
records map[string][]string,
|
||||
types []string,
|
||||
) map[string][]string {
|
||||
records = maps.Clone(records)
|
||||
|
||||
for _, rtype := range types {
|
||||
delete(records, rtype)
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
func recordsEqual(
|
||||
a, b map[string][]string,
|
||||
) bool {
|
||||
|
||||
Reference in New Issue
Block a user