Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 24117ae646 resolver: a nameserver is asked at each of its addresses in turn (closes #285)
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. A type that fails is
logged with the reason at each address, and a nameserver is reported as
timed out only when every query timed out. The README's DNS Resolution
Strategy says which addresses are asked.

Model: opus-5-5
2026-10-15 10:51:40 +00:00
3 changed files with 91 additions and 17 deletions
+25 -17
View File
@@ -784,11 +784,11 @@ type queryState struct {
answered bool answered bool
} }
// queryEachType asks the nameserver at its addresses nsIPs about hostname // queryEachType asks the nameserver about hostname's records of each type
// once for each record type in qtypes, and lists in resp.FailedTypes the // in qtypes, at its addresses nsIPs in turn until one gives a usable
// types whose query got no usable reply at any of them, logging each with // reply, and lists in resp.FailedTypes the types that got none at any of
// the reason unless ctx was cancelled: shutdown cancels it, and a query // them, logging each with the reasons unless ctx was cancelled: shutdown
// it cut short did not fail. // 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,
nsIPs []string, nsIPs []string,
@@ -851,8 +851,8 @@ func (r *Resolver) queryEachType(
// querySingleType asks the nameserver about hostname's records of type // querySingleType asks the nameserver about hostname's records of type
// qtype at its addresses nsIPs, one at a time, until one answers: with // qtype at its addresses nsIPs, one at a time, until one answers: with
// records, with none, or with NXDOMAIN. It returns the index in nsIPs of // 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 // the address that answered; when none did, it returns why each did not,
// did not. // each reason naming its address.
func (r *Resolver) querySingleType( func (r *Resolver) querySingleType(
ctx context.Context, ctx context.Context,
nsIPs []string, nsIPs []string,
@@ -861,16 +861,21 @@ func (r *Resolver) querySingleType(
resp *NameserverResponse, resp *NameserverResponse,
state *queryState, state *queryState,
) (int, error) { ) (int, error) {
var err error if len(nsIPs) == 0 {
return 0, ErrNoNameserverAnswered
}
var errs []error
for i, nsIP := range nsIPs { for i, nsIP := range nsIPs {
var msg *dns.Msg msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
msg, err = r.queryDNS(ctx, nsIP, hostname, qtype)
switch { switch {
case err == nil: case err == nil:
err = readReply(msg, resp, state) err = readReply(msg, resp, state)
if err != nil {
err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err)
}
case isTimeout(err): case isTimeout(err):
state.gotTimeout = true state.gotTimeout = true
case errors.Is(err, ErrRefused): case errors.Is(err, ErrRefused):
@@ -882,9 +887,11 @@ func (r *Resolver) querySingleType(
if err == nil { if err == nil {
return i, nil return i, nil
} }
errs = append(errs, err)
} }
return 0, err return 0, errors.Join(errs...)
} }
// 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
@@ -978,17 +985,15 @@ func isTimeout(err error) bool {
} }
// classifyResponse sets the nameserver's status. One that answered no // classifyResponse sets the nameserver's status. One that answered no
// record type has failed, and Error says why; one that answered some has // record type has failed, and Error says why; it says all queries timed
// the status of those answers. It has no data only when every type // 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
// answered with no records: a type in FailedTypes may have records, so a // answered with no records: a type in FailedTypes may have records, so a
// nameserver with one stays ok. // nameserver with one stays ok.
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:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotErrorReply && !state.answered: case state.gotErrorReply && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned " + state.errorReply resp.Error = "server returned " + state.errorReply
@@ -1001,6 +1006,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotReferral && !state.answered: case state.gotReferral && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned a referral" resp.Error = "server returned a referral"
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
// An NXDOMAIN reply with no records was taken by the first case. // An NXDOMAIN reply with no records was taken by the first case.
case !state.hasRecords && len(resp.FailedTypes) == 0: case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData resp.Status = StatusNoData
@@ -100,6 +100,20 @@ func TestClassifyResponse(t *testing.T) {
} }
} }
// TestClassifyResponse_RefusedAndTimedOut sets the status of a
// nameserver one of whose addresses refused every query while another
// never replied. It has failed with the refusal: not every query timed
// out.
func TestClassifyResponse_RefusedAndTimedOut(t *testing.T) {
t.Parallel()
resp := &NameserverResponse{Status: StatusOK, FailedTypes: []string{"A"}}
classifyResponse(resp, queryState{gotRefused: true, gotTimeout: true})
assert.Equal(t, StatusError, resp.Status)
assert.Equal(t, "server returned REFUSED", resp.Error)
}
// TestReadReply checks which replies to a query about one record type, // TestReadReply checks which replies to a query about one record type,
// built here, are an answer: one with the code NOERROR or NXDOMAIN. A // 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 // reply with any other code is not, and the type's query has failed; a
+52
View File
@@ -412,6 +412,58 @@ func TestQueryTypes_FirstAddressNeverAnswers(t *testing.T) {
assert.NotEmpty(t, resp.Records["AAAA"]) 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]+": ")
}
func TestQueryNameserver_AAAA(t *testing.T) { func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel() t.Parallel()