resolver: a nameserver is asked at each of its addresses in turn (closes #285)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A nameserver's queries went only to the first IPv4 address its name resolves to, so one unreachable address made it fail whenever that address came first in the answer. Each record type is now asked at the addresses one at a time until one gives a usable reply, and the next type is asked first at the address that gave it. The README's DNS Resolution Strategy says which addresses are asked. Model: opus-5-5
This commit is contained in:
@@ -133,18 +133,19 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
- 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
|
- 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
|
but the query for another gets no usable reply at any of its addresses (no
|
||||||
error reply such as SERVFAIL, a referral, or a reply too large for UDP whose
|
reply after two tries, an error reply such as SERVFAIL, a referral, or a reply
|
||||||
retry over TCP fails), the failure is logged with the reason, and the type is
|
too large for UDP whose retry over TCP fails), the failure is logged with the
|
||||||
listed in the nameserver's `failedTypes` and keeps the records saved for the
|
reason, and the type is listed in the nameserver's `failedTypes` and keeps the
|
||||||
nameserver by the previous check. On that check those records are not compared
|
records saved for the nameserver by the previous check. On that check those
|
||||||
with the other nameservers', so no record change or inconsistency is reported
|
records are not compared with the other nameservers', so no record change or
|
||||||
for the type; on the next check they are compared with the nameserver's answer
|
inconsistency is reported for the type; on the next check they are compared
|
||||||
as usual. When the previous check did not know the type's records either,
|
with the nameserver's answer as usual. When the previous check did not know
|
||||||
because the nameserver was new or failing then or the type was already listed
|
the type's records either, because the nameserver was new or failing then or
|
||||||
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
|
the type was already listed in `unknownTypes`, the type is also listed in
|
||||||
every comparison until it answers. A nameserver none of whose queries got a
|
`unknownTypes` and left out of every comparison until it answers. A nameserver
|
||||||
usable reply has failed (see NS query failure below).
|
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
|
||||||
@@ -549,6 +550,11 @@ 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
|
lookup can need others in turn; lookups go at most three deep, one inside
|
||||||
another, so delegations that point at each other still end.
|
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:
|
This approach ensures:
|
||||||
|
|
||||||
- Independence from any upstream resolver's cache or filtering.
|
- Independence from any upstream resolver's cache or filtering.
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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).
|
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
|
- 2026-10-13: an IPv6 address is written in brackets before its port, as in
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ func (r *Resolver) QueryServers(
|
|||||||
return r.queryServers(ctx, servers, zone, name, qtype)
|
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.
|
// QueryEachNS exports queryEachNS for testing.
|
||||||
func (r *Resolver) QueryEachNS(
|
func (r *Resolver) QueryEachNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -708,8 +708,9 @@ func (r *Resolver) QueryNameserver(
|
|||||||
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
|
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryNameserver queries a specific nameserver for the record types
|
// queryNameserver queries a specific nameserver, at the IPv4 addresses
|
||||||
// in qtypes and builds a NameserverResponse.
|
// its name resolves to, for the record types in qtypes and builds a
|
||||||
|
// NameserverResponse.
|
||||||
func (r *Resolver) queryNameserver(
|
func (r *Resolver) queryNameserver(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsHostname string,
|
nsHostname string,
|
||||||
@@ -727,7 +728,7 @@ func (r *Resolver) queryNameserver(
|
|||||||
|
|
||||||
hostname = dns.Fqdn(hostname)
|
hostname = dns.Fqdn(hostname)
|
||||||
|
|
||||||
return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes)
|
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryNameserverIP queries a nameserver by its IP address directly,
|
// QueryNameserverIP queries a nameserver by its IP address directly,
|
||||||
@@ -744,13 +745,18 @@ func (r *Resolver) QueryNameserverIP(
|
|||||||
|
|
||||||
hostname = dns.Fqdn(hostname)
|
hostname = dns.Fqdn(hostname)
|
||||||
|
|
||||||
return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes())
|
return r.queryTypes(
|
||||||
|
ctx, nsHostname, []string{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.
|
||||||
func (r *Resolver) queryTypes(
|
func (r *Resolver) queryTypes(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsHostname string,
|
nsHostname string,
|
||||||
nsIP string,
|
nsIPs []string,
|
||||||
hostname string,
|
hostname string,
|
||||||
qtypes []uint16,
|
qtypes []uint16,
|
||||||
) (*NameserverResponse, error) {
|
) (*NameserverResponse, error) {
|
||||||
@@ -760,7 +766,7 @@ func (r *Resolver) queryTypes(
|
|||||||
Status: StatusOK,
|
Status: StatusOK,
|
||||||
}
|
}
|
||||||
|
|
||||||
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
|
state := r.queryEachType(ctx, nsIPs, hostname, qtypes, resp)
|
||||||
classifyResponse(resp, state)
|
classifyResponse(resp, state)
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -778,28 +784,37 @@ type queryState struct {
|
|||||||
answered bool
|
answered bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryEachType asks the nameserver at nsIP about hostname once for each
|
// queryEachType asks the nameserver at its addresses nsIPs about hostname
|
||||||
// record type in qtypes, and lists in resp.FailedTypes the types whose
|
// once for each record type in qtypes, and lists in resp.FailedTypes the
|
||||||
// query got no usable reply, logging each with the reason unless ctx was
|
// types whose query got no usable reply at any of them, logging each with
|
||||||
// cancelled: shutdown cancels it, and a query it cut short did not fail.
|
// 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,
|
nsIPs []string,
|
||||||
hostname string,
|
hostname string,
|
||||||
qtypes []uint16,
|
qtypes []uint16,
|
||||||
resp *NameserverResponse,
|
resp *NameserverResponse,
|
||||||
) queryState {
|
) queryState {
|
||||||
var state queryState
|
var state queryState
|
||||||
|
|
||||||
|
// Reordered below; the caller's list is left as it was.
|
||||||
|
nsIPs = slices.Clone(nsIPs)
|
||||||
|
|
||||||
for _, qtype := range qtypes {
|
for _, qtype := range qtypes {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
|
answeredAt, err := r.querySingleType(
|
||||||
|
ctx, nsIPs, hostname, qtype, resp, &state,
|
||||||
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
state.answered = true
|
state.answered = true
|
||||||
|
|
||||||
|
// The next type is asked first at the address that answered.
|
||||||
|
nsIPs[0], nsIPs[answeredAt] = nsIPs[answeredAt], nsIPs[0]
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,20 +848,29 @@ func (r *Resolver) queryEachType(
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
// querySingleType asks the nameserver at nsIP about hostname's records
|
// querySingleType asks the nameserver about hostname's records of type
|
||||||
// of type qtype. It returns nil when the nameserver answered: with
|
// qtype at its addresses nsIPs, one at a time, until one answers: with
|
||||||
// records, with none, or with NXDOMAIN; otherwise it returns why not.
|
// records, with none, or with NXDOMAIN. It returns the index in nsIPs of
|
||||||
|
// the address that answered; when none did, it returns why the last one
|
||||||
|
// did not.
|
||||||
func (r *Resolver) querySingleType(
|
func (r *Resolver) querySingleType(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsIP string,
|
nsIPs []string,
|
||||||
hostname string,
|
hostname string,
|
||||||
qtype uint16,
|
qtype uint16,
|
||||||
resp *NameserverResponse,
|
resp *NameserverResponse,
|
||||||
state *queryState,
|
state *queryState,
|
||||||
) error {
|
) (int, error) {
|
||||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
var err error
|
||||||
if err != nil {
|
|
||||||
|
for i, nsIP := range nsIPs {
|
||||||
|
var msg *dns.Msg
|
||||||
|
|
||||||
|
msg, err = r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
err = readReply(msg, resp, state)
|
||||||
case isTimeout(err):
|
case isTimeout(err):
|
||||||
state.gotTimeout = true
|
state.gotTimeout = true
|
||||||
case errors.Is(err, ErrRefused):
|
case errors.Is(err, ErrRefused):
|
||||||
@@ -855,10 +879,12 @@ func (r *Resolver) querySingleType(
|
|||||||
state.netErr = err
|
state.netErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
if err == nil {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return readReply(msg, resp, state)
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// readReply adds to resp the records in msg, a nameserver's reply to a
|
// readReply adds to resp the records in msg, a nameserver's reply to a
|
||||||
|
|||||||
@@ -360,6 +360,58 @@ func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) {
|
|||||||
assert.Equal(t, resolver.StatusOK, resp.Status)
|
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"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueryNameserver_AAAA(t *testing.T) {
|
func TestQueryNameserver_AAAA(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user