Compare commits

1 Commits
Author SHA1 Message Date
sneak dd052ec7f7 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-13 21:57:47 +00:00
8 changed files with 170 additions and 71 deletions
+5 -3
View File
@@ -104,9 +104,11 @@ notification endpoint set, changes show only on the dashboard; see
NS change notification above. A domain listed in NS change notification above. A domain listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
notification. A domain with no delegation of its own has these records asked 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 at the servers of the zone it is in, as a hostname has. When none of those
domain's records were asked at on the previous check but not on this one gets servers answers, the lookup of its NS record set fails, and they are saved as
a hostname's NS change notification (see below), unless it was in the domain's 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 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 domain that does not exist has none: they are not asked for, and those saved
by an earlier check are removed without a notification. by an earlier check are removed without a notification.
+2 -2
View File
@@ -19,8 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Completed Steps # Completed Steps
- 2026-10-13: a hostname whose zone's nameservers all stop answering gets an NS - 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering
Failure for each, and an NS Recovery when they answer again (closes #284). 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 - 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). `[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 - 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
+3 -1
View File
@@ -31,9 +31,11 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
func (r *Resolver) FollowDelegation( func (r *Resolver) FollowDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
return r.followDelegation(ctx, domain, servers) return r.followDelegation(ctx, domain, zone, nameservers, servers)
} }
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers // FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
+12 -11
View File
@@ -204,10 +204,13 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips return ips
} }
// followDelegation follows referrals from servers, the root servers, to // followDelegation follows referrals to domain from servers, the
// domain and returns the NS set of domain's delegation. When the servers // addresses of the servers of zone, and returns the NS set of domain's
// of the zone domain is in answer that domain does not exist, the error // delegation. nameservers are the names a referral gave the servers of
// is ErrNXDomain. When they answer that it has no delegation of its own, // 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 // 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; it is a // 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 // *NoAnswerError when the servers that did not answer are those of a
@@ -215,13 +218,11 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
func (r *Resolver) followDelegation( func (r *Resolver) followDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
// servers are the root servers, the servers of zone ".". After a var withoutAddresses []string
// referral, nameservers are the names of the servers of zone.
zone := "."
var nameservers, withoutAddresses []string
for range maxDelegation { for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
@@ -666,7 +667,7 @@ func (r *Resolver) findAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "." 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) { if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err return nil, err
} }
@@ -1078,7 +1079,7 @@ func (r *Resolver) LookupNS(
} }
return r.followDelegation( return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(), ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(),
) )
} }
+23 -1
View File
@@ -959,12 +959,34 @@ func TestFollowDelegation_NoAnswer(t *testing.T) {
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FollowDelegation( nameservers, err := r.FollowDelegation(
t.Context(), "google.com.", []string{noAnswerAddress}, t.Context(), "google.com.", ".", nil, []string{noAnswerAddress},
) )
require.Error(t, err) require.Error(t, err)
assert.Empty(t, nameservers) 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 // TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
// of www.google.com as the fallback walk does. As in // of www.google.com as the fallback walk does. As in
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error. // TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
+20
View File
@@ -122,6 +122,26 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
w.runTLSChecks(ctx) 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. // FailedNameservers exports failedNameservers for testing.
func FailedNameservers( func FailedNameservers(
err error, err error,
+65 -47
View File
@@ -188,68 +188,86 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
} }
} }
// TestHostnameWhoseNameserversAllStopAnswering starts from a check of // TestZoneNameserversAllStopAnswering runs three checks of host from
// host in which nsA and nsB answered, then gives the watcher the error // the point where its lookup has returned. On the first, nsA and nsB,
// the walk to host's nameservers returns when it reached their zone and // the nameservers of its zone, answer. On the second, the lookup fails
// neither answered. No live zone has nameservers that never answer, so // with the error the walk to them returns when neither answers (see
// the error is built here. Both are saved as failed, with the reason, // the resolver's TestFollowDelegation_NoAnswerAfterReferral), given
// and each gets an NS Failure. On the next check both answer, and each // here because no live zone has nameservers that never answer. For a
// gets an NS Recovery. // hostname it is the lookup of its records that fails, and for a domain
func TestHostnameWhoseNameserversAllStopAnswering(t *testing.T) { // 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() t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
// The watcher is given each check's lookup 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)
records := map[string][]string{"A": {ip1}} records := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{ bothAnswer := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(records), nsA: response(records), nsB: response(records),
} }
noAnswer := &resolver.NoAnswerError{
deps.state.SetHostnameState(
host, watcher.BuildHostnameState(bothAnswer, nil, time.Now()),
)
err := &resolver.NoAnswerError{
Nameservers: []string{nsA, nsB}, Err: errNoReply, Nameservers: []string{nsA, nsB}, Err: errNoReply,
} }
prev, _ := deps.state.GetHostnameState(host) tests := []struct {
w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState( name string
watcher.FailedNameservers(err, prev), prev, time.Now(), isDomain bool
)) }{
{"hostname", false},
{"domain with no delegation of its own", true},
}
current, _ := deps.state.GetHostnameState(host) for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
for _, ns := range []string{nsA, nsB} { cfg := defaultTestConfig(t)
got := current.RecordsByNameserver[ns] if tt.isDomain {
if got.Status != failed().Status || got.Error != err.Error() { cfg.Domains = []string{host}
t.Errorf( } else {
"%s saved with status %q, error %q; want %q, %q", cfg.Hostnames = []string{host}
ns, got.Status, got.Error, failed().Status, err.Error(), }
// 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)
}
if n := countNotifications(deps, "NS Failure: "+host); n != 2 { w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
t.Errorf("sent %d NS Failure notifications, want 2", n)
}
w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState( if tt.isDomain {
bothAnswer, current, time.Now(), w.CheckDomainResult(t.Context(), host, nil, noAnswer)
)) } else {
w.CheckHostnameResult(t.Context(), host, nil, noAnswer)
}
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 { current, _ := deps.state.GetHostnameState(host)
t.Errorf("sent %d NS Recovery notifications, want 2", n)
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)
}
})
} }
} }
+40 -6
View File
@@ -291,12 +291,25 @@ func (w *Watcher) logFailedLookup(
w.log.Error(msg, args...) w.log.Error(msg, args...)
} }
// checkDomain looks up domain's delegation and checks domain with it
// (see checkDomainResult).
func (w *Watcher) checkDomain( func (w *Watcher) checkDomain(
ctx context.Context, ctx context.Context,
domain string, domain string,
) { ) {
nameservers, err := w.resolver.LookupNS(ctx, domain) 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. // A domain that does not exist has no nameservers.
nxdomain := errors.Is(err, resolver.ErrNXDomain) nxdomain := errors.Is(err, resolver.ErrNXDomain)
if nxdomain { if nxdomain {
@@ -311,6 +324,12 @@ func (w *Watcher) checkDomain(
"error", err, "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 return
} }
@@ -464,16 +483,13 @@ func (w *Watcher) detectNSAddressChanges(
} }
} }
// checkHostname checks hostname's records and reports whether it saved // checkHostname looks up hostname's records and checks them (see
// them; it does not when their lookup fails, unless the nameservers that // checkHostnameResult). It reports whether it saved them.
// did not answer are those of the previous check (see failedNameservers).
func (w *Watcher) checkHostname( func (w *Watcher) checkHostname(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) bool { ) bool {
results, err := w.resolver.LookupAllRecords(ctx, hostname) results, err := w.resolver.LookupAllRecords(ctx, hostname)
prev, _ := w.state.GetHostnameState(hostname)
if err != nil { if err != nil {
w.logFailedLookup( w.logFailedLookup(
ctx, ctx,
@@ -481,7 +497,25 @@ func (w *Watcher) checkHostname(
"hostname", hostname, "hostname", hostname,
"error", err, "error", err,
) )
}
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) results = failedNameservers(err, prev)
if results == nil { if results == nil {
return false return false
@@ -828,7 +862,7 @@ func (w *Watcher) detectRecordChanges(
// saved delegation, which detectNSChanges notifies when they are removed // saved delegation, which detectNSChanges notifies when they are removed
// from it. checkDomain saves a new delegation only after this runs, and // from it. checkDomain saves a new delegation only after this runs, and
// only together with the records, so the saved delegation and the // 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( func (w *Watcher) detectNSDisappearances(
ctx context.Context, ctx context.Context,
hostname string, hostname string,