resolver: query a hostname at its own zone's servers (closes #189) #191

Merged
clawbot merged 1 commits from issue-189-hostname-zone into next 2026-10-01 23:34:36 +02:00
4 changed files with 73 additions and 53 deletions
Showing only changes of commit aec3e2fb7e - Show all commits
+3 -1
View File
@@ -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
+2
View File
@@ -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
+15 -21
View File
@@ -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
}
+53 -31
View File
@@ -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,37 +351,58 @@ 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.
assert.GreaterOrEqual(
t,
countStatus(results, resolver.StatusOK),
liveQuorum(len(results)),
"a quorum of nameservers should answer OK: %s",
describeStatuses(results),
)
for _, hostname := range hostnames {
t.Run(hostname, func(t *testing.T) {
t.Parallel()
// 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(
results,
resolver.StatusOK,
resolver.StatusTimeout,
resolver.StatusError,
),
"every nameserver must answer OK or not answer at all: %s",
describeStatuses(results),
)
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),
liveQuorum(len(results)),
"a quorum of nameservers should answer OK: %s",
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.
assert.Empty(
t,
unsanctionedStatuses(
results,
resolver.StatusOK,
resolver.StatusTimeout,
resolver.StatusError,
),
"every nameserver must answer OK or not answer "+
"at all: %s",
describeStatuses(results),
)
})
}
}
func TestQueryAllNameservers_NXDomainFromAllNS(