resolver, watcher: a domain's nameservers are only its own delegation (closes #222)
check / check (push) Canceled after 0s

When a domain's parent zone's servers answer NXDOMAIN, LookupNS returns
ErrNXDomain. The watcher then saves the domain with no nameservers and
nxdomain set, shown on the dashboard and in /api/v1/status, asks for none
of its records and removes those saved, so its old nameservers go in one
NS Change. A domain with no delegation of its own gets an empty set and
its records are still asked at the zone it is in.
FindAuthoritativeNameservers moves to a parent name only on one of those
two answers; when the servers do not answer, it returns the error. After
an upgrade, a domain without its own delegation that was saved with its
parent zone's nameservers gets one NS Change; the README says so.

Model: opus-5-5
This commit is contained in:
2026-10-02 09:54:29 +00:00
parent 6332b48379
commit 01879aaa2d
15 changed files with 477 additions and 40 deletions
+4
View File
@@ -10,6 +10,10 @@ var (
"no authoritative nameservers found",
)
// ErrNXDomain is returned when the servers of the zone a domain
// is in answer NXDOMAIN: the domain does not exist.
ErrNXDomain = errors.New("domain does not exist")
// ErrNoNameserverAnswered is returned when every nameserver
// asked about a name timed out, failed or returned a referral,
// so whether the name has addresses is unknown.
+37
View File
@@ -17,6 +17,43 @@ func NewWithFailingTCP(log *slog.Logger) *Resolver {
return r
}
// NewWithQueryTimeout returns a Resolver whose queries over UDP give up
// after timeout, so a test that asks an address where nothing answers
// does not wait out the usual timeout.
func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
r := NewFromLogger(log)
r.client = &udpClient{timeout: timeout}
return r
}
// FollowDelegation exports followDelegation for testing.
func (r *Resolver) FollowDelegation(
ctx context.Context,
domain string,
servers []string,
) ([]string, error) {
return r.followDelegation(ctx, domain, servers)
}
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
// for testing.
func (r *Resolver) FindAuthoritativeNameserversFrom(
ctx context.Context,
domain string,
servers []string,
) ([]string, error) {
return r.findAuthoritativeNameservers(ctx, domain, servers)
}
// ResolveNSIterative exports resolveNSIterative for testing.
func (r *Resolver) ResolveNSIterative(
ctx context.Context,
domain string,
) ([]string, error) {
return r.resolveNSIterative(ctx, domain)
}
// ExtractRecordValue exports extractRecordValue for testing.
func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr)
+59 -21
View File
@@ -204,6 +204,12 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips
}
// followDelegation follows referrals from servers, the root servers, to
// domain and returns the NS set of domain's delegation. When the servers
// of the zone domain is in answer that domain does not exist, the error
// is ErrNXDomain. When they answer that it has no delegation of its own,
// because it is not the zone's apex, the set is empty and there is no
// error. Any other error means that no such answer came.
func (r *Resolver) followDelegation(
ctx context.Context,
domain string,
@@ -234,10 +240,15 @@ func (r *Resolver) followDelegation(
// 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.
// the answer, domain has no nameservers of its own: it does
// not exist, when the reply is NXDOMAIN, or else it is not the
// zone's apex.
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
return nil, ErrNXDomain
}
if resp.Authoritative {
return nil, ErrNoNameservers
return []string{}, nil
}
authNS := extractNSSet(resp.Ns)
@@ -486,7 +497,8 @@ func (r *Resolver) resolveNSIPs(
// resolveNSIterative queries for NS records using iterative
// resolution as a fallback when followDelegation finds no
// authoritative answer in the delegation chain.
// authoritative answer in the delegation chain. Its result means what
// followDelegation's does.
func (r *Resolver) resolveNSIterative(
ctx context.Context,
domain string,
@@ -516,6 +528,16 @@ func (r *Resolver) resolveNSIterative(
return nsNames, nil
}
// As in followDelegation: domain has no nameservers of its
// own.
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
return nil, ErrNXDomain
}
if resp.Authoritative {
return []string{}, nil
}
// Follow delegation.
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
@@ -601,12 +623,23 @@ func (r *Resolver) resolveARecord(
// FindAuthoritativeNameservers traces the delegation chain from
// root servers to discover all authoritative nameservers for the
// given domain, as the delegation from its parent zone's servers lists
// them. 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.
// them. When the servers asked answer that the name has no delegation
// of its own, or does not exist, it tries each parent name in turn, so
// it returns the nameservers of the zone the name is in. When they do
// not answer, it returns the error and tries no parent name.
func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context,
domain string,
) ([]string, error) {
return r.findAuthoritativeNameservers(ctx, domain, rootServerList())
}
// findAuthoritativeNameservers is FindAuthoritativeNameservers with each
// walk starting at servers, the root servers.
func (r *Resolver) findAuthoritativeNameservers(
ctx context.Context,
domain string,
servers []string,
) ([]string, error) {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
@@ -622,19 +655,16 @@ func (r *Resolver) FindAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(
ctx, candidate, rootServerList(),
)
if err == nil && len(nsNames) > 0 {
nsNames, err := r.followDelegation(ctx, candidate, servers)
if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err
}
if len(nsNames) > 0 {
sort.Strings(nsNames)
return nsNames, nil
}
// The root servers would refuse every parent name too.
if errors.Is(err, ErrIntercepted) {
return nil, err
}
}
return nil, ErrNoNameservers
@@ -852,9 +882,7 @@ func readReply(
// A reply with no answer that lists other nameservers, from a server
// that does not hold the name's zone, is a referral and says nothing
// about the name's records. A server named in the delegation that
// does not hold the zone may send one, as do a parent zone's servers
// when FindAuthoritativeNameservers found no delegation for the
// name's zone and moved on to a parent name.
// does not hold the zone may send one.
if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
@@ -1022,12 +1050,22 @@ func (r *Resolver) queryEachNS(
return results, nil
}
// LookupNS returns the NS record set for a domain.
// LookupNS returns the NS record set of a domain, as the delegation from
// its parent zone's servers lists it, and never a parent name's. When
// they answer that the domain does not exist, the error is ErrNXDomain.
// When they answer that it has no delegation of its own, the set is
// empty and there is no error.
func (r *Resolver) LookupNS(
ctx context.Context,
domain string,
) ([]string, error) {
return r.FindAuthoritativeNameservers(ctx, domain)
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
)
}
// LookupAllRecords performs iterative resolution to find all DNS
+2 -2
View File
@@ -187,8 +187,8 @@ func liveFindAuthoritative(
return out
}
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
// point, so that both entry points stay independently exercised.
// liveLookupNS looks up the NS record set of domain, a domain that has
// one, retrying until the delegation chain can be walked.
func liveLookupNS(
t *testing.T,
r *resolver.Resolver,
+174 -2
View File
@@ -25,6 +25,13 @@ import (
// Test helpers
// ----------------------------------------------------------------
// nonexistentDomain is a .com domain that does not exist.
const nonexistentDomain = "dnswatcher-test-does-not-exist.com"
// noAnswerAddress is 192.0.2.1, a documentation address: nothing
// answers there.
const noAnswerAddress = "192.0.2.1"
func newTestResolver(t *testing.T) *resolver.Resolver {
t.Helper()
@@ -88,6 +95,47 @@ func TestFindAuthoritativeNameservers_Subdomain(
assert.Equal(t, fromZone, fromHost)
}
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that
// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer
// that the name has no delegation of its own, so it gets their names,
// not those of the cmu.edu servers. Every referral on the way gives the
// nameservers' addresses, so the walk sends few queries.
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu")
fromZone := liveLookupNS(t, r, "cs.cmu.edu")
fromParent := liveLookupNS(t, r, "cmu.edu")
assert.Equal(t, fromZone, fromHost)
assert.NotEqual(t, fromParent, fromHost)
}
// TestFindAuthoritativeNameservers_NoAnswer starts each walk for
// www.google.com at 192.0.2.1, a documentation address where nothing
// answers. A walk that got no answer does not say that the name has no
// delegation of its own, so the lookup returns that walk's error, about
// www.google.com, and tries no parent name: trying google.com and com
// would end in ErrNoNameservers, or in the error of a walk for one of
// them.
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FindAuthoritativeNameserversFrom(
t.Context(), "www.google.com", []string{noAnswerAddress},
)
require.Error(t, err)
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
assert.Empty(t, nameservers)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T,
) {
@@ -831,8 +879,7 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
// nameservers of g.ntpns.org. The org servers delegate its parent zone,
// ntpns.org, without the addresses of its nameservers, so the walk has
// to look them up to ask them. If it did not, the walk for g.ntpns.org
// would fail and LookupNS would return the nameservers of ntpns.org,
// which a.ntpns.org is not one of.
// would fail.
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel()
@@ -842,6 +889,131 @@ func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
assert.Contains(t, nameservers, "a.ntpns.org.")
}
// TestLookupNS_DomainThatDoesNotExist looks up the nameservers of a .com
// domain that does not exist. The .com servers answer NXDOMAIN, so the
// error is ErrNXDomain, and the domain does not get their names.
func TestLookupNS_DomainThatDoesNotExist(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var (
nameservers []string
err error
)
livednstest.Retry(
t,
"LookupNS("+nonexistentDomain+")",
func(ctx context.Context) error {
nameservers, err = r.LookupNS(ctx, nonexistentDomain)
if errors.Is(err, resolver.ErrNXDomain) {
return nil
}
return err
},
)
require.ErrorIs(t, err, resolver.ErrNXDomain)
assert.Empty(t, nameservers)
}
// TestLookupNS_NoDelegationOfItsOwn looks up the nameservers of
// www.google.com, a name in the google.com zone with no delegation of
// its own, as a domain such as octocat.github.io is. The google.com
// servers answer with no NS records for it: the set is empty, and it is
// not ErrNXDomain.
func TestLookupNS_NoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var nameservers []string
livednstest.Retry(
t,
"LookupNS(www.google.com)",
func(ctx context.Context) error {
var err error
nameservers, err = r.LookupNS(ctx, "www.google.com")
return err
},
)
assert.Empty(t, nameservers)
}
// TestFollowDelegation_NoAnswer starts the walk LookupNS uses, for
// google.com, at 192.0.2.1, a documentation address where nothing
// answers. A walk that got no answer is an error, not an empty set,
// which the watcher would report as an NS Change with every nameserver
// removed.
func TestFollowDelegation_NoAnswer(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FollowDelegation(
t.Context(), "google.com.", []string{noAnswerAddress},
)
require.Error(t, err)
assert.Empty(t, nameservers)
}
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
// of www.google.com as the fallback walk does. As in
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
func TestResolveNSIterative_NoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var nameservers []string
livednstest.Retry(
t,
"ResolveNSIterative(www.google.com)",
func(ctx context.Context) error {
var err error
nameservers, err = r.ResolveNSIterative(ctx, "www.google.com")
return err
},
)
assert.Empty(t, nameservers)
}
// TestResolveNSIterative_DomainThatDoesNotExist walks to the nameservers
// of a .com domain that does not exist as the fallback walk does. As in
// TestLookupNS_DomainThatDoesNotExist, the error is ErrNXDomain.
func TestResolveNSIterative_DomainThatDoesNotExist(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var err error
livednstest.Retry(
t,
"ResolveNSIterative("+nonexistentDomain+")",
func(ctx context.Context) error {
_, err = r.ResolveNSIterative(ctx, nonexistentDomain)
if errors.Is(err, resolver.ErrNXDomain) {
return nil
}
return err
},
)
require.ErrorIs(t, err, resolver.ErrNXDomain)
}
// ----------------------------------------------------------------
// ResolveIPAddresses tests
// ----------------------------------------------------------------