resolver: look up every nameserver a referral gives no address for (closes #221)
check / check (push) Canceled after 0s

Looking up a nameserver's own address followed only the addresses a
referral gave, so a nameserver whose zone is delegated without them,
such as a.ntpns.org of pool.ntp.org, never resolved. The walk to a
name's nameservers looked addresses up only when a referral gave none,
so when it gave some it asked only those. Both now ask every
nameserver a referral names, looking up the addresses it lacks.

A lookup can start further lookups; maxLookupDepth stops them two
deep, so zones delegated to each other's nameservers, as desec.io and
desec.org are, still end. A referral that gives addresses for only
some nameservers now costs one lookup per nameserver without one.

Model: opus-5-5
This commit is contained in:
2026-10-02 05:54:36 +00:00
parent a18803ff28
commit 75b313b4f6
5 changed files with 175 additions and 24 deletions
+6 -2
View File
@@ -411,8 +411,12 @@ In steps 2 and 3 the servers are asked one at a time in a random order, chosen
anew each time, so no one root server gets every first query. A server that does anew each time, so no one root server gets every first query. A server that does
not reply, refuses the query, or gives an error reply such as SERVFAIL or a not reply, refuses the query, or gives an error reply such as SERVFAIL or a
referral that leads no closer to the name is passed over for the next one. When referral that leads no closer to the name is passed over for the next one. When
a referral names a zone's nameservers without their addresses, the addresses of a referral names a zone's nameservers without their addresses, or gives
all of them are looked up, so that each can be asked. addresses for only some of them, the addresses of the others are looked up, so
that each can be asked. This holds both in the walk to a name's nameservers and
in the lookup of a nameserver's own address. Such a lookup may need others in
turn; lookups go at most two deep, one inside another, so delegations that point
at each other still end.
This approach ensures: This approach ensures:
+2
View File
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: every nameserver a referral names without an address is looked up,
two deep at most, so `pool.ntp.org`'s nameservers resolve (closes #221).
- 2026-10-02: a resolver test that reads one record type from a nameserver's - 2026-10-02: a resolver test that reads one record type from a nameserver's
answer asks again when that type is missing from it (closes #218). answer asks again when that type is missing from it (closes #218).
- 2026-10-02: a plain `docker build .` of a clone stamps its tag or short - 2026-10-02: a plain `docker build .` of a clone stamps its tag or short
+12 -2
View File
@@ -48,12 +48,22 @@ func (r *Resolver) QueryEachNS(
return r.queryEachNS(ctx, nameservers, hostname, recordTypes()) return r.queryEachNS(ctx, nameservers, hostname, recordTypes())
} }
// ResolveNSIPs exports resolveNSIPs for testing. // ResolveNSIPs exports resolveNSIPs for testing, looking each name up
// as a lookup that no other lookup started.
func (r *Resolver) ResolveNSIPs( func (r *Resolver) ResolveNSIPs(
ctx context.Context, ctx context.Context,
nsNames []string, nsNames []string,
) []string { ) []string {
return r.resolveNSIPs(ctx, nsNames) return r.resolveNSIPs(ctx, nsNames, 1)
}
// ReferralServers exports referralServers for testing, for a referral
// met in the walk to a domain's nameservers.
func (r *Resolver) ReferralServers(
ctx context.Context,
resp *dns.Msg,
) []string {
return r.referralServers(ctx, resp, 0)
} }
// RootServerList exports rootServerList for testing. // RootServerList exports rootServerList for testing.
+52 -20
View File
@@ -19,6 +19,14 @@ const (
maxRetries = 2 maxRetries = 2
maxDelegation = 20 maxDelegation = 20
timeoutMultiplier = 2 timeoutMultiplier = 2
// maxLookupDepth is how many lookups of nameserver addresses may be
// under way one inside another. Looking up a nameserver's address
// can meet a referral that names nameservers without their
// addresses, which are then looked up in turn; without a limit,
// delegations that point at each other would never end. Each level
// multiplies the queries sent.
maxLookupDepth = 2
) )
// ErrRefused is returned when a DNS server refuses a query. // ErrRefused is returned when a DNS server refuses a query.
@@ -229,13 +237,7 @@ func (r *Resolver) followDelegation(
return r.resolveNSIterative(ctx, domain) return r.resolveNSIterative(ctx, domain)
} }
glue := extractGlue(resp.Extra) nextServers := r.referralServers(ctx, resp, 0)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 {
nextServers = r.resolveNSIPs(ctx, authNS)
}
if len(nextServers) == 0 { if len(nextServers) == 0 {
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
@@ -366,18 +368,50 @@ func nsSetFrom(resp *dns.Msg, domain string) []string {
return extractNSSet(resp.Answer) return extractNSSet(resp.Answer)
} }
// referralServers returns the IPv4 addresses of every nameserver that
// resp, a referral, names: the addresses resp gives for it, or else the
// addresses its name resolves to. depth is how many lookups of a
// nameserver's address the referral was met in, 0 in the walk to a
// domain's nameservers; at maxLookupDepth, the nameservers resp gives
// no addresses for are left out.
func (r *Resolver) referralServers(
ctx context.Context,
resp *dns.Msg,
depth int,
) []string {
glue := extractGlue(resp.Extra)
var ips, withoutAddresses []string
for _, ns := range extractNSSet(resp.Ns) {
nsIPs := glueIPs([]string{ns}, glue)
if len(nsIPs) == 0 {
withoutAddresses = append(withoutAddresses, ns)
}
ips = append(ips, nsIPs...)
}
if depth < maxLookupDepth {
ips = append(ips, r.resolveNSIPs(ctx, withoutAddresses, depth+1)...)
}
return ips
}
// resolveNSIPs returns the addresses of every nameserver in nsNames // resolveNSIPs returns the addresses of every nameserver in nsNames
// whose name resolves, for a referral that carries none. The walk can // whose name resolves, each looked up at depth (see resolveARecord).
// then go on to the zone's other nameservers when one gives no usable // The walk can then go on to the zone's other nameservers when one
// reply. // gives no usable reply.
func (r *Resolver) resolveNSIPs( func (r *Resolver) resolveNSIPs(
ctx context.Context, ctx context.Context,
nsNames []string, nsNames []string,
depth int,
) []string { ) []string {
var ips []string var ips []string
for _, ns := range nsNames { for _, ns := range nsNames {
resolved, err := r.resolveARecord(ctx, ns) resolved, err := r.resolveARecord(ctx, ns, depth)
if err == nil { if err == nil {
ips = append(ips, resolved...) ips = append(ips, resolved...)
} }
@@ -438,11 +472,14 @@ func (r *Resolver) resolveNSIterative(
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// resolveARecord resolves a hostname to IPv4 addresses using // resolveARecord resolves a hostname, a nameserver's name, to IPv4
// iterative resolution through the delegation chain. // addresses using iterative resolution through the delegation chain.
// depth is how many lookups of a nameserver's address are under way,
// this one included: 1 for a lookup that no other lookup started.
func (r *Resolver) resolveARecord( func (r *Resolver) resolveARecord(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
depth int,
) ([]string, error) { ) ([]string, error) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
@@ -485,13 +522,8 @@ func (r *Resolver) resolveARecord(
break break
} }
glue := extractGlue(resp.Extra) nextServers := r.referralServers(ctx, resp, depth)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 { if len(nextServers) == 0 {
// Resolve NS IPs iteratively — but guard
// against infinite recursion by using only
// already-resolved servers.
break break
} }
@@ -584,7 +616,7 @@ func (r *Resolver) queryNameserver(
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
nsIPs, err := r.resolveARecord(ctx, nsHostname) nsIPs, err := r.resolveARecord(ctx, nsHostname, 1)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err) return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err)
} }
+103
View File
@@ -162,6 +162,109 @@ func TestResolveNSIPs_EveryNameserver(t *testing.T) {
assert.ElementsMatch(t, want, got) assert.ElementsMatch(t, want, got)
} }
// TestResolveNSIPs_ZoneDelegatedWithoutAddresses looks up the address
// of a.ntpns.org, a nameserver of pool.ntp.org. The org servers delegate
// ntpns.org to nameservers in other zones and give none of their
// addresses, so those are looked up on the way.
func TestResolveNSIPs_ZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ips := liveResolveNSIPs(t, r, []string{"a.ntpns.org."}, 1)
for _, ip := range ips {
assert.NotNil(t, net.ParseIP(ip), "should be valid IP: %s", ip)
}
}
// TestResolveNSIPs_DelegationsPointAtEachOther looks up the address of
// ns1.desec.io. The io servers delegate desec.io to ns1.desec.io, with
// its address, and to ns2.desec.org, without; the org servers delegate
// desec.org to ns2.desec.org, with its address, and to ns1.desec.io,
// without. Looking up either address starts a lookup of the other, and
// the lookup must still end.
func TestResolveNSIPs_DelegationsPointAtEachOther(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ips := liveResolveNSIPs(t, r, []string{"ns1.desec.io."}, 1)
for _, ip := range ips {
assert.NotNil(t, net.ParseIP(ip), "should be valid IP: %s", ip)
}
}
// givenAddresses returns the IPv4 addresses that resp, a referral,
// gives for the nameservers it names.
func givenAddresses(resp *dns.Msg) []string {
var ips []string
for _, rr := range resp.Extra {
if a, ok := rr.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
return ips
}
// TestReferralServers_SomeWithoutAddresses asks the org servers about
// ntp.org, as the walk to a name under ntp.org does. Their referral
// names four nameservers and gives an address for ns1.everett.org
// alone. The addresses of the other three are looked up, so that the
// walk can ask them too.
func TestReferralServers_SomeWithoutAddresses(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var referral *dns.Msg
livednstest.Retry(
t,
"referral for ntp.org from the org servers",
func(ctx context.Context) error {
fromRoot, err := r.QueryServers(
ctx, resolver.RootServerList(), ".", "ntp.org.",
dns.TypeNS,
)
if err != nil {
return err
}
referral, err = r.QueryServers(
ctx, givenAddresses(fromRoot), "org.", "ntp.org.",
dns.TypeNS,
)
return err
},
)
given := givenAddresses(referral)
require.NotEmpty(t, given, "the referral gives no addresses")
var servers []string
livednstest.Retry(
t,
"ReferralServers(referral for ntp.org)",
func(ctx context.Context) error {
servers = r.ReferralServers(ctx, referral)
if len(servers) <= len(given) {
return fmt.Errorf(
"%w: %d addresses, the referral gives %d",
livednstest.ErrNoAnswer, len(servers), len(given),
)
}
return nil
},
)
assert.Subset(t, servers, given)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// QueryNameserver tests // QueryNameserver tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------