1 Commits
Author SHA1 Message Date
sneak e143ffbca8 resolver: a domain's nameservers are only its own delegation (closes #222)
check / check (push) Canceled after 0s
LookupNS now follows the delegation for the domain alone. When the parent
zone's servers answer that it has no delegation, as for a domain that does
not exist, the set is empty, and the watcher's NS comparison reports every
nameserver removed.

FindAuthoritativeNameservers, used for hostnames, still moves to a parent
name when the servers answer that the name has no delegation of its own,
but returns the error when they do not answer, where it used to take a
parent zone's nameservers. The fallback walk treats an authoritative
answer the same way.

A domain with no delegation still has its own records asked at the
servers of the zone it is in.

Model: opus-5-5
2026-10-02 08:39:08 +00:00
17 changed files with 80 additions and 1035 deletions
+9 -33
View File
@@ -84,8 +84,7 @@ 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. The lookup fails when no nameserver it asks
answers every one of its queries, for A, AAAA and CNAME.
kept and nothing is sent.
- Also watches the domain's own records as a hostname's are watched (see DNS
Hostname Monitoring below): its A, AAAA, CNAME, MX, TXT, SRV, CAA and NS
records, stored per nameserver. Their changes are notified as a hostname's
@@ -107,19 +106,6 @@ notification endpoint set, changes show only on the dashboard; see
fails and the hostname's records from the previous check are kept.
- 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, a referral, or a reply too large for UDP whose
retry over TCP fails), the failure is logged with the reason, and the type is
listed in the nameserver's `failedTypes` and keeps the records saved for the
nameserver by the previous check. On that check those records are not compared
with the other nameservers', so no record change or inconsistency is reported
for the type; on the next check they are compared with the nameserver's answer
as usual. When the previous check did not know the type's records either,
because the nameserver was new or failing then or the type was already listed
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
every comparison 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
@@ -148,9 +134,8 @@ notification endpoint set, changes show only on the dashboard; see
they keep disagreeing, including after a restart. A nameserver that was
not in the previous check (newly added, or back after dropping out), or
failed on it, and answers differently is reported on the check where it
answers. So is a pair that differs in a record type whose query to either
nameserver failed on the previous check. If a pair agrees again and later
disagrees, the alert is sent again.
answers. If a pair agrees again and later disagrees, the alert is sent
again.
- **CNAME address change**: The addresses at the end of a name's CNAME chain
differ from those of the previous check. They are found when its
nameservers answer with a CNAME and no address; a name that answers with
@@ -225,9 +210,7 @@ includes:
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname or domain affected.
- **Port changes**: Which IP:port, its new state, and the domains and the
hostnames that resolve to it, on a `Domains:` line and a `Hostnames:` line. A
line that would name nothing is left out.
- **Port changes**: Which IP:port, its new state, all associated hostnames.
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP.
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
@@ -569,7 +552,7 @@ reachability:
| Status | Meaning |
| ------- | -------------------------------------------------------- |
| `ok` | Query succeeded, records are current except as below |
| `ok` | Query succeeded, records are current |
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
@@ -578,13 +561,6 @@ 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 lists that
type in `failedTypes` and holds its records from the previous check, which may
not be current. When the previous check did not know the type's records either,
because the nameserver was new or failing then or the type was already listed in
`unknownTypes`, the type is also listed in `unknownTypes`, and `records` holds
nothing for it. Both lists are left out when empty.
`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.
@@ -592,10 +568,10 @@ without a notification.
`cnameAddresses` lists the sorted addresses at the end of the chain of every
CNAME target a hostname's nameservers gave, found when they answered with a
CNAME and no address; it is empty when they answered with an address. When a
chain cannot be followed, or none of the name's nameservers answered its queries
for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
earlier check saved one. A state file without it loads, and the first check
after that saves it without a notification.
chain cannot be followed, or none of the name's nameservers answered, the
previous check's list is kept, or `null` when no earlier check saved one. A
state file without it loads, and the first check after that saves it without a
notification.
A port entry's `hostnames` lists every name that resolves to its address,
domains included. A port entry in the older format, with one `hostname` instead
-4
View File
@@ -21,10 +21,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-02: a domain that does not exist has no nameservers, not its parent
zone's; no name gets a parent's when its servers did not answer (closes #222).
- 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 Port Change notification lists the port's domains on a
`Domains:` line and its hostnames on a `Hostnames:` line (closes #248).
- 2026-10-02: the dashboard's Ports table and `/api/v1/status` port entries list
a port's domains apart from its hostnames (closes #245).
- 2026-10-02: nameservers a referral names without addresses are looked up,
-6
View File
@@ -22,12 +22,6 @@ 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.
-11
View File
@@ -2,21 +2,10 @@ 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)
+26 -110
View File
@@ -8,7 +8,6 @@ import (
"net"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -100,9 +99,6 @@ 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,
@@ -329,20 +325,13 @@ func (r *Resolver) queryServers(
return nil, fmt.Errorf("all servers failed: %w", lastErr)
}
// isErrorReply reports whether msg is an error reply: one with any code
// but NOERROR and NXDOMAIN, such as SERVFAIL, NOTIMP or FORMERR. An error
// reply says nothing about the name's records.
func isErrorReply(msg *dns.Msg) bool {
return msg.Rcode != dns.RcodeSuccess && msg.Rcode != dns.RcodeNameError
}
// usableReply reports whether resp, a reply from one of the servers of
// zone to a query about name, is usable. An error reply such as SERVFAIL
// is not. Nor is a referral, unless it refers the query to a zone below
// zone that name is in: a server that refers it back to zone, up or
// sideways does not serve zone as it should.
func usableReply(resp *dns.Msg, zone string, name string) bool {
if isErrorReply(resp) {
if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError {
return false
}
@@ -737,21 +726,15 @@ func (r *Resolver) queryTypes(
}
type queryState struct {
gotNXDomain bool
gotErrorReply bool
errorReply string // its code, such as SERVFAIL, or number if unnamed
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords bool
answered bool
gotNXDomain bool
gotSERVFAIL bool
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords 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, logging each with the reason unless ctx was
// cancelled: shutdown cancels it, and a query it cut short did not fail.
func (r *Resolver) queryEachType(
ctx context.Context,
nsIP string,
@@ -766,34 +749,7 @@ func (r *Resolver) queryEachType(
break
}
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
if err == nil {
state.answered = true
continue
}
rtype := dns.TypeToString[qtype]
resp.FailedTypes = append(resp.FailedTypes, rtype)
if errors.Is(ctx.Err(), context.Canceled) {
continue
}
r.log.Warn(
"record type query failed",
"hostname", hostname,
"nameserver", resp.Nameserver,
"type", rtype,
"error", err,
)
}
// 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)
r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
}
for k := range resp.Records {
@@ -803,9 +759,6 @@ func (r *Resolver) queryEachType(
return state
}
// querySingleType asks the nameserver at nsIP about hostname's records
// of type qtype. It returns nil when the nameserver answered: with
// records, with none, or with NXDOMAIN; otherwise it returns why not.
func (r *Resolver) querySingleType(
ctx context.Context,
nsIP string,
@@ -813,7 +766,7 @@ func (r *Resolver) querySingleType(
qtype uint16,
resp *NameserverResponse,
state *queryState,
) error {
) {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
switch {
@@ -825,40 +778,19 @@ func (r *Resolver) querySingleType(
state.netErr = err
}
return err
return
}
return readReply(msg, resp, state)
}
// readReply adds to resp the records in msg, a nameserver's reply to a
// query about one record type. It returns nil when the nameserver
// answered: with records, with none, or with NXDOMAIN; otherwise it
// returns why not.
func readReply(
msg *dns.Msg,
resp *NameserverResponse,
state *queryState,
) error {
if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true
return nil
return
}
if isErrorReply(msg) {
state.gotErrorReply = true
if msg.Rcode == dns.RcodeServerFailure {
state.gotSERVFAIL = true
code, named := dns.RcodeToString[msg.Rcode]
if !named {
code = strconv.Itoa(msg.Rcode)
}
state.errorReply = code
return fmt.Errorf(
"server returned %s: %w", state.errorReply, ErrUnusableReply,
)
return
}
// A reply with no answer that lists other nameservers, from a server
@@ -869,20 +801,10 @@ func readReply(
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
return fmt.Errorf("server returned a referral: %w", ErrUnusableReply)
}
// A reply still truncated is one whose TCP retry failed, and holds
// only the records that fit.
if msg.Truncated {
state.netErr = ErrTruncated
return ErrTruncated
return
}
collectAnswerRecords(msg, resp, state)
return nil
}
// collectAnswerRecords adds the records in msg's answer to resp, each
@@ -921,26 +843,23 @@ 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.answered:
case state.gotTimeout && !state.hasRecords:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotErrorReply && !state.answered:
case state.gotSERVFAIL && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned " + state.errorReply
case state.gotRefused && !state.answered:
resp.Error = "server returned SERVFAIL"
case state.gotRefused && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.answered:
case state.netErr != nil && !state.hasRecords:
resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error()
case state.gotReferral && !state.answered:
case state.gotReferral && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned a referral"
case !state.hasRecords && !state.gotNXDomain:
@@ -1110,11 +1029,9 @@ func (r *Resolver) resolveIPWithCNAME(
}
// collectIPs returns the addresses in the nameservers' answers and the
// 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.
// 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.
func collectIPs(
results map[string]*NameserverResponse,
) ([]string, string, error) {
@@ -1127,8 +1044,7 @@ func collectIPs(
answered := false
for _, resp := range results {
if resp.Status == StatusTimeout || resp.Status == StatusError ||
len(resp.FailedTypes) > 0 {
if resp.Status == StatusTimeout || resp.Status == StatusError {
continue
}
@@ -1,131 +0,0 @@
package resolver
import (
"strconv"
"syscall"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestClassifyResponse sets a nameserver's status from the results of
// its queries, built here. One that answered some record types, even
// with no records, has not failed when its query for another type got
// no usable reply, whatever the reason; one whose every query got none
// has.
func TestClassifyResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
results queryState
wantStatus string
wantError string
}{
{
"some types answered with no records, another timed out",
queryState{answered: true, gotTimeout: true},
StatusNoData, "",
},
{
"some types answered with no records, another got SERVFAIL",
queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
},
StatusNoData, "",
},
{
"some types answered with no records, another was refused",
queryState{answered: true, gotRefused: true},
StatusNoData, "",
},
{
"some types answered with no records, another got a network error",
queryState{answered: true, netErr: syscall.ECONNREFUSED},
StatusNoData, "",
},
{
"some types answered with no records, another's reply was " +
"truncated and its retry over TCP failed",
queryState{answered: true, netErr: ErrTruncated},
StatusNoData, "",
},
{
"some types answered with no records, another got a referral",
queryState{answered: true, gotReferral: true},
StatusNoData, "",
},
{
"every query timed out",
queryState{gotTimeout: true},
StatusTimeout, "all queries timed out",
},
{
"every query got NOTIMP",
queryState{gotErrorReply: true, errorReply: "NOTIMP"},
StatusError, "server returned NOTIMP",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp := &NameserverResponse{Status: StatusOK}
classifyResponse(resp, tt.results)
assert.Equal(t, tt.wantStatus, resp.Status)
assert.Equal(t, tt.wantError, resp.Error)
})
}
}
// TestReadReply checks which replies to a query about one record type,
// built here, are an answer: one with the code NOERROR or NXDOMAIN. A
// reply with any other code is not, and the type's query has failed; a
// nameserver whose only reply it is has failed, and Error gives the
// code, or its number when the code has no name.
func TestReadReply(t *testing.T) {
t.Parallel()
tests := []struct {
rcode int
wantStatus string
wantError string
}{
{dns.RcodeSuccess, StatusNoData, ""},
{dns.RcodeNameError, StatusNXDomain, ""},
{dns.RcodeServerFailure, StatusError, "server returned SERVFAIL"},
{dns.RcodeNotImplemented, StatusError, "server returned NOTIMP"},
{dns.RcodeFormatError, StatusError, "server returned FORMERR"},
{12, StatusError, "server returned 12"}, // unassigned, no name
}
for _, tt := range tests {
t.Run(strconv.Itoa(tt.rcode), func(t *testing.T) {
t.Parallel()
msg := new(dns.Msg)
msg.Authoritative = true
msg.Rcode = tt.rcode
resp := &NameserverResponse{Records: map[string][]string{}}
var state queryState
err := readReply(msg, resp, &state)
classifyResponse(resp, state)
if tt.wantStatus == StatusError {
require.ErrorIs(t, err, ErrUnusableReply)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantStatus, resp.Status)
assert.Equal(t, tt.wantError, resp.Error)
})
}
}
-19
View File
@@ -43,25 +43,6 @@ 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.
+4 -8
View File
@@ -31,15 +31,11 @@ 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
FailedTypes []string
Status string
Error string
Nameserver string
Records map[string][]string
Status string
Error string
}
// Resolver performs iterative DNS resolution from root servers.
+9 -56
View File
@@ -1,7 +1,6 @@
package resolver_test
import (
"bytes"
"context"
"errors"
"fmt"
@@ -89,20 +88,21 @@ func TestFindAuthoritativeNameservers_Subdomain(
}
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that
// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer
// that the name has no delegation of its own, so it gets their names,
// not those of the cmu.edu servers. Every referral on the way gives the
// nameservers' addresses, so the walk sends few queries.
// nameservers of a name in compute-1.amazonaws.com, a zone that
// amazonaws.com delegates to other servers. The servers of
// compute-1.amazonaws.com answer that the name has no delegation of its
// own, so it gets their names, not those of the amazonaws.com servers.
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu")
fromZone := liveLookupNS(t, r, "cs.cmu.edu")
fromParent := liveLookupNS(t, r, "cmu.edu")
fromHost := liveFindAuthoritative(
t, r, "ec2-3-80-0-1.compute-1.amazonaws.com",
)
fromZone := liveLookupNS(t, r, "compute-1.amazonaws.com")
fromParent := liveLookupNS(t, r, "amazonaws.com")
assert.Equal(t, fromZone, fromHost)
assert.NotEqual(t, fromParent, fromHost)
@@ -387,30 +387,6 @@ 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, and
// logged with the reason, 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")
var logs bytes.Buffer
r := resolver.NewWithFailingTCP(slog.New(slog.NewTextHandler(&logs, nil)))
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")
assert.Contains(t, logs.String(),
"hostname=google.com. nameserver="+ns+" type=TXT error=",
)
}
func TestQueryNameserver_NXDomain(t *testing.T) {
t.Parallel()
@@ -1072,29 +1048,6 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error)
}
// TestQueryNameserverIP_CancelledLogsNothing cancels the context while
// a query to 192.0.2.1, where nothing answers, is waiting for a reply,
// as shutdown does. The query was cut short, not failed, so nothing is
// logged.
func TestQueryNameserverIP_CancelledLogsNothing(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil)))
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
time.AfterFunc(100*time.Millisecond, cancel)
_, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1", "example.com",
)
require.NoError(t, err)
assert.Empty(t, logs.String())
}
// TestCollectIPs_NoNameserverAnswered takes the response of a
// nameserver at 192.0.2.1, where nothing answers, as
// TestQueryNameserverIP_Timeout does. Addresses collected from
+4 -11
View File
@@ -46,18 +46,11 @@ type DomainState struct {
}
// NameserverRecordState holds one NS's response for a hostname.
// FailedTypes lists the record types whose query to the nameserver
// failed on this check: Records holds for them the records saved by the
// previous check, which are kept. UnknownTypes lists those of them 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"`
UnknownTypes []string `json:"unknownTypes,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
Records map[string][]string `json:"records"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
}
// HostnameState holds per-nameserver monitoring state for a hostname.
-55
View File
@@ -236,61 +236,6 @@ func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) {
}
}
// TestSaveLoadRoundTrip_FailedTypes checks that a nameserver's
// failedTypes and unknownTypes survive a save and load. Without
// unknownTypes, a type whose records were not known would load as one
// with no records.
func TestSaveLoadRoundTrip_FailedTypes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
s := state.NewForTestWithDataDir(dir)
failed := []string{"TXT", "CAA"}
unknown := []string{"CAA"}
s.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
Records: map[string][]string{"TXT": {"v=spf1 -all"}},
FailedTypes: failed,
UnknownTypes: unknown,
Status: "ok",
},
},
})
err := s.Save()
if err != nil {
t.Fatalf("Save() error: %v", err)
}
loaded := state.NewForTestWithDataDir(dir)
err = loaded.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
hs, ok := loaded.GetHostnameState(testHostname)
if !ok {
t.Fatal("missing hostname " + testHostname)
}
ns1 := hs.RecordsByNameserver[testNS1]
if ns1 == nil {
t.Fatal("missing nameserver " + testNS1)
}
if !reflect.DeepEqual(ns1.FailedTypes, failed) {
t.Errorf("failedTypes: got %#v", ns1.FailedTypes)
}
if !reflect.DeepEqual(ns1.UnknownTypes, unknown) {
t.Errorf("unknownTypes: got %#v", ns1.UnknownTypes)
}
}
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
// before the addresses at the end of a hostname's CNAME chain were
// saved. They load as not known (nil), not as none.
-36
View File
@@ -202,42 +202,6 @@ func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) {
}
}
// TestCNAMEWhoseAddressQueryFailedKeepsPrevious checks a name whose
// nameserver answered, but whose query for A, AAAA or CNAME failed with
// nothing kept for it. That is not an answer with no address: the
// addresses the previous check saved from following its CNAME are kept,
// and nothing is looked up, the watcher having no resolver.
func TestCNAMEWhoseAddressQueryFailedKeepsPrevious(t *testing.T) {
t.Parallel()
for _, rtype := range []string{"A", "AAAA", "CNAME"} {
t.Run(rtype, func(t *testing.T) {
t.Parallel()
w := watcher.NewForTest(nil, nil, nil, nil, nil, nil)
current := saved(map[string]*state.NameserverRecordState{
nsA: {
Records: map[string][]string{},
FailedTypes: []string{rtype},
UnknownTypes: []string{rtype},
Status: "ok",
},
})
prev := cnameState(oldIP)
w.ResolveCNAMEAddresses(t.Context(), host, current, prev)
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
t.Errorf(
"saved %v, want %v",
current.CNAMEAddresses, prev.CNAMEAddresses,
)
}
})
}
}
// cnameTo builds the records of a nameserver that answered with a CNAME
// to target and no address.
func cnameTo(target string) map[string][]string {
+1 -2
View File
@@ -120,8 +120,7 @@ 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, prev, now)
return buildHostnameState(results, now)
}
-364
View File
@@ -1,364 +0,0 @@
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. TXT is always
// saved in FailedTypes, and in UnknownTypes when there was nothing to
// keep.
func TestFailedTypeKeepsPreviousRecords(t *testing.T) {
t.Parallel()
aOnly := map[string][]string{"A": {ip1}}
withTXT := map[string][]string{"A": {ip1}, txt: {spf1}}
txtKept := &state.NameserverRecordState{
Records: withTXT, FailedTypes: []string{txt}, Status: "ok",
}
txtNotKnown := &state.NameserverRecordState{
Records: aOnly,
FailedTypes: []string{txt},
UnknownTypes: []string{txt},
Status: "ok",
}
tests := []struct {
name string
prev *state.HostnameState
wantRecords map[string][]string
wantUnknown []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,
},
{
"TXT failed on the previous check, which kept its records",
saved(map[string]*state.NameserverRecordState{nsA: txtKept}),
withTXT, 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 with nothing to keep",
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, []string{txt}) ||
!slices.Equal(got.UnknownTypes, tt.wantUnknown) {
t.Errorf(
"saved status %q, records %v, failed types %v, "+
"unknown types %v; want ok, %v, [%s], %v",
got.Status, got.Records, got.FailedTypes,
got.UnknownTypes, tt.wantRecords, txt, tt.wantUnknown,
)
}
})
}
}
// 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. nsB's TXT query fails on one check, and
// nothing changes.
func TestFailedTypeAlerts(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}, txt: {spf1}}
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",
},
}
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},
},
}
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)
}
})
}
}
// TestFailedTypeComparedOnceItAnswers saves the checks of each case in
// turn as TestFailedTypeAlerts does. nsB's TXT query fails on one check,
// and the TXT record changes: the change is sent as a Record Change for
// each nameserver on the check where it answers it, and an Inconsistency
// only when nsB still answers the old record.
func TestFailedTypeComparedOnceItAnswers(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),
}
bothChange := map[string]*resolver.NameserverResponse{
nsA: response(changed), nsB: response(changed),
}
aChangesBTXTFails := map[string]*resolver.NameserverResponse{
nsA: response(changed), nsB: response(aOnly, txt),
}
bStillOld := map[string]*resolver.NameserverResponse{
nsA: response(changed), nsB: response(records),
}
tests := []struct {
name string
checks []map[string]*resolver.NameserverResponse
want alertCounts
}{
{
"change made while the type failed",
[]map[string]*resolver.NameserverResponse{
bothAnswer, bTXTFails, bothChange,
},
alertCounts{recordChanges: 2},
},
{
"change seen at one nameserver while the other's type failed",
[]map[string]*resolver.NameserverResponse{
bothAnswer, aChangesBTXTFails, bothChange,
},
alertCounts{recordChanges: 2},
},
{
"old record answered after the type failed",
[]map[string]*resolver.NameserverResponse{
bothAnswer, aChangesBTXTFails, bStillOld,
},
alertCounts{recordChanges: 1, inconsistencies: 1},
},
{
"change after the type failed on the first check and answered",
[]map[string]*resolver.NameserverResponse{
bTXTFails, bothAnswer, 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)
}
})
}
}
// TestFailedTypeLeftOutOfMessages checks that a Record Change and an
// Inconsistency name only the record types they compared. nsB's TXT
// records are not known on the first check, and on the second either
// answered or still not known; nsB's A record changes, so both alerts
// are sent and name the A record alone.
func TestFailedTypeLeftOutOfMessages(t *testing.T) {
t.Parallel()
withTXT := map[string][]string{"A": {ip1}, txt: {spf1}}
txtNotKnown := func(address string) *state.NameserverRecordState {
return &state.NameserverRecordState{
Records: map[string][]string{"A": {address}},
FailedTypes: []string{txt},
UnknownTypes: []string{txt},
Status: "ok",
}
}
before := saved(map[string]*state.NameserverRecordState{
nsA: answered(withTXT), nsB: txtNotKnown(ip1),
})
tests := []struct {
name string
after *state.HostnameState
}{
{
"TXT answers",
saved(map[string]*state.NameserverRecordState{
nsA: answered(withTXT),
nsB: answered(map[string][]string{"A": {ip2}, txt: {spf1}}),
}),
},
{
"TXT still not known",
saved(map[string]*state.NameserverRecordState{
nsA: answered(withTXT), nsB: txtNotKnown(ip2),
}),
},
}
want := map[string]string{
"Record Change: " + host: "Hostname: " + host +
"\nNameserver: " + nsB + "\nType: A\nOld: " + ip1 + "\nNew: " + ip2,
"Inconsistency: " + host: "Hostname: " + host +
"\nType: A\n" + nsA + ": " + ip1 + "\n" + nsB + ": " + ip2,
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// The hostname change detection uses only the notifier.
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(t.Context(), host, before, tt.after)
notifications := notifier.getNotifications()
if len(notifications) != len(want) {
t.Fatalf(
"sent %d notifications, want %d: %v",
len(notifications), len(want), notifications,
)
}
for _, n := range notifications {
if n.Message != want[n.Title] {
t.Errorf(
"%s message:\n%s\nwant:\n%s",
n.Title, n.Message, want[n.Title],
)
}
}
})
}
}
-48
View File
@@ -165,51 +165,3 @@ func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
t.Errorf("sent %v, want one message with %q", notifications, counts)
}
}
// A Port Change notification lists the configured apex domain and the
// hostname that resolve to the port's address on separate lines. The
// port checks read the saved hostname state and look nothing up, so the
// watcher has no resolver.
func TestPortChangeListsDomainsApartFromHostnames(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
// Both names resolve to ip1, whose port 443 the previous check
// found open. It is closed now.
for _, name := range []string{domain, host} {
deps.state.SetHostnameState(name, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
}
key := ip1 + ":443"
deps.state.SetPortState(key, &state.PortState{
Open: true, Hostnames: []string{domain, host},
})
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
title := "Port Change: " + key
want := `Domains: example.net
Hostnames: www.example.net
Address: 192.0.2.1:443
Port now closed`
got := deps.notifier.getNotifications()
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
}
}
+3 -3
View File
@@ -201,7 +201,7 @@ func TestNameserverThatNeverAnswers(t *testing.T) {
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(),
map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(),
)
got := hs.RecordsByNameserver[nsA]
@@ -256,7 +256,7 @@ func TestNameserverThatAnswersNXDOMAIN(t *testing.T) {
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
)
got := hs.RecordsByNameserver[ns]
@@ -320,7 +320,7 @@ func TestNameserverThatRefuses(t *testing.T) {
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
)
got := hs.RecordsByNameserver[ns]
+24 -138
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"maps"
"slices"
"sort"
"strings"
@@ -402,10 +401,8 @@ func (w *Watcher) checkHostname(
return
}
prev, _ := w.state.GetHostnameState(hostname)
w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
ctx, hostname, buildHostnameState(results, time.Now().UTC()),
)
}
@@ -435,9 +432,8 @@ func (w *Watcher) updateHostnameState(
// the addresses found for all of them are saved, so nameservers that
// disagree on the target do not change the result from check to check.
// The addresses saved in prev, which may be nil, are kept when none of
// the name's nameservers answered its queries for A, AAAA and CNAME,
// and when a target cannot be followed, as when no nameserver of a zone
// in its chain answers.
// the name's nameservers answered, and when a target cannot be
// followed, as when no nameserver of a zone in its chain answers.
func (w *Watcher) resolveCNAMEAddresses(
ctx context.Context,
hostname string,
@@ -455,10 +451,7 @@ func (w *Watcher) resolveCNAMEAddresses(
targets := make(map[string]bool)
for _, nsState := range current.RecordsByNameserver {
if nsState.Status != statusOK ||
slices.Contains(nsState.FailedTypes, "A") ||
slices.Contains(nsState.FailedTypes, "AAAA") ||
slices.Contains(nsState.FailedTypes, "CNAME") {
if nsState.Status != statusOK {
continue
}
@@ -504,13 +497,11 @@ func (w *Watcher) resolveCNAMEAddresses(
}
// buildHostnameState saves each nameserver's response. A nameserver
// that answered, even with NXDOMAIN or no records, is saved as ok, with
// the record types whose query failed; one that timed out or failed is
// saved as error with the reason, and its empty record set is not an
// answer. prev is the hostname's state from the previous check, or nil.
// 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.
func buildHostnameState(
results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time,
) *state.HostnameState {
hs := &state.HostnameState{
@@ -522,7 +513,7 @@ func buildHostnameState(
for ns, resp := range results {
nsState := &state.NameserverRecordState{
Records: maps.Clone(resp.Records),
Records: resp.Records,
Status: statusOK,
LastChecked: now,
}
@@ -531,15 +522,6 @@ func buildHostnameState(
resp.Status == resolver.StatusError {
nsState.Status = statusError
nsState.Error = resp.Error
} else {
nsState.FailedTypes = resp.FailedTypes
var prevNS *state.NameserverRecordState
if prev != nil {
prevNS = prev.RecordsByNameserver[ns]
}
keepFailedTypes(nsState, prevNS)
}
hs.RecordsByNameserver[ns] = nsState
@@ -548,27 +530,6 @@ func buildHostnameState(
return hs
}
// keepFailedTypes copies into nsState, for each record type in its
// FailedTypes, 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 UnknownTypes, the type goes in
// nsState.UnknownTypes instead.
func keepFailedTypes(nsState, prevNS *state.NameserverRecordState) {
for _, rtype := range nsState.FailedTypes {
if prevNS == nil || prevNS.Status != statusOK ||
slices.Contains(prevNS.UnknownTypes, rtype) {
nsState.UnknownTypes = append(nsState.UnknownTypes, rtype)
continue
}
if records, ok := prevNS.Records[rtype]; ok {
nsState.Records[rtype] = records
}
}
}
func (w *Watcher) detectHostnameChanges(
ctx context.Context,
hostname string,
@@ -581,50 +542,17 @@ func (w *Watcher) detectHostnameChanges(
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
}
// isDomain reports whether name is a configured apex domain, whose own
// records are checked and saved as a hostname's are.
func (w *Watcher) isDomain(name string) bool {
return slices.Contains(w.config.Domains, name)
}
// nameLine is the line a notification about name's records starts with:
// "Domain: " and the name for a configured apex domain, and
// "Hostname: " otherwise.
// "Domain: " and the name for a configured apex domain, whose own
// records are checked as a hostname's are, and "Hostname: " otherwise.
func (w *Watcher) nameLine(name string) string {
if w.isDomain(name) {
if slices.Contains(w.config.Domains, name) {
return "Domain: " + name
}
return "Hostname: " + name
}
// portNameLines lists the names that resolve to a port's address, the
// configured apex domains on one line and the hostnames on the next,
// leaving out a line that would name nothing.
func (w *Watcher) portNameLines(names []string) string {
var domains, hostnames []string
for _, name := range names {
if w.isDomain(name) {
domains = append(domains, name)
} else {
hostnames = append(hostnames, name)
}
}
var lines []string
if len(domains) > 0 {
lines = append(lines, "Domains: "+strings.Join(domains, ", "))
}
if len(hostnames) > 0 {
lines = append(lines, "Hostnames: "+strings.Join(hostnames, ", "))
}
return strings.Join(lines, "\n")
}
// detectCNAMEAddressChanges notifies when the addresses at the end of
// hostname's CNAME chain differ from those the previous check saved,
// including a change from or to none. When the previous addresses are
@@ -657,10 +585,7 @@ func (w *Watcher) detectCNAMEAddressChanges(
// detectRecordChanges compares each nameserver's records with those of
// the previous check. Only answers are compared: a nameserver that
// failed on either check has no records to compare. The records kept
// for a record type whose query failed are compared too, but not those
// of a type in UnknownTypes on either check, which the message leaves
// out as well.
// failed on either check has no records to compare.
func (w *Watcher) detectRecordChanges(
ctx context.Context,
hostname string,
@@ -672,11 +597,7 @@ func (w *Watcher) detectRecordChanges(
continue
}
unknown := slices.Concat(prevNS.UnknownTypes, cur.UnknownTypes)
oldRecords := withoutTypes(prevNS.Records, unknown)
newRecords := withoutTypes(cur.Records, unknown)
if recordsEqual(oldRecords, newRecords) {
if recordsEqual(prevNS.Records, cur.Records) {
continue
}
@@ -684,8 +605,8 @@ func (w *Watcher) detectRecordChanges(
"%s\nNameserver: %s\n%s",
w.nameLine(hostname), ns,
recordDifferences(
"Old", oldRecords,
"New", newRecords,
"Old", prevNS.Records,
"New", cur.Records,
),
)
@@ -772,19 +693,13 @@ func (w *Watcher) detectInconsistencies(
) {
for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1]
state1 := current.RecordsByNameserver[ns1]
state2 := current.RecordsByNameserver[ns2]
// The record types left out of the comparison are left out of
// the message too.
failed := slices.Concat(state1.FailedTypes, state2.FailedTypes)
msg := fmt.Sprintf(
"%s\n%s",
w.nameLine(hostname),
recordDifferences(
ns1, withoutTypes(state1.Records, failed),
ns2, withoutTypes(state2.Records, failed),
ns1, current.RecordsByNameserver[ns1].Records,
ns2, current.RecordsByNameserver[ns2].Records,
),
)
@@ -802,9 +717,7 @@ func (w *Watcher) detectInconsistencies(
// except pairs where both nameservers answered in prev and already
// differed there. A nameserver missing from prev, or that failed there,
// is paired with every nameserver it differs from. A nameserver that
// failed in current has no records to compare and is in no pair. In
// both checks, a record type whose query failed at either nameserver is
// not compared.
// failed in current has no records to compare and is in no pair.
func newlyDisagreeingPairs(
prev, current *state.HostnameState,
) [][2]string {
@@ -821,9 +734,9 @@ func newlyDisagreeingPairs(
for i, ns1 := range nameservers {
for _, ns2 := range nameservers[i+1:] {
if nameserversAgree(
current.RecordsByNameserver[ns1],
current.RecordsByNameserver[ns2],
if recordsEqual(
current.RecordsByNameserver[ns1].Records,
current.RecordsByNameserver[ns2].Records,
) {
continue
}
@@ -833,7 +746,7 @@ func newlyDisagreeingPairs(
if ok1 && ok2 &&
prev1.Status == statusOK && prev2.Status == statusOK &&
!nameserversAgree(prev1, prev2) {
!recordsEqual(prev1.Records, prev2.Records) {
continue
}
@@ -1039,8 +952,8 @@ func (w *Watcher) checkSinglePort(
}
msg := fmt.Sprintf(
"%s\nAddress: %s\nPort now %s",
w.portNameLines(hostnames), key, stateStr,
"Hosts: %s\nAddress: %s\nPort now %s",
strings.Join(hostnames, ", "), key, stateStr,
)
w.notify.SendNotification(
@@ -1322,33 +1235,6 @@ func toSet(items []string) map[string]bool {
return set
}
// nameserversAgree reports whether two nameservers' states from the same
// check hold the same records, leaving out the record types either lists
// in FailedTypes: the records held for those are kept from an earlier
// check, or not known.
func nameserversAgree(a, b *state.NameserverRecordState) bool {
failed := slices.Concat(a.FailedTypes, b.FailedTypes)
return recordsEqual(
withoutTypes(a.Records, failed), withoutTypes(b.Records, failed),
)
}
// withoutTypes returns a copy of records without the record types in
// types.
func withoutTypes(
records map[string][]string,
types []string,
) map[string][]string {
records = maps.Clone(records)
for _, rtype := range types {
delete(records, rtype)
}
return records
}
func recordsEqual(
a, b map[string][]string,
) bool {