Compare commits
1
Commits
next
..
817df7fd34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
817df7fd34 |
@@ -104,11 +104,9 @@ 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. When none of those
|
at the servers of the zone it is in, as a hostname has. A nameserver the
|
||||||
servers answers, the lookup of its NS record set fails, and they are saved as
|
domain's records were asked at on the previous check but not on this one gets
|
||||||
failed and notified as a hostname's are (see below). A nameserver the domain's
|
a hostname's NS change notification (see below), unless it was in 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.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering
|
- 2026-10-13: a hostname whose zone's nameservers all stop answering gets an NS
|
||||||
gets an NS Failure for each, then an NS Recovery (closes #284).
|
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
|
||||||
|
|||||||
@@ -31,11 +31,9 @@ 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, zone, nameservers, servers)
|
return r.followDelegation(ctx, domain, servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
|
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
|
||||||
|
|||||||
@@ -204,13 +204,10 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
// followDelegation follows referrals to domain from servers, the
|
// followDelegation follows referrals from servers, the root servers, to
|
||||||
// addresses of the servers of zone, and returns the NS set of domain's
|
// domain and returns the NS set of domain's delegation. When the servers
|
||||||
// delegation. nameservers are the names a referral gave the servers of
|
// of the zone domain is in answer that domain does not exist, the error
|
||||||
// zone; the walk to a name's nameservers starts at the root servers, the
|
// is ErrNXDomain. When they answer that it has no delegation of its own,
|
||||||
// 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
|
||||||
@@ -218,11 +215,13 @@ 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) {
|
||||||
var withoutAddresses []string
|
// servers are the root servers, the servers of zone ".". After a
|
||||||
|
// 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 {
|
||||||
@@ -667,7 +666,7 @@ func (r *Resolver) findAuthoritativeNameservers(
|
|||||||
|
|
||||||
candidate := strings.Join(labels[i:], ".") + "."
|
candidate := strings.Join(labels[i:], ".") + "."
|
||||||
|
|
||||||
nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers)
|
nsNames, err := r.followDelegation(ctx, candidate, servers)
|
||||||
if err != nil && !errors.Is(err, ErrNXDomain) {
|
if err != nil && !errors.Is(err, ErrNXDomain) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1079,7 +1078,7 @@ func (r *Resolver) LookupNS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return r.followDelegation(
|
return r.followDelegation(
|
||||||
ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(),
|
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -959,34 +959,12 @@ 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.", ".", nil, []string{noAnswerAddress},
|
t.Context(), "google.com.", []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.
|
||||||
|
|||||||
@@ -122,26 +122,6 @@ 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,
|
||||||
|
|||||||
@@ -188,86 +188,68 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestZoneNameserversAllStopAnswering runs three checks of host from
|
// TestHostnameWhoseNameserversAllStopAnswering starts from a check of
|
||||||
// the point where its lookup has returned. On the first, nsA and nsB,
|
// host in which nsA and nsB answered, then gives the watcher the error
|
||||||
// the nameservers of its zone, answer. On the second, the lookup fails
|
// the walk to host's nameservers returns when it reached their zone and
|
||||||
// with the error the walk to them returns when neither answers (see
|
// neither answered. No live zone has nameservers that never answer, so
|
||||||
// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given
|
// the error is built here. Both are saved as failed, with the reason,
|
||||||
// here because no live zone has nameservers that never answer. For a
|
// and each gets an NS Failure. On the next check both answer, and each
|
||||||
// hostname it is the lookup of its records that fails, and for a domain
|
// gets an NS Recovery.
|
||||||
// with no delegation of its own the lookup of its delegation. Both are
|
func TestHostnameWhoseNameserversAllStopAnswering(t *testing.T) {
|
||||||
// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
prev, _ := deps.state.GetHostnameState(host)
|
||||||
name string
|
w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState(
|
||||||
isDomain bool
|
watcher.FailedNameservers(err, prev), prev, time.Now(),
|
||||||
}{
|
))
|
||||||
{"hostname", false},
|
|
||||||
{"domain with no delegation of its own", true},
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
if n := countNotifications(deps, "NS Failure: "+host); n != 2 {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Errorf("sent %d NS Failure notifications, want 2", n)
|
||||||
t.Parallel()
|
}
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
w.UpdateHostnameState(t.Context(), host, watcher.BuildHostnameState(
|
||||||
if tt.isDomain {
|
bothAnswer, current, time.Now(),
|
||||||
cfg.Domains = []string{host}
|
))
|
||||||
} else {
|
|
||||||
cfg.Hostnames = []string{host}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The watcher is given each lookup's result, so it has no
|
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 {
|
||||||
// resolver.
|
t.Errorf("sent %d NS Recovery notifications, want 2", n)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -291,25 +291,12 @@ 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 {
|
||||||
@@ -324,12 +311,6 @@ func (w *Watcher) checkDomainResult(
|
|||||||
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,13 +464,16 @@ func (w *Watcher) detectNSAddressChanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHostname looks up hostname's records and checks them (see
|
// checkHostname checks hostname's records and reports whether it saved
|
||||||
// checkHostnameResult). It reports whether it saved them.
|
// 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,
|
||||||
@@ -497,25 +481,7 @@ 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
|
||||||
@@ -862,7 +828,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
|
||||||
// nameservers in prev come from the same check.
|
// records 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user