resolver, watcher: a domain's nameservers are only its own delegation (closes #222)
check / check (push) Canceled after 0s
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user