diff --git a/README.md b/README.md index 4f14c1d..4b852ba 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ notification endpoint set, changes show only on the dashboard; see different addresses than on the previous check. A nameserver added or removed gets only the NS change notification. When the lookup of a nameserver's addresses fails or finds none, its previous addresses are - kept and nothing is sent. + kept and nothing is sent. The lookup fails when no nameserver it asks + answers every one of its queries, for A, AAAA and CNAME. ### DNS Hostname Monitoring (Subdomains) @@ -91,6 +92,15 @@ notification endpoint set, changes show only on the dashboard; see its last two labels (a name under `co.uk`, or in a delegated subdomain). - Queries **each** authoritative nameserver independently for **all** record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. +- Each record type is a query of its own. When a nameserver answers some types + but the query for another gets no usable reply (no reply after two tries, an + error reply such as SERVFAIL, or a reply too large for UDP whose retry over + TCP fails), that type keeps the records saved for the nameserver by the + previous check, and no record change or inconsistency is reported for it. When + there are none to keep, because the nameserver was new or failing on the + previous check, the type is listed in the nameserver's `failedTypes` and left + out of comparisons until it answers. A nameserver none of whose queries got a + usable reply has failed (see NS query failure below). - Stores results **per nameserver**. The state for a hostname is not a merged view — it is a map from nameserver to record set. - DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in @@ -502,7 +512,7 @@ reachability: | Status | Meaning | | ------- | -------------------------------------------------------- | -| `ok` | Query succeeded, records are current | +| `ok` | Query succeeded, records are current except as below | | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | A nameserver that answers NXDOMAIN or with no records has status `ok` and empty @@ -511,6 +521,11 @@ nameservers, has status `error`, empty `records`, and the reason in `error`. A certificate entry whose TLS connection or handshake failed likewise has status `error`, the reason in `error`, and the certificate fields left empty or zero. +A nameserver with status `ok` whose query for one record type failed holds that +type's records from the previous check, which may not be current. When there +were none to keep, the type is listed in `failedTypes`, which is left out when +empty, and `records` holds nothing for it. + `nameserverAddresses` lists, by nameserver, the sorted addresses its name resolves to. A state file without it loads, and the next check fills it in without a notification. diff --git a/TODO.md b/TODO.md index 927a598..9fb4a74 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: a record type whose query to a nameserver fails keeps its previous + records and alerts nothing; the other types are still saved (closes #231). - 2026-10-02: a resolver test that reads one record type from a nameserver's answer asks again when that type is missing from it (closes #218). - 2026-10-02: a plain `docker build .` of a clone stamps its tag or short diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index 5e019c0..83b091a 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -22,6 +22,12 @@ var ( "reply is an error or a referral that leads no closer", ) + // ErrTruncated is the reason given for a reply too large for UDP + // whose retry over TCP failed. + ErrTruncated = errors.New( + "reply truncated and its retry over TCP failed", + ) + // ErrIntercepted is returned when every root server refused a // query. Root servers refuse no query, so the refusals came from // something on the network answering in their place. diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 17c57d7..2cb7592 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -2,10 +2,21 @@ package resolver import ( "context" + "log/slog" + "time" "github.com/miekg/dns" ) +// NewWithFailingTCP returns a Resolver whose TCP client gives up before +// it can connect, so the retry over TCP of every truncated reply fails. +func NewWithFailingTCP(log *slog.Logger) *Resolver { + r := NewFromLogger(log) + r.tcp = &tcpClient{timeout: time.Nanosecond} + + return r +} + // ExtractRecordValue exports extractRecordValue for testing. func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 1f06a24..aa130f9 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -89,6 +89,9 @@ func (r *Resolver) tryExchange( return resp, err } +// retryTCP returns the reply to msg over TCP when resp, its reply over +// UDP, is truncated. When that fails it returns resp, still truncated, +// which holds only the records that fit. func (r *Resolver) retryTCP( ctx context.Context, msg *dns.Msg, @@ -638,8 +641,12 @@ type queryState struct { gotReferral bool netErr error hasRecords bool + answered bool } +// queryEachType asks the nameserver at nsIP about hostname once for each +// record type in qtypes, and lists in resp.FailedTypes the types whose +// query got no usable reply. func (r *Resolver) queryEachType( ctx context.Context, nsIP string, @@ -654,7 +661,20 @@ func (r *Resolver) queryEachType( break } - r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) + if r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) { + state.answered = true + } else { + resp.FailedTypes = append( + resp.FailedTypes, dns.TypeToString[qtype], + ) + } + } + + // The reply about another type can carry the name's CNAME. When the + // query for CNAME itself failed, that is left out too, so Records + // holds nothing for a failed type. + for _, rtype := range resp.FailedTypes { + delete(resp.Records, rtype) } for k := range resp.Records { @@ -664,6 +684,9 @@ func (r *Resolver) queryEachType( return state } +// querySingleType asks the nameserver at nsIP about hostname's records +// of type qtype, and reports whether it answered: with records, with +// none, or with NXDOMAIN. func (r *Resolver) querySingleType( ctx context.Context, nsIP string, @@ -671,7 +694,7 @@ func (r *Resolver) querySingleType( qtype uint16, resp *NameserverResponse, state *queryState, -) { +) bool { msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) if err != nil { switch { @@ -683,19 +706,19 @@ func (r *Resolver) querySingleType( state.netErr = err } - return + return false } if msg.Rcode == dns.RcodeNameError { state.gotNXDomain = true - return + return true } if msg.Rcode == dns.RcodeServerFailure { state.gotSERVFAIL = true - return + return false } // A reply with no answer that lists other nameservers, from a server @@ -708,10 +731,20 @@ func (r *Resolver) querySingleType( len(extractNSSet(msg.Ns)) > 0 { state.gotReferral = true - return + return false + } + + // A reply still truncated is one whose TCP retry failed, and holds + // only the records that fit. + if msg.Truncated { + state.netErr = ErrTruncated + + return false } collectAnswerRecords(msg, resp, state) + + return true } func collectAnswerRecords( @@ -743,23 +776,26 @@ func isTimeout(err error) bool { return false } +// classifyResponse sets the nameserver's status. One that answered no +// record type has failed, and Error says why; one that answered some has +// the status of those answers. func classifyResponse(resp *NameserverResponse, state queryState) { switch { case state.gotNXDomain && !state.hasRecords: resp.Status = StatusNXDomain - case state.gotTimeout && !state.hasRecords: + case state.gotTimeout && !state.answered: resp.Status = StatusTimeout resp.Error = "all queries timed out" - case state.gotSERVFAIL && !state.hasRecords: + case state.gotSERVFAIL && !state.answered: resp.Status = StatusError resp.Error = "server returned SERVFAIL" - case state.gotRefused && !state.hasRecords: + case state.gotRefused && !state.answered: resp.Status = StatusError resp.Error = "server returned REFUSED" - case state.netErr != nil && !state.hasRecords: + case state.netErr != nil && !state.answered: resp.Status = StatusError resp.Error = "network error: " + state.netErr.Error() - case state.gotReferral && !state.hasRecords: + case state.gotReferral && !state.answered: resp.Status = StatusError resp.Error = "server returned a referral" case !state.hasRecords && !state.gotNXDomain: @@ -920,9 +956,11 @@ func (r *Resolver) resolveIPWithCNAME( } // collectIPs returns the addresses in the nameservers' answers and the -// first CNAME target among them. It returns ErrNoNameserverAnswered when -// every nameserver timed out, failed or returned a referral: that is not -// a name with no addresses. +// first CNAME target among them. A nameserver whose query for one of the +// types failed gave only part of the addresses, and is left out. It +// returns ErrNoNameserverAnswered when every nameserver timed out, +// failed, returned a referral or was left out: that is not a name with +// no addresses. func collectIPs( results map[string]*NameserverResponse, ) ([]string, string, error) { @@ -935,7 +973,8 @@ func collectIPs( answered := false for _, resp := range results { - if resp.Status == StatusTimeout || resp.Status == StatusError { + if resp.Status == StatusTimeout || resp.Status == StatusError || + len(resp.FailedTypes) > 0 { continue } diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go index 4f539ae..00f71eb 100644 --- a/internal/resolver/iterative_test.go +++ b/internal/resolver/iterative_test.go @@ -43,6 +43,25 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) { assert.Empty(t, ips) } +// TestCollectIPs_FailedTypeIsNoAnswer checks that a nameserver whose +// query for one of the types failed is no answer: its addresses are +// only part of them. +func TestCollectIPs_FailedTypeIsNoAnswer(t *testing.T) { + t.Parallel() + + ips, _, err := resolver.CollectIPs( + map[string]*resolver.NameserverResponse{ + nsExample1: { + Records: map[string][]string{"A": {"192.0.2.1"}}, + FailedTypes: []string{"AAAA"}, + Status: resolver.StatusOK, + }, + }, + ) + require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered) + assert.Empty(t, ips) +} + const ( // exampleCom is the zone most cases of TestUsableReply and // TestNSSetFrom are about, and wwwExampleCom a name in it. diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 83b3f47..1bdfdb9 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -31,11 +31,15 @@ type Params struct { } // NameserverResponse holds one nameserver's response for a query. +// FailedTypes lists the record types whose query got no usable reply, +// and Records holds nothing for them: their records are not known. When +// no record type got one, Status and Error say the nameserver failed. type NameserverResponse struct { - Nameserver string - Records map[string][]string - Status string - Error string + Nameserver string + Records map[string][]string + FailedTypes []string + Status string + Error string } // Resolver performs iterative DNS resolution from root servers. diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index a07c718..ccd76df 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -245,6 +245,23 @@ func TestQueryNameserver_TXT(t *testing.T) { ) } +// TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails asks a google.com +// nameserver about google.com with a resolver whose retries over TCP +// fail. google.com's TXT records do not fit in a reply over UDP, so TXT +// is reported as failed, holding none of the records that fit, while +// the nameserver, which answered the other types, is ok. +func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) { + t.Parallel() + + ns := findOneNSForDomain(t, newTestResolver(t), "google.com") + r := resolver.NewWithFailingTCP(slog.Default()) + resp := liveQueryNameserver(t, r, ns, "google.com") + + assert.Equal(t, resolver.StatusOK, resp.Status) + assert.Contains(t, resp.FailedTypes, "TXT") + assert.NotContains(t, resp.Records, "TXT") +} + func TestQueryNameserver_NXDomain(t *testing.T) { t.Parallel() diff --git a/internal/state/state.go b/internal/state/state.go index a8af3f9..edcecf0 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -44,9 +44,14 @@ type DomainState struct { LastChecked time.Time `json:"lastChecked"` } -// NameserverRecordState holds one NS's response for a hostname. +// NameserverRecordState holds one NS's response for a hostname. A record +// type whose query failed keeps the records saved by the previous check. +// FailedTypes lists such types whose records the previous check did not +// know either, as when the nameserver was new or failing then: Records +// holds nothing for them. type NameserverRecordState struct { Records map[string][]string `json:"records"` + FailedTypes []string `json:"failedTypes,omitempty"` Status string `json:"status"` Error string `json:"error,omitempty"` LastChecked time.Time `json:"lastChecked"` diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 59f0319..e9531a3 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -79,7 +79,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) } diff --git a/internal/watcher/failedtype_test.go b/internal/watcher/failedtype_test.go new file mode 100644 index 0000000..27da0a9 --- /dev/null +++ b/internal/watcher/failedtype_test.go @@ -0,0 +1,213 @@ +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. +func TestFailedTypeKeepsPreviousRecords(t *testing.T) { + t.Parallel() + + aOnly := map[string][]string{"A": {ip1}} + withTXT := map[string][]string{"A": {ip1}, txt: {spf1}} + txtNotKnown := &state.NameserverRecordState{ + Records: aOnly, FailedTypes: []string{txt}, Status: "ok", + } + + tests := []struct { + name string + prev *state.HostnameState + wantRecords map[string][]string + wantFailed []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, + }, + {"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 too", + 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, tt.wantFailed) { + t.Errorf( + "saved status %q, records %v, failed types %v; "+ + "want ok, %v, %v", + got.Status, got.Records, got.FailedTypes, + tt.wantRecords, tt.wantFailed, + ) + } + }) + } +} + +// 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. +func TestFailedTypeAlerts(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), + } + 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", + }, + } + bothChange := map[string]*resolver.NameserverResponse{ + nsA: response(changed), nsB: response(changed), + } + + 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}, + }, + { + "change made while a type failed is sent when it answers", + []map[string]*resolver.NameserverResponse{ + bothAnswer, bTXTFails, 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) + } + }) + } +} diff --git a/internal/watcher/nsfailure_test.go b/internal/watcher/nsfailure_test.go index afae908..e9147c1 100644 --- a/internal/watcher/nsfailure_test.go +++ b/internal/watcher/nsfailure_test.go @@ -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] diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index bcee203..5d28e9a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "maps" "slices" "sort" "strings" @@ -266,9 +267,9 @@ func (w *Watcher) checkDomain( return } - newState := buildHostnameState(results, now) - prevHS, hasPrevHS := w.state.GetHostnameState(domain) + newState := buildHostnameState(results, prevHS, now) + if hasPrevHS && !w.firstRun { w.detectHostnameChanges(ctx, domain, prevHS, newState) } @@ -398,9 +399,9 @@ func (w *Watcher) checkHostname( return } - newState := buildHostnameState(results, time.Now().UTC()) - prev, hasPrev := w.state.GetHostnameState(hostname) + newState := buildHostnameState(results, prev, time.Now().UTC()) + if hasPrev && !w.firstRun { w.detectHostnameChanges(ctx, hostname, prev, newState) } @@ -411,9 +412,11 @@ func (w *Watcher) checkHostname( // 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. +// 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{ @@ -425,7 +428,7 @@ func buildHostnameState( for ns, resp := range results { nsState := &state.NameserverRecordState{ - Records: resp.Records, + Records: maps.Clone(resp.Records), Status: statusOK, LastChecked: now, } @@ -434,6 +437,13 @@ func buildHostnameState( resp.Status == resolver.StatusError { nsState.Status = statusError nsState.Error = resp.Error + } else { + var prevNS *state.NameserverRecordState + if prev != nil { + prevNS = prev.RecordsByNameserver[ns] + } + + keepFailedTypes(nsState, prevNS, resp.FailedTypes) } hs.RecordsByNameserver[ns] = nsState @@ -442,6 +452,30 @@ func buildHostnameState( return hs } +// keepFailedTypes copies into nsState, for each record type in +// failedTypes, whose query to the nameserver failed, 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 +// FailedTypes, the type goes in nsState.FailedTypes instead. +func keepFailedTypes( + nsState, prevNS *state.NameserverRecordState, + failedTypes []string, +) { + for _, rtype := range failedTypes { + if prevNS == nil || prevNS.Status != statusOK || + slices.Contains(prevNS.FailedTypes, rtype) { + nsState.FailedTypes = append(nsState.FailedTypes, rtype) + + continue + } + + if records, ok := prevNS.Records[rtype]; ok { + nsState.Records[rtype] = records + } + } +} + func (w *Watcher) detectHostnameChanges( ctx context.Context, hostname string, @@ -467,7 +501,7 @@ func (w *Watcher) detectRecordChanges( continue } - if recordsEqual(prevNS.Records, cur.Records) { + if sameRecords(prevNS, cur) { continue } @@ -600,9 +634,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 sameRecords( + current.RecordsByNameserver[ns1], + current.RecordsByNameserver[ns2], ) { continue } @@ -612,7 +646,7 @@ func newlyDisagreeingPairs( if ok1 && ok2 && prev1.Status == statusOK && prev2.Status == statusOK && - !recordsEqual(prev1.Records, prev2.Records) { + !sameRecords(prev1, prev2) { continue } @@ -1090,6 +1124,21 @@ func toSet(items []string) map[string]bool { return set } +// sameRecords reports whether two nameserver states hold the same +// records, leaving out the record types either lists in FailedTypes: +// their records are not known. +func sameRecords(a, b *state.NameserverRecordState) bool { + aRecords := maps.Clone(a.Records) + bRecords := maps.Clone(b.Records) + + for _, rtype := range slices.Concat(a.FailedTypes, b.FailedTypes) { + delete(aRecords, rtype) + delete(bRecords, rtype) + } + + return recordsEqual(aRecords, bRecords) +} + func recordsEqual( a, b map[string][]string, ) bool {