Compare commits
1
Commits
next
...
623e7b2c69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623e7b2c69 |
@@ -133,18 +133,19 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
- Queries **each** authoritative nameserver independently for **all** record
|
||||
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||
- Each record type is a query of its own. When a nameserver answers some types
|
||||
but the query for another gets no usable reply (no reply after two tries, an
|
||||
error reply such as SERVFAIL, a referral, or a reply too large for UDP whose
|
||||
retry over TCP fails), the failure is logged with the reason, and the type is
|
||||
listed in the nameserver's `failedTypes` and keeps the records saved for the
|
||||
nameserver by the previous check. On that check those records are not compared
|
||||
with the other nameservers', so no record change or inconsistency is reported
|
||||
for the type; on the next check they are compared with the nameserver's answer
|
||||
as usual. When the previous check did not know the type's records either,
|
||||
because the nameserver was new or failing then or the type was already listed
|
||||
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
|
||||
every comparison until it answers. A nameserver none of whose queries got a
|
||||
usable reply has failed (see NS query failure below).
|
||||
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).
|
||||
- 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
|
||||
@@ -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
|
||||
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,6 +19,8 @@ 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
|
||||
|
||||
@@ -96,6 +96,17 @@ 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,
|
||||
|
||||
@@ -708,8 +708,9 @@ func (r *Resolver) QueryNameserver(
|
||||
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
|
||||
}
|
||||
|
||||
// queryNameserver queries a specific nameserver for the record types
|
||||
// in qtypes and builds a NameserverResponse.
|
||||
// queryNameserver queries a specific nameserver, at the IPv4 addresses
|
||||
// its name resolves to, for the record types in qtypes and builds a
|
||||
// NameserverResponse.
|
||||
func (r *Resolver) queryNameserver(
|
||||
ctx context.Context,
|
||||
nsHostname string,
|
||||
@@ -727,7 +728,7 @@ func (r *Resolver) queryNameserver(
|
||||
|
||||
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,
|
||||
@@ -744,13 +745,18 @@ func (r *Resolver) QueryNameserverIP(
|
||||
|
||||
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(
|
||||
ctx context.Context,
|
||||
nsHostname string,
|
||||
nsIP string,
|
||||
nsIPs []string,
|
||||
hostname string,
|
||||
qtypes []uint16,
|
||||
) (*NameserverResponse, error) {
|
||||
@@ -760,7 +766,7 @@ func (r *Resolver) queryTypes(
|
||||
Status: StatusOK,
|
||||
}
|
||||
|
||||
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
|
||||
state := r.queryEachType(ctx, nsIPs, hostname, qtypes, resp)
|
||||
classifyResponse(resp, state)
|
||||
|
||||
return resp, nil
|
||||
@@ -778,28 +784,37 @@ type queryState struct {
|
||||
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.
|
||||
// queryEachType asks the nameserver at its addresses nsIPs about hostname
|
||||
// once for each record type in qtypes, and lists in resp.FailedTypes the
|
||||
// types whose query got no usable reply at any of them, logging each with
|
||||
// the reason unless ctx was cancelled: shutdown cancels it, and a query
|
||||
// it cut short did not fail.
|
||||
func (r *Resolver) queryEachType(
|
||||
ctx context.Context,
|
||||
nsIP string,
|
||||
nsIPs []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
|
||||
}
|
||||
|
||||
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
|
||||
answeredAt, err := r.querySingleType(
|
||||
ctx, nsIPs, 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
|
||||
}
|
||||
|
||||
@@ -833,20 +848,29 @@ func (r *Resolver) queryEachType(
|
||||
return state
|
||||
}
|
||||
|
||||
// querySingleType asks the nameserver at nsIP about hostname's records
|
||||
// of type qtype. It returns nil when the nameserver answered: with
|
||||
// records, with none, or with NXDOMAIN; otherwise it returns why not.
|
||||
// querySingleType asks the nameserver about hostname's records of type
|
||||
// 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
|
||||
// the address that answered; when none did, it returns why the last one
|
||||
// did not.
|
||||
func (r *Resolver) querySingleType(
|
||||
ctx context.Context,
|
||||
nsIP string,
|
||||
nsIPs []string,
|
||||
hostname string,
|
||||
qtype uint16,
|
||||
resp *NameserverResponse,
|
||||
state *queryState,
|
||||
) error {
|
||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||
if err != nil {
|
||||
) (int, error) {
|
||||
var err error
|
||||
|
||||
for i, nsIP := range nsIPs {
|
||||
var msg *dns.Msg
|
||||
|
||||
msg, err = r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
err = readReply(msg, resp, state)
|
||||
case isTimeout(err):
|
||||
state.gotTimeout = true
|
||||
case errors.Is(err, ErrRefused):
|
||||
@@ -855,10 +879,12 @@ func (r *Resolver) querySingleType(
|
||||
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
|
||||
|
||||
@@ -360,6 +360,58 @@ 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"])
|
||||
}
|
||||
|
||||
func TestQueryNameserver_AAAA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user