diff --git a/README.md b/README.md index 1bcbf1c..dd94301 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index 0ebda04..4fb2e39 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index b628dd9..8c53adb 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -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 diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 0ea1a1f..23471b8 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -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 diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 35df296..e5689f7 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -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(), ) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 58033b7..1c535e7 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -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. diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index a3448e9..f13a0b0 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -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, diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index 166ada4..c92d8d6 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -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, diff --git a/internal/watcher/nsfailure_test.go b/internal/watcher/nsfailure_test.go index e9147c1..745a185 100644 --- a/internal/watcher/nsfailure_test.go +++ b/internal/watcher/nsfailure_test.go @@ -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 diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 45962b9..f0f26ff 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -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,