1 Commits
Author SHA1 Message Date
clawbot b79726acc6 watcher: a hostname whose zone's nameservers all stop answering gets NS Failure (closes #284)
check / check (push) Waiting to run
When none of the nameservers of a hostname's zone answered, the walk to
them failed and the check was only logged. The walk now returns a
NoAnswerError naming the nameservers of the zone it reached through a
referral when none of them answers. When those are the nameservers the
previous check saved for the name, each is saved as failed with the
reason, so it gets an NS Failure and later an NS Recovery. A domain with
no delegation of its own, whose NS lookup is that same walk, gets the
same. A walk that fails before that zone, as when the host loses its
network, is still only logged.

Model: opus-5-5
2026-10-14 00:27:29 +02:00
10 changed files with 328 additions and 24 deletions
+12 -4
View File
@@ -104,9 +104,11 @@ notification endpoint set, changes show only on the dashboard; see
NS change notification above. A domain listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
notification. A domain with no delegation of its own has these records asked
at the servers of the zone it is in, as a hostname has. A nameserver the
domain's records were asked at on the previous check but not on this one gets
a hostname's NS change notification (see below), unless it was in the domain's
at the servers of the zone it is in, as a hostname has. When none of those
servers answers, the lookup of its NS record set fails, and they are saved as
failed and notified as a hostname's are (see below). A nameserver the domain's
records were asked at on the previous check but not on this one gets a
hostname's NS change notification (see below), unless it was in the domain's
delegation on the previous check, as when that delegation is withdrawn. A
domain that does not exist has none: they are not asked for, and those saved
by an earlier check are removed without a notification.
@@ -121,7 +123,13 @@ notification endpoint set, changes show only on the dashboard; see
trace moves from a name to its parent only when the servers asked answer that
the name has no delegation of its own, or does not exist. When they do not
answer, the check fails and the hostname's records from the previous check are
kept.
kept, unless they are the nameservers the previous check saved for the
hostname. Each of those is then saved as failed, with the reason, as a
domain's nameservers are when none of them answers, so it gets an NS query
failure notification, and an NS recovery notification when it answers again
(see below). A trace that gets no answer before it reaches them, as when the
host running dnswatcher loses its network, fails the check without a
notification.
- Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Each record type is a query of its own. When a nameserver answers some types
+2
View File
@@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Completed Steps
- 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering
gets an NS Failure for each, then an NS Recovery (closes #284).
- 2026-10-13: an IPv6 address is written in brackets before its port, as in
`[2001:db8::1]:443`, in keys and Port Change notifications (closes #270).
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
+17
View File
@@ -2,6 +2,23 @@ package resolver
import "errors"
// NoAnswerError is returned when the walk to a name's nameservers
// reached a zone through a referral and none of that zone's
// nameservers, Nameservers as the referral names them, answered. Err
// says why they did not.
type NoAnswerError struct {
Nameservers []string
Err error
}
func (e *NoAnswerError) Error() string {
return e.Err.Error()
}
func (e *NoAnswerError) Unwrap() error {
return e.Err
}
// Sentinel errors returned by the resolver.
var (
// ErrNoNameservers is returned when no authoritative NS
+3 -1
View File
@@ -31,9 +31,11 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
func (r *Resolver) FollowDelegation(
ctx context.Context,
domain string,
zone string,
nameservers []string,
servers []string,
) ([]string, error) {
return r.followDelegation(ctx, domain, servers)
return r.followDelegation(ctx, domain, zone, nameservers, servers)
}
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
+23 -11
View File
@@ -204,20 +204,24 @@ 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,
// followDelegation follows referrals to domain from servers, the
// addresses of the servers of zone, and returns the NS set of domain's
// delegation. nameservers are the names a referral gave the servers of
// zone; the walk to a name's nameservers starts at the root servers, the
// servers of zone ".", which no referral names. 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.
// error. Any other error means that no such answer came; it is a
// *NoAnswerError when the servers that did not answer are those of a
// zone a referral named.
func (r *Resolver) followDelegation(
ctx context.Context,
domain string,
zone string,
nameservers []string,
servers []string,
) ([]string, error) {
// servers are the root servers, the servers of zone ".".
zone := "."
var withoutAddresses []string
for range maxDelegation {
@@ -228,6 +232,12 @@ func (r *Resolver) followDelegation(
resp, err := r.queryZone(
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
)
// A query that ctx cut short says nothing about the servers.
if err != nil && len(nameservers) > 0 && checkCtx(ctx) == nil {
return nil, &NoAnswerError{Nameservers: nameservers, Err: err}
}
if err != nil {
return nil, err
}
@@ -256,6 +266,7 @@ func (r *Resolver) followDelegation(
return r.resolveNSIterative(ctx, domain)
}
nameservers = authNS
servers, withoutAddresses = referralNameservers(resp)
zone = referralZone(resp)
}
@@ -626,7 +637,8 @@ func (r *Resolver) resolveARecord(
// 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.
// not answer, it returns the error and tries no parent name; when a
// referral named them, the error is a *NoAnswerError naming them.
func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context,
domain string,
@@ -655,7 +667,7 @@ func (r *Resolver) findAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(ctx, candidate, servers)
nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers)
if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err
}
@@ -1067,7 +1079,7 @@ func (r *Resolver) LookupNS(
}
return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(),
)
}
+29 -2
View File
@@ -121,7 +121,9 @@ func TestFindAuthoritativeNameservers_DelegatedSubdomain(
// 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.
// them. The servers that did not answer are those the walk started at,
// which no referral named, so the error is no NoAnswerError, as when
// the host running dnswatcher has lost its network.
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
t.Parallel()
@@ -134,6 +136,9 @@ func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
assert.Empty(t, nameservers)
var noAnswer *resolver.NoAnswerError
assert.NotErrorAs(t, err, &noAnswer)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
@@ -954,12 +959,34 @@ func TestFollowDelegation_NoAnswer(t *testing.T) {
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FollowDelegation(
t.Context(), "google.com.", []string{noAnswerAddress},
t.Context(), "google.com.", ".", nil, []string{noAnswerAddress},
)
require.Error(t, err)
assert.Empty(t, nameservers)
}
// TestFollowDelegation_NoAnswerAfterReferral starts the walk for
// www.example.net at the servers of example.net as a referral names
// them, ns1.example.net and ns2.example.net, at 192.0.2.1, where nothing
// answers. The error is a NoAnswerError naming them, which the watcher
// compares with the nameservers it saved for the name.
func TestFollowDelegation_NoAnswerAfterReferral(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers := []string{"ns1.example.net.", "ns2.example.net."}
_, err := r.FollowDelegation(
t.Context(), "www.example.net.", "example.net.", nameservers,
[]string{noAnswerAddress},
)
var noAnswer *resolver.NoAnswerError
require.ErrorAs(t, err, &noAnswer)
assert.Equal(t, nameservers, noAnswer.Nameservers)
assert.Contains(t, err.Error(), "query www.example.net. @"+noAnswerAddress)
}
// 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.
+28
View File
@@ -122,6 +122,34 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
w.runTLSChecks(ctx)
}
// CheckHostnameResult exports checkHostnameResult for testing.
func (w *Watcher) CheckHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
return w.checkHostnameResult(ctx, hostname, results, err)
}
// CheckDomainResult exports checkDomainResult for testing.
func (w *Watcher) CheckDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
w.checkDomainResult(ctx, domain, nameservers, err)
}
// FailedNameservers exports failedNameservers for testing.
func FailedNameservers(
err error,
prev *state.HostnameState,
) map[string]*resolver.NameserverResponse {
return failedNameservers(err, prev)
}
// BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState(
results map[string]*resolver.NameserverResponse,
+4 -1
View File
@@ -20,7 +20,10 @@ type DNSResolver interface {
) ([]string, error)
// LookupAllRecords queries all record types for a hostname,
// returning each nameserver's response keyed by nameserver.
// returning each nameserver's response keyed by nameserver. When
// the walk to the nameservers reached a zone through a referral and
// none of that zone's nameservers answered, the error is a
// *resolver.NoAnswerError naming them.
LookupAllRecords(
ctx context.Context,
hostname string,
+133
View File
@@ -2,6 +2,7 @@ package watcher_test
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
@@ -14,6 +15,10 @@ import (
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// errNoReply is why a walk that got no reply from a zone's servers
// failed.
var errNoReply = errors.New("all servers failed: i/o timeout")
// answered is what a check saves for a nameserver that answered with
// these records.
func answered(records map[string][]string) *state.NameserverRecordState {
@@ -183,6 +188,134 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
}
}
// TestZoneNameserversAllStopAnswering runs three checks of host from
// the point where its lookup has returned. On the first, nsA and nsB,
// the nameservers of its zone, answer. On the second, the lookup fails
// with the error the walk to them returns when neither answers (see
// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given
// here because no live zone has nameservers that never answer. For a
// hostname it is the lookup of its records that fails, and for a domain
// with no delegation of its own the lookup of its delegation. Both are
// saved as failed, with the reason, and each gets an NS Failure. On the
// third, both answer, and each gets an NS Recovery. A domain's records
// are checked as a hostname's, so its first and third checks are too.
func TestZoneNameserversAllStopAnswering(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(records),
}
noAnswer := &resolver.NoAnswerError{
Nameservers: []string{nsA, nsB}, Err: errNoReply,
}
tests := []struct {
name string
isDomain bool
}{
{"hostname", false},
{"domain with no delegation of its own", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
if tt.isDomain {
cfg.Domains = []string{host}
} else {
cfg.Hostnames = []string{host}
}
// The watcher is given each lookup's result, so it has no
// resolver.
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
if tt.isDomain {
w.CheckDomainResult(t.Context(), host, nil, noAnswer)
} else {
w.CheckHostnameResult(t.Context(), host, nil, noAnswer)
}
current, _ := deps.state.GetHostnameState(host)
for _, ns := range []string{nsA, nsB} {
got := current.RecordsByNameserver[ns]
if got.Status != failed().Status || got.Error != errNoReply.Error() {
t.Errorf(
"%s saved with status %q, error %q; want %q, %q",
ns, got.Status, got.Error, failed().Status, errNoReply,
)
}
}
if n := countNotifications(deps, "NS Failure: "+host); n != 2 {
t.Errorf("sent %d NS Failure notifications, want 2", n)
}
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 {
t.Errorf("sent %d NS Recovery notifications, want 2", n)
}
})
}
}
// TestFailedNameserversOnlyForThoseOfThePreviousCheck gives
// FailedNameservers lookup errors after a check in which nsA and nsB
// answered. Only an error that names both makes them failed.
func TestFailedNameserversOnlyForThoseOfThePreviousCheck(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswered := saved(map[string]*state.NameserverRecordState{
nsA: answered(records), nsB: answered(records),
})
noAnswer := func(nameservers ...string) error {
return &resolver.NoAnswerError{Nameservers: nameservers, Err: errNoReply}
}
tests := []struct {
name string
err error
prev *state.HostnameState
wantFailed bool
}{
{"both did not answer", noAnswer(nsA, nsB), bothAnswered, true},
{
"the servers of a parent zone did not answer",
noAnswer(nsC), bothAnswered, false,
},
{
"the zone's delegation lists only nsA now",
noAnswer(nsA), bothAnswered, false,
},
{"no root server answered", errNoReply, bothAnswered, false},
{"no previous check", noAnswer(nsA, nsB), nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := watcher.FailedNameservers(tt.err, tt.prev)
if (got != nil) != tt.wantFailed {
t.Errorf("returned %v, want failed: %v", got, tt.wantFailed)
}
})
}
}
// TestNameserverThatNeverAnswers asks a nameserver address where
// nothing answers, 192.0.2.1, and checks what the watcher saves for it.
// The deadline outlasts the resolver's first two-second try, as in the
+77 -5
View File
@@ -291,12 +291,25 @@ func (w *Watcher) logFailedLookup(
w.log.Error(msg, args...)
}
// checkDomain looks up domain's delegation and checks domain with it
// (see checkDomainResult).
func (w *Watcher) checkDomain(
ctx context.Context,
domain string,
) {
nameservers, err := w.resolver.LookupNS(ctx, domain)
w.checkDomainResult(ctx, domain, nameservers, err)
}
// checkDomainResult finishes a check of domain from nameservers and err,
// what the lookup of its delegation returned.
func (w *Watcher) checkDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
// A domain that does not exist has no nameservers.
nxdomain := errors.Is(err, resolver.ErrNXDomain)
if nxdomain {
@@ -311,6 +324,12 @@ func (w *Watcher) checkDomain(
"error", err,
)
// For a domain with no delegation of its own, the servers that
// did not answer can be those of the zone it is in, at which
// its records are asked. They are then saved as failed, as a
// hostname's are.
w.checkHostnameResult(ctx, domain, nil, err)
return
}
@@ -464,8 +483,8 @@ func (w *Watcher) detectNSAddressChanges(
}
}
// checkHostname checks hostname's records and reports whether it saved
// them; it does not when their lookup fails.
// checkHostname looks up hostname's records and checks them (see
// checkHostnameResult). It reports whether it saved them.
func (w *Watcher) checkHostname(
ctx context.Context,
hostname string,
@@ -478,12 +497,31 @@ func (w *Watcher) checkHostname(
"hostname", hostname,
"error", err,
)
return false
}
return w.checkHostnameResult(ctx, hostname, results, err)
}
// checkHostnameResult finishes a check of hostname from results and err,
// what the lookup of its records returned, and reports whether it saved
// them. When the lookup failed, it saves nothing, unless the nameservers
// that did not answer are those of the previous check (see
// failedNameservers).
func (w *Watcher) checkHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
prev, _ := w.state.GetHostnameState(hostname)
if err != nil {
results = failedNameservers(err, prev)
if results == nil {
return false
}
}
w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
)
@@ -491,6 +529,40 @@ func (w *Watcher) checkHostname(
return true
}
// failedNameservers returns a failed response for each nameserver in
// prev, a name's state from the previous check, with err as the reason,
// when err says that the walk to the name's nameservers reached their
// zone and none of them answered. For any other error, as when the walk
// got no answer before it reached their zone, or when prev is nil, it
// returns nil.
func failedNameservers(
err error,
prev *state.HostnameState,
) map[string]*resolver.NameserverResponse {
var noAnswer *resolver.NoAnswerError
if !errors.As(err, &noAnswer) || prev == nil {
return nil
}
saved := slices.Sorted(maps.Keys(prev.RecordsByNameserver))
if !slices.Equal(noAnswer.Nameservers, saved) {
return nil
}
results := make(map[string]*resolver.NameserverResponse)
for _, ns := range saved {
results[ns] = &resolver.NameserverResponse{
Nameserver: ns,
Records: make(map[string][]string),
Status: resolver.StatusError,
Error: err.Error(),
}
}
return results
}
// updateHostnameState finishes a check of hostname from newState, built
// from its nameservers' answers: it follows the CNAME in them, notifies
// what changed since the previous check, and saves newState.
@@ -790,7 +862,7 @@ func (w *Watcher) detectRecordChanges(
// saved delegation, which detectNSChanges notifies when they are removed
// from it. checkDomain saves a new delegation only after this runs, and
// only together with the records, so the saved delegation and the
// records in prev come from the same check.
// nameservers in prev come from the same check.
func (w *Watcher) detectNSDisappearances(
ctx context.Context,
hostname string,