Compare commits

..
8 Commits
Author SHA1 Message Date
clawbot 06ae357165 resolver: drop the unused timeout multiplier and the comment claiming it (closes #296)
check / check (push) Waiting to run
The resolver declared timeoutMultiplier and passed each try's number to
exchangeWithTimeout, which threw it away with a comment saying the
client config grew the timeout. Nothing did: every try waits the same
2-second queryTimeoutDuration. The constant goes, and exchangeWithTimeout
is folded into its one caller, tryExchange, whose comment now says the
timeout is fixed. Behaviour is unchanged.

Model: opus-5-5
2026-10-15 18:31:07 +02:00
clawbot f6b7aefcbf docs: TESTING.md constraints match the resolver and the test-time policy (closes #289)
check / check (push) Canceled after 0s
The Constraints list named a query timeout calibrated to round-trip time and a
limited root-server fan-out; the resolver has neither. It now gives the
2-second timeout and the second try over UDP, says a zone's servers are asked
one at a time in a random order, and gives the 60-second cap and 20-second
target for make test from REPO_POLICIES.md. The line on flaky failures now
says, as the README does, that a flaky live test is fixed with robustness,
never with mocks or skip flags.

Model: opus-5-5
2026-10-15 18:01:51 +02:00
clawbot db3b09c5d8 handlers: a directory under /s/ answers 404, not a listing (closes #286)
check / check (push) Canceled after 0s
http.FileServer answered /s/ and /s/css/ with an HTML listing of the
embedded files, and /s/css with a redirect to that listing. HandleStatic
in internal/handlers now looks the name up in the embedded files first
and answers 404 when it names a directory; files are served as before.
The routes mount it under /s.

Model: opus-5-5
2026-10-15 17:01:52 +02:00
clawbot caa3aa3c12 notify: a reply other than 2xx is a failed delivery (closes #287)
check / check (push) Canceled after 0s
A notification endpoint that answered with a redirect was counted as
delivered: the request is not sent through a client, so the redirect
is never followed, and only a status of 400 or more counted as a
failure. Now any status outside 200-299 fails the delivery for ntfy,
Slack and Mattermost, so it is retried and logged like the others.

Model: opus-5-5
2026-10-15 16:14:20 +02:00
clawbot dee69c4780 resolver: a nameserver is asked at each of its addresses in turn (closes #285)
check / check (push) Canceled after 0s
A nameserver's queries went only to the first IPv4 address its name
resolves to, so one unreachable address made it fail whenever that
address came first in the answer. Each record type is now asked at the
addresses one at a time until one gives a usable reply, and the next
type is asked first at the address that gave it. A type that fails is
logged with the reason at each address, and a nameserver is reported as
timed out only when every query timed out; one given no address has
failed. The README's DNS Resolution Strategy says which addresses are
asked.

Model: opus-5-5
2026-10-15 15:48:15 +02:00
clawbot 5e2c456e85 notify: the log names a notification endpoint, not its webhook URL (closes #288)
check / check (push) Canceled after 0s
With debug logging on, each delivery logged the full Slack, Mattermost
or ntfy URL, whose path or query lets anyone who has it post to the
channel or topic. The two debug lines now give the endpoint's name and
the title; sendSlack is told which of Slack or Mattermost it sends to.
A webhook URL that does not parse gave a startup error quoting the
whole URL; it now gives only the reason. Errors from a failed request
come from the transport, not from an HTTP client, and carry no part of
the path or query; a test now checks that too.

Model: opus-5-5
2026-10-15 14:48:17 +02:00
clawbot b79726acc6 watcher: a hostname whose zone's nameservers all stop answering gets NS Failure (closes #284)
check / check (push) Canceled after 0s
When none of the nameservers of a hostname's zone answered, the walk to
them failed and the check was only logged. The walk now returns a
NoAnswerError naming the nameservers of the zone it reached through a
referral when none of them answers. When those are the nameservers the
previous check saved for the name, each is saved as failed with the
reason, so it gets an NS Failure and later an NS Recovery. A domain with
no delegation of its own, whose NS lookup is that same walk, gets the
same. A walk that fails before that zone, as when the host loses its
network, is still only logged.

Model: opus-5-5
2026-10-14 00:27:29 +02:00
clawbot aaccabad50 watcher: an IPv6 address is written in brackets before its port (closes #270)
check / check (push) Successful in 4m33s
Port and certificate keys were joined as address:port, so an IPv6
address ran into its port: 2a01:4f8:2200:fd11::1:80 is also an address
on its own. Keys are now joined with net.JoinHostPort and split with
net.SplitHostPort, giving [2a01:4f8:2200:fd11::1]:80 in the state file,
/api/v1/status, the dashboard and Port Change notifications; an IPv4
address is unchanged. A certificate key still splits at its last colon,
since a hostname has none. Tests that built IPv6 keys by hand now use
the bracketed form, and a test checks that an IPv6 address's
certificate entry is removed when its port 443 closes.

Model: opus-5-5
2026-10-13 22:27:34 +02:00
20 changed files with 1009 additions and 165 deletions
+32 -17
View File
@@ -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,22 +123,29 @@ 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
but the query for another gets no usable reply (no reply after two tries, an
error reply such as SERVFAIL, a referral, or a reply too large for UDP whose
retry over TCP fails), the failure is logged with the reason, and the type is
listed in the nameserver's `failedTypes` and keeps the records saved for the
nameserver by the previous check. On that check those records are not compared
with the other nameservers', so no record change or inconsistency is reported
for the type; on the next check they are compared with the nameserver's answer
as usual. When the previous check did not know the type's records either,
because the nameserver was new or failing then or the type was already listed
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
every comparison until it answers. A nameserver none of whose queries got a
usable reply has failed (see NS query failure below).
but the query for another gets no usable reply at any of its addresses (no
reply after two tries, an error reply such as SERVFAIL, a referral, or a reply
too large for UDP whose retry over TCP fails), the failure is logged with the
reason, and the type is listed in the nameserver's `failedTypes` and keeps the
records saved for the nameserver by the previous check. On that check those
records are not compared with the other nameservers', so no record change or
inconsistency is reported for the type; on the next check they are compared
with the nameserver's answer as usual. When the previous check did not know
the type's records either, because the nameserver was new or failing then or
the type was already listed in `unknownTypes`, the type is also listed in
`unknownTypes` and left out of every comparison until it answers. A nameserver
none of whose queries got a usable reply has failed (see NS query failure
below).
- Stores results **per nameserver**. The state for a hostname is not a merged
view — it is a map from nameserver to record set.
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
@@ -277,7 +286,7 @@ includes:
Each endpoint is sent each notification on its own, in the background. A
delivery that fails (a network error, no reply within 10 seconds, or an HTTP
status of 400 or more) is retried up to 5 times: the first retry after about 1
status other than 2xx) is retried up to 5 times: the first retry after about 1
second, each wait after that twice as long up to 60 seconds, every wait varied
at random by up to 25%. A delivery still failing after that is logged and
dropped.
@@ -541,6 +550,11 @@ name's nameservers and in the lookup of a nameserver's own address. Such a
lookup can need others in turn; lookups go at most three deep, one inside
another, so delegations that point at each other still end.
In step 4 each record type is asked at the IPv4 addresses the nameserver's name
resolves to, one at a time, until one gives a usable reply; the next type is
asked first at that address. A type's query fails only when none of them gives
one, and the nameserver has failed only when that is so for every type.
This approach ensures:
- Independence from any upstream resolver's cache or filtering.
@@ -896,6 +910,7 @@ internal/
templates/dashboard.html Dashboard template (embedded)
status.go /api/v1/status
healthcheck.go Health check handler
static.go Static assets under /s/
server/
server.go HTTP server lifecycle
routes.go Route definitions
+9 -7
View File
@@ -19,16 +19,18 @@ works correctly in production.
### Constraints
- Tests hit real DNS infrastructure and require network access
- Test duration depends on network conditions; timeout tuning keeps the suite
within the 60-second target
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus
processing margin
- Root server fan-out is limited to reduce parallel query load
- Test duration depends on network conditions. `make test` must finish in under
60 seconds, the hard cap in `REPO_POLICIES.md`; under 20 seconds is the target
- Each query to a nameserver has a 2-second timeout, and a query over UDP that
gets no reply is sent once more (`queryTimeoutDuration` and `maxRetries` in
`internal/resolver/iterative.go`)
- In the walk to a name's nameservers, the servers of each zone are asked one at
a time in a random order, as the README's DNS Resolution Strategy describes
- Live lookups that expect an answer go through `internal/livednstest`, which
limits how many run at once in a test binary and retries a lookup that got
none
- Flaky failures from transient network issues are acceptable and should be
investigated as potential resolver bugs, not papered over with mocks or skip
- A flaky live test is a robustness problem, fixed with retries with backoff,
several independent nameservers or longer timeouts, never with mocks or skip
flags
### What NOT to do
+17
View File
@@ -19,6 +19,23 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Completed Steps
- 2026-10-15: the resolver no longer says its query timeout grows on each try;
every try waits the same 2 seconds, and the unused `timeoutMultiplier` is gone
(closes #296).
- 2026-10-15: `TESTING.md` Constraints give the resolver's 2-second query
timeout and second try, say a zone's servers are asked one at a time in a
random order, give the 60-second cap and 20-second target for `make test`, and
say a flaky live test is fixed with robustness, not with mocks (closes #289).
- 2026-10-15: a request under `/s/` that names a directory, such as `/s/` or
`/s/css`, answers 404 instead of a listing of its files (closes #286).
- 2026-10-15: a notification endpoint that answers with a redirect, or any
status other than 2xx, is a failed delivery, retried and logged (closes #287).
- 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it
has failed only when none of them gives a usable reply (closes #285).
- 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
`[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
+36
View File
@@ -0,0 +1,36 @@
package handlers
import (
"io/fs"
"net/http"
"strings"
"sneak.berlin/go/dnswatcher/static"
)
// HandleStatic returns the handler for the embedded static assets. It
// is mounted under /s, with that prefix stripped from the path. A
// request that names a directory answers 404: http.FileServer would
// answer it with a listing of the directory, or with a redirect to
// that listing when the trailing slash is missing.
func (h *Handlers) HandleStatic() http.HandlerFunc {
files := http.FileServer(http.FS(static.Static))
return func(
writer http.ResponseWriter,
request *http.Request,
) {
// fs.Stat refuses a name that is empty or ends in a slash,
// so /s/ and /s/css/ answer 404 here along with /s/css.
name := strings.TrimPrefix(request.URL.Path, "/")
info, err := fs.Stat(static.Static, name)
if err != nil || info.IsDir() {
http.NotFound(writer, request)
return
}
files.ServeHTTP(writer, request)
}
}
+185
View File
@@ -6,10 +6,12 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"testing"
@@ -38,6 +40,13 @@ const (
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.
const testHost = "example.com"
@@ -1094,6 +1103,182 @@ 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()
}
// assertEachEndpointRetriedThenFailed fails the test unless, for each
// of ntfy, Slack and Mattermost, the lines of logs whose error
// contains reason are the retry line and then the final failure line.
func assertEachEndpointRetriedThenFailed(
t *testing.T, logs, reason string,
) {
t.Helper()
// For each endpoint, the messages of its lines whose error
// contains reason, in the order they were logged.
got := map[string][]string{}
for _, record := range parseLog(t, logs) {
if strings.Contains(record.Error, reason) {
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 contains %q = %v, want %v",
reason, got, want)
}
}
// 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)
assertEachEndpointRetriedThenFailed(t, logs, "connection refused")
}
// ── SlackPayload JSON marshaling ──────────────────────────
func TestSlackPayloadJSON(t *testing.T) {
+3 -2
View File
@@ -81,7 +81,8 @@ func (svc *Service) SendNtfy(
return svc.sendNtfy(ctx, topicURL, title, message, priority)
}
// SendSlack exports sendSlack for testing.
// SendSlack exports sendSlack for testing. It sends as the
// "slack" endpoint.
func (svc *Service) SendSlack(
ctx context.Context,
webhookURL *url.URL,
@@ -89,7 +90,7 @@ func (svc *Service) SendSlack(
title, message, priority string,
) error {
return svc.sendSlack(
ctx, webhookURL, failed, title, message, priority,
ctx, webhookURL, "slack", failed, title, message, priority,
)
}
+30 -13
View File
@@ -25,9 +25,6 @@ import (
// HTTP client timeout.
const httpClientTimeout = 10 * time.Second
// HTTP status code thresholds.
const httpStatusClientError = 400
// Sentinel errors for notification failures.
var (
// ErrNtfyFailed indicates the ntfy request failed.
@@ -56,6 +53,15 @@ func IsAllowedScheme(scheme string) bool {
func ValidateWebhookURL(raw string) (*url.URL, error) {
u, err := url.ParseRequestURI(raw)
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)
}
@@ -277,7 +283,7 @@ func (svc *Service) dispatchSlack(
svc.dispatch(ctx, "slack", func(c context.Context) error {
return svc.sendSlack(
c, svc.slackWebhookURL, ErrSlackFailed,
c, svc.slackWebhookURL, "slack", ErrSlackFailed,
title, message, priority,
)
})
@@ -295,8 +301,8 @@ func (svc *Service) dispatchMattermost(
ctx, "mattermost",
func(c context.Context) error {
return svc.sendSlack(
c, svc.mattermostWebhookURL, ErrMattermostFailed,
title, message, priority,
c, svc.mattermostWebhookURL, "mattermost",
ErrMattermostFailed, title, message, priority,
)
},
)
@@ -307,9 +313,11 @@ func (svc *Service) sendNtfy(
topicURL *url.URL,
title, message, priority string,
) 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(
"sending ntfy notification",
"topic", topicURL.String(),
"endpoint", "ntfy",
"title", title,
)
@@ -333,7 +341,10 @@ func (svc *Service) sendNtfy(
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= httpStatusClientError {
// Only a 2xx reply means ntfy took the notification. A
// redirect is not followed, so it is a failure too.
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf(
"%w: status %d",
ErrNtfyFailed, resp.StatusCode,
@@ -372,12 +383,15 @@ type SlackAttachment struct {
}
// sendSlack posts to a Slack or Mattermost incoming webhook, which
// take the same payload. An HTTP error status is returned wrapped in
// failed, ErrSlackFailed or ErrMattermostFailed, so the error names
// the endpoint.
// take the same payload. endpoint, "slack" or "mattermost", names
// the webhook in the log in place of its URL, which lets anyone who
// has it post to the channel. A reply status other than 2xx is
// returned wrapped in failed, ErrSlackFailed or ErrMattermostFailed,
// so the error names the endpoint.
func (svc *Service) sendSlack(
ctx context.Context,
webhookURL *url.URL,
endpoint string,
failed error,
title, message, priority string,
) error {
@@ -388,7 +402,7 @@ func (svc *Service) sendSlack(
svc.log.Debug(
"sending webhook notification",
"url", webhookURL.String(),
"endpoint", endpoint,
"title", title,
)
@@ -423,7 +437,10 @@ func (svc *Service) sendSlack(
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= httpStatusClientError {
// Only a 2xx reply means the webhook took the notification. A
// redirect is not followed, so it is a failure too.
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf(
"%w: status %d",
failed, resp.StatusCode,
+17
View File
@@ -79,6 +79,23 @@ 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) {
t.Parallel()
+30
View File
@@ -536,3 +536,33 @@ func TestSendNotificationPermanentFailureLogsError(
return attempts >= 3
})
}
// TestSendNotificationRedirectFails checks that a 302 reply is a
// failed delivery at each endpoint: the server is sent the first
// attempt and the retry, and each endpoint logs a retry line and
// then the final failure line, both naming the 302.
func TestSendNotificationRedirectFails(t *testing.T) {
t.Parallel()
var requests atomic.Int64
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
http.Redirect(w, r, "/moved", http.StatusFound)
}),
)
defer srv.Close()
logs := sendToEachEndpoint(t, srv.URL, "t")
// ntfy, Slack and Mattermost each send once and retry once.
const wantRequests = 3 * 2
if sent := requests.Load(); sent != wantRequests {
t.Errorf("server got %d requests, want %d", sent, wantRequests)
}
assertEachEndpointRetriedThenFailed(t, logs, "status 302")
}
+21
View File
@@ -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
@@ -19,6 +36,10 @@ var (
// so whether the name has addresses is unknown.
ErrNoNameserverAnswered = errors.New("no nameserver answered")
// ErrNoNameserverAddress is returned when a nameserver is to be
// asked and no address for it was given.
ErrNoNameserverAddress = errors.New("nameserver has no address")
// ErrUnusableReply is returned when a server replied with an
// error such as SERVFAIL, or with a referral that leads no
// closer to the name asked about.
+14 -1
View File
@@ -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
@@ -94,6 +96,17 @@ func (r *Resolver) QueryServers(
return r.queryServers(ctx, servers, zone, name, qtype)
}
// QueryTypes exports queryTypes for testing.
func (r *Resolver) QueryTypes(
ctx context.Context,
nsHostname string,
nsIPs []string,
hostname string,
qtypes []uint16,
) (*NameserverResponse, error) {
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
}
// QueryEachNS exports queryEachNS for testing.
func (r *Resolver) QueryEachNS(
ctx context.Context,
+88 -55
View File
@@ -19,7 +19,6 @@ const (
queryTimeoutDuration = 2 * time.Second
maxRetries = 2
maxDelegation = 20
timeoutMultiplier = 2
// maxLookupDepth is how many lookups of nameserver addresses may be
// under way one inside another. Looking up a nameserver's address
@@ -62,19 +61,8 @@ func checkCtx(ctx context.Context) error {
return nil
}
func (r *Resolver) exchangeWithTimeout(
ctx context.Context,
msg *dns.Msg,
addr string,
attempt int,
) (*dns.Msg, error) {
_ = attempt // timeout escalation handled by client config
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
return resp, err
}
// tryExchange sends msg to addr over UDP, up to maxRetries times, until
// a reply comes. Every try waits the same queryTimeoutDuration.
func (r *Resolver) tryExchange(
ctx context.Context,
msg *dns.Msg,
@@ -84,14 +72,12 @@ func (r *Resolver) tryExchange(
var err error
for attempt := range maxRetries {
for range maxRetries {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
resp, err = r.exchangeWithTimeout(
ctx, msg, addr, attempt,
)
resp, _, err = r.client.ExchangeContext(ctx, msg, addr)
if err == nil {
break
}
@@ -204,20 +190,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 +218,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 +252,7 @@ func (r *Resolver) followDelegation(
return r.resolveNSIterative(ctx, domain)
}
nameservers = authNS
servers, withoutAddresses = referralNameservers(resp)
zone = referralZone(resp)
}
@@ -626,7 +623,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 +653,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
}
@@ -696,8 +694,9 @@ func (r *Resolver) QueryNameserver(
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
}
// queryNameserver queries a specific nameserver for the record types
// in qtypes and builds a NameserverResponse.
// queryNameserver queries a specific nameserver, at the IPv4 addresses
// its name resolves to, for the record types in qtypes and builds a
// NameserverResponse.
func (r *Resolver) queryNameserver(
ctx context.Context,
nsHostname string,
@@ -715,7 +714,7 @@ func (r *Resolver) queryNameserver(
hostname = dns.Fqdn(hostname)
return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes)
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
}
// QueryNameserverIP queries a nameserver by its IP address directly,
@@ -732,23 +731,33 @@ func (r *Resolver) QueryNameserverIP(
hostname = dns.Fqdn(hostname)
return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes())
return r.queryTypes(
ctx, nsHostname, []string{nsIP}, hostname, recordTypes(),
)
}
// queryTypes asks the nameserver nsHostname, at its addresses nsIPs,
// about hostname's records of each type in qtypes and builds a
// NameserverResponse. With no address there is nothing to ask, and it
// returns ErrNoNameserverAddress.
func (r *Resolver) queryTypes(
ctx context.Context,
nsHostname string,
nsIP string,
nsIPs []string,
hostname string,
qtypes []uint16,
) (*NameserverResponse, error) {
if len(nsIPs) == 0 {
return nil, fmt.Errorf("%w: %s", ErrNoNameserverAddress, nsHostname)
}
resp := &NameserverResponse{
Nameserver: nsHostname,
Records: make(map[string][]string),
Status: StatusOK,
}
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
state := r.queryEachType(ctx, nsIPs, hostname, qtypes, resp)
classifyResponse(resp, state)
return resp, nil
@@ -766,28 +775,37 @@ type queryState struct {
answered bool
}
// queryEachType asks the nameserver at nsIP about hostname once for each
// record type in qtypes, and lists in resp.FailedTypes the types whose
// query got no usable reply, logging each with the reason unless ctx was
// cancelled: shutdown cancels it, and a query it cut short did not fail.
// queryEachType asks the nameserver about hostname's records of each type
// in qtypes, at its addresses nsIPs in turn until one gives a usable
// reply, and lists in resp.FailedTypes the types that got none at any of
// them, logging each with the reasons unless ctx was cancelled: shutdown
// cancels it, and a query it cut short did not fail.
func (r *Resolver) queryEachType(
ctx context.Context,
nsIP string,
nsIPs []string,
hostname string,
qtypes []uint16,
resp *NameserverResponse,
) queryState {
var state queryState
// Reordered below; the caller's list is left as it was.
nsIPs = slices.Clone(nsIPs)
for _, qtype := range qtypes {
if checkCtx(ctx) != nil {
break
}
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
answeredAt, err := r.querySingleType(
ctx, nsIPs, hostname, qtype, resp, &state,
)
if err == nil {
state.answered = true
// The next type is asked first at the address that answered.
nsIPs[0], nsIPs[answeredAt] = nsIPs[answeredAt], nsIPs[0]
continue
}
@@ -821,20 +839,30 @@ func (r *Resolver) queryEachType(
return state
}
// querySingleType asks the nameserver at nsIP about hostname's records
// of type qtype. It returns nil when the nameserver answered: with
// records, with none, or with NXDOMAIN; otherwise it returns why not.
// querySingleType asks the nameserver about hostname's records of type
// qtype at its addresses nsIPs, of which there is at least one, one at a
// time, until one answers: with records, with none, or with NXDOMAIN.
// It returns the index in nsIPs of the address that answered; when none
// did, it returns why each did not, each reason naming its address.
func (r *Resolver) querySingleType(
ctx context.Context,
nsIP string,
nsIPs []string,
hostname string,
qtype uint16,
resp *NameserverResponse,
state *queryState,
) error {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
) (int, error) {
var errs []error
for i, nsIP := range nsIPs {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
switch {
case err == nil:
err = readReply(msg, resp, state)
if err != nil {
err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err)
}
case isTimeout(err):
state.gotTimeout = true
case errors.Is(err, ErrRefused):
@@ -843,10 +871,14 @@ func (r *Resolver) querySingleType(
state.netErr = err
}
return err
if err == nil {
return i, nil
}
errs = append(errs, err)
}
return readReply(msg, resp, state)
return 0, errors.Join(errs...)
}
// readReply adds to resp the records in msg, a nameserver's reply to a
@@ -940,17 +972,15 @@ func isTimeout(err error) bool {
}
// classifyResponse sets the nameserver's status. One that answered no
// record type has failed, and Error says why; one that answered some has
// the status of those answers. It has no data only when every type
// record type has failed, and Error says why; it says all queries timed
// out only when every query, at every address, did. One that answered
// some has the status of those answers. It has no data only when every type
// answered with no records: a type in FailedTypes may have records, so a
// nameserver with one stays ok.
func classifyResponse(resp *NameserverResponse, state queryState) {
switch {
case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotErrorReply && !state.answered:
resp.Status = StatusError
resp.Error = "server returned " + state.errorReply
@@ -963,6 +993,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotReferral && !state.answered:
resp.Status = StatusError
resp.Error = "server returned a referral"
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
// An NXDOMAIN reply with no records was taken by the first case.
case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData
@@ -1067,7 +1100,7 @@ func (r *Resolver) LookupNS(
}
return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(),
)
}
+36 -51
View File
@@ -15,8 +15,10 @@ import (
// that answered some record types, even with no records, has not failed
// when its query for another type got no usable reply, whatever the
// reason, and is ok, not nodata: that type may have records. One whose
// every query got none has failed. Only one whose every type answered
// with no records is nodata.
// every query got none has failed, and its error says all queries timed
// out only when every one did: a reply such as a refusal, or a network
// error, at one of its addresses is given instead. Only one whose every
// type answered with no records is nodata.
func TestClassifyResponse(t *testing.T) {
t.Parallel()
@@ -27,63 +29,46 @@ func TestClassifyResponse(t *testing.T) {
wantStatus string
wantError string
}{
{
"every type answered with no records",
{"every type answered with no records",
queryState{answered: true},
nil,
StatusNoData, "",
},
{
"some types answered with no records, another timed out",
nil, StatusNoData, ""},
{"some types answered with no records, another timed out",
queryState{answered: true, gotTimeout: true},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another got SERVFAIL",
queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another was refused",
[]string{"A"}, StatusOK, ""},
{"some types answered with no records, another got SERVFAIL",
queryState{answered: true, gotErrorReply: true, errorReply: "SERVFAIL"},
[]string{"A"}, StatusOK, ""},
{"some types answered with no records, another was refused",
queryState{answered: true, gotRefused: true},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another got a network error",
[]string{"A"}, StatusOK, ""},
{"some types answered with no records, another got a network error",
queryState{answered: true, netErr: syscall.ECONNREFUSED},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another's reply was " +
"truncated and its retry over TCP failed",
[]string{"A"}, StatusOK, ""},
{"some types answered with no records, another's reply was " +
"truncated and its retry over TCP failed",
queryState{answered: true, netErr: ErrTruncated},
[]string{"TXT"},
StatusOK, "",
},
{
"some types answered with no records, another got a referral",
[]string{"TXT"}, StatusOK, ""},
{"some types answered with no records, another got a referral",
queryState{answered: true, gotReferral: true},
[]string{"A"},
StatusOK, "",
},
{
"every query timed out",
[]string{"A"}, StatusOK, ""},
{"every query timed out",
queryState{gotTimeout: true},
[]string{"A", "AAAA", "CNAME"},
StatusTimeout, "all queries timed out",
},
{
"every query got NOTIMP",
[]string{"A", "AAAA", "CNAME"}, StatusTimeout, "all queries timed out"},
{"every query got NOTIMP",
queryState{gotErrorReply: true, errorReply: "NOTIMP"},
[]string{"A", "AAAA", "CNAME"},
StatusError, "server returned NOTIMP",
},
[]string{"A", "AAAA", "CNAME"}, StatusError, "server returned NOTIMP"},
{"every query got SERVFAIL at one address, timed out at another",
queryState{gotErrorReply: true, errorReply: "SERVFAIL", gotTimeout: true},
[]string{"A"}, StatusError, "server returned SERVFAIL"},
{"every query was refused at one address, timed out at another",
queryState{gotRefused: true, gotTimeout: true},
[]string{"A"}, StatusError, "server returned REFUSED"},
{"every query got a network error at one address, timed out at another",
queryState{netErr: syscall.ECONNREFUSED, gotTimeout: true},
[]string{"A"}, StatusError, "network error: connection refused"},
{"every query got a referral at one address, timed out at another",
queryState{gotReferral: true, gotTimeout: true},
[]string{"A"}, StatusError, "server returned a referral"},
}
for _, tt := range tests {
+208 -4
View File
@@ -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(
@@ -355,6 +360,124 @@ func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) {
assert.Equal(t, resolver.StatusOK, resp.Status)
}
// TestQueryTypes_FirstAddressNeverAnswers asks a google.com nameserver
// about google.com's A and AAAA records at a list of addresses that puts
// 192.0.2.1, where nothing answers, before the nameserver's own. The
// nameserver answers at its own address, so it is ok, with its records.
// Each attempt has time for one round of tries at 192.0.2.1, two tries
// of two seconds, and not for two, so AAAA must be asked first at the
// address that answered A.
func TestQueryTypes_FirstAddressNeverAnswers(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com")
nsIPs := append(
[]string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)...,
)
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", google.com)",
func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
defer cancel()
var err error
resp, err = r.QueryTypes(
ctx, ns, nsIPs, "google.com.",
[]uint16{dns.TypeA, dns.TypeAAAA},
)
if err != nil {
return err
}
if len(resp.FailedTypes) > 0 {
return fmt.Errorf(
"%w: %s gave no usable reply for %s",
livednstest.ErrNoAnswer, ns,
strings.Join(resp.FailedTypes, ", "),
)
}
return nil
},
)
assert.Equal(t, resolver.StatusOK, resp.Status)
assert.NotEmpty(t, resp.Records["A"])
assert.NotEmpty(t, resp.Records["AAAA"])
}
// TestQueryTypes_EveryAddressFails asks a google.com nameserver about
// cloudflare.com, a zone it does not serve, at 192.0.2.1, where nothing
// answers, and then at its own address, which refuses. The nameserver
// has failed with the refusal, not a timeout, and the warning about the
// type gives the reason at each address.
func TestQueryTypes_EveryAddressFails(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil)))
ns := findOneNSForDomain(t, r, "google.com")
nsIPs := append(
[]string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)...,
)
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", cloudflare.com)",
func(ctx context.Context) error {
logs.Reset()
var err error
resp, err = r.QueryTypes(
ctx, ns, nsIPs, "cloudflare.com.", []uint16{dns.TypeA},
)
if err != nil {
return err
}
// A timeout or a network error at its own address is no
// reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status)
assert.Equal(t, "server returned REFUSED", resp.Error)
assert.Contains(t, logs.String(), "@"+noAnswerAddress+": ")
assert.Contains(t, logs.String(), "@"+nsIPs[1]+": ")
}
// TestQueryTypes_NoAddress asks a nameserver given no address. There is
// nothing to ask it at, so it has failed, with an error that says it has
// no address; it is not ok.
func TestQueryTypes_NoAddress(t *testing.T) {
t.Parallel()
resp, err := newTestResolver(t).QueryTypes(
t.Context(), "ns1.example.net.", nil, "www.example.net.",
[]uint16{dns.TypeA},
)
require.ErrorIs(t, err, resolver.ErrNoNameserverAddress)
assert.Nil(t, resp)
}
func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel()
@@ -419,8 +542,9 @@ func TestQueryNameserver_TXT(t *testing.T) {
// nameserver about google.com with a resolver whose retries over TCP
// fail. google.com's TXT records do not fit in a reply over UDP, so TXT
// is reported as failed, holding none of the records that fit, and
// logged with the reason, while the nameserver, which answered the other
// types, is ok.
// logged with the reason, which names the address whose reply was
// truncated, while the nameserver, which answered the other types, is
// ok.
func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
t.Parallel()
@@ -437,6 +561,13 @@ func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
assert.Contains(t, logs.String(),
"hostname=google.com. nameserver="+ns+" type=TXT error=",
)
nsIPs := liveResolveNSIPs(t, newTestResolver(t), []string{ns}, 1)
for _, ip := range nsIPs {
assert.Contains(t, logs.String(),
"@"+ip+": "+resolver.ErrTruncated.Error(),
)
}
}
func TestQueryNameserver_NXDomain(t *testing.T) {
@@ -490,6 +621,57 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error)
}
// TestQueryNameserver_EveryAddressRefuses asks ada.ns.cloudflare.com,
// whose name has several IPv4 addresses, about google.com, a zone it
// does not serve, which it refuses. A record type is asked at each of
// the nameserver's addresses before it fails, so the warning about it
// names every one.
func TestQueryNameserver_EveryAddressRefuses(t *testing.T) {
t.Parallel()
const ns = "ada.ns.cloudflare.com."
var logs bytes.Buffer
r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil)))
nsIPs := liveResolveNSIPs(t, r, []string{ns}, 2)
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryNameserver("+ns+", google.com)",
func(ctx context.Context) error {
logs.Reset()
var err error
resp, err = r.QueryNameserver(ctx, ns, "google.com")
if err != nil {
return err
}
// A timeout or a network error at every address is no
// reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status)
assert.Equal(t, "server returned REFUSED", resp.Error)
for _, ip := range nsIPs {
assert.Contains(t, logs.String(), "@"+ip+": ")
}
}
// TestQueryServers_RecursiveResolverRefused passes a public recursive
// resolver to QueryServers as the server of google.com. These resolvers
// refuse a query that does not ask for recursion and answer one that
@@ -954,12 +1136,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.
+1 -9
View File
@@ -8,8 +8,6 @@ import (
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/dnswatcher/static"
)
// requestTimeout is the maximum duration for handling a request.
@@ -47,13 +45,7 @@ func (s *Server) SetupRoutes() {
public.Get("/", s.handlers.HandleDashboard())
// Static assets (embedded CSS/JS)
public.Mount(
"/s",
http.StripPrefix(
"/s",
http.FileServer(http.FS(static.Static)),
),
)
public.Mount("/s", http.StripPrefix("/s", s.handlers.HandleStatic()))
// Health check (standard well-known path)
public.Get(
+40
View File
@@ -1,6 +1,7 @@
package server_test
import (
"mime"
"net/http"
"net/http/httptest"
"testing"
@@ -288,3 +289,42 @@ func TestMetricsRateLimitComesBeforeAuth(t *testing.T) {
t.Errorf("another address: status = %d, want 200", rec.Code)
}
}
// TestStaticDirectoriesAnswerNotFound checks that a request naming a
// directory of the static assets, with or without the trailing slash,
// answers 404 rather than a listing of it or a redirect to one, and
// that the stylesheet is still served as CSS.
func TestStaticDirectoriesAnswerNotFound(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
srv := routedServer(t)
for _, path := range []string{"/s", "/s/", "/s/css", "/s/css/"} {
rec := serve(srv, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil,
))
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s: status = %d, want 404", path, rec.Code)
}
}
rec := serve(srv, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/s/css/tailwind.min.css", nil,
))
if rec.Code != http.StatusOK {
t.Errorf("GET stylesheet: status = %d, want 200", rec.Code)
}
contentType := rec.Header().Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType != "text/css" {
t.Errorf(
"GET stylesheet: Content-Type = %q, want text/css",
contentType,
)
}
}
+28
View File
@@ -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,
+4 -1
View File
@@ -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,
+133
View File
@@ -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
+77 -5
View File
@@ -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,