Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e143ffbca8 |
@@ -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
|
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. The lookup fails when no nameserver it asks
|
kept and nothing is sent.
|
||||||
answers every one of its queries, for A, AAAA and CNAME.
|
|
||||||
- Also watches the domain's own records as a hostname's are watched (see DNS
|
- 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
|
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
|
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.
|
fails and the hostname's records from the previous check are kept.
|
||||||
- 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, 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
|
- 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
|
||||||
@@ -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
|
they keep disagreeing, including after a restart. A nameserver that was
|
||||||
not in the previous check (newly added, or back after dropping out), or
|
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
|
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
|
answers. If a pair agrees again and later disagrees, the alert is sent
|
||||||
nameserver failed on the previous check. If a pair agrees again and later
|
again.
|
||||||
disagrees, the alert is sent again.
|
|
||||||
- **CNAME address change**: The addresses at the end of a name's CNAME chain
|
- **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
|
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
|
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 recoveries**: Which nameserver recovered, which hostname/domain.
|
||||||
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
|
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
|
||||||
which hostname or domain affected.
|
which hostname or domain affected.
|
||||||
- **Port changes**: Which IP:port, its new state, and the domains and the
|
- **Port changes**: Which IP:port, its new state, all associated hostnames.
|
||||||
hostnames that resolve to it, on a `Domains:` line and a `Hostnames:` line. A
|
|
||||||
line that would name nothing is left out.
|
|
||||||
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
|
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
|
||||||
hostname and IP.
|
hostname and IP.
|
||||||
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
|
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
|
||||||
@@ -569,7 +552,7 @@ reachability:
|
|||||||
|
|
||||||
| Status | Meaning |
|
| 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) |
|
| `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
|
||||||
@@ -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
|
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 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
|
`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.
|
||||||
@@ -592,10 +568,10 @@ without a notification.
|
|||||||
`cnameAddresses` lists the sorted addresses at the end of the chain of every
|
`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 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
|
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
|
chain cannot be followed, or none of the name's nameservers answered, the
|
||||||
for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
|
previous check's list is kept, or `null` when no earlier check saved one. A
|
||||||
earlier check saved one. A state file without it loads, and the first check
|
state file without it loads, and the first check after that saves it without a
|
||||||
after that saves it without a notification.
|
notification.
|
||||||
|
|
||||||
A port entry's `hostnames` lists every name that resolves to its address,
|
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
|
domains included. A port entry in the older format, with one `hostname` instead
|
||||||
|
|||||||
@@ -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
|
- 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).
|
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
|
- 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).
|
a port's domains apart from its hostnames (closes #245).
|
||||||
- 2026-10-02: nameservers a referral names without addresses are looked up,
|
- 2026-10-02: nameservers a referral names without addresses are looked up,
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ 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,21 +2,10 @@ 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)
|
||||||
|
|||||||
+20
-104
@@ -8,7 +8,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -100,9 +99,6 @@ 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,
|
||||||
@@ -329,20 +325,13 @@ func (r *Resolver) queryServers(
|
|||||||
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
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
|
// 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
|
// 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
|
// 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
|
// zone that name is in: a server that refers it back to zone, up or
|
||||||
// sideways does not serve zone as it should.
|
// sideways does not serve zone as it should.
|
||||||
func usableReply(resp *dns.Msg, zone string, name string) bool {
|
func usableReply(resp *dns.Msg, zone string, name string) bool {
|
||||||
if isErrorReply(resp) {
|
if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,20 +727,14 @@ func (r *Resolver) queryTypes(
|
|||||||
|
|
||||||
type queryState struct {
|
type queryState struct {
|
||||||
gotNXDomain bool
|
gotNXDomain bool
|
||||||
gotErrorReply bool
|
gotSERVFAIL bool
|
||||||
errorReply string // its code, such as SERVFAIL, or number if unnamed
|
|
||||||
gotRefused bool
|
gotRefused bool
|
||||||
gotTimeout bool
|
gotTimeout bool
|
||||||
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, 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(
|
func (r *Resolver) queryEachType(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsIP string,
|
nsIP string,
|
||||||
@@ -766,34 +749,7 @@ func (r *Resolver) queryEachType(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for k := range resp.Records {
|
for k := range resp.Records {
|
||||||
@@ -803,9 +759,6 @@ func (r *Resolver) queryEachType(
|
|||||||
return state
|
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(
|
func (r *Resolver) querySingleType(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsIP string,
|
nsIP string,
|
||||||
@@ -813,7 +766,7 @@ func (r *Resolver) querySingleType(
|
|||||||
qtype uint16,
|
qtype uint16,
|
||||||
resp *NameserverResponse,
|
resp *NameserverResponse,
|
||||||
state *queryState,
|
state *queryState,
|
||||||
) error {
|
) {
|
||||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
@@ -825,40 +778,19 @@ func (r *Resolver) querySingleType(
|
|||||||
state.netErr = err
|
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 {
|
if msg.Rcode == dns.RcodeNameError {
|
||||||
state.gotNXDomain = true
|
state.gotNXDomain = true
|
||||||
|
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if isErrorReply(msg) {
|
if msg.Rcode == dns.RcodeServerFailure {
|
||||||
state.gotErrorReply = true
|
state.gotSERVFAIL = true
|
||||||
|
|
||||||
code, named := dns.RcodeToString[msg.Rcode]
|
return
|
||||||
if !named {
|
|
||||||
code = strconv.Itoa(msg.Rcode)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.errorReply = code
|
|
||||||
|
|
||||||
return fmt.Errorf(
|
|
||||||
"server returned %s: %w", state.errorReply, ErrUnusableReply,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reply with no answer that lists other nameservers, from a server
|
// A reply with no answer that lists other nameservers, from a server
|
||||||
@@ -869,20 +801,10 @@ func readReply(
|
|||||||
len(extractNSSet(msg.Ns)) > 0 {
|
len(extractNSSet(msg.Ns)) > 0 {
|
||||||
state.gotReferral = true
|
state.gotReferral = true
|
||||||
|
|
||||||
return fmt.Errorf("server returned a referral: %w", ErrUnusableReply)
|
return
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collectAnswerRecords(msg, resp, state)
|
collectAnswerRecords(msg, resp, state)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectAnswerRecords adds the records in msg's answer to resp, each
|
// collectAnswerRecords adds the records in msg's answer to resp, each
|
||||||
@@ -921,26 +843,23 @@ 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.answered:
|
case state.gotTimeout && !state.hasRecords:
|
||||||
resp.Status = StatusTimeout
|
resp.Status = StatusTimeout
|
||||||
resp.Error = "all queries timed out"
|
resp.Error = "all queries timed out"
|
||||||
case state.gotErrorReply && !state.answered:
|
case state.gotSERVFAIL && !state.hasRecords:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "server returned " + state.errorReply
|
resp.Error = "server returned SERVFAIL"
|
||||||
case state.gotRefused && !state.answered:
|
case state.gotRefused && !state.hasRecords:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "server returned REFUSED"
|
resp.Error = "server returned REFUSED"
|
||||||
case state.netErr != nil && !state.answered:
|
case state.netErr != nil && !state.hasRecords:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "network error: " + state.netErr.Error()
|
resp.Error = "network error: " + state.netErr.Error()
|
||||||
case state.gotReferral && !state.answered:
|
case state.gotReferral && !state.hasRecords:
|
||||||
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:
|
||||||
@@ -1110,11 +1029,9 @@ 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. A nameserver whose query for one of the
|
// first CNAME target among them. It returns ErrNoNameserverAnswered when
|
||||||
// types failed gave only part of the addresses, and is left out. It
|
// every nameserver timed out, failed or returned a referral: that is not
|
||||||
// returns ErrNoNameserverAnswered when every nameserver timed out,
|
// a name with no addresses.
|
||||||
// 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) {
|
||||||
@@ -1127,8 +1044,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,25 +43,6 @@ 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,13 +31,9 @@ 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
|
||||||
FailedTypes []string
|
|
||||||
Status string
|
Status string
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package resolver_test
|
package resolver_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -89,20 +88,21 @@ func TestFindAuthoritativeNameservers_Subdomain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
|
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
|
||||||
// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that
|
// nameservers of a name in compute-1.amazonaws.com, a zone that
|
||||||
// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer
|
// amazonaws.com delegates to other servers. The servers of
|
||||||
// that the name has no delegation of its own, so it gets their names,
|
// compute-1.amazonaws.com answer that the name has no delegation of its
|
||||||
// not those of the cmu.edu servers. Every referral on the way gives the
|
// own, so it gets their names, not those of the amazonaws.com servers.
|
||||||
// nameservers' addresses, so the walk sends few queries.
|
|
||||||
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
|
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu")
|
fromHost := liveFindAuthoritative(
|
||||||
fromZone := liveLookupNS(t, r, "cs.cmu.edu")
|
t, r, "ec2-3-80-0-1.compute-1.amazonaws.com",
|
||||||
fromParent := liveLookupNS(t, r, "cmu.edu")
|
)
|
||||||
|
fromZone := liveLookupNS(t, r, "compute-1.amazonaws.com")
|
||||||
|
fromParent := liveLookupNS(t, r, "amazonaws.com")
|
||||||
|
|
||||||
assert.Equal(t, fromZone, fromHost)
|
assert.Equal(t, fromZone, fromHost)
|
||||||
assert.NotEqual(t, fromParent, 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) {
|
func TestQueryNameserver_NXDomain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1072,29 +1048,6 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
|
|||||||
assert.NotEmpty(t, resp.Error)
|
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
|
// TestCollectIPs_NoNameserverAnswered takes the response of a
|
||||||
// nameserver at 192.0.2.1, where nothing answers, as
|
// nameserver at 192.0.2.1, where nothing answers, as
|
||||||
// TestQueryNameserverIP_Timeout does. Addresses collected from
|
// TestQueryNameserverIP_Timeout does. Addresses collected from
|
||||||
|
|||||||
@@ -46,15 +46,8 @@ type DomainState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NameserverRecordState holds one NS's response for a hostname.
|
// 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 {
|
type NameserverRecordState struct {
|
||||||
Records map[string][]string `json:"records"`
|
Records map[string][]string `json:"records"`
|
||||||
FailedTypes []string `json:"failedTypes,omitempty"`
|
|
||||||
UnknownTypes []string `json:"unknownTypes,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"`
|
||||||
|
|||||||
@@ -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
|
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
|
||||||
// before the addresses at the end of a hostname's CNAME chain were
|
// before the addresses at the end of a hostname's CNAME chain were
|
||||||
// saved. They load as not known (nil), not as none.
|
// saved. They load as not known (nil), not as none.
|
||||||
|
|||||||
@@ -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
|
// cnameTo builds the records of a nameserver that answered with a CNAME
|
||||||
// to target and no address.
|
// to target and no address.
|
||||||
func cnameTo(target string) map[string][]string {
|
func cnameTo(target string) map[string][]string {
|
||||||
|
|||||||
@@ -120,8 +120,7 @@ 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, prev, now)
|
return buildHostnameState(results, now)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -165,51 +165,3 @@ func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
|
|||||||
t.Errorf("sent %v, want one message with %q", notifications, counts)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestNameserverThatNeverAnswers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hs := watcher.BuildHostnameState(
|
hs := watcher.BuildHostnameState(
|
||||||
map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(),
|
map[string]*resolver.NameserverResponse{nsA: resp}, 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}, nil, time.Now(),
|
map[string]*resolver.NameserverResponse{ns: resp}, 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}, nil, time.Now(),
|
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
got := hs.RecordsByNameserver[ns]
|
got := hs.RecordsByNameserver[ns]
|
||||||
|
|||||||
+24
-138
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -402,10 +401,8 @@ func (w *Watcher) checkHostname(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prev, _ := w.state.GetHostnameState(hostname)
|
|
||||||
|
|
||||||
w.updateHostnameState(
|
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
|
// 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.
|
// 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 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,
|
// the name's nameservers answered, and when a target cannot be
|
||||||
// and when a target cannot be followed, as when no nameserver of a zone
|
// followed, as when no nameserver of a zone in its chain answers.
|
||||||
// in its chain answers.
|
|
||||||
func (w *Watcher) resolveCNAMEAddresses(
|
func (w *Watcher) resolveCNAMEAddresses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
@@ -455,10 +451,7 @@ func (w *Watcher) resolveCNAMEAddresses(
|
|||||||
targets := make(map[string]bool)
|
targets := make(map[string]bool)
|
||||||
|
|
||||||
for _, nsState := range current.RecordsByNameserver {
|
for _, nsState := range current.RecordsByNameserver {
|
||||||
if nsState.Status != statusOK ||
|
if nsState.Status != statusOK {
|
||||||
slices.Contains(nsState.FailedTypes, "A") ||
|
|
||||||
slices.Contains(nsState.FailedTypes, "AAAA") ||
|
|
||||||
slices.Contains(nsState.FailedTypes, "CNAME") {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,13 +497,11 @@ func (w *Watcher) resolveCNAMEAddresses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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, with
|
// that answered, even with NXDOMAIN or no records, is saved as ok; one
|
||||||
// the record types whose query failed; one that timed out or failed is
|
// that timed out or failed is saved as error with the reason, and its
|
||||||
// saved as error with the reason, and its empty record set is not an
|
// empty record set is not an answer.
|
||||||
// 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{
|
||||||
@@ -522,7 +513,7 @@ func buildHostnameState(
|
|||||||
|
|
||||||
for ns, resp := range results {
|
for ns, resp := range results {
|
||||||
nsState := &state.NameserverRecordState{
|
nsState := &state.NameserverRecordState{
|
||||||
Records: maps.Clone(resp.Records),
|
Records: resp.Records,
|
||||||
Status: statusOK,
|
Status: statusOK,
|
||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
}
|
}
|
||||||
@@ -531,15 +522,6 @@ 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 {
|
|
||||||
nsState.FailedTypes = resp.FailedTypes
|
|
||||||
|
|
||||||
var prevNS *state.NameserverRecordState
|
|
||||||
if prev != nil {
|
|
||||||
prevNS = prev.RecordsByNameserver[ns]
|
|
||||||
}
|
|
||||||
|
|
||||||
keepFailedTypes(nsState, prevNS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hs.RecordsByNameserver[ns] = nsState
|
hs.RecordsByNameserver[ns] = nsState
|
||||||
@@ -548,27 +530,6 @@ func buildHostnameState(
|
|||||||
return hs
|
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(
|
func (w *Watcher) detectHostnameChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
@@ -581,50 +542,17 @@ func (w *Watcher) detectHostnameChanges(
|
|||||||
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
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:
|
// nameLine is the line a notification about name's records starts with:
|
||||||
// "Domain: " and the name for a configured apex domain, and
|
// "Domain: " and the name for a configured apex domain, whose own
|
||||||
// "Hostname: " otherwise.
|
// records are checked as a hostname's are, and "Hostname: " otherwise.
|
||||||
func (w *Watcher) nameLine(name string) string {
|
func (w *Watcher) nameLine(name string) string {
|
||||||
if w.isDomain(name) {
|
if slices.Contains(w.config.Domains, name) {
|
||||||
return "Domain: " + name
|
return "Domain: " + name
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Hostname: " + 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
|
// detectCNAMEAddressChanges notifies when the addresses at the end of
|
||||||
// hostname's CNAME chain differ from those the previous check saved,
|
// hostname's CNAME chain differ from those the previous check saved,
|
||||||
// including a change from or to none. When the previous addresses are
|
// 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
|
// detectRecordChanges compares each nameserver's records with those of
|
||||||
// the previous check. Only answers are compared: a nameserver that
|
// the previous check. Only answers are compared: a nameserver that
|
||||||
// failed on either check has no records to compare. The records kept
|
// failed on either check has no records to compare.
|
||||||
// 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.
|
|
||||||
func (w *Watcher) detectRecordChanges(
|
func (w *Watcher) detectRecordChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
@@ -672,11 +597,7 @@ func (w *Watcher) detectRecordChanges(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
unknown := slices.Concat(prevNS.UnknownTypes, cur.UnknownTypes)
|
if recordsEqual(prevNS.Records, cur.Records) {
|
||||||
oldRecords := withoutTypes(prevNS.Records, unknown)
|
|
||||||
newRecords := withoutTypes(cur.Records, unknown)
|
|
||||||
|
|
||||||
if recordsEqual(oldRecords, newRecords) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,8 +605,8 @@ func (w *Watcher) detectRecordChanges(
|
|||||||
"%s\nNameserver: %s\n%s",
|
"%s\nNameserver: %s\n%s",
|
||||||
w.nameLine(hostname), ns,
|
w.nameLine(hostname), ns,
|
||||||
recordDifferences(
|
recordDifferences(
|
||||||
"Old", oldRecords,
|
"Old", prevNS.Records,
|
||||||
"New", newRecords,
|
"New", cur.Records,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -772,19 +693,13 @@ func (w *Watcher) detectInconsistencies(
|
|||||||
) {
|
) {
|
||||||
for _, pair := range newlyDisagreeingPairs(prev, current) {
|
for _, pair := range newlyDisagreeingPairs(prev, current) {
|
||||||
ns1, ns2 := pair[0], pair[1]
|
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(
|
msg := fmt.Sprintf(
|
||||||
"%s\n%s",
|
"%s\n%s",
|
||||||
w.nameLine(hostname),
|
w.nameLine(hostname),
|
||||||
recordDifferences(
|
recordDifferences(
|
||||||
ns1, withoutTypes(state1.Records, failed),
|
ns1, current.RecordsByNameserver[ns1].Records,
|
||||||
ns2, withoutTypes(state2.Records, failed),
|
ns2, current.RecordsByNameserver[ns2].Records,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -802,9 +717,7 @@ func (w *Watcher) detectInconsistencies(
|
|||||||
// except pairs where both nameservers answered in prev and already
|
// except pairs where both nameservers answered in prev and already
|
||||||
// differed there. A nameserver missing from prev, or that failed there,
|
// differed there. A nameserver missing from prev, or that failed there,
|
||||||
// is paired with every nameserver it differs from. A nameserver that
|
// 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
|
// failed in current has no records to compare and is in no pair.
|
||||||
// both checks, a record type whose query failed at either nameserver is
|
|
||||||
// not compared.
|
|
||||||
func newlyDisagreeingPairs(
|
func newlyDisagreeingPairs(
|
||||||
prev, current *state.HostnameState,
|
prev, current *state.HostnameState,
|
||||||
) [][2]string {
|
) [][2]string {
|
||||||
@@ -821,9 +734,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 nameserversAgree(
|
if recordsEqual(
|
||||||
current.RecordsByNameserver[ns1],
|
current.RecordsByNameserver[ns1].Records,
|
||||||
current.RecordsByNameserver[ns2],
|
current.RecordsByNameserver[ns2].Records,
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -833,7 +746,7 @@ func newlyDisagreeingPairs(
|
|||||||
|
|
||||||
if ok1 && ok2 &&
|
if ok1 && ok2 &&
|
||||||
prev1.Status == statusOK && prev2.Status == statusOK &&
|
prev1.Status == statusOK && prev2.Status == statusOK &&
|
||||||
!nameserversAgree(prev1, prev2) {
|
!recordsEqual(prev1.Records, prev2.Records) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,8 +952,8 @@ func (w *Watcher) checkSinglePort(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"%s\nAddress: %s\nPort now %s",
|
"Hosts: %s\nAddress: %s\nPort now %s",
|
||||||
w.portNameLines(hostnames), key, stateStr,
|
strings.Join(hostnames, ", "), key, stateStr,
|
||||||
)
|
)
|
||||||
|
|
||||||
w.notify.SendNotification(
|
w.notify.SendNotification(
|
||||||
@@ -1322,33 +1235,6 @@ func toSet(items []string) map[string]bool {
|
|||||||
return set
|
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(
|
func recordsEqual(
|
||||||
a, b map[string][]string,
|
a, b map[string][]string,
|
||||||
) bool {
|
) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user