resolver, watcher: a record type whose query fails keeps its previous records (closes #231)
check / check (push) Canceled after 0s

The resolver lists in FailedTypes each record type whose query to a
nameserver got no usable reply: none after two tries, an error reply, a
referral, or a truncated reply whose TCP retry failed. Records holds
nothing for such a type, never none or the part that fit. A nameserver
that answered no type has failed, as before. The watcher keeps the
previous check's records for a failed type; when that check did not know
them either (first check, new or failing nameserver), the type is saved
in failedTypes and left out of record and inconsistency comparisons.
ResolveIPAddresses no longer counts a nameserver whose A, AAAA or CNAME
query failed as an answer.

Model: opus-5-5
This commit is contained in:
2026-10-02 06:00:30 +00:00
parent a18803ff28
commit 0ed7667ef5
13 changed files with 418 additions and 37 deletions
+17 -2
View File
@@ -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 different addresses than on the previous check. A nameserver added or
removed gets only the NS change notification. When the lookup of a removed gets only the NS change notification. When the lookup of a
nameserver's addresses fails or finds none, its previous addresses are 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) ### 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). its last two labels (a name under `co.uk`, or in a delegated subdomain).
- Queries **each** authoritative nameserver independently for **all** record - Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. 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 - Stores results **per nameserver**. The state for a hostname is not a merged
view — it is a map from nameserver to record set. view — it is a map from nameserver to record set.
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in - DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
@@ -502,7 +512,7 @@ reachability:
| Status | Meaning | | 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) | | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty 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 certificate entry whose TLS connection or handshake failed likewise has status
`error`, the reason in `error`, and the certificate fields left empty or zero. `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 `nameserverAddresses` lists, by nameserver, the sorted addresses its name
resolves to. A state file without it loads, and the next check fills it in resolves to. A state file without it loads, and the next check fills it in
without a notification. without a notification.
+2
View File
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # 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 - 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). 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 - 2026-10-02: a plain `docker build .` of a clone stamps its tag or short
+6
View File
@@ -22,6 +22,12 @@ var (
"reply is an error or a referral that leads no closer", "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 // ErrIntercepted is returned when every root server refused a
// query. Root servers refuse no query, so the refusals came from // query. Root servers refuse no query, so the refusals came from
// something on the network answering in their place. // something on the network answering in their place.
+11
View File
@@ -2,10 +2,21 @@ package resolver
import ( import (
"context" "context"
"log/slog"
"time"
"github.com/miekg/dns" "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. // ExtractRecordValue exports extractRecordValue for testing.
func ExtractRecordValue(rr dns.RR) string { func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr) return extractRecordValue(rr)
+54 -15
View File
@@ -89,6 +89,9 @@ func (r *Resolver) tryExchange(
return resp, err 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( func (r *Resolver) retryTCP(
ctx context.Context, ctx context.Context,
msg *dns.Msg, msg *dns.Msg,
@@ -638,8 +641,12 @@ type queryState struct {
gotReferral bool gotReferral bool
netErr error netErr error
hasRecords bool 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( func (r *Resolver) queryEachType(
ctx context.Context, ctx context.Context,
nsIP string, nsIP string,
@@ -654,7 +661,20 @@ func (r *Resolver) queryEachType(
break 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 { for k := range resp.Records {
@@ -664,6 +684,9 @@ func (r *Resolver) queryEachType(
return state 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( func (r *Resolver) querySingleType(
ctx context.Context, ctx context.Context,
nsIP string, nsIP string,
@@ -671,7 +694,7 @@ func (r *Resolver) querySingleType(
qtype uint16, qtype uint16,
resp *NameserverResponse, resp *NameserverResponse,
state *queryState, state *queryState,
) { ) bool {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil { if err != nil {
switch { switch {
@@ -683,19 +706,19 @@ func (r *Resolver) querySingleType(
state.netErr = err state.netErr = err
} }
return return false
} }
if msg.Rcode == dns.RcodeNameError { if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true state.gotNXDomain = true
return return true
} }
if msg.Rcode == dns.RcodeServerFailure { if msg.Rcode == dns.RcodeServerFailure {
state.gotSERVFAIL = true state.gotSERVFAIL = true
return return false
} }
// A reply with no answer that lists other nameservers, from a server // 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 { len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true 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) collectAnswerRecords(msg, resp, state)
return true
} }
func collectAnswerRecords( func collectAnswerRecords(
@@ -743,23 +776,26 @@ func isTimeout(err error) bool {
return false 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) { func classifyResponse(resp *NameserverResponse, state queryState) {
switch { switch {
case state.gotNXDomain && !state.hasRecords: case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain resp.Status = StatusNXDomain
case state.gotTimeout && !state.hasRecords: case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout resp.Status = StatusTimeout
resp.Error = "all queries timed out" resp.Error = "all queries timed out"
case state.gotSERVFAIL && !state.hasRecords: case state.gotSERVFAIL && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned SERVFAIL" resp.Error = "server returned SERVFAIL"
case state.gotRefused && !state.hasRecords: case state.gotRefused && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned REFUSED" resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.hasRecords: case state.netErr != nil && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error() resp.Error = "network error: " + state.netErr.Error()
case state.gotReferral && !state.hasRecords: case state.gotReferral && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned a referral" resp.Error = "server returned a referral"
case !state.hasRecords && !state.gotNXDomain: case !state.hasRecords && !state.gotNXDomain:
@@ -920,9 +956,11 @@ func (r *Resolver) resolveIPWithCNAME(
} }
// collectIPs returns the addresses in the nameservers' answers and the // collectIPs returns the addresses in the nameservers' answers and the
// first CNAME target among them. It returns ErrNoNameserverAnswered when // first CNAME target among them. A nameserver whose query for one of the
// every nameserver timed out, failed or returned a referral: that is not // types failed gave only part of the addresses, and is left out. It
// a name with no addresses. // 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( func collectIPs(
results map[string]*NameserverResponse, results map[string]*NameserverResponse,
) ([]string, string, error) { ) ([]string, string, error) {
@@ -935,7 +973,8 @@ func collectIPs(
answered := false answered := false
for _, resp := range results { for _, resp := range results {
if resp.Status == StatusTimeout || resp.Status == StatusError { if resp.Status == StatusTimeout || resp.Status == StatusError ||
len(resp.FailedTypes) > 0 {
continue continue
} }
+19
View File
@@ -43,6 +43,25 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
assert.Empty(t, ips) 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 ( const (
// exampleCom is the zone most cases of TestUsableReply and // exampleCom is the zone most cases of TestUsableReply and
// TestNSSetFrom are about, and wwwExampleCom a name in it. // TestNSSetFrom are about, and wwwExampleCom a name in it.
+8 -4
View File
@@ -31,11 +31,15 @@ type Params struct {
} }
// NameserverResponse holds one nameserver's response for a query. // 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 { type NameserverResponse struct {
Nameserver string Nameserver string
Records map[string][]string Records map[string][]string
Status string FailedTypes []string
Error string Status string
Error string
} }
// Resolver performs iterative DNS resolution from root servers. // Resolver performs iterative DNS resolution from root servers.
+17
View File
@@ -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) { func TestQueryNameserver_NXDomain(t *testing.T) {
t.Parallel() t.Parallel()
+6 -1
View File
@@ -44,9 +44,14 @@ type DomainState struct {
LastChecked time.Time `json:"lastChecked"` 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 { type NameserverRecordState struct {
Records map[string][]string `json:"records"` Records map[string][]string `json:"records"`
FailedTypes []string `json:"failedTypes,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
+2 -1
View File
@@ -79,7 +79,8 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
// BuildHostnameState exports buildHostnameState for testing. // BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState( func BuildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time, now time.Time,
) *state.HostnameState { ) *state.HostnameState {
return buildHostnameState(results, now) return buildHostnameState(results, prev, now)
} }
+213
View File
@@ -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)
}
})
}
}
+3 -3
View File
@@ -201,7 +201,7 @@ func TestNameserverThatNeverAnswers(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(), map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(),
) )
got := hs.RecordsByNameserver[nsA] got := hs.RecordsByNameserver[nsA]
@@ -256,7 +256,7 @@ func TestNameserverThatAnswersNXDOMAIN(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
) )
got := hs.RecordsByNameserver[ns] got := hs.RecordsByNameserver[ns]
@@ -320,7 +320,7 @@ func TestNameserverThatRefuses(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
) )
got := hs.RecordsByNameserver[ns] got := hs.RecordsByNameserver[ns]
+60 -11
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"slices" "slices"
"sort" "sort"
"strings" "strings"
@@ -266,9 +267,9 @@ func (w *Watcher) checkDomain(
return return
} }
newState := buildHostnameState(results, now)
prevHS, hasPrevHS := w.state.GetHostnameState(domain) prevHS, hasPrevHS := w.state.GetHostnameState(domain)
newState := buildHostnameState(results, prevHS, now)
if hasPrevHS && !w.firstRun { if hasPrevHS && !w.firstRun {
w.detectHostnameChanges(ctx, domain, prevHS, newState) w.detectHostnameChanges(ctx, domain, prevHS, newState)
} }
@@ -398,9 +399,9 @@ func (w *Watcher) checkHostname(
return return
} }
newState := buildHostnameState(results, time.Now().UTC())
prev, hasPrev := w.state.GetHostnameState(hostname) prev, hasPrev := w.state.GetHostnameState(hostname)
newState := buildHostnameState(results, prev, time.Now().UTC())
if hasPrev && !w.firstRun { if hasPrev && !w.firstRun {
w.detectHostnameChanges(ctx, hostname, prev, newState) w.detectHostnameChanges(ctx, hostname, prev, newState)
} }
@@ -411,9 +412,11 @@ func (w *Watcher) checkHostname(
// buildHostnameState saves each nameserver's response. A nameserver // buildHostnameState saves each nameserver's response. A nameserver
// that answered, even with NXDOMAIN or no records, is saved as ok; one // 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 // 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( func buildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time, now time.Time,
) *state.HostnameState { ) *state.HostnameState {
hs := &state.HostnameState{ hs := &state.HostnameState{
@@ -425,7 +428,7 @@ func buildHostnameState(
for ns, resp := range results { for ns, resp := range results {
nsState := &state.NameserverRecordState{ nsState := &state.NameserverRecordState{
Records: resp.Records, Records: maps.Clone(resp.Records),
Status: statusOK, Status: statusOK,
LastChecked: now, LastChecked: now,
} }
@@ -434,6 +437,13 @@ func buildHostnameState(
resp.Status == resolver.StatusError { resp.Status == resolver.StatusError {
nsState.Status = statusError nsState.Status = statusError
nsState.Error = resp.Error 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 hs.RecordsByNameserver[ns] = nsState
@@ -442,6 +452,30 @@ func buildHostnameState(
return hs 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( func (w *Watcher) detectHostnameChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -467,7 +501,7 @@ func (w *Watcher) detectRecordChanges(
continue continue
} }
if recordsEqual(prevNS.Records, cur.Records) { if sameRecords(prevNS, cur) {
continue continue
} }
@@ -600,9 +634,9 @@ func newlyDisagreeingPairs(
for i, ns1 := range nameservers { for i, ns1 := range nameservers {
for _, ns2 := range nameservers[i+1:] { for _, ns2 := range nameservers[i+1:] {
if recordsEqual( if sameRecords(
current.RecordsByNameserver[ns1].Records, current.RecordsByNameserver[ns1],
current.RecordsByNameserver[ns2].Records, current.RecordsByNameserver[ns2],
) { ) {
continue continue
} }
@@ -612,7 +646,7 @@ func newlyDisagreeingPairs(
if ok1 && ok2 && if ok1 && ok2 &&
prev1.Status == statusOK && prev2.Status == statusOK && prev1.Status == statusOK && prev2.Status == statusOK &&
!recordsEqual(prev1.Records, prev2.Records) { !sameRecords(prev1, prev2) {
continue continue
} }
@@ -1090,6 +1124,21 @@ func toSet(items []string) map[string]bool {
return set 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( func recordsEqual(
a, b map[string][]string, a, b map[string][]string,
) bool { ) bool {