Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2912c0dd0d |
@@ -133,19 +133,18 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
- 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 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).
|
||||
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).
|
||||
- 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
|
||||
@@ -286,7 +285,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 other than 2xx) is retried up to 5 times: the first retry after about 1
|
||||
status of 400 or more) 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.
|
||||
@@ -550,11 +549,6 @@ 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.
|
||||
|
||||
+7
-5
@@ -19,11 +19,13 @@ 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
|
||||
|
||||
@@ -19,12 +19,10 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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-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, and give the 60-second cap and 20-second target for `make test`
|
||||
(closes #289).
|
||||
- 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
|
||||
|
||||
@@ -6,12 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -40,13 +38,6 @@ 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"
|
||||
|
||||
@@ -1103,182 +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()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -81,8 +81,7 @@ func (svc *Service) SendNtfy(
|
||||
return svc.sendNtfy(ctx, topicURL, title, message, priority)
|
||||
}
|
||||
|
||||
// SendSlack exports sendSlack for testing. It sends as the
|
||||
// "slack" endpoint.
|
||||
// SendSlack exports sendSlack for testing.
|
||||
func (svc *Service) SendSlack(
|
||||
ctx context.Context,
|
||||
webhookURL *url.URL,
|
||||
@@ -90,7 +89,7 @@ func (svc *Service) SendSlack(
|
||||
title, message, priority string,
|
||||
) error {
|
||||
return svc.sendSlack(
|
||||
ctx, webhookURL, "slack", failed, title, message, priority,
|
||||
ctx, webhookURL, failed, title, message, priority,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-30
@@ -25,6 +25,9 @@ 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.
|
||||
@@ -53,15 +56,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -283,7 +277,7 @@ func (svc *Service) dispatchSlack(
|
||||
|
||||
svc.dispatch(ctx, "slack", func(c context.Context) error {
|
||||
return svc.sendSlack(
|
||||
c, svc.slackWebhookURL, "slack", ErrSlackFailed,
|
||||
c, svc.slackWebhookURL, ErrSlackFailed,
|
||||
title, message, priority,
|
||||
)
|
||||
})
|
||||
@@ -301,8 +295,8 @@ func (svc *Service) dispatchMattermost(
|
||||
ctx, "mattermost",
|
||||
func(c context.Context) error {
|
||||
return svc.sendSlack(
|
||||
c, svc.mattermostWebhookURL, "mattermost",
|
||||
ErrMattermostFailed, title, message, priority,
|
||||
c, svc.mattermostWebhookURL, ErrMattermostFailed,
|
||||
title, message, priority,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -313,11 +307,9 @@ 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",
|
||||
"endpoint", "ntfy",
|
||||
"topic", topicURL.String(),
|
||||
"title", title,
|
||||
)
|
||||
|
||||
@@ -341,10 +333,7 @@ func (svc *Service) sendNtfy(
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 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 {
|
||||
if resp.StatusCode >= httpStatusClientError {
|
||||
return fmt.Errorf(
|
||||
"%w: status %d",
|
||||
ErrNtfyFailed, resp.StatusCode,
|
||||
@@ -383,15 +372,12 @@ type SlackAttachment struct {
|
||||
}
|
||||
|
||||
// sendSlack posts to a Slack or Mattermost incoming webhook, which
|
||||
// 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.
|
||||
// take the same payload. An HTTP error status 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 {
|
||||
@@ -402,7 +388,7 @@ func (svc *Service) sendSlack(
|
||||
|
||||
svc.log.Debug(
|
||||
"sending webhook notification",
|
||||
"endpoint", endpoint,
|
||||
"url", webhookURL.String(),
|
||||
"title", title,
|
||||
)
|
||||
|
||||
@@ -437,10 +423,7 @@ func (svc *Service) sendSlack(
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 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 {
|
||||
if resp.StatusCode >= httpStatusClientError {
|
||||
return fmt.Errorf(
|
||||
"%w: status %d",
|
||||
failed, resp.StatusCode,
|
||||
|
||||
@@ -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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -536,33 +536,3 @@ 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")
|
||||
}
|
||||
|
||||
@@ -36,10 +36,6 @@ 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.
|
||||
|
||||
@@ -96,17 +96,6 @@ 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,
|
||||
|
||||
@@ -708,9 +708,8 @@ func (r *Resolver) QueryNameserver(
|
||||
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
|
||||
}
|
||||
|
||||
// queryNameserver queries a specific nameserver, at the IPv4 addresses
|
||||
// its name resolves to, for the record types in qtypes and builds a
|
||||
// NameserverResponse.
|
||||
// queryNameserver queries a specific nameserver for the record types
|
||||
// in qtypes and builds a NameserverResponse.
|
||||
func (r *Resolver) queryNameserver(
|
||||
ctx context.Context,
|
||||
nsHostname string,
|
||||
@@ -728,7 +727,7 @@ func (r *Resolver) queryNameserver(
|
||||
|
||||
hostname = dns.Fqdn(hostname)
|
||||
|
||||
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
|
||||
return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes)
|
||||
}
|
||||
|
||||
// QueryNameserverIP queries a nameserver by its IP address directly,
|
||||
@@ -745,33 +744,23 @@ func (r *Resolver) QueryNameserverIP(
|
||||
|
||||
hostname = dns.Fqdn(hostname)
|
||||
|
||||
return r.queryTypes(
|
||||
ctx, nsHostname, []string{nsIP}, hostname, recordTypes(),
|
||||
)
|
||||
return r.queryTypes(ctx, nsHostname, 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,
|
||||
nsIPs []string,
|
||||
nsIP 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, nsIPs, hostname, qtypes, resp)
|
||||
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
|
||||
classifyResponse(resp, state)
|
||||
|
||||
return resp, nil
|
||||
@@ -789,37 +778,28 @@ type queryState struct {
|
||||
answered bool
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (r *Resolver) queryEachType(
|
||||
ctx context.Context,
|
||||
nsIPs []string,
|
||||
nsIP 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
|
||||
}
|
||||
|
||||
answeredAt, err := r.querySingleType(
|
||||
ctx, nsIPs, hostname, qtype, resp, &state,
|
||||
)
|
||||
err := r.querySingleType(ctx, nsIP, 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
|
||||
}
|
||||
|
||||
@@ -853,30 +833,20 @@ func (r *Resolver) queryEachType(
|
||||
return state
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (r *Resolver) querySingleType(
|
||||
ctx context.Context,
|
||||
nsIPs []string,
|
||||
nsIP string,
|
||||
hostname string,
|
||||
qtype uint16,
|
||||
resp *NameserverResponse,
|
||||
state *queryState,
|
||||
) (int, error) {
|
||||
var errs []error
|
||||
|
||||
for i, nsIP := range nsIPs {
|
||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||
|
||||
) error {
|
||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||
if err != nil {
|
||||
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):
|
||||
@@ -885,14 +855,10 @@ func (r *Resolver) querySingleType(
|
||||
state.netErr = err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return i, nil
|
||||
}
|
||||
|
||||
errs = append(errs, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return 0, errors.Join(errs...)
|
||||
return readReply(msg, resp, state)
|
||||
}
|
||||
|
||||
// readReply adds to resp the records in msg, a nameserver's reply to a
|
||||
@@ -986,15 +952,17 @@ func isTimeout(err error) bool {
|
||||
}
|
||||
|
||||
// classifyResponse sets the nameserver's status. One that answered no
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -1007,9 +975,6 @@ 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
|
||||
|
||||
@@ -15,10 +15,8 @@ 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, 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.
|
||||
// every query got none has failed. Only one whose every type answered
|
||||
// with no records is nodata.
|
||||
func TestClassifyResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -29,46 +27,63 @@ 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"},
|
||||
{"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"},
|
||||
[]string{"A", "AAAA", "CNAME"},
|
||||
StatusError, "server returned NOTIMP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -360,124 +360,6 @@ 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()
|
||||
|
||||
@@ -542,9 +424,8 @@ 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, which names the address whose reply was
|
||||
// truncated, while the nameserver, which answered the other types, is
|
||||
// ok.
|
||||
// logged with the reason, while the nameserver, which answered the other
|
||||
// types, is ok.
|
||||
func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -561,13 +442,6 @@ 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) {
|
||||
@@ -621,57 +495,6 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user