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: 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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
+60
-11
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user