Compare commits
1
Commits
next
..
bd22c1ce97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd22c1ce97 |
@@ -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.
|
||||||
@@ -123,13 +121,7 @@ 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, unless they are the nameservers the previous check saved for the
|
kept.
|
||||||
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
|
||||||
|
|||||||
@@ -10,23 +10,17 @@
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0
|
pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked
|
||||||
milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66
|
trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-15: the log names a notification endpoint (`slack`, `mattermost`,
|
|
||||||
`ntfy`) and never shows its URL's path or query (closes #288).
|
|
||||||
- 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
|
- 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
|
|
||||||
Status says nothing about git tags (closes #281).
|
|
||||||
- 2026-10-13: a port check that finds port 443 closed removes that address's
|
- 2026-10-13: a port check that finds port 443 closed removes that address's
|
||||||
certificate entries, so none is left shown as `ok` (closes #265).
|
certificate entries, so none is left shown as `ok` (closes #265).
|
||||||
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
|
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
|
||||||
@@ -209,4 +203,6 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59
|
- 1.0 readiness: run it with a real config and read the logs:
|
||||||
|
https://git.eeqj.de/sneak/dnswatcher/issues/66
|
||||||
|
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -40,13 +38,6 @@ const (
|
|||||||
prioUrgent = "urgent"
|
prioUrgent = "urgent"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Endpoint names, as the log gives them, used across multiple tests.
|
|
||||||
const (
|
|
||||||
endpointNtfy = "ntfy"
|
|
||||||
endpointSlack = "slack"
|
|
||||||
endpointMattermost = "mattermost"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testHost is the hostname used in request construction tests.
|
// testHost is the hostname used in request construction tests.
|
||||||
const testHost = "example.com"
|
const testHost = "example.com"
|
||||||
|
|
||||||
@@ -1103,172 +1094,6 @@ func TestSendNotificationErrorNamesEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── webhook URLs kept out of the log ──────────────────────
|
|
||||||
|
|
||||||
// webhookPathAndQuery follows the server's address in every
|
|
||||||
// endpoint's URL in the tests below. A webhook URL's path or query
|
|
||||||
// lets anyone post to the channel or topic, so neither word marked
|
|
||||||
// secret in it may reach the log.
|
|
||||||
const webhookPathAndQuery = "/hooks/secret-in-path?token=secret-in-query"
|
|
||||||
|
|
||||||
// assertNoWebhookSecret fails the test if text holds either word
|
|
||||||
// marked secret in webhookPathAndQuery.
|
|
||||||
func assertNoWebhookSecret(t *testing.T, text string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, secret := range []string{"secret-in-path", "secret-in-query"} {
|
|
||||||
if strings.Contains(text, secret) {
|
|
||||||
t.Errorf("%q from the webhook URL is in:\n%s", secret, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// logRecord is the part of a JSON log line the tests below read.
|
|
||||||
type logRecord struct {
|
|
||||||
Level string `json:"level"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLog decodes each line of a JSON log.
|
|
||||||
func parseLog(t *testing.T, logs string) []logRecord {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var records []logRecord
|
|
||||||
|
|
||||||
for line := range strings.Lines(logs) {
|
|
||||||
var record logRecord
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(line), &record)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding log line %q: %v", line, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
records = append(records, record)
|
|
||||||
}
|
|
||||||
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendToEachEndpoint sends one notification with the given title to
|
|
||||||
// ntfy, Slack and Mattermost, each at baseURL followed by
|
|
||||||
// webhookPathAndQuery, and retries a failed delivery once. When
|
|
||||||
// every delivery has finished it returns the JSON log written,
|
|
||||||
// debug lines included.
|
|
||||||
func sendToEachEndpoint(t *testing.T, baseURL, title string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
target, err := url.Parse(baseURL + webhookPathAndQuery)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parsing webhook URL: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
svc, logs := newLoggingService(http.DefaultTransport)
|
|
||||||
svc.SetNtfyURL(target)
|
|
||||||
svc.SetSlackWebhookURL(target)
|
|
||||||
svc.SetMattermostWebhookURL(target)
|
|
||||||
svc.SetSleepFunc(instantSleep)
|
|
||||||
svc.SetRetryConfig(notify.RetryConfig{
|
|
||||||
MaxRetries: 1,
|
|
||||||
BaseDelay: time.Millisecond,
|
|
||||||
MaxDelay: time.Millisecond,
|
|
||||||
})
|
|
||||||
|
|
||||||
svc.SendNotification(context.Background(), title, "m", prioInfo)
|
|
||||||
|
|
||||||
waitForCondition(t, func() bool {
|
|
||||||
return svc.OutstandingDeliveries() == 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return logs.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendNotificationDebugLogOmitsWebhookURL checks that, with debug
|
|
||||||
// logging on, a delivery to each endpoint logs the endpoint's name and
|
|
||||||
// the title, and no part of the URL's path or query.
|
|
||||||
func TestSendNotificationDebugLogOmitsWebhookURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const title = "Port Change"
|
|
||||||
|
|
||||||
srv := httptest.NewServer(
|
|
||||||
http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
logs := sendToEachEndpoint(t, srv.URL, title)
|
|
||||||
|
|
||||||
assertNoWebhookSecret(t, logs)
|
|
||||||
|
|
||||||
got := map[string]string{}
|
|
||||||
|
|
||||||
for _, record := range parseLog(t, logs) {
|
|
||||||
if record.Level == slog.LevelDebug.String() {
|
|
||||||
got[record.Endpoint] = record.Title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
want := map[string]string{
|
|
||||||
endpointNtfy: title,
|
|
||||||
endpointSlack: title,
|
|
||||||
endpointMattermost: title,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !maps.Equal(got, want) {
|
|
||||||
t.Errorf("debug lines' endpoint and title = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendNotificationErrorLogOmitsWebhookURL checks that a delivery
|
|
||||||
// to each endpoint that cannot connect logs the transport's error,
|
|
||||||
// which names the refused connection, on its retry line and on its
|
|
||||||
// final failure line, and that no line holds any part of the URL's
|
|
||||||
// path or query.
|
|
||||||
func TestSendNotificationErrorLogOmitsWebhookURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Closed before anything is sent, so every attempt fails to
|
|
||||||
// connect.
|
|
||||||
srv := httptest.NewServer(http.NotFoundHandler())
|
|
||||||
srv.Close()
|
|
||||||
|
|
||||||
logs := sendToEachEndpoint(t, srv.URL, "t")
|
|
||||||
|
|
||||||
assertNoWebhookSecret(t, logs)
|
|
||||||
|
|
||||||
// For each endpoint, the messages of its lines whose error names
|
|
||||||
// the refused connection, in the order they were logged.
|
|
||||||
got := map[string][]string{}
|
|
||||||
|
|
||||||
for _, record := range parseLog(t, logs) {
|
|
||||||
if strings.Contains(record.Error, "connection refused") {
|
|
||||||
got[record.Endpoint] = append(
|
|
||||||
got[record.Endpoint], record.Msg,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := []string{
|
|
||||||
"notification delivery failed, retrying",
|
|
||||||
"failed to send notification after retries",
|
|
||||||
}
|
|
||||||
want := map[string][]string{
|
|
||||||
endpointNtfy: lines,
|
|
||||||
endpointSlack: lines,
|
|
||||||
endpointMattermost: lines,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !maps.EqualFunc(got, want, slices.Equal) {
|
|
||||||
t.Errorf("lines whose error names the refused connection = %v, "+
|
|
||||||
"want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SlackPayload JSON marshaling ──────────────────────────
|
// ── SlackPayload JSON marshaling ──────────────────────────
|
||||||
|
|
||||||
func TestSlackPayloadJSON(t *testing.T) {
|
func TestSlackPayloadJSON(t *testing.T) {
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ func (svc *Service) SendNtfy(
|
|||||||
return svc.sendNtfy(ctx, topicURL, title, message, priority)
|
return svc.sendNtfy(ctx, topicURL, title, message, priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendSlack exports sendSlack for testing. It sends as the
|
// SendSlack exports sendSlack for testing.
|
||||||
// "slack" endpoint.
|
|
||||||
func (svc *Service) SendSlack(
|
func (svc *Service) SendSlack(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
webhookURL *url.URL,
|
webhookURL *url.URL,
|
||||||
@@ -90,7 +89,7 @@ func (svc *Service) SendSlack(
|
|||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
return svc.sendSlack(
|
return svc.sendSlack(
|
||||||
ctx, webhookURL, "slack", failed, title, message, priority,
|
ctx, webhookURL, failed, title, message, priority,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,15 +56,6 @@ func IsAllowedScheme(scheme string) bool {
|
|||||||
func ValidateWebhookURL(raw string) (*url.URL, error) {
|
func ValidateWebhookURL(raw string) (*url.URL, error) {
|
||||||
u, err := url.ParseRequestURI(raw)
|
u, err := url.ParseRequestURI(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A *url.Error quotes the whole URL, and this error is
|
|
||||||
// logged at startup. A webhook URL's path or query lets
|
|
||||||
// anyone post to the channel or topic, so keep only the
|
|
||||||
// reason.
|
|
||||||
var urlErr *url.Error
|
|
||||||
if errors.As(err, &urlErr) {
|
|
||||||
err = urlErr.Err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +277,7 @@ func (svc *Service) dispatchSlack(
|
|||||||
|
|
||||||
svc.dispatch(ctx, "slack", func(c context.Context) error {
|
svc.dispatch(ctx, "slack", func(c context.Context) error {
|
||||||
return svc.sendSlack(
|
return svc.sendSlack(
|
||||||
c, svc.slackWebhookURL, "slack", ErrSlackFailed,
|
c, svc.slackWebhookURL, ErrSlackFailed,
|
||||||
title, message, priority,
|
title, message, priority,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -304,8 +295,8 @@ func (svc *Service) dispatchMattermost(
|
|||||||
ctx, "mattermost",
|
ctx, "mattermost",
|
||||||
func(c context.Context) error {
|
func(c context.Context) error {
|
||||||
return svc.sendSlack(
|
return svc.sendSlack(
|
||||||
c, svc.mattermostWebhookURL, "mattermost",
|
c, svc.mattermostWebhookURL, ErrMattermostFailed,
|
||||||
ErrMattermostFailed, title, message, priority,
|
title, message, priority,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -316,11 +307,9 @@ func (svc *Service) sendNtfy(
|
|||||||
topicURL *url.URL,
|
topicURL *url.URL,
|
||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
// The topic URL is left out of the log: it lets anyone who has
|
|
||||||
// it read and post to the topic.
|
|
||||||
svc.log.Debug(
|
svc.log.Debug(
|
||||||
"sending ntfy notification",
|
"sending ntfy notification",
|
||||||
"endpoint", "ntfy",
|
"topic", topicURL.String(),
|
||||||
"title", title,
|
"title", title,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -383,15 +372,12 @@ type SlackAttachment struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendSlack posts to a Slack or Mattermost incoming webhook, which
|
// sendSlack posts to a Slack or Mattermost incoming webhook, which
|
||||||
// take the same payload. endpoint, "slack" or "mattermost", names
|
// take the same payload. An HTTP error status is returned wrapped in
|
||||||
// the webhook in the log in place of its URL, which lets anyone who
|
// failed, ErrSlackFailed or ErrMattermostFailed, so the error names
|
||||||
// has it post to the channel. An HTTP error status is returned
|
// the endpoint.
|
||||||
// wrapped in failed, ErrSlackFailed or ErrMattermostFailed, so the
|
|
||||||
// error names the endpoint.
|
|
||||||
func (svc *Service) sendSlack(
|
func (svc *Service) sendSlack(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
webhookURL *url.URL,
|
webhookURL *url.URL,
|
||||||
endpoint string,
|
|
||||||
failed error,
|
failed error,
|
||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
@@ -402,7 +388,7 @@ func (svc *Service) sendSlack(
|
|||||||
|
|
||||||
svc.log.Debug(
|
svc.log.Debug(
|
||||||
"sending webhook notification",
|
"sending webhook notification",
|
||||||
"endpoint", endpoint,
|
"url", webhookURL.String(),
|
||||||
"title", title,
|
"title", title,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -79,23 +79,6 @@ func TestValidateWebhookURLInvalid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestValidateWebhookURLErrorOmitsURL checks that the error for a
|
|
||||||
// webhook URL that does not parse, here because a carriage return
|
|
||||||
// was left at its end, leaves out the URL's path and query: the
|
|
||||||
// error is logged at startup.
|
|
||||||
func TestValidateWebhookURLErrorOmitsURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := notify.ValidateWebhookURL(
|
|
||||||
"https://hooks.example.com" + webhookPathAndQuery + "\r",
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected an error for a URL ending in a carriage return")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoWebhookSecret(t, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsAllowedScheme(t *testing.T) {
|
func TestIsAllowedScheme(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,6 @@ 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
|
||||||
|
|||||||
@@ -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,24 +204,20 @@ 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.
|
||||||
// *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,
|
||||||
zone string,
|
|
||||||
nameservers []string,
|
|
||||||
servers []string,
|
servers []string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
|
// servers are the root servers, the servers of zone ".".
|
||||||
|
zone := "."
|
||||||
|
|
||||||
var withoutAddresses []string
|
var withoutAddresses []string
|
||||||
|
|
||||||
for range maxDelegation {
|
for range maxDelegation {
|
||||||
@@ -232,12 +228,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -266,7 +256,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -637,8 +626,7 @@ 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; when a
|
// not answer, it returns the error and tries no parent name.
|
||||||
// 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,
|
||||||
@@ -667,7 +655,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 +1067,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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,9 +121,7 @@ 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. The servers that did not answer are those the walk started at,
|
// them.
|
||||||
// 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()
|
||||||
|
|
||||||
@@ -136,9 +134,6 @@ 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(
|
||||||
@@ -959,34 +954,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,34 +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.
|
|
||||||
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,10 +20,7 @@ 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. When
|
// returning each nameserver's response keyed by nameserver.
|
||||||
// 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,
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import (
|
|||||||
// certificate keys and the Port Change notification write the address
|
// certificate keys and the Port Change notification write the address
|
||||||
// in brackets, so its last group cannot be read as the port, and the
|
// in brackets, so its last group cannot be read as the port, and the
|
||||||
// second port checks keep the certificate entry, whose address they read
|
// second port checks keep the certificate entry, whose address they read
|
||||||
// back from its key. When a last port check finds port 443 closed, the
|
// back from its key. Nothing is looked up.
|
||||||
// certificate entry is removed. Nothing is looked up.
|
|
||||||
func TestIPv6AddressInBrackets(t *testing.T) {
|
func TestIPv6AddressInBrackets(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -71,13 +70,4 @@ Port now open`
|
|||||||
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
|
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
|
||||||
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
|
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.portChecker.closed = true
|
|
||||||
|
|
||||||
w.CheckAllPorts(t.Context())
|
|
||||||
|
|
||||||
gotCerts = slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
|
|
||||||
if len(gotCerts) != 0 {
|
|
||||||
t.Errorf("certificate entries %v with port 443 closed, want none", gotCerts)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package watcher_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,10 +14,6 @@ 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 {
|
||||||
@@ -188,134 +183,6 @@ 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
|
// 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
|
||||||
|
|||||||
@@ -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,8 +464,8 @@ 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.
|
||||||
func (w *Watcher) checkHostname(
|
func (w *Watcher) checkHostname(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
@@ -497,31 +478,12 @@ func (w *Watcher) checkHostname(
|
|||||||
"hostname", hostname,
|
"hostname", hostname,
|
||||||
"error", err,
|
"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)
|
prev, _ := w.state.GetHostnameState(hostname)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
results = failedNameservers(err, prev)
|
|
||||||
if results == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.updateHostnameState(
|
w.updateHostnameState(
|
||||||
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
||||||
)
|
)
|
||||||
@@ -529,40 +491,6 @@ func (w *Watcher) checkHostnameResult(
|
|||||||
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.
|
||||||
@@ -862,7 +790,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