Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de3c4b820 |
@@ -104,11 +104,9 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
NS change notification above. A domain listed in
|
||||
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
|
||||
notification. A domain with no delegation of its own has these records asked
|
||||
at the servers of the zone it is in, as a hostname has. When none of those
|
||||
servers answers, the lookup of its NS record set fails, and they are saved as
|
||||
failed and notified as a hostname's are (see below). A nameserver the domain's
|
||||
records were asked at on the previous check but not on this one gets a
|
||||
hostname's NS change notification (see below), unless it was in the domain's
|
||||
at the servers of the zone it is in, as a hostname has. A nameserver the
|
||||
domain's records were asked at on the previous check but not on this one gets
|
||||
a hostname's NS change notification (see below), unless it was in the domain's
|
||||
delegation on the previous check, as when that delegation is withdrawn. A
|
||||
domain that does not exist has none: they are not asked for, and those saved
|
||||
by an earlier check are removed without a notification.
|
||||
@@ -123,29 +121,22 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
trace moves from a name to its parent only when the servers asked answer that
|
||||
the name has no delegation of its own, or does not exist. When they do not
|
||||
answer, the check fails and the hostname's records from the previous check are
|
||||
kept, unless they are the nameservers the previous check saved for the
|
||||
hostname. Each of those is then saved as failed, with the reason, as a
|
||||
domain's nameservers are when none of them answers, so it gets an NS query
|
||||
failure notification, and an NS recovery notification when it answers again
|
||||
(see below). A trace that gets no answer before it reaches them, as when the
|
||||
host running dnswatcher loses its network, fails the check without a
|
||||
notification.
|
||||
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 at any of its addresses (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).
|
||||
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
|
||||
@@ -550,11 +541,6 @@ name's nameservers and in the lookup of a nameserver's own address. Such a
|
||||
lookup can need others in turn; lookups go at most three deep, one inside
|
||||
another, so delegations that point at each other still end.
|
||||
|
||||
In step 4 each record type is asked at the IPv4 addresses the nameserver's name
|
||||
resolves to, one at a time, until one gives a usable reply; the next type is
|
||||
asked first at that address. A type's query fails only when none of them gives
|
||||
one, and the nameserver has failed only when that is so for every type.
|
||||
|
||||
This approach ensures:
|
||||
|
||||
- Independence from any upstream resolver's cache or filtering.
|
||||
|
||||
@@ -19,10 +19,6 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it
|
||||
has failed only when none of them gives a usable reply (closes #285).
|
||||
- 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering
|
||||
gets an NS Failure for each, then an NS Recovery (closes #284).
|
||||
- 2026-10-13: an IPv6 address is written in brackets before its port, as in
|
||||
`[2001:db8::1]:443`, in keys and Port Change notifications (closes #270).
|
||||
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
|
||||
|
||||
@@ -2,23 +2,6 @@ package resolver
|
||||
|
||||
import "errors"
|
||||
|
||||
// NoAnswerError is returned when the walk to a name's nameservers
|
||||
// reached a zone through a referral and none of that zone's
|
||||
// nameservers, Nameservers as the referral names them, answered. Err
|
||||
// says why they did not.
|
||||
type NoAnswerError struct {
|
||||
Nameservers []string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *NoAnswerError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *NoAnswerError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Sentinel errors returned by the resolver.
|
||||
var (
|
||||
// ErrNoNameservers is returned when no authoritative NS
|
||||
@@ -36,10 +19,6 @@ var (
|
||||
// so whether the name has addresses is unknown.
|
||||
ErrNoNameserverAnswered = errors.New("no nameserver answered")
|
||||
|
||||
// ErrNoNameserverAddress is returned when a nameserver is to be
|
||||
// asked and no address for it was given.
|
||||
ErrNoNameserverAddress = errors.New("nameserver has no address")
|
||||
|
||||
// ErrUnusableReply is returned when a server replied with an
|
||||
// error such as SERVFAIL, or with a referral that leads no
|
||||
// closer to the name asked about.
|
||||
|
||||
@@ -31,11 +31,9 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
|
||||
func (r *Resolver) FollowDelegation(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
zone string,
|
||||
nameservers []string,
|
||||
servers []string,
|
||||
) ([]string, error) {
|
||||
return r.followDelegation(ctx, domain, zone, nameservers, servers)
|
||||
return r.followDelegation(ctx, domain, servers)
|
||||
}
|
||||
|
||||
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
|
||||
@@ -96,17 +94,6 @@ func (r *Resolver) QueryServers(
|
||||
return r.queryServers(ctx, servers, zone, name, qtype)
|
||||
}
|
||||
|
||||
// QueryTypes exports queryTypes for testing.
|
||||
func (r *Resolver) QueryTypes(
|
||||
ctx context.Context,
|
||||
nsHostname string,
|
||||
nsIPs []string,
|
||||
hostname string,
|
||||
qtypes []uint16,
|
||||
) (*NameserverResponse, error) {
|
||||
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
|
||||
}
|
||||
|
||||
// QueryEachNS exports queryEachNS for testing.
|
||||
func (r *Resolver) QueryEachNS(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -204,24 +204,20 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
|
||||
return ips
|
||||
}
|
||||
|
||||
// followDelegation follows referrals to domain from servers, the
|
||||
// addresses of the servers of zone, and returns the NS set of domain's
|
||||
// delegation. nameservers are the names a referral gave the servers of
|
||||
// zone; the walk to a name's nameservers starts at the root servers, the
|
||||
// servers of zone ".", which no referral names. When the servers of the
|
||||
// zone domain is in answer that domain does not exist, the error is
|
||||
// ErrNXDomain. When they answer that it has no delegation of its own,
|
||||
// followDelegation follows referrals from servers, the root servers, to
|
||||
// domain and returns the NS set of domain's delegation. When the servers
|
||||
// of the zone domain is in answer that domain does not exist, the error
|
||||
// is ErrNXDomain. When they answer that it has no delegation of its own,
|
||||
// because it is not the zone's apex, the set is empty and there is no
|
||||
// error. Any other error means that no such answer came; it is a
|
||||
// *NoAnswerError when the servers that did not answer are those of a
|
||||
// zone a referral named.
|
||||
// error. Any other error means that no such answer came.
|
||||
func (r *Resolver) followDelegation(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
zone string,
|
||||
nameservers []string,
|
||||
servers []string,
|
||||
) ([]string, error) {
|
||||
// servers are the root servers, the servers of zone ".".
|
||||
zone := "."
|
||||
|
||||
var withoutAddresses []string
|
||||
|
||||
for range maxDelegation {
|
||||
@@ -232,12 +228,6 @@ func (r *Resolver) followDelegation(
|
||||
resp, err := r.queryZone(
|
||||
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
|
||||
)
|
||||
|
||||
// A query that ctx cut short says nothing about the servers.
|
||||
if err != nil && len(nameservers) > 0 && checkCtx(ctx) == nil {
|
||||
return nil, &NoAnswerError{Nameservers: nameservers, Err: err}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -266,7 +256,6 @@ func (r *Resolver) followDelegation(
|
||||
return r.resolveNSIterative(ctx, domain)
|
||||
}
|
||||
|
||||
nameservers = authNS
|
||||
servers, withoutAddresses = referralNameservers(resp)
|
||||
zone = referralZone(resp)
|
||||
}
|
||||
@@ -637,8 +626,7 @@ func (r *Resolver) resolveARecord(
|
||||
// them. When the servers asked answer that the name has no delegation
|
||||
// of its own, or does not exist, it tries each parent name in turn, so
|
||||
// it returns the nameservers of the zone the name is in. When they do
|
||||
// not answer, it returns the error and tries no parent name; when a
|
||||
// referral named them, the error is a *NoAnswerError naming them.
|
||||
// not answer, it returns the error and tries no parent name.
|
||||
func (r *Resolver) FindAuthoritativeNameservers(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
@@ -667,7 +655,7 @@ func (r *Resolver) findAuthoritativeNameservers(
|
||||
|
||||
candidate := strings.Join(labels[i:], ".") + "."
|
||||
|
||||
nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers)
|
||||
nsNames, err := r.followDelegation(ctx, candidate, servers)
|
||||
if err != nil && !errors.Is(err, ErrNXDomain) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -708,9 +696,8 @@ func (r *Resolver) QueryNameserver(
|
||||
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
|
||||
}
|
||||
|
||||
// queryNameserver queries a specific nameserver, at the IPv4 addresses
|
||||
// its name resolves to, for the record types in qtypes and builds a
|
||||
// NameserverResponse.
|
||||
// queryNameserver queries a specific nameserver for the record types
|
||||
// in qtypes and builds a NameserverResponse.
|
||||
func (r *Resolver) queryNameserver(
|
||||
ctx context.Context,
|
||||
nsHostname string,
|
||||
@@ -728,7 +715,7 @@ func (r *Resolver) queryNameserver(
|
||||
|
||||
hostname = dns.Fqdn(hostname)
|
||||
|
||||
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
|
||||
return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes)
|
||||
}
|
||||
|
||||
// QueryNameserverIP queries a nameserver by its IP address directly,
|
||||
@@ -745,33 +732,23 @@ func (r *Resolver) QueryNameserverIP(
|
||||
|
||||
hostname = dns.Fqdn(hostname)
|
||||
|
||||
return r.queryTypes(
|
||||
ctx, nsHostname, []string{nsIP}, hostname, recordTypes(),
|
||||
)
|
||||
return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes())
|
||||
}
|
||||
|
||||
// queryTypes asks the nameserver nsHostname, at its addresses nsIPs,
|
||||
// about hostname's records of each type in qtypes and builds a
|
||||
// NameserverResponse. With no address there is nothing to ask, and it
|
||||
// returns ErrNoNameserverAddress.
|
||||
func (r *Resolver) queryTypes(
|
||||
ctx context.Context,
|
||||
nsHostname string,
|
||||
nsIPs []string,
|
||||
nsIP string,
|
||||
hostname string,
|
||||
qtypes []uint16,
|
||||
) (*NameserverResponse, error) {
|
||||
if len(nsIPs) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNoNameserverAddress, nsHostname)
|
||||
}
|
||||
|
||||
resp := &NameserverResponse{
|
||||
Nameserver: nsHostname,
|
||||
Records: make(map[string][]string),
|
||||
Status: StatusOK,
|
||||
}
|
||||
|
||||
state := r.queryEachType(ctx, nsIPs, hostname, qtypes, resp)
|
||||
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
|
||||
classifyResponse(resp, state)
|
||||
|
||||
return resp, nil
|
||||
@@ -789,37 +766,28 @@ type queryState struct {
|
||||
answered bool
|
||||
}
|
||||
|
||||
// queryEachType asks the nameserver about hostname's records of each type
|
||||
// in qtypes, at its addresses nsIPs in turn until one gives a usable
|
||||
// reply, and lists in resp.FailedTypes the types that got none at any of
|
||||
// them, logging each with the reasons unless ctx was cancelled: shutdown
|
||||
// cancels it, and a query it cut short did not fail.
|
||||
// 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,
|
||||
nsIPs []string,
|
||||
nsIP string,
|
||||
hostname string,
|
||||
qtypes []uint16,
|
||||
resp *NameserverResponse,
|
||||
) queryState {
|
||||
var state queryState
|
||||
|
||||
// Reordered below; the caller's list is left as it was.
|
||||
nsIPs = slices.Clone(nsIPs)
|
||||
|
||||
for _, qtype := range qtypes {
|
||||
if checkCtx(ctx) != nil {
|
||||
break
|
||||
}
|
||||
|
||||
answeredAt, err := r.querySingleType(
|
||||
ctx, nsIPs, hostname, qtype, resp, &state,
|
||||
)
|
||||
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
|
||||
if err == nil {
|
||||
state.answered = true
|
||||
|
||||
// The next type is asked first at the address that answered.
|
||||
nsIPs[0], nsIPs[answeredAt] = nsIPs[answeredAt], nsIPs[0]
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -853,30 +821,20 @@ func (r *Resolver) queryEachType(
|
||||
return state
|
||||
}
|
||||
|
||||
// querySingleType asks the nameserver about hostname's records of type
|
||||
// qtype at its addresses nsIPs, of which there is at least one, one at a
|
||||
// time, until one answers: with records, with none, or with NXDOMAIN.
|
||||
// It returns the index in nsIPs of the address that answered; when none
|
||||
// did, it returns why each did not, each reason naming its address.
|
||||
// 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,
|
||||
nsIPs []string,
|
||||
nsIP string,
|
||||
hostname string,
|
||||
qtype uint16,
|
||||
resp *NameserverResponse,
|
||||
state *queryState,
|
||||
) (int, error) {
|
||||
var errs []error
|
||||
|
||||
for i, nsIP := range nsIPs {
|
||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||
|
||||
) error {
|
||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
err = readReply(msg, resp, state)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err)
|
||||
}
|
||||
case isTimeout(err):
|
||||
state.gotTimeout = true
|
||||
case errors.Is(err, ErrRefused):
|
||||
@@ -885,14 +843,10 @@ func (r *Resolver) querySingleType(
|
||||
state.netErr = err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return i, nil
|
||||
}
|
||||
|
||||
errs = append(errs, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return 0, errors.Join(errs...)
|
||||
return readReply(msg, resp, state)
|
||||
}
|
||||
|
||||
// readReply adds to resp the records in msg, a nameserver's reply to a
|
||||
@@ -986,15 +940,17 @@ func isTimeout(err error) bool {
|
||||
}
|
||||
|
||||
// classifyResponse sets the nameserver's status. One that answered no
|
||||
// record type has failed, and Error says why; it says all queries timed
|
||||
// out only when every query, at every address, did. One that answered
|
||||
// some has the status of those answers. It has no data only when every type
|
||||
// record type has failed, and Error says why; one that answered some has
|
||||
// the status of those answers. It has no data only when every type
|
||||
// answered with no records: a type in FailedTypes may have records, so a
|
||||
// nameserver with one stays ok.
|
||||
func classifyResponse(resp *NameserverResponse, state queryState) {
|
||||
switch {
|
||||
case state.gotNXDomain && !state.hasRecords:
|
||||
resp.Status = StatusNXDomain
|
||||
case state.gotTimeout && !state.answered:
|
||||
resp.Status = StatusTimeout
|
||||
resp.Error = "all queries timed out"
|
||||
case state.gotErrorReply && !state.answered:
|
||||
resp.Status = StatusError
|
||||
resp.Error = "server returned " + state.errorReply
|
||||
@@ -1007,9 +963,6 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
||||
case state.gotReferral && !state.answered:
|
||||
resp.Status = StatusError
|
||||
resp.Error = "server returned a referral"
|
||||
case state.gotTimeout && !state.answered:
|
||||
resp.Status = StatusTimeout
|
||||
resp.Error = "all queries timed out"
|
||||
// An NXDOMAIN reply with no records was taken by the first case.
|
||||
case !state.hasRecords && len(resp.FailedTypes) == 0:
|
||||
resp.Status = StatusNoData
|
||||
@@ -1114,7 +1067,7 @@ func (r *Resolver) LookupNS(
|
||||
}
|
||||
|
||||
return r.followDelegation(
|
||||
ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(),
|
||||
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,8 @@ import (
|
||||
// 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, and is ok, not nodata: that type may have records. One whose
|
||||
// every query got none has failed, and its error says all queries timed
|
||||
// out only when every one did: a reply such as a refusal, or a network
|
||||
// error, at one of its addresses is given instead. Only one whose every
|
||||
// type answered with no records is nodata.
|
||||
// every query got none has failed. Only one whose every type answered
|
||||
// with no records is nodata.
|
||||
func TestClassifyResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -29,46 +27,63 @@ func TestClassifyResponse(t *testing.T) {
|
||||
wantStatus string
|
||||
wantError string
|
||||
}{
|
||||
{"every type answered with no records",
|
||||
{
|
||||
"every type answered with no records",
|
||||
queryState{answered: true},
|
||||
nil, StatusNoData, ""},
|
||||
{"some types answered with no records, another timed out",
|
||||
nil,
|
||||
StatusNoData, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another timed out",
|
||||
queryState{answered: true, gotTimeout: true},
|
||||
[]string{"A"}, StatusOK, ""},
|
||||
{"some types answered with no records, another got SERVFAIL",
|
||||
queryState{answered: true, gotErrorReply: true, errorReply: "SERVFAIL"},
|
||||
[]string{"A"}, StatusOK, ""},
|
||||
{"some types answered with no records, another was refused",
|
||||
[]string{"A"},
|
||||
StatusOK, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another got SERVFAIL",
|
||||
queryState{
|
||||
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
|
||||
},
|
||||
[]string{"A"},
|
||||
StatusOK, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another was refused",
|
||||
queryState{answered: true, gotRefused: true},
|
||||
[]string{"A"}, StatusOK, ""},
|
||||
{"some types answered with no records, another got a network error",
|
||||
[]string{"A"},
|
||||
StatusOK, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another got a network error",
|
||||
queryState{answered: true, netErr: syscall.ECONNREFUSED},
|
||||
[]string{"A"}, StatusOK, ""},
|
||||
{"some types answered with no records, another's reply was " +
|
||||
"truncated and its retry over TCP failed",
|
||||
[]string{"A"},
|
||||
StatusOK, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another's reply was " +
|
||||
"truncated and its retry over TCP failed",
|
||||
queryState{answered: true, netErr: ErrTruncated},
|
||||
[]string{"TXT"}, StatusOK, ""},
|
||||
{"some types answered with no records, another got a referral",
|
||||
[]string{"TXT"},
|
||||
StatusOK, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another got a referral",
|
||||
queryState{answered: true, gotReferral: true},
|
||||
[]string{"A"}, StatusOK, ""},
|
||||
{"every query timed out",
|
||||
[]string{"A"},
|
||||
StatusOK, "",
|
||||
},
|
||||
{
|
||||
"every query timed out",
|
||||
queryState{gotTimeout: true},
|
||||
[]string{"A", "AAAA", "CNAME"}, StatusTimeout, "all queries timed out"},
|
||||
{"every query got NOTIMP",
|
||||
[]string{"A", "AAAA", "CNAME"},
|
||||
StatusTimeout, "all queries timed out",
|
||||
},
|
||||
{
|
||||
"every query got NOTIMP",
|
||||
queryState{gotErrorReply: true, errorReply: "NOTIMP"},
|
||||
[]string{"A", "AAAA", "CNAME"}, StatusError, "server returned NOTIMP"},
|
||||
{"every query got SERVFAIL at one address, timed out at another",
|
||||
queryState{gotErrorReply: true, errorReply: "SERVFAIL", gotTimeout: true},
|
||||
[]string{"A"}, StatusError, "server returned SERVFAIL"},
|
||||
{"every query was refused at one address, timed out at another",
|
||||
queryState{gotRefused: true, gotTimeout: true},
|
||||
[]string{"A"}, StatusError, "server returned REFUSED"},
|
||||
{"every query got a network error at one address, timed out at another",
|
||||
queryState{netErr: syscall.ECONNREFUSED, gotTimeout: true},
|
||||
[]string{"A"}, StatusError, "network error: connection refused"},
|
||||
{"every query got a referral at one address, timed out at another",
|
||||
queryState{gotReferral: true, gotTimeout: true},
|
||||
[]string{"A"}, StatusError, "server returned a referral"},
|
||||
[]string{"A", "AAAA", "CNAME"},
|
||||
StatusError, "server returned NOTIMP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -121,9 +121,7 @@ func TestFindAuthoritativeNameservers_DelegatedSubdomain(
|
||||
// delegation of its own, so the lookup returns that walk's error, about
|
||||
// www.google.com, and tries no parent name: trying google.com and com
|
||||
// would end in ErrNoNameservers, or in the error of a walk for one of
|
||||
// them. The servers that did not answer are those the walk started at,
|
||||
// which no referral named, so the error is no NoAnswerError, as when
|
||||
// the host running dnswatcher has lost its network.
|
||||
// them.
|
||||
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -136,9 +134,6 @@ func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
||||
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
|
||||
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
|
||||
assert.Empty(t, nameservers)
|
||||
|
||||
var noAnswer *resolver.NoAnswerError
|
||||
assert.NotErrorAs(t, err, &noAnswer)
|
||||
}
|
||||
|
||||
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
||||
@@ -360,124 +355,6 @@ func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) {
|
||||
assert.Equal(t, resolver.StatusOK, resp.Status)
|
||||
}
|
||||
|
||||
// TestQueryTypes_FirstAddressNeverAnswers asks a google.com nameserver
|
||||
// about google.com's A and AAAA records at a list of addresses that puts
|
||||
// 192.0.2.1, where nothing answers, before the nameserver's own. The
|
||||
// nameserver answers at its own address, so it is ok, with its records.
|
||||
// Each attempt has time for one round of tries at 192.0.2.1, two tries
|
||||
// of two seconds, and not for two, so AAAA must be asked first at the
|
||||
// address that answered A.
|
||||
func TestQueryTypes_FirstAddressNeverAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
ns := findOneNSForDomain(t, r, "google.com")
|
||||
nsIPs := append(
|
||||
[]string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)...,
|
||||
)
|
||||
|
||||
var resp *resolver.NameserverResponse
|
||||
|
||||
livednstest.Retry(
|
||||
t,
|
||||
"QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", google.com)",
|
||||
func(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
|
||||
resp, err = r.QueryTypes(
|
||||
ctx, ns, nsIPs, "google.com.",
|
||||
[]uint16{dns.TypeA, dns.TypeAAAA},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(resp.FailedTypes) > 0 {
|
||||
return fmt.Errorf(
|
||||
"%w: %s gave no usable reply for %s",
|
||||
livednstest.ErrNoAnswer, ns,
|
||||
strings.Join(resp.FailedTypes, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
assert.Equal(t, resolver.StatusOK, resp.Status)
|
||||
assert.NotEmpty(t, resp.Records["A"])
|
||||
assert.NotEmpty(t, resp.Records["AAAA"])
|
||||
}
|
||||
|
||||
// TestQueryTypes_EveryAddressFails asks a google.com nameserver about
|
||||
// cloudflare.com, a zone it does not serve, at 192.0.2.1, where nothing
|
||||
// answers, and then at its own address, which refuses. The nameserver
|
||||
// has failed with the refusal, not a timeout, and the warning about the
|
||||
// type gives the reason at each address.
|
||||
func TestQueryTypes_EveryAddressFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logs bytes.Buffer
|
||||
|
||||
r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
ns := findOneNSForDomain(t, r, "google.com")
|
||||
nsIPs := append(
|
||||
[]string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)...,
|
||||
)
|
||||
|
||||
var resp *resolver.NameserverResponse
|
||||
|
||||
livednstest.Retry(
|
||||
t,
|
||||
"QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", cloudflare.com)",
|
||||
func(ctx context.Context) error {
|
||||
logs.Reset()
|
||||
|
||||
var err error
|
||||
|
||||
resp, err = r.QueryTypes(
|
||||
ctx, ns, nsIPs, "cloudflare.com.", []uint16{dns.TypeA},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A timeout or a network error at its own address is no
|
||||
// reply at all.
|
||||
if resp.Status == resolver.StatusTimeout ||
|
||||
strings.HasPrefix(resp.Error, "network error") {
|
||||
return fmt.Errorf(
|
||||
"%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
assert.Equal(t, resolver.StatusError, resp.Status)
|
||||
assert.Equal(t, "server returned REFUSED", resp.Error)
|
||||
assert.Contains(t, logs.String(), "@"+noAnswerAddress+": ")
|
||||
assert.Contains(t, logs.String(), "@"+nsIPs[1]+": ")
|
||||
}
|
||||
|
||||
// TestQueryTypes_NoAddress asks a nameserver given no address. There is
|
||||
// nothing to ask it at, so it has failed, with an error that says it has
|
||||
// no address; it is not ok.
|
||||
func TestQueryTypes_NoAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp, err := newTestResolver(t).QueryTypes(
|
||||
t.Context(), "ns1.example.net.", nil, "www.example.net.",
|
||||
[]uint16{dns.TypeA},
|
||||
)
|
||||
require.ErrorIs(t, err, resolver.ErrNoNameserverAddress)
|
||||
assert.Nil(t, resp)
|
||||
}
|
||||
|
||||
func TestQueryNameserver_AAAA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -542,9 +419,8 @@ func TestQueryNameserver_TXT(t *testing.T) {
|
||||
// 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, which names the address whose reply was
|
||||
// truncated, while the nameserver, which answered the other types, is
|
||||
// ok.
|
||||
// logged with the reason, while the nameserver, which answered the other
|
||||
// types, is ok.
|
||||
func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -561,13 +437,6 @@ func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
|
||||
assert.Contains(t, logs.String(),
|
||||
"hostname=google.com. nameserver="+ns+" type=TXT error=",
|
||||
)
|
||||
|
||||
nsIPs := liveResolveNSIPs(t, newTestResolver(t), []string{ns}, 1)
|
||||
for _, ip := range nsIPs {
|
||||
assert.Contains(t, logs.String(),
|
||||
"@"+ip+": "+resolver.ErrTruncated.Error(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryNameserver_NXDomain(t *testing.T) {
|
||||
@@ -1085,34 +954,12 @@ func TestFollowDelegation_NoAnswer(t *testing.T) {
|
||||
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
|
||||
|
||||
nameservers, err := r.FollowDelegation(
|
||||
t.Context(), "google.com.", ".", nil, []string{noAnswerAddress},
|
||||
t.Context(), "google.com.", []string{noAnswerAddress},
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, nameservers)
|
||||
}
|
||||
|
||||
// TestFollowDelegation_NoAnswerAfterReferral starts the walk for
|
||||
// www.example.net at the servers of example.net as a referral names
|
||||
// them, ns1.example.net and ns2.example.net, at 192.0.2.1, where nothing
|
||||
// answers. The error is a NoAnswerError naming them, which the watcher
|
||||
// compares with the nameservers it saved for the name.
|
||||
func TestFollowDelegation_NoAnswerAfterReferral(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
|
||||
nameservers := []string{"ns1.example.net.", "ns2.example.net."}
|
||||
|
||||
_, err := r.FollowDelegation(
|
||||
t.Context(), "www.example.net.", "example.net.", nameservers,
|
||||
[]string{noAnswerAddress},
|
||||
)
|
||||
|
||||
var noAnswer *resolver.NoAnswerError
|
||||
require.ErrorAs(t, err, &noAnswer)
|
||||
assert.Equal(t, nameservers, noAnswer.Nameservers)
|
||||
assert.Contains(t, err.Error(), "query www.example.net. @"+noAnswerAddress)
|
||||
}
|
||||
|
||||
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
|
||||
// of www.google.com as the fallback walk does. As in
|
||||
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
|
||||
|
||||
@@ -122,34 +122,6 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
|
||||
w.runTLSChecks(ctx)
|
||||
}
|
||||
|
||||
// CheckHostnameResult exports checkHostnameResult for testing.
|
||||
func (w *Watcher) CheckHostnameResult(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
results map[string]*resolver.NameserverResponse,
|
||||
err error,
|
||||
) bool {
|
||||
return w.checkHostnameResult(ctx, hostname, results, err)
|
||||
}
|
||||
|
||||
// CheckDomainResult exports checkDomainResult for testing.
|
||||
func (w *Watcher) CheckDomainResult(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
nameservers []string,
|
||||
err error,
|
||||
) {
|
||||
w.checkDomainResult(ctx, domain, nameservers, err)
|
||||
}
|
||||
|
||||
// FailedNameservers exports failedNameservers for testing.
|
||||
func FailedNameservers(
|
||||
err error,
|
||||
prev *state.HostnameState,
|
||||
) map[string]*resolver.NameserverResponse {
|
||||
return failedNameservers(err, prev)
|
||||
}
|
||||
|
||||
// BuildHostnameState exports buildHostnameState for testing.
|
||||
func BuildHostnameState(
|
||||
results map[string]*resolver.NameserverResponse,
|
||||
|
||||
@@ -20,10 +20,7 @@ type DNSResolver interface {
|
||||
) ([]string, error)
|
||||
|
||||
// LookupAllRecords queries all record types for a hostname,
|
||||
// returning each nameserver's response keyed by nameserver. When
|
||||
// the walk to the nameservers reached a zone through a referral and
|
||||
// none of that zone's nameservers answered, the error is a
|
||||
// *resolver.NoAnswerError naming them.
|
||||
// returning each nameserver's response keyed by nameserver.
|
||||
LookupAllRecords(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
|
||||
@@ -2,7 +2,6 @@ package watcher_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -15,10 +14,6 @@ import (
|
||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||
)
|
||||
|
||||
// errNoReply is why a walk that got no reply from a zone's servers
|
||||
// failed.
|
||||
var errNoReply = errors.New("all servers failed: i/o timeout")
|
||||
|
||||
// answered is what a check saves for a nameserver that answered with
|
||||
// these records.
|
||||
func answered(records map[string][]string) *state.NameserverRecordState {
|
||||
@@ -188,134 +183,6 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneNameserversAllStopAnswering runs three checks of host from
|
||||
// the point where its lookup has returned. On the first, nsA and nsB,
|
||||
// the nameservers of its zone, answer. On the second, the lookup fails
|
||||
// with the error the walk to them returns when neither answers (see
|
||||
// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given
|
||||
// here because no live zone has nameservers that never answer. For a
|
||||
// hostname it is the lookup of its records that fails, and for a domain
|
||||
// with no delegation of its own the lookup of its delegation. Both are
|
||||
// saved as failed, with the reason, and each gets an NS Failure. On the
|
||||
// third, both answer, and each gets an NS Recovery. A domain's records
|
||||
// are checked as a hostname's, so its first and third checks are too.
|
||||
func TestZoneNameserversAllStopAnswering(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := map[string][]string{"A": {ip1}}
|
||||
bothAnswer := map[string]*resolver.NameserverResponse{
|
||||
nsA: response(records), nsB: response(records),
|
||||
}
|
||||
noAnswer := &resolver.NoAnswerError{
|
||||
Nameservers: []string{nsA, nsB}, Err: errNoReply,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isDomain bool
|
||||
}{
|
||||
{"hostname", false},
|
||||
{"domain with no delegation of its own", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
if tt.isDomain {
|
||||
cfg.Domains = []string{host}
|
||||
} else {
|
||||
cfg.Hostnames = []string{host}
|
||||
}
|
||||
|
||||
// The watcher is given each lookup's result, so it has no
|
||||
// resolver.
|
||||
deps := newTestDeps(t, cfg)
|
||||
w := watcher.NewForTest(
|
||||
cfg, deps.state, nil,
|
||||
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||
)
|
||||
w.SetFirstRun(false)
|
||||
|
||||
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
|
||||
|
||||
if tt.isDomain {
|
||||
w.CheckDomainResult(t.Context(), host, nil, noAnswer)
|
||||
} else {
|
||||
w.CheckHostnameResult(t.Context(), host, nil, noAnswer)
|
||||
}
|
||||
|
||||
current, _ := deps.state.GetHostnameState(host)
|
||||
|
||||
for _, ns := range []string{nsA, nsB} {
|
||||
got := current.RecordsByNameserver[ns]
|
||||
if got.Status != failed().Status || got.Error != errNoReply.Error() {
|
||||
t.Errorf(
|
||||
"%s saved with status %q, error %q; want %q, %q",
|
||||
ns, got.Status, got.Error, failed().Status, errNoReply,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if n := countNotifications(deps, "NS Failure: "+host); n != 2 {
|
||||
t.Errorf("sent %d NS Failure notifications, want 2", n)
|
||||
}
|
||||
|
||||
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
|
||||
|
||||
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 {
|
||||
t.Errorf("sent %d NS Recovery notifications, want 2", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedNameserversOnlyForThoseOfThePreviousCheck gives
|
||||
// FailedNameservers lookup errors after a check in which nsA and nsB
|
||||
// answered. Only an error that names both makes them failed.
|
||||
func TestFailedNameserversOnlyForThoseOfThePreviousCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := map[string][]string{"A": {ip1}}
|
||||
bothAnswered := saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(records), nsB: answered(records),
|
||||
})
|
||||
noAnswer := func(nameservers ...string) error {
|
||||
return &resolver.NoAnswerError{Nameservers: nameservers, Err: errNoReply}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
prev *state.HostnameState
|
||||
wantFailed bool
|
||||
}{
|
||||
{"both did not answer", noAnswer(nsA, nsB), bothAnswered, true},
|
||||
{
|
||||
"the servers of a parent zone did not answer",
|
||||
noAnswer(nsC), bothAnswered, false,
|
||||
},
|
||||
{
|
||||
"the zone's delegation lists only nsA now",
|
||||
noAnswer(nsA), bothAnswered, false,
|
||||
},
|
||||
{"no root server answered", errNoReply, bothAnswered, false},
|
||||
{"no previous check", noAnswer(nsA, nsB), nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := watcher.FailedNameservers(tt.err, tt.prev)
|
||||
if (got != nil) != tt.wantFailed {
|
||||
t.Errorf("returned %v, want failed: %v", got, tt.wantFailed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNameserverThatNeverAnswers asks a nameserver address where
|
||||
// nothing answers, 192.0.2.1, and checks what the watcher saves for it.
|
||||
// The deadline outlasts the resolver's first two-second try, as in the
|
||||
|
||||
@@ -291,25 +291,12 @@ func (w *Watcher) logFailedLookup(
|
||||
w.log.Error(msg, args...)
|
||||
}
|
||||
|
||||
// checkDomain looks up domain's delegation and checks domain with it
|
||||
// (see checkDomainResult).
|
||||
func (w *Watcher) checkDomain(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
) {
|
||||
nameservers, err := w.resolver.LookupNS(ctx, domain)
|
||||
|
||||
w.checkDomainResult(ctx, domain, nameservers, err)
|
||||
}
|
||||
|
||||
// checkDomainResult finishes a check of domain from nameservers and err,
|
||||
// what the lookup of its delegation returned.
|
||||
func (w *Watcher) checkDomainResult(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
nameservers []string,
|
||||
err error,
|
||||
) {
|
||||
// A domain that does not exist has no nameservers.
|
||||
nxdomain := errors.Is(err, resolver.ErrNXDomain)
|
||||
if nxdomain {
|
||||
@@ -324,12 +311,6 @@ func (w *Watcher) checkDomainResult(
|
||||
"error", err,
|
||||
)
|
||||
|
||||
// For a domain with no delegation of its own, the servers that
|
||||
// did not answer can be those of the zone it is in, at which
|
||||
// its records are asked. They are then saved as failed, as a
|
||||
// hostname's are.
|
||||
w.checkHostnameResult(ctx, domain, nil, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -483,8 +464,8 @@ func (w *Watcher) detectNSAddressChanges(
|
||||
}
|
||||
}
|
||||
|
||||
// checkHostname looks up hostname's records and checks them (see
|
||||
// checkHostnameResult). It reports whether it saved them.
|
||||
// checkHostname checks hostname's records and reports whether it saved
|
||||
// them; it does not when their lookup fails.
|
||||
func (w *Watcher) checkHostname(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
@@ -497,31 +478,12 @@ func (w *Watcher) checkHostname(
|
||||
"hostname", hostname,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return w.checkHostnameResult(ctx, hostname, results, err)
|
||||
}
|
||||
|
||||
// checkHostnameResult finishes a check of hostname from results and err,
|
||||
// what the lookup of its records returned, and reports whether it saved
|
||||
// them. When the lookup failed, it saves nothing, unless the nameservers
|
||||
// that did not answer are those of the previous check (see
|
||||
// failedNameservers).
|
||||
func (w *Watcher) checkHostnameResult(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
results map[string]*resolver.NameserverResponse,
|
||||
err error,
|
||||
) bool {
|
||||
prev, _ := w.state.GetHostnameState(hostname)
|
||||
|
||||
if err != nil {
|
||||
results = failedNameservers(err, prev)
|
||||
if results == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
w.updateHostnameState(
|
||||
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
||||
)
|
||||
@@ -529,40 +491,6 @@ func (w *Watcher) checkHostnameResult(
|
||||
return true
|
||||
}
|
||||
|
||||
// failedNameservers returns a failed response for each nameserver in
|
||||
// prev, a name's state from the previous check, with err as the reason,
|
||||
// when err says that the walk to the name's nameservers reached their
|
||||
// zone and none of them answered. For any other error, as when the walk
|
||||
// got no answer before it reached their zone, or when prev is nil, it
|
||||
// returns nil.
|
||||
func failedNameservers(
|
||||
err error,
|
||||
prev *state.HostnameState,
|
||||
) map[string]*resolver.NameserverResponse {
|
||||
var noAnswer *resolver.NoAnswerError
|
||||
if !errors.As(err, &noAnswer) || prev == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
saved := slices.Sorted(maps.Keys(prev.RecordsByNameserver))
|
||||
if !slices.Equal(noAnswer.Nameservers, saved) {
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make(map[string]*resolver.NameserverResponse)
|
||||
|
||||
for _, ns := range saved {
|
||||
results[ns] = &resolver.NameserverResponse{
|
||||
Nameserver: ns,
|
||||
Records: make(map[string][]string),
|
||||
Status: resolver.StatusError,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// updateHostnameState finishes a check of hostname from newState, built
|
||||
// from its nameservers' answers: it follows the CNAME in them, notifies
|
||||
// what changed since the previous check, and saves newState.
|
||||
@@ -862,7 +790,7 @@ func (w *Watcher) detectRecordChanges(
|
||||
// saved delegation, which detectNSChanges notifies when they are removed
|
||||
// from it. checkDomain saves a new delegation only after this runs, and
|
||||
// only together with the records, so the saved delegation and the
|
||||
// nameservers in prev come from the same check.
|
||||
// records in prev come from the same check.
|
||||
func (w *Watcher) detectNSDisappearances(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
|
||||
Reference in New Issue
Block a user