resolver: query a hostname at its own zone's servers (closes #189) #191
@@ -61,7 +61,9 @@ rejected.
|
||||
- Accepts a list of DNS hostnames (subdomains, distinguished from apex
|
||||
domains via the Public Suffix List).
|
||||
- Every **1 hour**, performs a full iterative trace to discover the
|
||||
authoritative nameservers for the hostname's parent domain.
|
||||
authoritative nameservers of the zone the hostname is in, which is not
|
||||
always its last two labels (a name under `co.uk`, or in a delegated
|
||||
subdomain).
|
||||
- Queries **each** authoritative nameserver independently for **all**
|
||||
record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||
- Stores results **per nameserver**. The state for a hostname is not a
|
||||
|
||||
@@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-01: a hostname is queried at the servers of the zone it is in, found
|
||||
by following delegations for the name, not its last two labels (closes #189).
|
||||
- 2026-10-01: each nameserver's addresses are saved with its domain, and a
|
||||
change while it stays in the delegation is notified (closes #105).
|
||||
- 2026-10-01: the watcher saves state when it stops, and shutdown waits for that
|
||||
|
||||
@@ -17,7 +17,6 @@ const (
|
||||
maxRetries = 2
|
||||
maxDelegation = 20
|
||||
timeoutMultiplier = 2
|
||||
minDomainLabels = 2
|
||||
)
|
||||
|
||||
// ErrRefused is returned when a DNS server refuses a query.
|
||||
@@ -225,6 +224,15 @@ func (r *Resolver) followDelegation(
|
||||
return ansNS, nil
|
||||
}
|
||||
|
||||
// An authoritative reply comes from the servers of the zone
|
||||
// domain is in; it is not a referral, even when its authority
|
||||
// section lists that zone's NS records. Without NS records in
|
||||
// the answer, domain is not the zone's apex and has no
|
||||
// nameservers of its own.
|
||||
if resp.Authoritative {
|
||||
return nil, ErrNoNameservers
|
||||
}
|
||||
|
||||
authNS := extractNSSet(resp.Ns)
|
||||
if len(authNS) == 0 {
|
||||
return r.resolveNSIterative(ctx, domain)
|
||||
@@ -407,7 +415,9 @@ func (r *Resolver) resolveARecord(
|
||||
|
||||
// FindAuthoritativeNameservers traces the delegation chain from
|
||||
// root servers to discover all authoritative nameservers for the
|
||||
// given domain. Walks up the label hierarchy for subdomains.
|
||||
// given domain. For a name that is not a zone apex it tries each
|
||||
// parent name in turn, so it returns the nameservers of the zone the
|
||||
// name is in.
|
||||
func (r *Resolver) FindAuthoritativeNameservers(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
@@ -653,22 +663,8 @@ func extractRecordValue(rr dns.RR) string {
|
||||
}
|
||||
}
|
||||
|
||||
// parentDomain returns the registerable parent domain.
|
||||
func parentDomain(hostname string) string {
|
||||
hostname = dns.Fqdn(strings.ToLower(hostname))
|
||||
labels := dns.SplitDomainName(hostname)
|
||||
|
||||
if len(labels) <= minDomainLabels {
|
||||
return strings.Join(labels, ".") + "."
|
||||
}
|
||||
|
||||
return strings.Join(
|
||||
labels[len(labels)-minDomainLabels:], ".",
|
||||
) + "."
|
||||
}
|
||||
|
||||
// QueryAllNameservers discovers auth NSes for the hostname's
|
||||
// parent domain, then queries each one independently.
|
||||
// QueryAllNameservers discovers the auth NSes of the zone the
|
||||
// hostname is in, then queries each one independently.
|
||||
func (r *Resolver) QueryAllNameservers(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
@@ -677,9 +673,7 @@ func (r *Resolver) QueryAllNameservers(
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
parent := parentDomain(hostname)
|
||||
|
||||
nameservers, err := r.FindAuthoritativeNameservers(ctx, parent)
|
||||
nameservers, err := r.FindAuthoritativeNameservers(ctx, hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -79,9 +79,10 @@ func TestFindAuthoritativeNameservers_Subdomain(
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
nameservers := liveFindAuthoritative(t, r, "www.google.com")
|
||||
fromHost := liveFindAuthoritative(t, r, "www.google.com")
|
||||
fromZone := liveFindAuthoritative(t, r, "google.com")
|
||||
|
||||
assert.NotEmpty(t, nameservers)
|
||||
assert.Equal(t, fromZone, fromHost)
|
||||
}
|
||||
|
||||
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
||||
@@ -350,12 +351,29 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
|
||||
func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
results := liveQueryAllNameservers(t, r, "google.com")
|
||||
// The last two names are in zones other than their last two
|
||||
// labels: google.co.uk, under the two-label suffix co.uk, and
|
||||
// compute-1.amazonaws.com, which amazonaws.com delegates to other
|
||||
// servers and which has a host name for each of its addresses.
|
||||
// Servers above a name's zone only refer onward, which gives
|
||||
// nodata, so ok shows the name was asked at its own zone's
|
||||
// servers.
|
||||
hostnames := []string{
|
||||
"google.com",
|
||||
"www.google.co.uk",
|
||||
"ec2-3-80-0-1.compute-1.amazonaws.com",
|
||||
}
|
||||
|
||||
// A quorum, not unanimity: one authoritative server being
|
||||
// slow or rate-limiting us is a property of the live
|
||||
// internet, not a resolver defect.
|
||||
for _, hostname := range hostnames {
|
||||
t.Run(hostname, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
results := liveQueryAllNameservers(t, r, hostname)
|
||||
|
||||
// A quorum, not unanimity: one authoritative server
|
||||
// being slow or rate-limiting us is a property of the
|
||||
// live internet, not a resolver defect.
|
||||
assert.GreaterOrEqual(
|
||||
t,
|
||||
countStatus(results, resolver.StatusOK),
|
||||
@@ -364,12 +382,13 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
|
||||
describeStatuses(results),
|
||||
)
|
||||
|
||||
// Quorum tolerates SILENCE only. Every individual result must
|
||||
// be either the expected answer or a non-answer: ok, timeout
|
||||
// or error, and nothing else. Stated as a closed allowlist so
|
||||
// that a wrong answer no one thought to ban — nxdomain and
|
||||
// nodata today, any status added later — fails here rather
|
||||
// than sliding through under the quorum.
|
||||
// Quorum tolerates SILENCE only. Every individual
|
||||
// result must be either the expected answer or a
|
||||
// non-answer: ok, timeout or error, and nothing else.
|
||||
// Stated as a closed allowlist so that a wrong answer
|
||||
// no one thought to ban — nxdomain and nodata today,
|
||||
// any status added later — fails here rather than
|
||||
// sliding through under the quorum.
|
||||
assert.Empty(
|
||||
t,
|
||||
unsanctionedStatuses(
|
||||
@@ -378,9 +397,12 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
|
||||
resolver.StatusTimeout,
|
||||
resolver.StatusError,
|
||||
),
|
||||
"every nameserver must answer OK or not answer at all: %s",
|
||||
"every nameserver must answer OK or not answer "+
|
||||
"at all: %s",
|
||||
describeStatuses(results),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAllNameservers_NXDomainFromAllNS(
|
||||
|
||||
Reference in New Issue
Block a user