Compare commits
1
Commits
next
...
817df7fd34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
817df7fd34 |
@@ -121,7 +121,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
|
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
|
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
|
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
|
- Queries **each** authoritative nameserver independently for **all** record
|
||||||
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||||
- Each record type is a query of its own. When a nameserver answers some types
|
- Each record type is a query of its own. When a nameserver answers some types
|
||||||
|
|||||||
@@ -19,6 +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
|
||||||
|
Failure for each, and an NS Recovery when they answer again (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
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ package resolver
|
|||||||
|
|
||||||
import "errors"
|
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.
|
// Sentinel errors returned by the resolver.
|
||||||
var (
|
var (
|
||||||
// ErrNoNameservers is returned when no authoritative NS
|
// ErrNoNameservers is returned when no authoritative NS
|
||||||
|
|||||||
@@ -209,16 +209,19 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
|
|||||||
// of the zone domain is in answer that domain does not exist, the error
|
// 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,
|
// 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.
|
// 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(
|
func (r *Resolver) followDelegation(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
servers []string,
|
servers []string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
// servers are the root servers, the servers of zone ".".
|
// servers are the root servers, the servers of zone ".". After a
|
||||||
|
// referral, nameservers are the names of the servers of zone.
|
||||||
zone := "."
|
zone := "."
|
||||||
|
|
||||||
var withoutAddresses []string
|
var nameservers, withoutAddresses []string
|
||||||
|
|
||||||
for range maxDelegation {
|
for range maxDelegation {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
@@ -228,6 +231,12 @@ func (r *Resolver) followDelegation(
|
|||||||
resp, err := r.queryZone(
|
resp, err := r.queryZone(
|
||||||
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -256,6 +265,7 @@ func (r *Resolver) followDelegation(
|
|||||||
return r.resolveNSIterative(ctx, domain)
|
return r.resolveNSIterative(ctx, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nameservers = authNS
|
||||||
servers, withoutAddresses = referralNameservers(resp)
|
servers, withoutAddresses = referralNameservers(resp)
|
||||||
zone = referralZone(resp)
|
zone = referralZone(resp)
|
||||||
}
|
}
|
||||||
@@ -626,7 +636,8 @@ func (r *Resolver) resolveARecord(
|
|||||||
// them. When the servers asked answer that the name has no delegation
|
// 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
|
// 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
|
// 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(
|
func (r *Resolver) FindAuthoritativeNameservers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ func TestFindAuthoritativeNameservers_DelegatedSubdomain(
|
|||||||
// delegation of its own, so the lookup returns that walk's error, about
|
// 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
|
// 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
|
// 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) {
|
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -134,6 +136,9 @@ func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
|||||||
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
|
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
|
||||||
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
|
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
|
||||||
assert.Empty(t, nameservers)
|
assert.Empty(t, nameservers)
|
||||||
|
|
||||||
|
var noAnswer *resolver.NoAnswerError
|
||||||
|
assert.NotErrorAs(t, err, &noAnswer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
|
|||||||
w.runTLSChecks(ctx)
|
w.runTLSChecks(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// BuildHostnameState exports buildHostnameState for testing.
|
||||||
func BuildHostnameState(
|
func BuildHostnameState(
|
||||||
results map[string]*resolver.NameserverResponse,
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ type DNSResolver interface {
|
|||||||
) ([]string, error)
|
) ([]string, error)
|
||||||
|
|
||||||
// LookupAllRecords queries all record types for a hostname,
|
// 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(
|
LookupAllRecords(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package watcher_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +15,10 @@ import (
|
|||||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
"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
|
// answered is what a check saves for a nameserver that answered with
|
||||||
// these records.
|
// these records.
|
||||||
func answered(records map[string][]string) *state.NameserverRecordState {
|
func answered(records map[string][]string) *state.NameserverRecordState {
|
||||||
@@ -183,6 +188,116 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHostnameWhoseNameserversAllStopAnswering starts from a check of
|
||||||
|
// host in which nsA and nsB answered, then gives the watcher the error
|
||||||
|
// the walk to host's nameservers returns when it reached their zone and
|
||||||
|
// neither answered. No live zone has nameservers that never answer, so
|
||||||
|
// the error is built here. Both are saved as failed, with the reason,
|
||||||
|
// and each gets an NS Failure. On the next check both answer, and each
|
||||||
|
// gets an NS Recovery.
|
||||||
|
func TestHostnameWhoseNameserversAllStopAnswering(t *testing.T) {
|
||||||
|
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}}
|
||||||
|
bothAnswer := map[string]*resolver.NameserverResponse{
|
||||||
|
nsA: response(records), nsB: response(records),
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.state.SetHostnameState(
|
||||||
|
host, watcher.BuildHostnameState(bothAnswer, nil, time.Now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := &resolver.NoAnswerError{
|
||||||
|
Nameservers: []string{nsA, nsB}, Err: errNoReply,
|
||||||
|
}
|
||||||
|
|
||||||
|
prev, _ := deps.state.GetHostnameState(host)
|
||||||
|
w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState(
|
||||||
|
watcher.FailedNameservers(err, prev), prev, time.Now(),
|
||||||
|
))
|
||||||
|
|
||||||
|
current, _ := deps.state.GetHostnameState(host)
|
||||||
|
|
||||||
|
for _, ns := range []string{nsA, nsB} {
|
||||||
|
got := current.RecordsByNameserver[ns]
|
||||||
|
if got.Status != failed().Status || got.Error != err.Error() {
|
||||||
|
t.Errorf(
|
||||||
|
"%s saved with status %q, error %q; want %q, %q",
|
||||||
|
ns, got.Status, got.Error, failed().Status, err.Error(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countNotifications(deps, "NS Failure: "+host); n != 2 {
|
||||||
|
t.Errorf("sent %d NS Failure notifications, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState(
|
||||||
|
bothAnswer, current, time.Now(),
|
||||||
|
))
|
||||||
|
|
||||||
|
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
|
// TestNameserverThatNeverAnswers asks a nameserver address where
|
||||||
// nothing answers, 192.0.2.1, and checks what the watcher saves for it.
|
// 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
|
// The deadline outlasts the resolver's first two-second try, as in the
|
||||||
|
|||||||
@@ -465,12 +465,15 @@ func (w *Watcher) detectNSAddressChanges(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkHostname checks hostname's records and reports whether it saved
|
// checkHostname checks hostname's records and reports whether it saved
|
||||||
// them; it does not when their lookup fails.
|
// them; it does not when their lookup fails, unless the nameservers that
|
||||||
|
// 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,
|
||||||
@@ -479,10 +482,11 @@ func (w *Watcher) checkHostname(
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
results = failedNameservers(err, prev)
|
||||||
|
if results == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
prev, _ := w.state.GetHostnameState(hostname)
|
|
||||||
|
|
||||||
w.updateHostnameState(
|
w.updateHostnameState(
|
||||||
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
||||||
@@ -491,6 +495,40 @@ func (w *Watcher) checkHostname(
|
|||||||
return true
|
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
|
// updateHostnameState finishes a check of hostname from newState, built
|
||||||
// from its nameservers' answers: it follows the CNAME in them, notifies
|
// from its nameservers' answers: it follows the CNAME in them, notifies
|
||||||
// what changed since the previous check, and saves newState.
|
// what changed since the previous check, and saves newState.
|
||||||
|
|||||||
Reference in New Issue
Block a user