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