Compare commits
1
Commits
next
...
0ed7667ef5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed7667ef5 |
@@ -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