Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 1504b9c64b watcher: a removed nameserver is an NS Change, not an NS Failure (closes #262)
check / check (push) Canceled after 0s
A nameserver that left a domain's delegation was reported twice: as the
domain's NS Change, and by the check of the domain's own records as an
error-level NS Failure saying it disappeared. The check of a name's
records now sends one NS Change at warning level, starting Hostname: or
Domain:, listing the nameservers it was asked at before and not now.
For a domain it leaves out those of its previous delegation, which the
domain's own NS Change lists, so a domain's records are now checked
before its new delegation is saved. Nothing is sent for a nameserver
that joins. The README says what each case gets.

A nameserver that was failing before it left is now listed too; the
NS Failure skipped it.

Model: opus-5-5
2026-10-13 13:40:56 +00:00
31 changed files with 252 additions and 1826 deletions
+24 -70
View File
@@ -78,9 +78,8 @@ notification endpoint set, changes show only on the dashboard; see
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
not existing (see Web Dashboard and HTTP API). A domain that exists but has no not existing (see Web Dashboard and HTTP API). A domain that exists but has no
delegation of its own, such as `octocat.github.io`, has no nameservers either. delegation of its own, such as `octocat.github.io`, has no nameservers either.
When the parent zone's servers do not answer, or the lookup of the domain's When the parent zone's servers do not answer, the check fails and the set from
own records (below) fails, the check fails and the set from the previous check the previous check is kept.
is kept.
- Any change triggers a notification: - Any change triggers a notification:
- NS added to or removed from that set. A domain that had nameservers on the - NS added to or removed from that set. A domain that had nameservers on the
previous check and no longer exists gets one with all of them removed. previous check and no longer exists gets one with all of them removed.
@@ -104,11 +103,9 @@ notification endpoint set, changes show only on the dashboard; see
NS change notification above. A domain listed in NS change notification above. A domain listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
notification. A domain with no delegation of its own has these records asked notification. A domain with no delegation of its own has these records asked
at the servers of the zone it is in, as a hostname has. When none of those at the servers of the zone it is in, as a hostname has. A nameserver the
servers answers, the lookup of its NS record set fails, and they are saved as domain's records were asked at on the previous check but not on this one gets
failed and notified as a hostname's are (see below). A nameserver the domain's a hostname's NS change notification (see below), unless it was in the domain's
records were asked at on the previous check but not on this one gets a
hostname's NS change notification (see below), unless it was in the domain's
delegation on the previous check, as when that delegation is withdrawn. A delegation on the previous check, as when that delegation is withdrawn. A
domain that does not exist has none: they are not asked for, and those saved domain that does not exist has none: they are not asked for, and those saved
by an earlier check are removed without a notification. by an earlier check are removed without a notification.
@@ -123,29 +120,22 @@ notification endpoint set, changes show only on the dashboard; see
trace moves from a name to its parent only when the servers asked answer that trace moves from a name to its parent only when the servers asked answer that
the name has no delegation of its own, or does not exist. When they do not the name has no delegation of its own, or does not exist. When they do not
answer, the check fails and the hostname's records from the previous check are answer, the check fails and the hostname's records from the previous check are
kept, unless they are the nameservers the previous check saved for the kept.
hostname. Each of those is then saved as failed, with the reason, as a
domain's nameservers are when none of them answers, so it gets an NS query
failure notification, and an NS recovery notification when it answers again
(see below). A trace that gets no answer before it reaches them, as when the
host running dnswatcher loses its network, fails the check without a
notification.
- Queries **each** authoritative nameserver independently for **all** record - Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Each record type is a query of its own. When a nameserver answers some types - Each record type is a query of its own. When a nameserver answers some types
but the query for another gets no usable reply at any of its addresses (no but the query for another gets no usable reply (no reply after two tries, an
reply after two tries, an error reply such as SERVFAIL, a referral, or a reply error reply such as SERVFAIL, a referral, or a reply too large for UDP whose
too large for UDP whose retry over TCP fails), the failure is logged with the retry over TCP fails), the failure is logged with the reason, and the type is
reason, and the type is listed in the nameserver's `failedTypes` and keeps the listed in the nameserver's `failedTypes` and keeps the records saved for the
records saved for the nameserver by the previous check. On that check those nameserver by the previous check. On that check those records are not compared
records are not compared with the other nameservers', so no record change or with the other nameservers', so no record change or inconsistency is reported
inconsistency is reported for the type; on the next check they are compared for the type; on the next check they are compared with the nameserver's answer
with the nameserver's answer as usual. When the previous check did not know as usual. When the previous check did not know the type's records either,
the type's records either, because the nameserver was new or failing then or because the nameserver was new or failing then or the type was already listed
the type was already listed in `unknownTypes`, the type is also listed in in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
`unknownTypes` and left out of every comparison until it answers. A nameserver every comparison until it answers. A nameserver none of whose queries got a
none of whose queries got a usable reply has failed (see NS query failure usable reply has failed (see NS query failure below).
below).
- Stores results **per nameserver**. The state for a hostname is not a merged - Stores results **per nameserver**. The state for a hostname is not a merged
view — it is a map from nameserver to record set. view — it is a map from nameserver to record set.
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in - DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
@@ -232,14 +222,8 @@ notification endpoint set, changes show only on the dashboard; see
- Certificate CN, issuer, or SANs changed (replacement detected, reports old - Certificate CN, issuer, or SANs changed (replacement detected, reports old
and new CN and issuer). and new CN and issuer).
- TLS connection failure to a previously-reachable IP:443 (handshake error, - TLS connection failure to a previously-reachable IP:443 (handshake error,
timeout, or connection refused when port 443 closed after the port check timeout, connection refused after previously succeeding).
that found it open).
- TLS recovery: a previously-failing IP:443 now completes a handshake again. - TLS recovery: a previously-failing IP:443 now completes a handshake again.
- A port check that finds port 443 closed removes the certificate entries for
that IP address, without a notification of its own: the port change
notification reports the closed port. When the port opens again, the next TLS
check saves the certificate as new, without a certificate change or recovery
notification.
### Notifications ### Notifications
@@ -286,7 +270,7 @@ includes:
Each endpoint is sent each notification on its own, in the background. A 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 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 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 at random by up to 25%. A delivery still failing after that is logged and
dropped. dropped.
@@ -309,10 +293,8 @@ clears them.
port entry left with no name goes, so the dashboard and `/api/v1/status` no port entry left with no name goes, so the dashboard and `/api/v1/status` no
longer list or count it. The first check's port checks remove the port entries longer list or count it. The first check's port checks remove the port entries
of addresses no configured name has. of addresses no configured name has.
- Each port check also removes, without a notification, the certificate entries - Each port check also removes the certificate entries for an address their name
for an address whose port 443 it found closed, and those for an address their no longer resolves to, except while none of the name's nameservers answer.
name no longer resolves to, except while none of the name's nameservers
answer.
### Web Dashboard ### Web Dashboard
@@ -452,7 +434,7 @@ following precedence (highest to lowest):
| Variable | Description | Default | | Variable | Description | Default |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `PORT` | HTTP listen port; a port that cannot be bound, such as one already in use, stops startup | `8080` | | `PORT` | HTTP listen port | `8080` |
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` | | `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | | `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | | `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
@@ -466,7 +448,7 @@ following precedence (highest to lowest):
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | | `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` | | `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` | | `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics; required when `DNSWATCHER_METRICS_USERNAME` is set, and startup stops without it | `""` | | `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | | `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no **`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
@@ -550,29 +532,12 @@ 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 lookup can need others in turn; lookups go at most three deep, one inside
another, so delegations that point at each other still end. 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: This approach ensures:
- Independence from any upstream resolver's cache or filtering. - Independence from any upstream resolver's cache or filtering.
- Ability to detect split-horizon or inconsistent responses across authoritative - Ability to detect split-horizon or inconsistent responses across authoritative
servers. servers.
A root server never offers recursion, so a reply marked "recursion available"
from a root server's address came from a recursive resolver on the network
answering in its place, as on a network where a router rule, a VPN or a DNS
filter sends all DNS to one resolver. The first such reply from a root server's
address, a refusal included, stops the lookup at once with the error "this
network intercepts DNS queries", naming that address, and no other root server
is asked. A lookup that every root server refuses without that mark fails with
the same error. Every lookup starts at the root servers, so on such a network
every lookup fails with that error, whether or not that resolver has the name
cached, and dnswatcher's tests fail there too. That is by design: nothing asks
again another way or otherwise works around the interception.
A watched name's records are stored as its nameservers return them, CNAME A watched name's records are stored as its nameservers return them, CNAME
included. When they return a CNAME and no address, the chain of every CNAME included. When they return a CNAME and no address, the chain of every CNAME
target they gave is followed (with a depth limit to prevent loops) to the A and target they gave is followed (with a depth limit to prevent loops) to the A and
@@ -692,20 +657,10 @@ for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
earlier check saved one. A state file without it loads, and the first check earlier check saved one. A state file without it loads, and the first check
after that saves it without a notification. after that saves it without a notification.
A port entry's key is its address and port, and a certificate entry's key its
address, port 443 and hostname, joined by colons. An IPv6 address is written in
brackets in these keys, as in `[2001:db8::1]:443` and
`[2001:db8::1]:443:www.example.com`. `/api/v1/status` and the dashboard show the
same keys.
A port entry's `hostnames` lists every name that resolves to its address, A port entry's `hostnames` lists every name that resolves to its address,
domains included. A port entry in the older format, with one `hostname` instead domains included. A port entry in the older format, with one `hostname` instead
of the `hostnames` list, loads as a list of that one name. of the `hostnames` list, loads as a list of that one name.
A state file that leaves out any of `domains`, `hostnames`, `ports` and
`certificates`, or holds `null` for one, loads with that section empty. A file
holding `{}` loads as empty state.
--- ---
## Entrypoints ## Entrypoints
@@ -922,7 +877,6 @@ internal/
templates/dashboard.html Dashboard template (embedded) templates/dashboard.html Dashboard template (embedded)
status.go /api/v1/status status.go /api/v1/status
healthcheck.go Health check handler healthcheck.go Health check handler
static.go Static assets under /s/
server/ server/
server.go HTTP server lifecycle server.go HTTP server lifecycle
routes.go Route definitions routes.go Route definitions
+7 -9
View File
@@ -19,18 +19,16 @@ works correctly in production.
### Constraints ### Constraints
- Tests hit real DNS infrastructure and require network access - Tests hit real DNS infrastructure and require network access
- Test duration depends on network conditions. `make test` must finish in under - Test duration depends on network conditions; timeout tuning keeps the suite
60 seconds, the hard cap in `REPO_POLICIES.md`; under 20 seconds is the target within the 60-second target
- Each query to a nameserver has a 2-second timeout, and a query over UDP that - Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus
gets no reply is sent once more (`queryTimeoutDuration` and `maxRetries` in processing margin
`internal/resolver/iterative.go`) - Root server fan-out is limited to reduce parallel query load
- 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 - 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 limits how many run at once in a test binary and retries a lookup that got
none none
- A flaky live test is a robustness problem, fixed with retries with backoff, - Flaky failures from transient network issues are acceptable and should be
several independent nameservers or longer timeouts, never with mocks or skip investigated as potential resolver bugs, not papered over with mocks or skip
flags flags
### What NOT to do ### What NOT to do
+6 -45
View File
@@ -10,58 +10,17 @@
# Status # Status
pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0 pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked
milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Next Step # Next Step
1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66 trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-16: the first reply marked "recursion available" from a root server's
address, a refusal included, stops the lookup with "this network intercepts
DNS queries", so on a network that sends DNS to a resolver dnswatcher and its
tests fail, by design (closes #300).
- 2026-10-15: a query its caller's context ends before it is sent records no
reason about the nameserver, and a try the context's deadline cuts short is a
timeout, so a nameserver whose only failures were timeouts is `timeout`, not a
network error or `ok` (closes #298).
- 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
Status says nothing about git tags (closes #281).
- 2026-10-13: a port check that finds port 443 closed removes that address's
certificate entries, so none is left shown as `ok` (closes #265).
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
that section empty; the first check no longer crashes on it (closes #267).
- 2026-10-13: a stop before the first scan completes sends no startup
notification; a scan that ran to the end still sends it (closes #271).
- 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change; - 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change;
one leaving a hostname's zone sends NS Change, not NS Failure (closes #262). one leaving a hostname's zone sends NS Change, not NS Failure (closes #262).
- 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without
`DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268).
- 2026-10-13: a save syncs the state file to disk before renaming it into place,
so a crash cannot leave it empty and stop the next start (closes #264).
- 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming
the address, instead of running without the HTTP server (closes #266).
- 2026-10-13: a bad `PORT`, `DNSWATCHER_TLS_EXPIRY_WARNING` or true/false - 2026-10-13: a bad `PORT`, `DNSWATCHER_TLS_EXPIRY_WARNING` or true/false
setting stops startup instead of being read as 0 or false (closes #269). setting stops startup instead of being read as 0 or false (closes #269).
- 2026-10-13: a check saving the state while the dashboard or `/api/v1/status` - 2026-10-13: a check saving the state while the dashboard or `/api/v1/status`
@@ -230,4 +189,6 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Future Steps # Future Steps
- DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59 - 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
-11
View File
@@ -61,13 +61,6 @@ var ErrInvalidTLSExpiryWarning = errors.New(
// unset and means the default. // unset and means the default.
var ErrInvalidBool = errors.New("value must be true or false") var ErrInvalidBool = errors.New("value must be true or false")
// ErrNoMetricsPassword is returned when DNSWATCHER_METRICS_USERNAME is set and
// DNSWATCHER_METRICS_PASSWORD is empty. Basic Auth on /metrics would otherwise
// let in anyone who sends the username with an empty password.
var ErrNoMetricsPassword = errors.New(
"DNSWATCHER_METRICS_PASSWORD must be set when DNSWATCHER_METRICS_USERNAME is set",
)
// Params contains dependencies for Config. // Params contains dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -115,10 +108,6 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return nil, err return nil, err
} }
if cfg.MetricsUsername != "" && cfg.MetricsPassword == "" {
return nil, ErrNoMetricsPassword
}
configureDebugLogging(cfg, params) configureDebugLogging(cfg, params)
return cfg, nil return cfg, nil
-18
View File
@@ -354,24 +354,6 @@ func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) {
require.ErrorContains(t, err, `"www.example.net"`) require.ErrorContains(t, err, `"www.example.net"`)
} }
// TestNew_MetricsUsernameWithoutPasswordStopsStartup checks an unset and an
// empty password; TestNew_EnvironmentOverrides checks a username with one.
func TestNew_MetricsUsernameWithoutPasswordStopsStartup(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", "prom")
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password unset")
require.ErrorContains(t, err, "DNSWATCHER_METRICS_PASSWORD")
viper.Reset()
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "")
_, err = config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password empty")
}
func TestNew_CustomDNSIntervalDuration(t *testing.T) { func TestNew_CustomDNSIntervalDuration(t *testing.T) {
viper.Reset() viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_TARGETS", "example.com")
-36
View File
@@ -1,36 +0,0 @@
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,12 +6,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"log/slog"
"maps" "maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -40,13 +38,6 @@ const (
prioUrgent = "urgent" prioUrgent = "urgent"
) )
// Endpoint names, as the log gives them, used across multiple tests.
const (
endpointNtfy = "ntfy"
endpointSlack = "slack"
endpointMattermost = "mattermost"
)
// testHost is the hostname used in request construction tests. // testHost is the hostname used in request construction tests.
const testHost = "example.com" const testHost = "example.com"
@@ -1103,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 ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────
func TestSlackPayloadJSON(t *testing.T) { func TestSlackPayloadJSON(t *testing.T) {
+2 -3
View File
@@ -81,8 +81,7 @@ func (svc *Service) SendNtfy(
return svc.sendNtfy(ctx, topicURL, title, message, priority) return svc.sendNtfy(ctx, topicURL, title, message, priority)
} }
// SendSlack exports sendSlack for testing. It sends as the // SendSlack exports sendSlack for testing.
// "slack" endpoint.
func (svc *Service) SendSlack( func (svc *Service) SendSlack(
ctx context.Context, ctx context.Context,
webhookURL *url.URL, webhookURL *url.URL,
@@ -90,7 +89,7 @@ func (svc *Service) SendSlack(
title, message, priority string, title, message, priority string,
) error { ) error {
return svc.sendSlack( return svc.sendSlack(
ctx, webhookURL, "slack", failed, title, message, priority, ctx, webhookURL, failed, title, message, priority,
) )
} }
+13 -30
View File
@@ -25,6 +25,9 @@ import (
// HTTP client timeout. // HTTP client timeout.
const httpClientTimeout = 10 * time.Second const httpClientTimeout = 10 * time.Second
// HTTP status code thresholds.
const httpStatusClientError = 400
// Sentinel errors for notification failures. // Sentinel errors for notification failures.
var ( var (
// ErrNtfyFailed indicates the ntfy request failed. // ErrNtfyFailed indicates the ntfy request failed.
@@ -53,15 +56,6 @@ func IsAllowedScheme(scheme string) bool {
func ValidateWebhookURL(raw string) (*url.URL, error) { func ValidateWebhookURL(raw string) (*url.URL, error) {
u, err := url.ParseRequestURI(raw) u, err := url.ParseRequestURI(raw)
if err != nil { if err != nil {
// A *url.Error quotes the whole URL, and this error is
// logged at startup. A webhook URL's path or query lets
// anyone post to the channel or topic, so keep only the
// reason.
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
return nil, fmt.Errorf("invalid URL: %w", err) return nil, fmt.Errorf("invalid URL: %w", err)
} }
@@ -283,7 +277,7 @@ func (svc *Service) dispatchSlack(
svc.dispatch(ctx, "slack", func(c context.Context) error { svc.dispatch(ctx, "slack", func(c context.Context) error {
return svc.sendSlack( return svc.sendSlack(
c, svc.slackWebhookURL, "slack", ErrSlackFailed, c, svc.slackWebhookURL, ErrSlackFailed,
title, message, priority, title, message, priority,
) )
}) })
@@ -301,8 +295,8 @@ func (svc *Service) dispatchMattermost(
ctx, "mattermost", ctx, "mattermost",
func(c context.Context) error { func(c context.Context) error {
return svc.sendSlack( return svc.sendSlack(
c, svc.mattermostWebhookURL, "mattermost", c, svc.mattermostWebhookURL, ErrMattermostFailed,
ErrMattermostFailed, title, message, priority, title, message, priority,
) )
}, },
) )
@@ -313,11 +307,9 @@ func (svc *Service) sendNtfy(
topicURL *url.URL, topicURL *url.URL,
title, message, priority string, title, message, priority string,
) error { ) error {
// The topic URL is left out of the log: it lets anyone who has
// it read and post to the topic.
svc.log.Debug( svc.log.Debug(
"sending ntfy notification", "sending ntfy notification",
"endpoint", "ntfy", "topic", topicURL.String(),
"title", title, "title", title,
) )
@@ -341,10 +333,7 @@ func (svc *Service) sendNtfy(
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
// Only a 2xx reply means ntfy took the notification. A if resp.StatusCode >= httpStatusClientError {
// redirect is not followed, so it is a failure too.
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf( return fmt.Errorf(
"%w: status %d", "%w: status %d",
ErrNtfyFailed, resp.StatusCode, ErrNtfyFailed, resp.StatusCode,
@@ -383,15 +372,12 @@ type SlackAttachment struct {
} }
// sendSlack posts to a Slack or Mattermost incoming webhook, which // sendSlack posts to a Slack or Mattermost incoming webhook, which
// take the same payload. endpoint, "slack" or "mattermost", names // take the same payload. An HTTP error status is returned wrapped in
// the webhook in the log in place of its URL, which lets anyone who // failed, ErrSlackFailed or ErrMattermostFailed, so the error names
// has it post to the channel. A reply status other than 2xx is // the endpoint.
// returned wrapped in failed, ErrSlackFailed or ErrMattermostFailed,
// so the error names the endpoint.
func (svc *Service) sendSlack( func (svc *Service) sendSlack(
ctx context.Context, ctx context.Context,
webhookURL *url.URL, webhookURL *url.URL,
endpoint string,
failed error, failed error,
title, message, priority string, title, message, priority string,
) error { ) error {
@@ -402,7 +388,7 @@ func (svc *Service) sendSlack(
svc.log.Debug( svc.log.Debug(
"sending webhook notification", "sending webhook notification",
"endpoint", endpoint, "url", webhookURL.String(),
"title", title, "title", title,
) )
@@ -437,10 +423,7 @@ func (svc *Service) sendSlack(
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
// Only a 2xx reply means the webhook took the notification. A if resp.StatusCode >= httpStatusClientError {
// redirect is not followed, so it is a failure too.
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf( return fmt.Errorf(
"%w: status %d", "%w: status %d",
failed, resp.StatusCode, failed, resp.StatusCode,
-17
View File
@@ -79,23 +79,6 @@ func TestValidateWebhookURLInvalid(t *testing.T) {
} }
} }
// TestValidateWebhookURLErrorOmitsURL checks that the error for a
// webhook URL that does not parse, here because a carriage return
// was left at its end, leaves out the URL's path and query: the
// error is logged at startup.
func TestValidateWebhookURLErrorOmitsURL(t *testing.T) {
t.Parallel()
_, err := notify.ValidateWebhookURL(
"https://hooks.example.com" + webhookPathAndQuery + "\r",
)
if err == nil {
t.Fatal("expected an error for a URL ending in a carriage return")
}
assertNoWebhookSecret(t, err.Error())
}
func TestIsAllowedScheme(t *testing.T) { func TestIsAllowedScheme(t *testing.T) {
t.Parallel() t.Parallel()
-30
View File
@@ -536,33 +536,3 @@ func TestSendNotificationPermanentFailureLogsError(
return attempts >= 3 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")
}
+3 -28
View File
@@ -2,23 +2,6 @@ package resolver
import "errors" import "errors"
// NoAnswerError is returned when the walk to a name's nameservers
// reached a zone through a referral and none of that zone's
// nameservers, Nameservers as the referral names them, answered. Err
// says why they did not.
type NoAnswerError struct {
Nameservers []string
Err error
}
func (e *NoAnswerError) Error() string {
return e.Err.Error()
}
func (e *NoAnswerError) Unwrap() error {
return e.Err
}
// Sentinel errors returned by the resolver. // Sentinel errors returned by the resolver.
var ( var (
// ErrNoNameservers is returned when no authoritative NS // ErrNoNameservers is returned when no authoritative NS
@@ -36,10 +19,6 @@ var (
// so whether the name has addresses is unknown. // so whether the name has addresses is unknown.
ErrNoNameserverAnswered = errors.New("no nameserver answered") 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 // ErrUnusableReply is returned when a server replied with an
// error such as SERVFAIL, or with a referral that leads no // error such as SERVFAIL, or with a referral that leads no
// closer to the name asked about. // closer to the name asked about.
@@ -53,13 +32,9 @@ var (
"reply truncated and its retry over TCP failed", "reply truncated and its retry over TCP failed",
) )
// ErrIntercepted is returned when a root server's address gave a // ErrIntercepted is returned when every root server refused a
// reply marked recursion available, a refusal included, which ends // query. Root servers refuse no query, so the refusals came from
// the lookup at once, or when every root server refused a query. // something on the network answering in their place.
// Root servers do neither, so those replies came from something on
// the network answering in their place. On such a network
// dnswatcher fails with it, by design: nothing works around the
// interception.
ErrIntercepted = errors.New("this network intercepts DNS queries") ErrIntercepted = errors.New("this network intercepts DNS queries")
// ErrCNAMEDepthExceeded is returned when a CNAME chain // ErrCNAMEDepthExceeded is returned when a CNAME chain
+1 -14
View File
@@ -31,11 +31,9 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
func (r *Resolver) FollowDelegation( func (r *Resolver) FollowDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
return r.followDelegation(ctx, domain, zone, nameservers, servers) return r.followDelegation(ctx, domain, servers)
} }
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers // FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
@@ -96,17 +94,6 @@ func (r *Resolver) QueryServers(
return r.queryServers(ctx, servers, zone, name, qtype) 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. // QueryEachNS exports queryEachNS for testing.
func (r *Resolver) QueryEachNS( func (r *Resolver) QueryEachNS(
ctx context.Context, ctx context.Context,
+62 -122
View File
@@ -19,6 +19,7 @@ const (
queryTimeoutDuration = 2 * time.Second queryTimeoutDuration = 2 * time.Second
maxRetries = 2 maxRetries = 2
maxDelegation = 20 maxDelegation = 20
timeoutMultiplier = 2
// maxLookupDepth is how many lookups of nameserver addresses may be // maxLookupDepth is how many lookups of nameserver addresses may be
// under way one inside another. Looking up a nameserver's address // under way one inside another. Looking up a nameserver's address
@@ -61,11 +62,19 @@ func checkCtx(ctx context.Context) error {
return nil return nil
} }
// tryExchange sends msg to addr over UDP, up to maxRetries times, until func (r *Resolver) exchangeWithTimeout(
// a reply comes. Every try waits the same queryTimeoutDuration, or less ctx context.Context,
// when ctx's deadline comes first. When ctx has ended before a further msg *dns.Msg,
// try, it returns the error of the try already made, such as a timeout, addr string,
// or ErrContextCanceled when none was made. attempt int,
) (*dns.Msg, error) {
_ = attempt // timeout escalation handled by client config
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
return resp, err
}
func (r *Resolver) tryExchange( func (r *Resolver) tryExchange(
ctx context.Context, ctx context.Context,
msg *dns.Msg, msg *dns.Msg,
@@ -75,16 +84,14 @@ func (r *Resolver) tryExchange(
var err error var err error
for range maxRetries { for attempt := range maxRetries {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
if err == nil { return nil, ErrContextCanceled
return nil, ErrContextCanceled
}
return nil, err
} }
resp, _, err = r.client.ExchangeContext(ctx, msg, addr) resp, err = r.exchangeWithTimeout(
ctx, msg, addr, attempt,
)
if err == nil { if err == nil {
break break
} }
@@ -115,8 +122,7 @@ func (r *Resolver) retryTCP(
} }
// queryDNS sends a DNS query to a specific server IP, never asking it // queryDNS sends a DNS query to a specific server IP, never asking it
// for recursion. A reply of REFUSED is returned together with // for recursion. A reply of REFUSED is returned as ErrRefused.
// ErrRefused, so that the caller can still read the reply's flags.
func (r *Resolver) queryDNS( func (r *Resolver) queryDNS(
ctx context.Context, ctx context.Context,
serverIP string, serverIP string,
@@ -140,7 +146,7 @@ func (r *Resolver) queryDNS(
} }
if resp.Rcode == dns.RcodeRefused { if resp.Rcode == dns.RcodeRefused {
return resp, fmt.Errorf( return nil, fmt.Errorf(
"query %s @%s: %w", name, serverIP, ErrRefused, "query %s @%s: %w", name, serverIP, ErrRefused,
) )
} }
@@ -198,24 +204,20 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips return ips
} }
// followDelegation follows referrals to domain from servers, the // followDelegation follows referrals from servers, the root servers, to
// addresses of the servers of zone, and returns the NS set of domain's // domain and returns the NS set of domain's delegation. When the servers
// delegation. nameservers are the names a referral gave the servers of // of the zone domain is in answer that domain does not exist, the error
// zone; the walk to a name's nameservers starts at the root servers, the // is ErrNXDomain. When they answer that it has no delegation of its own,
// servers of zone ".", which no referral names. When the servers of the
// zone domain is in answer that domain does not exist, the error is
// ErrNXDomain. When they answer that it has no delegation of its own,
// because it is not the zone's apex, the set is empty and there is no // because it is not the zone's apex, the set is empty and there is no
// error. Any other error means that no such answer came; it is a // error. Any other error means that no such answer came.
// *NoAnswerError when the servers that did not answer are those of a
// zone a referral named.
func (r *Resolver) followDelegation( func (r *Resolver) followDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
// servers are the root servers, the servers of zone ".".
zone := "."
var withoutAddresses []string var withoutAddresses []string
for range maxDelegation { for range maxDelegation {
@@ -226,12 +228,6 @@ func (r *Resolver) followDelegation(
resp, err := r.queryZone( resp, err := r.queryZone(
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0, ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
) )
// A query that ctx cut short says nothing about the servers.
if err != nil && len(nameservers) > 0 && checkCtx(ctx) == nil {
return nil, &NoAnswerError{Nameservers: nameservers, Err: err}
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -260,7 +256,6 @@ func (r *Resolver) followDelegation(
return r.resolveNSIterative(ctx, domain) return r.resolveNSIterative(ctx, domain)
} }
nameservers = authNS
servers, withoutAddresses = referralNameservers(resp) servers, withoutAddresses = referralNameservers(resp)
zone = referralZone(resp) zone = referralZone(resp)
} }
@@ -285,15 +280,10 @@ func shuffled(
} }
// queryServers asks servers, the servers of zone, about name in a random // queryServers asks servers, the servers of zone, about name in a random
// order until one gives a usable reply. When they are the root servers, // order until one gives a usable reply. A server that times out, refuses
// the first reply marked recursion available, a refusal included, ends // or gives a reply that is not usable is passed over for the next. When
// the lookup with ErrIntercepted, naming the address it came from, and // every server refused, the error says so, and when they are the root
// no other root server is asked: root servers offer no recursion, so // servers it is ErrIntercepted.
// that reply came from something on the network answering in their
// place. Otherwise a server that times out, refuses or gives a reply
// that is not usable is passed over for the next. When every server
// refused, the error says so, and when they are the root servers it is
// ErrIntercepted.
func (r *Resolver) queryServers( func (r *Resolver) queryServers(
ctx context.Context, ctx context.Context,
servers []string, servers []string,
@@ -310,17 +300,7 @@ func (r *Resolver) queryServers(
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
// queryDNS returns a refusal together with its reply, so a
// refusal's mark is checked here too.
resp, err := r.queryDNS(ctx, ip, name, qtype) resp, err := r.queryDNS(ctx, ip, name, qtype)
if resp != nil && zone == "." && resp.RecursionAvailable {
return nil, fmt.Errorf(
"query %s @%s: reply marked recursion available, "+
"so not from a root server: %w",
name, ip, ErrIntercepted,
)
}
if err == nil && !usableReply(resp, zone, name) { if err == nil && !usableReply(resp, zone, name) {
err = fmt.Errorf( err = fmt.Errorf(
"query %s @%s: %w", name, ip, ErrUnusableReply, "query %s @%s: %w", name, ip, ErrUnusableReply,
@@ -646,8 +626,7 @@ func (r *Resolver) resolveARecord(
// them. When the servers asked answer that the name has no delegation // them. When the servers asked answer that the name has no delegation
// of its own, or does not exist, it tries each parent name in turn, so // of its own, or does not exist, it tries each parent name in turn, so
// it returns the nameservers of the zone the name is in. When they do // it returns the nameservers of the zone the name is in. When they do
// not answer, it returns the error and tries no parent name; when a // not answer, it returns the error and tries no parent name.
// referral named them, the error is a *NoAnswerError naming them.
func (r *Resolver) FindAuthoritativeNameservers( func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context, ctx context.Context,
domain string, domain string,
@@ -676,7 +655,7 @@ func (r *Resolver) findAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "." candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers) nsNames, err := r.followDelegation(ctx, candidate, servers)
if err != nil && !errors.Is(err, ErrNXDomain) { if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err return nil, err
} }
@@ -717,9 +696,8 @@ func (r *Resolver) QueryNameserver(
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes()) return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
} }
// queryNameserver queries a specific nameserver, at the IPv4 addresses // queryNameserver queries a specific nameserver for the record types
// its name resolves to, for the record types in qtypes and builds a // in qtypes and builds a NameserverResponse.
// NameserverResponse.
func (r *Resolver) queryNameserver( func (r *Resolver) queryNameserver(
ctx context.Context, ctx context.Context,
nsHostname string, nsHostname string,
@@ -737,7 +715,7 @@ func (r *Resolver) queryNameserver(
hostname = dns.Fqdn(hostname) 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, // QueryNameserverIP queries a nameserver by its IP address directly,
@@ -754,33 +732,23 @@ func (r *Resolver) QueryNameserverIP(
hostname = dns.Fqdn(hostname) hostname = dns.Fqdn(hostname)
return r.queryTypes( return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes())
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( func (r *Resolver) queryTypes(
ctx context.Context, ctx context.Context,
nsHostname string, nsHostname string,
nsIPs []string, nsIP string,
hostname string, hostname string,
qtypes []uint16, qtypes []uint16,
) (*NameserverResponse, error) { ) (*NameserverResponse, error) {
if len(nsIPs) == 0 {
return nil, fmt.Errorf("%w: %s", ErrNoNameserverAddress, nsHostname)
}
resp := &NameserverResponse{ resp := &NameserverResponse{
Nameserver: nsHostname, Nameserver: nsHostname,
Records: make(map[string][]string), Records: make(map[string][]string),
Status: StatusOK, Status: StatusOK,
} }
state := r.queryEachType(ctx, nsIPs, hostname, qtypes, resp) state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
classifyResponse(resp, state) classifyResponse(resp, state)
return resp, nil return resp, nil
@@ -798,37 +766,28 @@ type queryState struct {
answered bool answered bool
} }
// queryEachType asks the nameserver about hostname's records of each type // queryEachType asks the nameserver at nsIP about hostname once for each
// in qtypes, at its addresses nsIPs in turn until one gives a usable // record type in qtypes, and lists in resp.FailedTypes the types whose
// reply, and lists in resp.FailedTypes the types that got none at any of // query got no usable reply, logging each with the reason unless ctx was
// them, logging each with the reasons unless ctx was cancelled: shutdown // cancelled: shutdown cancels it, and a query it cut short did not fail.
// cancels it, and a query it cut short did not fail.
func (r *Resolver) queryEachType( func (r *Resolver) queryEachType(
ctx context.Context, ctx context.Context,
nsIPs []string, nsIP string,
hostname string, hostname string,
qtypes []uint16, qtypes []uint16,
resp *NameserverResponse, resp *NameserverResponse,
) queryState { ) queryState {
var state queryState var state queryState
// Reordered below; the caller's list is left as it was.
nsIPs = slices.Clone(nsIPs)
for _, qtype := range qtypes { for _, qtype := range qtypes {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
break break
} }
answeredAt, err := r.querySingleType( err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
ctx, nsIPs, hostname, qtype, resp, &state,
)
if err == nil { if err == nil {
state.answered = true state.answered = true
// The next type is asked first at the address that answered.
nsIPs[0], nsIPs[answeredAt] = nsIPs[answeredAt], nsIPs[0]
continue continue
} }
@@ -862,34 +821,20 @@ func (r *Resolver) queryEachType(
return state return state
} }
// querySingleType asks the nameserver about hostname's records of type // querySingleType asks the nameserver at nsIP about hostname's records
// qtype at its addresses nsIPs, of which there is at least one, one at a // of type qtype. It returns nil when the nameserver answered: with
// time, until one answers: with records, with none, or with NXDOMAIN. // records, with none, or with NXDOMAIN; otherwise it returns why not.
// 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( func (r *Resolver) querySingleType(
ctx context.Context, ctx context.Context,
nsIPs []string, nsIP string,
hostname string, hostname string,
qtype uint16, qtype uint16,
resp *NameserverResponse, resp *NameserverResponse,
state *queryState, state *queryState,
) (int, error) { ) error {
var errs []error msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
for i, nsIP := range nsIPs {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
switch { switch {
case err == nil:
err = readReply(msg, resp, state)
if err != nil {
err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err)
}
case errors.Is(err, ErrContextCanceled):
// The caller's context ended before the query was sent.
// That says nothing about the nameserver, so no reason is
// recorded.
case isTimeout(err): case isTimeout(err):
state.gotTimeout = true state.gotTimeout = true
case errors.Is(err, ErrRefused): case errors.Is(err, ErrRefused):
@@ -898,14 +843,10 @@ func (r *Resolver) querySingleType(
state.netErr = err state.netErr = err
} }
if err == nil { return err
return i, nil
}
errs = append(errs, 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 // readReply adds to resp the records in msg, a nameserver's reply to a
@@ -999,15 +940,17 @@ func isTimeout(err error) bool {
} }
// classifyResponse sets the nameserver's status. One that answered no // classifyResponse sets the nameserver's status. One that answered no
// record type has failed, and Error says why; it says all queries timed // record type has failed, and Error says why; one that answered some has
// out only when every query, at every address, did. One that answered // the status of those answers. It has no data only when every type
// 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 // answered with no records: a type in FailedTypes may have records, so a
// nameserver with one stays ok. // nameserver with one stays ok.
func classifyResponse(resp *NameserverResponse, state queryState) { func classifyResponse(resp *NameserverResponse, state queryState) {
switch { switch {
case state.gotNXDomain && !state.hasRecords: case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain resp.Status = StatusNXDomain
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotErrorReply && !state.answered: case state.gotErrorReply && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned " + state.errorReply resp.Error = "server returned " + state.errorReply
@@ -1020,9 +963,6 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotReferral && !state.answered: case state.gotReferral && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned a referral" 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. // An NXDOMAIN reply with no records was taken by the first case.
case !state.hasRecords && len(resp.FailedTypes) == 0: case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData resp.Status = StatusNoData
@@ -1127,7 +1067,7 @@ func (r *Resolver) LookupNS(
} }
return r.followDelegation( return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(), ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
) )
} }
+51 -68
View File
@@ -1,8 +1,6 @@
package resolver package resolver
import ( import (
"context"
"log/slog"
"strconv" "strconv"
"syscall" "syscall"
"testing" "testing"
@@ -17,10 +15,8 @@ import (
// that answered some record types, even with no records, has not failed // that answered some record types, even with no records, has not failed
// when its query for another type got no usable reply, whatever the // 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 // 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 // every query got none has failed. Only one whose every type answered
// out only when every one did: a reply such as a refusal, or a network // with no records is nodata.
// 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) { func TestClassifyResponse(t *testing.T) {
t.Parallel() t.Parallel()
@@ -31,46 +27,63 @@ func TestClassifyResponse(t *testing.T) {
wantStatus string wantStatus string
wantError string wantError string
}{ }{
{"every type answered with no records", {
"every type answered with no records",
queryState{answered: true}, queryState{answered: true},
nil, StatusNoData, ""}, nil,
{"some types answered with no records, another timed out", StatusNoData, "",
},
{
"some types answered with no records, another timed out",
queryState{answered: true, gotTimeout: true}, queryState{answered: true, gotTimeout: true},
[]string{"A"}, StatusOK, ""}, []string{"A"},
{"some types answered with no records, another got SERVFAIL", StatusOK, "",
queryState{answered: true, gotErrorReply: true, errorReply: "SERVFAIL"}, },
[]string{"A"}, StatusOK, ""}, {
{"some types answered with no records, another was refused", "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}, queryState{answered: true, gotRefused: true},
[]string{"A"}, StatusOK, ""}, []string{"A"},
{"some types answered with no records, another got a network error", StatusOK, "",
},
{
"some types answered with no records, another got a network error",
queryState{answered: true, netErr: syscall.ECONNREFUSED}, queryState{answered: true, netErr: syscall.ECONNREFUSED},
[]string{"A"}, StatusOK, ""}, []string{"A"},
{"some types answered with no records, another's reply was " + StatusOK, "",
"truncated and its retry over TCP failed", },
{
"some types answered with no records, another's reply was " +
"truncated and its retry over TCP failed",
queryState{answered: true, netErr: ErrTruncated}, queryState{answered: true, netErr: ErrTruncated},
[]string{"TXT"}, StatusOK, ""}, []string{"TXT"},
{"some types answered with no records, another got a referral", StatusOK, "",
},
{
"some types answered with no records, another got a referral",
queryState{answered: true, gotReferral: true}, queryState{answered: true, gotReferral: true},
[]string{"A"}, StatusOK, ""}, []string{"A"},
{"every query timed out", StatusOK, "",
},
{
"every query timed out",
queryState{gotTimeout: true}, queryState{gotTimeout: true},
[]string{"A", "AAAA", "CNAME"}, StatusTimeout, "all queries timed out"}, []string{"A", "AAAA", "CNAME"},
{"every query got NOTIMP", StatusTimeout, "all queries timed out",
},
{
"every query got NOTIMP",
queryState{gotErrorReply: true, errorReply: "NOTIMP"}, queryState{gotErrorReply: true, errorReply: "NOTIMP"},
[]string{"A", "AAAA", "CNAME"}, StatusError, "server returned NOTIMP"}, []string{"A", "AAAA", "CNAME"},
{"every query got SERVFAIL at one address, timed out at another", StatusError, "server returned NOTIMP",
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 { for _, tt := range tests {
@@ -134,33 +147,3 @@ func TestReadReply(t *testing.T) {
}) })
} }
} }
// TestQuerySingleType_ContextEnded asks a nameserver about one record
// type with a context that has already ended, so no query is sent. A
// query the context ended before it was sent says nothing about the
// nameserver and records no reason: a nameserver whose earlier query
// timed out is still reported as timed out, not as a network error.
func TestQuerySingleType_ContextEnded(t *testing.T) {
t.Parallel()
r := NewFromLogger(slog.New(slog.DiscardHandler))
ctx, cancel := context.WithCancel(t.Context())
cancel()
resp := &NameserverResponse{Records: map[string][]string{}, Status: StatusOK}
// The query for an earlier record type timed out.
state := queryState{gotTimeout: true}
_, err := r.querySingleType(
ctx, []string{"192.0.2.1"}, "example.com.", dns.TypeA, resp, &state,
)
require.ErrorIs(t, err, ErrContextCanceled)
assert.Equal(t, queryState{gotTimeout: true}, state)
classifyResponse(resp, state)
assert.Equal(t, StatusTimeout, resp.Status)
assert.Equal(t, "all queries timed out", resp.Error)
}
+8 -336
View File
@@ -121,9 +121,7 @@ func TestFindAuthoritativeNameservers_DelegatedSubdomain(
// delegation of its own, so the lookup returns that walk's error, about // delegation of its own, so the lookup returns that walk's error, about
// www.google.com, and tries no parent name: trying google.com and com // www.google.com, and tries no parent name: trying google.com and com
// would end in ErrNoNameservers, or in the error of a walk for one of // would end in ErrNoNameservers, or in the error of a walk for one of
// them. The servers that did not answer are those the walk started at, // them.
// which no referral named, so the error is no NoAnswerError, as when
// the host running dnswatcher has lost its network.
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) { func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
t.Parallel() t.Parallel()
@@ -136,9 +134,6 @@ func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
require.NotErrorIs(t, err, resolver.ErrNoNameservers) require.NotErrorIs(t, err, resolver.ErrNoNameservers)
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress) assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
assert.Empty(t, nameservers) assert.Empty(t, nameservers)
var noAnswer *resolver.NoAnswerError
assert.NotErrorAs(t, err, &noAnswer)
} }
func TestFindAuthoritativeNameservers_ReturnsSorted( func TestFindAuthoritativeNameservers_ReturnsSorted(
@@ -360,124 +355,6 @@ func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) {
assert.Equal(t, resolver.StatusOK, resp.Status) 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) { func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel() t.Parallel()
@@ -542,9 +419,8 @@ func TestQueryNameserver_TXT(t *testing.T) {
// nameserver about google.com with a resolver whose retries over TCP // 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 // 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 // is reported as failed, holding none of the records that fit, and
// logged with the reason, which names the address whose reply was // logged with the reason, while the nameserver, which answered the other
// truncated, while the nameserver, which answered the other types, is // types, is ok.
// ok.
func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) { func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
t.Parallel() t.Parallel()
@@ -561,13 +437,6 @@ func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
assert.Contains(t, logs.String(), assert.Contains(t, logs.String(),
"hostname=google.com. nameserver="+ns+" type=TXT error=", "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) { func TestQueryNameserver_NXDomain(t *testing.T) {
@@ -621,57 +490,6 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error) 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 // TestQueryServers_RecursiveResolverRefused passes a public recursive
// resolver to QueryServers as the server of google.com. These resolvers // resolver to QueryServers as the server of google.com. These resolvers
// refuse a query that does not ask for recursion and answer one that // refuse a query that does not ask for recursion and answer one that
@@ -850,74 +668,6 @@ func TestQueryServers_NotEveryRootServerRefused(t *testing.T) {
require.NotContains(t, err.Error(), "refused a query for") require.NotContains(t, err.Error(), "refused a query for")
} }
// TestQueryServers_RecursiveResolversAsRootServers passes public
// recursive resolvers to QueryServers as the servers of the root zone,
// as a network that sends every DNS query to a resolver would. Their
// replies are marked recursion available, which no root server's is:
// SERVFAIL, REFUSED, or an answer from a resolver's cache, as 1.1.1.1
// gives for com., which would be used if a root server gave it. The
// first such reply ends the lookup with ErrIntercepted, naming the
// resolver that gave it. In the first group the replies differ; every
// resolver in the second refuses a query that does not ask for
// recursion, as 9.9.9.9 does, so there a refusal ends the lookup
// instead of being passed over. Each group has resolvers of four
// operators, and a resolver that gives no reply is passed over for the
// next, so one of them answering is enough.
func TestQueryServers_RecursiveResolversAsRootServers(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
groups := [][]string{
{"1.1.1.1", "8.8.8.8", "9.9.9.9", "208.67.222.222"},
{"64.6.64.6", "185.222.222.222", "4.2.2.1", "94.140.14.14"},
}
for _, resolvers := range groups {
for _, name := range []string{"com.", "desec.io."} {
var err error
livednstest.Retry(
t,
"QueryServers("+strings.Join(resolvers, ", ")+
" as root servers, "+name+")",
func(ctx context.Context) error {
_, err = r.QueryServers(
ctx, resolvers, ".", name, dns.TypeNS,
)
// When no resolver gave a reply marked recursion
// available, none may have replied at all, so the
// attempt is tried again.
if err != nil && !strings.Contains(
err.Error(), "marked recursion available",
) {
return fmt.Errorf(
"%w: %w", livednstest.ErrNoAnswer, err,
)
}
return nil
},
)
require.ErrorIs(t, err, resolver.ErrIntercepted)
// The error names the resolver whose reply ended the
// lookup.
messages := make([]string, 0, len(resolvers))
for _, ip := range resolvers {
messages = append(messages,
"query "+name+" @"+ip+": reply marked recursion "+
"available, so not from a root server: "+
"this network intercepts DNS queries",
)
}
require.Contains(t, messages, err.Error())
}
}
}
func TestQueryNameserver_RecordsSorted(t *testing.T) { func TestQueryNameserver_RecordsSorted(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1204,34 +954,12 @@ func TestFollowDelegation_NoAnswer(t *testing.T) {
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FollowDelegation( nameservers, err := r.FollowDelegation(
t.Context(), "google.com.", ".", nil, []string{noAnswerAddress}, t.Context(), "google.com.", []string{noAnswerAddress},
) )
require.Error(t, err) require.Error(t, err)
assert.Empty(t, nameservers) assert.Empty(t, nameservers)
} }
// TestFollowDelegation_NoAnswerAfterReferral starts the walk for
// www.example.net at the servers of example.net as a referral names
// them, ns1.example.net and ns2.example.net, at 192.0.2.1, where nothing
// answers. The error is a NoAnswerError naming them, which the watcher
// compares with the nameservers it saved for the name.
func TestFollowDelegation_NoAnswerAfterReferral(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers := []string{"ns1.example.net.", "ns2.example.net."}
_, err := r.FollowDelegation(
t.Context(), "www.example.net.", "example.net.", nameservers,
[]string{noAnswerAddress},
)
var noAnswer *resolver.NoAnswerError
require.ErrorAs(t, err, &noAnswer)
assert.Equal(t, nameservers, noAnswer.Nameservers)
assert.Contains(t, err.Error(), "query www.example.net. @"+noAnswerAddress)
}
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers // TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
// of www.google.com as the fallback walk does. As in // of www.google.com as the fallback walk does. As in
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error. // TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
@@ -1448,10 +1176,10 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
// Nothing answers at 192.0.2.1, a documentation address. The // Nothing answers at 192.0.2.1, a documentation address. The
// resolver tries each query twice, and each try gives up after two // resolver tries each query twice, and the first try gives up
// seconds. This deadline ends during the second try of the first // after two seconds. A deadline that ends during the first try
// record type; TestQueryNameserverIP_DeadlineDuringFirstTry covers // makes the status vary from run to run between error and
// one that ends during the first. // timeout, so the deadline must outlast the first try.
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second, context.Background(), 3*time.Second,
) )
@@ -1467,62 +1195,6 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error) assert.NotEmpty(t, resp.Error)
} }
// TestQueryNameserverIP_DeadlineDuringFirstTry gives a query to
// 192.0.2.1, where nothing answers, a deadline that ends during its
// first try. That try ends at the deadline as a timeout, so the
// nameserver is reported as timed out, not as answering.
func TestQueryNameserverIP_DeadlineDuringFirstTry(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
// Well under the two seconds one try waits.
ctx, cancel := context.WithTimeout(
context.Background(), 500*time.Millisecond,
)
t.Cleanup(cancel)
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusTimeout, resp.Status)
assert.NotEmpty(t, resp.Error)
}
// TestQueryNameserverIP_CancelDuringFirstTry cancels the context while
// the first try of a query to 192.0.2.1, where nothing answers, waits
// for a reply. That try's timeout is all that is learned about the
// nameserver, so it is reported as timed out, not as answering.
func TestQueryNameserverIP_CancelDuringFirstTry(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
// The first try starts at once and waits two seconds for a reply;
// the cancel comes a second in. The DNS client cuts a try short at
// the context's deadline but not at a cancel, so the try still
// waits its full two seconds and ends as a timeout a second after
// the cancel. By then the context has certainly ended, so no second
// try is made. With a deadline instead, the try ends at the moment
// the context does, and whether a second try is made depends on
// which the resolver sees first.
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
time.AfterFunc(time.Second, cancel)
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusTimeout, resp.Status)
assert.NotEmpty(t, resp.Error)
}
// TestQueryNameserverIP_CancelledLogsNothing cancels the context while // TestQueryNameserverIP_CancelledLogsNothing cancels the context while
// a query to 192.0.2.1, where nothing answers, is waiting for a reply, // a query to 192.0.2.1, where nothing answers, is waiting for a reply,
// as shutdown does. The query was cut short, not failed, so nothing is // as shutdown does. The query was cut short, not failed, so nothing is
+2 -2
View File
@@ -13,8 +13,8 @@ import (
const RequestTimeout time.Duration = requestTimeout const RequestTimeout time.Duration = requestTimeout
// SetListenPort overrides the port Run binds. A test uses it to hand // SetListenPort overrides the port Run binds. A test uses it to hand
// Run an unbindable port, so that Run stores its http.Server and then // Run an unbindable port so ListenAndServe fails immediately and Run
// returns an error without listening. // returns after storing its http.Server.
func SetListenPort(s *Server, port int) { func SetListenPort(s *Server, port int) {
s.port = port s.port = port
} }
+9 -1
View File
@@ -8,6 +8,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/dnswatcher/static"
) )
// requestTimeout is the maximum duration for handling a request. // requestTimeout is the maximum duration for handling a request.
@@ -45,7 +47,13 @@ func (s *Server) SetupRoutes() {
public.Get("/", s.handlers.HandleDashboard()) public.Get("/", s.handlers.HandleDashboard())
// Static assets (embedded CSS/JS) // Static assets (embedded CSS/JS)
public.Mount("/s", http.StripPrefix("/s", s.handlers.HandleStatic())) public.Mount(
"/s",
http.StripPrefix(
"/s",
http.FileServer(http.FS(static.Static)),
),
)
// Health check (standard well-known path) // Health check (standard well-known path)
public.Get( public.Get(
-40
View File
@@ -1,7 +1,6 @@
package server_test package server_test
import ( import (
"mime"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -289,42 +288,3 @@ func TestMetricsRateLimitComesBeforeAuth(t *testing.T) {
t.Errorf("another address: status = %d, want 200", rec.Code) 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,
)
}
}
+9 -21
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"time" "time"
@@ -113,7 +112,7 @@ func New(
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(_ context.Context) error {
srv.startupTime = time.Now() srv.startupTime = time.Now()
err := srv.enableSentry() err := srv.enableSentry()
@@ -121,7 +120,9 @@ func New(
return err return err
} }
return srv.Run(ctx) go srv.Run()
return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx) return srv.Shutdown(ctx)
@@ -148,10 +149,8 @@ func newHTTPServer(
} }
} }
// Run binds the listen port, then serves HTTP on it in a new goroutine // Run starts the HTTP server.
// and returns. A port that cannot be bound is returned as an error, so func (s *Server) Run() {
// that startup stops instead of running without the HTTP server.
func (s *Server) Run(ctx context.Context) error {
s.SetupRoutes() s.SetupRoutes()
listenAddr := fmt.Sprintf(":%d", s.port) listenAddr := fmt.Sprintf(":%d", s.port)
@@ -159,21 +158,10 @@ func (s *Server) Run(ctx context.Context) error {
s.log.Info("http server starting", "addr", listenAddr) s.log.Info("http server starting", "addr", listenAddr)
lc := &net.ListenConfig{} err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
listener, err := lc.Listen(ctx, "tcp", listenAddr) s.log.Error("http server error", "error", err)
if err != nil {
return fmt.Errorf("starting http server: %w", err)
} }
go func() {
err := s.httpServer.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("http server error", "error", err)
}
}()
return nil
} }
// Shutdown gracefully shuts down the server, then sends the error // Shutdown gracefully shuts down the server, then sends the error
+8 -46
View File
@@ -1,8 +1,6 @@
package server_test package server_test
import ( import (
"net"
"strings"
"testing" "testing"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -60,13 +58,15 @@ func buildServer(t *testing.T) *server.Server {
} }
// TestRunWiresSocketTimeouts pins that the http.Server the running // TestRunWiresSocketTimeouts pins that the http.Server the running
// server actually serves — the one Run builds and serves on the port // server actually serves — the one Run builds and hands to
// it binds — carries every socket-level timeout, plus the two // ListenAndServe — carries every socket-level timeout, plus the two
// relationships the values must satisfy. // relationships the values must satisfy.
// //
// Run is given an unbindable port: it builds and stores s.httpServer, // Run is driven to completion with an unbindable port: it builds and
// then the bind fails and Run returns the error without ever // stores s.httpServer, then ListenAndServe fails at once and Run
// listening. Nothing here measures elapsed time. // returns without ever listening. The assertions run in the same
// goroutine after Run returns, so reading s.httpServer is free of any
// data race. Nothing here measures elapsed time.
// //
// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the // ReadTimeout must be at least ReadHeaderTimeout. net/http reads the
// headers under ReadHeaderTimeout, then sets the read deadline for the // headers under ReadHeaderTimeout, then sets the read deadline for the
@@ -84,10 +84,7 @@ func TestRunWiresSocketTimeouts(t *testing.T) {
srv := buildServer(t) srv := buildServer(t)
server.SetListenPort(srv, -1) server.SetListenPort(srv, -1)
err := srv.Run(t.Context()) srv.Run()
if err == nil {
t.Fatal("Run bound port -1")
}
hs := server.HTTPServerOf(srv) hs := server.HTTPServerOf(srv)
if hs == nil { if hs == nil {
@@ -136,38 +133,3 @@ func TestRunWiresSocketTimeouts(t *testing.T) {
) )
} }
} }
func TestPortInUseStopsStartup(t *testing.T) {
// Bound on every address, as Run binds, so that Run's bind fails.
lc := &net.ListenConfig{}
taken, err := lc.Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("binding a port for the test: %v", err)
}
defer func() { _ = taken.Close() }()
_, port, err := net.SplitHostPort(taken.Addr().String())
if err != nil {
t.Fatalf("reading the bound port: %v", err)
}
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
t.Setenv("PORT", port)
app := newServerApp(fx.Invoke(func(*server.Server) {}))
err = app.Start(t.Context())
if err == nil {
_ = app.Stop(t.Context())
t.Fatal("startup succeeded on a port that is already bound")
}
if !strings.Contains(err.Error(), ":"+port) {
t.Errorf("startup error does not name port %s: %v", port, err)
}
}
+2 -40
View File
@@ -4,7 +4,6 @@ package state
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps" "maps"
@@ -216,24 +215,6 @@ func (s *State) Load() error {
return fmt.Errorf("parsing state file: %w", err) return fmt.Errorf("parsing state file: %w", err)
} }
// A section the file leaves out, or holds as null, loads empty: the
// setters write into these maps, and writing into a nil map panics.
if snapshot.Domains == nil {
snapshot.Domains = make(map[string]*DomainState)
}
if snapshot.Hostnames == nil {
snapshot.Hostnames = make(map[string]*HostnameState)
}
if snapshot.Ports == nil {
snapshot.Ports = make(map[string]*PortState)
}
if snapshot.Certificates == nil {
snapshot.Certificates = make(map[string]*CertificateState)
}
// A state file saved before each record value was stored once can // A state file saved before each record value was stored once can
// hold a hostname's CNAME once for every record type asked for. // hold a hostname's CNAME once for every record type asked for.
// Each value is kept once, so the first check does not see a // Each value is kept once, so the first check does not see a
@@ -272,20 +253,10 @@ func (s *State) Save() error {
return fmt.Errorf("creating data directory: %w", err) return fmt.Errorf("creating data directory: %w", err)
} }
// Atomic write: write to temp file, then rename. The temp file is // Atomic write: write to temp file, then rename
// synced before the rename and the directory after it, or a crash
// can leave the renamed state file empty.
tmpPath := path + ".tmp" tmpPath := path + ".tmp"
//nolint:gosec // path is from trusted config err = os.WriteFile(tmpPath, data, filePermissions)
tmpFile, err := os.OpenFile(
tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermissions,
)
if err == nil {
_, err = tmpFile.Write(data)
err = errors.Join(err, tmpFile.Sync(), tmpFile.Close())
}
if err != nil { if err != nil {
return fmt.Errorf("writing temp state file: %w", err) return fmt.Errorf("writing temp state file: %w", err)
} }
@@ -295,15 +266,6 @@ func (s *State) Save() error {
return fmt.Errorf("renaming state file: %w", err) return fmt.Errorf("renaming state file: %w", err)
} }
dir, err := os.Open(filepath.Dir(path))
if err == nil {
err = errors.Join(dir.Sync(), dir.Close())
}
if err != nil {
return fmt.Errorf("syncing data directory: %w", err)
}
s.log.Debug("state saved to disk", "path", path) s.log.Debug("state saved to disk", "path", path)
return nil return nil
-99
View File
@@ -618,53 +618,6 @@ func TestSaveAtomicWrite(t *testing.T) {
} }
} }
// TestSaveOverLeftoverTempFile verifies that a temp file left by a
// crash, longer than the state Save writes, leaves nothing of itself in
// the state file, and that no temp file remains after Save.
func TestSaveOverLeftoverTempFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
statePath := filepath.Join(dir, "state.json")
tmpPath := statePath + ".tmp"
err := os.WriteFile(tmpPath, []byte(strings.Repeat("x", 64*1024)), 0o600)
if err != nil {
t.Fatalf("writing leftover temp file: %v", err)
}
s := state.NewForTestWithDataDir(dir)
populateState(t, s)
err = s.Save()
if err != nil {
t.Fatalf("Save() error: %v", err)
}
//nolint:gosec // test file with known path
data, err := os.ReadFile(statePath)
if err != nil {
t.Fatalf("reading state file: %v", err)
}
var snap state.Snapshot
err = json.Unmarshal(data, &snap)
if err != nil {
t.Fatalf("state file is not complete JSON: %v", err)
}
if len(snap.Domains) != 2 {
t.Errorf("state file has %d domains, want 2", len(snap.Domains))
}
_, err = os.Stat(tmpPath)
if !os.IsNotExist(err) {
t.Error("temp file should not exist after save")
}
}
// TestLoadMissingFile verifies that loading a nonexistent file is not an error. // TestLoadMissingFile verifies that loading a nonexistent file is not an error.
func TestLoadMissingFile(t *testing.T) { func TestLoadMissingFile(t *testing.T) {
t.Parallel() t.Parallel()
@@ -695,58 +648,6 @@ func TestLoadMissingFile(t *testing.T) {
} }
} }
// TestLoadFileWithoutSections loads a state file holding {}, and files
// that each leave out one section. Every section loads as an empty map.
// TestFirstCheckAfterLoadingFileWithoutSections in the watcher tests
// runs the first check after such a load.
func TestLoadFileWithoutSections(t *testing.T) {
t.Parallel()
tests := []struct {
name string
file string
}{
{"no sections", `{}`},
{"no domains", `{"hostnames": {}, "ports": {}, "certificates": {}}`},
{"no hostnames", `{"domains": {}, "ports": {}, "certificates": {}}`},
{"no ports", `{"domains": {}, "hostnames": {}, "certificates": {}}`},
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := os.WriteFile(
filepath.Join(dir, "state.json"), []byte(tt.file), 0o600,
)
if err != nil {
t.Fatalf("writing state file: %v", err)
}
s := state.NewForTestWithDataDir(dir)
err = s.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
want := state.Snapshot{
Domains: map[string]*state.DomainState{},
Hostnames: map[string]*state.HostnameState{},
Ports: map[string]*state.PortState{},
Certificates: map[string]*state.CertificateState{},
}
if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) {
t.Errorf("loaded %#v, want %#v", got, want)
}
})
}
}
// TestLoadCorruptFile verifies that a corrupt state file returns an error. // TestLoadCorruptFile verifies that a corrupt state file returns an error.
func TestLoadCorruptFile(t *testing.T) { func TestLoadCorruptFile(t *testing.T) {
t.Parallel() t.Parallel()
-24
View File
@@ -83,30 +83,6 @@ func TestCancelledCheckSavesNothing(t *testing.T) {
} }
} }
// TestStopBeforeFirstScanSendsNoStartupNotification runs a watcher with
// the startup notification on and its context already cancelled, so the
// first scan is cut short before its lookup is sent. The scan did not
// complete, so no startup notification may be sent.
func TestStopBeforeFirstScanSendsNoStartupNotification(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg)
ctx, cancel := context.WithCancel(t.Context())
cancel()
w.Run(ctx)
notifications := deps.notifier.getNotifications()
if len(notifications) != 0 {
t.Errorf("sent %v, want no notifications", notifications)
}
}
// newLoggingWatcher returns a watcher for a domain and a hostname, with // newLoggingWatcher returns a watcher for a domain and a hostname, with
// the real resolver, that writes what it logs at warning level or above // the real resolver, that writes what it logs at warning level or above
// into the returned buffer. // into the returned buffer.
-28
View File
@@ -122,34 +122,6 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
w.runTLSChecks(ctx) w.runTLSChecks(ctx)
} }
// CheckHostnameResult exports checkHostnameResult for testing.
func (w *Watcher) CheckHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
return w.checkHostnameResult(ctx, hostname, results, err)
}
// CheckDomainResult exports checkDomainResult for testing.
func (w *Watcher) CheckDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
w.checkDomainResult(ctx, domain, nameservers, err)
}
// FailedNameservers exports failedNameservers for testing.
func FailedNameservers(
err error,
prev *state.HostnameState,
) map[string]*resolver.NameserverResponse {
return failedNameservers(err, prev)
}
// BuildHostnameState exports buildHostnameState for testing. // BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState( func BuildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
+1 -4
View File
@@ -20,10 +20,7 @@ type DNSResolver interface {
) ([]string, error) ) ([]string, error)
// LookupAllRecords queries all record types for a hostname, // LookupAllRecords queries all record types for a hostname,
// returning each nameserver's response keyed by nameserver. When // returning each nameserver's response keyed by nameserver.
// the walk to the nameservers reached a zone through a referral and
// none of that zone's nameservers answered, the error is a
// *resolver.NoAnswerError naming them.
LookupAllRecords( LookupAllRecords(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
-83
View File
@@ -1,83 +0,0 @@
package watcher_test
import (
"maps"
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// TestIPv6AddressInBrackets runs the port checks, not the watcher's
// first, on hostname state built here for a hostname at an IPv6 address
// whose port 443 the previous check found closed. It is open now. Then
// it runs the TLS checks and the port checks again. The port and
// certificate keys and the Port Change notification write the address
// in brackets, so its last group cannot be read as the port, and the
// second port checks keep the certificate entry, whose address they read
// back from its key. When a last port check finds port 443 closed, the
// certificate entry is removed. Nothing is looked up.
func TestIPv6AddressInBrackets(t *testing.T) {
t.Parallel()
const aaaaType = "AAAA"
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
deps.state.SetHostnameState(host, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{aaaaType: {"2001:db8::1"}}),
},
))
deps.state.SetPortState("[2001:db8::1]:443", &state.PortState{
Hostnames: []string{host},
})
w.CheckAllPorts(t.Context())
w.RunTLSChecks(t.Context())
w.CheckAllPorts(t.Context())
snap := deps.state.GetSnapshot()
gotPorts := slices.Sorted(maps.Keys(snap.Ports))
wantPorts := []string{"[2001:db8::1]:443", "[2001:db8::1]:80"}
if !slices.Equal(gotPorts, wantPorts) {
t.Errorf("port entries %v, want %v", gotPorts, wantPorts)
}
gotCerts := slices.Sorted(maps.Keys(snap.Certificates))
wantCerts := []string{"[2001:db8::1]:443:www.example.net"}
if !slices.Equal(gotCerts, wantCerts) {
t.Errorf("certificate entries %v, want %v", gotCerts, wantCerts)
}
title := "Port Change: [2001:db8::1]:443"
want := `Hostnames: www.example.net
Address: [2001:db8::1]:443
Port now open`
got := deps.notifier.getNotifications()
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
}
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
gotCerts = slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
if len(gotCerts) != 0 {
t.Errorf("certificate entries %v with port 443 closed, want none", gotCerts)
}
}
-133
View File
@@ -2,7 +2,6 @@ package watcher_test
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
@@ -15,10 +14,6 @@ import (
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
// errNoReply is why a walk that got no reply from a zone's servers
// failed.
var errNoReply = errors.New("all servers failed: i/o timeout")
// answered is what a check saves for a nameserver that answered with // answered is what a check saves for a nameserver that answered with
// these records. // these records.
func answered(records map[string][]string) *state.NameserverRecordState { func answered(records map[string][]string) *state.NameserverRecordState {
@@ -188,134 +183,6 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
} }
} }
// TestZoneNameserversAllStopAnswering runs three checks of host from
// the point where its lookup has returned. On the first, nsA and nsB,
// the nameservers of its zone, answer. On the second, the lookup fails
// with the error the walk to them returns when neither answers (see
// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given
// here because no live zone has nameservers that never answer. For a
// hostname it is the lookup of its records that fails, and for a domain
// with no delegation of its own the lookup of its delegation. Both are
// saved as failed, with the reason, and each gets an NS Failure. On the
// third, both answer, and each gets an NS Recovery. A domain's records
// are checked as a hostname's, so its first and third checks are too.
func TestZoneNameserversAllStopAnswering(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(records),
}
noAnswer := &resolver.NoAnswerError{
Nameservers: []string{nsA, nsB}, Err: errNoReply,
}
tests := []struct {
name string
isDomain bool
}{
{"hostname", false},
{"domain with no delegation of its own", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
if tt.isDomain {
cfg.Domains = []string{host}
} else {
cfg.Hostnames = []string{host}
}
// The watcher is given each lookup's result, so it has no
// resolver.
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
if tt.isDomain {
w.CheckDomainResult(t.Context(), host, nil, noAnswer)
} else {
w.CheckHostnameResult(t.Context(), host, nil, noAnswer)
}
current, _ := deps.state.GetHostnameState(host)
for _, ns := range []string{nsA, nsB} {
got := current.RecordsByNameserver[ns]
if got.Status != failed().Status || got.Error != errNoReply.Error() {
t.Errorf(
"%s saved with status %q, error %q; want %q, %q",
ns, got.Status, got.Error, failed().Status, errNoReply,
)
}
}
if n := countNotifications(deps, "NS Failure: "+host); n != 2 {
t.Errorf("sent %d NS Failure notifications, want 2", n)
}
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 {
t.Errorf("sent %d NS Recovery notifications, want 2", n)
}
})
}
}
// TestFailedNameserversOnlyForThoseOfThePreviousCheck gives
// FailedNameservers lookup errors after a check in which nsA and nsB
// answered. Only an error that names both makes them failed.
func TestFailedNameserversOnlyForThoseOfThePreviousCheck(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswered := saved(map[string]*state.NameserverRecordState{
nsA: answered(records), nsB: answered(records),
})
noAnswer := func(nameservers ...string) error {
return &resolver.NoAnswerError{Nameservers: nameservers, Err: errNoReply}
}
tests := []struct {
name string
err error
prev *state.HostnameState
wantFailed bool
}{
{"both did not answer", noAnswer(nsA, nsB), bothAnswered, true},
{
"the servers of a parent zone did not answer",
noAnswer(nsC), bothAnswered, false,
},
{
"the zone's delegation lists only nsA now",
noAnswer(nsA), bothAnswered, false,
},
{"no root server answered", errNoReply, bothAnswered, false},
{"no previous check", noAnswer(nsA, nsB), nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := watcher.FailedNameservers(tt.err, tt.prev)
if (got != nil) != tt.wantFailed {
t.Errorf("returned %v, want failed: %v", got, tt.wantFailed)
}
})
}
}
// TestNameserverThatNeverAnswers asks a nameserver address where // TestNameserverThatNeverAnswers asks a nameserver address where
// nothing answers, 192.0.2.1, and checks what the watcher saves for it. // nothing answers, 192.0.2.1, and checks what the watcher saves for it.
// The deadline outlasts the resolver's first two-second try, as in the // The deadline outlasts the resolver's first two-second try, as in the
+3 -70
View File
@@ -2,9 +2,7 @@ package watcher_test
import ( import (
"maps" "maps"
"net"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
@@ -174,7 +172,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
"A": {ip1}, "AAAA": {ip6}, "A": {ip1}, "AAAA": {ip6},
}), }),
}), }),
[]string{ip1 + ":443:" + host, "[" + ip6 + "]:443:" + host}, []string{ip1 + ":443:" + host, ip6 + ":443:" + host},
}, },
{ {
"no nameserver answered", "no nameserver answered",
@@ -184,7 +182,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
[]string{ []string{
ip1 + ":443:" + host, ip1 + ":443:" + host,
ip2 + ":443:" + host, ip2 + ":443:" + host,
"[" + ip6 + "]:443:" + host, ip6 + ":443:" + host,
}, },
}, },
} }
@@ -207,8 +205,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
for _, ip := range []string{ip1, ip2, ip6} { for _, ip := range []string{ip1, ip2, ip6} {
deps.state.SetCertificateState( deps.state.SetCertificateState(
net.JoinHostPort(ip, "443")+":"+host, ip+":443:"+host, &state.CertificateState{Status: "ok"},
&state.CertificateState{Status: "ok"},
) )
} }
@@ -225,67 +222,3 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
}) })
} }
} }
// TestCertificateStateForAPortClosed runs the port checks on hostname
// and port state built here for a configured hostname at ip1, whose
// port 443 the previous check found open and whose certificate entry
// is ok. The port is closed now: the certificate entry is removed, and
// only the Port Change is notified. When the port opens again, the next
// TLS check saves the certificate as new and notifies nothing about it.
// Nothing is looked up.
func TestCertificateStateForAPortClosed(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
certKey := ip1 + ":443:" + host
deps.state.SetHostnameState(host, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
deps.state.SetPortState(ip1+":443", &state.PortState{
Open: true, Hostnames: []string{host},
})
deps.state.SetCertificateState(certKey, &state.CertificateState{
CommonName: host, Status: "ok",
})
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
if _, ok := deps.state.GetCertificateState(certKey); ok {
t.Errorf("certificate entry %s kept with port 443 closed", certKey)
}
sent := deps.notifier.getNotifications()
if title := "Port Change: " + ip1 + ":443"; len(sent) != 1 ||
sent[0].Title != title {
t.Errorf("sent %v, want one %q", sent, title)
}
deps.portChecker.closed = false
w.CheckAllPorts(t.Context())
w.RunTLSChecks(t.Context())
cert, ok := deps.state.GetCertificateState(certKey)
if !ok || cert.Status != "ok" {
t.Errorf("certificate entry %s after port 443 opened: %+v", certKey, cert)
}
for _, n := range deps.notifier.getNotifications() {
if strings.HasPrefix(n.Title, "TLS") {
t.Errorf("sent %v, want no TLS notification", n)
}
}
}
+33 -124
View File
@@ -6,10 +6,8 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"maps" "maps"
"net"
"slices" "slices"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -135,12 +133,7 @@ func (w *Watcher) Run(ctx context.Context) {
w.cleanupRemovedTargets() w.cleanupRemovedTargets()
w.RunOnce(ctx) w.RunOnce(ctx)
w.maybeSendTestNotification(ctx)
// RunOnce returns as usual when shutdown cuts the scan short, and
// the startup notification says the scan completed.
if ctx.Err() == nil {
w.maybeSendTestNotification(ctx)
}
dnsTicker := time.NewTicker(w.config.DNSInterval) dnsTicker := time.NewTicker(w.config.DNSInterval)
tlsTicker := time.NewTicker(w.config.TLSInterval) tlsTicker := time.NewTicker(w.config.TLSInterval)
@@ -291,25 +284,12 @@ func (w *Watcher) logFailedLookup(
w.log.Error(msg, args...) w.log.Error(msg, args...)
} }
// checkDomain looks up domain's delegation and checks domain with it
// (see checkDomainResult).
func (w *Watcher) checkDomain( func (w *Watcher) checkDomain(
ctx context.Context, ctx context.Context,
domain string, domain string,
) { ) {
nameservers, err := w.resolver.LookupNS(ctx, domain) nameservers, err := w.resolver.LookupNS(ctx, domain)
w.checkDomainResult(ctx, domain, nameservers, err)
}
// checkDomainResult finishes a check of domain from nameservers and err,
// what the lookup of its delegation returned.
func (w *Watcher) checkDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
// A domain that does not exist has no nameservers. // A domain that does not exist has no nameservers.
nxdomain := errors.Is(err, resolver.ErrNXDomain) nxdomain := errors.Is(err, resolver.ErrNXDomain)
if nxdomain { if nxdomain {
@@ -324,12 +304,6 @@ func (w *Watcher) checkDomainResult(
"error", err, "error", err,
) )
// For a domain with no delegation of its own, the servers that
// did not answer can be those of the zone it is in, at which
// its records are asked. They are then saved as failed, as a
// hostname's are.
w.checkHostnameResult(ctx, domain, nil, err)
return return
} }
@@ -348,23 +322,22 @@ func (w *Watcher) checkDomainResult(
ctx, nameservers, prevAddresses, ctx, nameservers, prevAddresses,
) )
if hasPrev && !w.firstRun {
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
}
// A domain that does not exist has no records of its own: none are // A domain that does not exist has no records of its own: none are
// asked for, and those saved by an earlier check are removed. // asked for, and those saved by an earlier check are removed.
// Otherwise the apex domain's records are also checked and saved as // Otherwise the apex domain's records are also checked and saved as
// a hostname's, so that the port and TLS checks find its addresses. // a hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine). When // Notifications about them name it as a domain (see nameLine). They
// their lookup fails, the check fails as when the NS lookup does, so // are checked before the domain's new nameservers are saved:
// the saved delegation and the saved records come from the same // detectNSDisappearances reads those of the previous check.
// check, as detectNSDisappearances needs.
if nxdomain { if nxdomain {
w.state.DeleteHostnameState(domain) w.state.DeleteHostnameState(domain)
} else if !w.checkHostname(ctx, domain) { } else {
return w.checkHostname(ctx, domain)
}
if hasPrev && !w.firstRun {
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
} }
w.state.SetDomainState(domain, &state.DomainState{ w.state.SetDomainState(domain, &state.DomainState{
@@ -483,12 +456,10 @@ func (w *Watcher) detectNSAddressChanges(
} }
} }
// checkHostname looks up hostname's records and checks them (see
// checkHostnameResult). It reports whether it saved them.
func (w *Watcher) checkHostname( func (w *Watcher) checkHostname(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) bool { ) {
results, err := w.resolver.LookupAllRecords(ctx, hostname) results, err := w.resolver.LookupAllRecords(ctx, hostname)
if err != nil { if err != nil {
w.logFailedLookup( w.logFailedLookup(
@@ -497,70 +468,15 @@ func (w *Watcher) checkHostname(
"hostname", hostname, "hostname", hostname,
"error", err, "error", err,
) )
return
} }
return w.checkHostnameResult(ctx, hostname, results, err)
}
// checkHostnameResult finishes a check of hostname from results and err,
// what the lookup of its records returned, and reports whether it saved
// them. When the lookup failed, it saves nothing, unless the nameservers
// that did not answer are those of the previous check (see
// failedNameservers).
func (w *Watcher) checkHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
prev, _ := w.state.GetHostnameState(hostname) prev, _ := w.state.GetHostnameState(hostname)
if err != nil {
results = failedNameservers(err, prev)
if results == nil {
return false
}
}
w.updateHostnameState( w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()), ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
) )
return true
}
// failedNameservers returns a failed response for each nameserver in
// prev, a name's state from the previous check, with err as the reason,
// when err says that the walk to the name's nameservers reached their
// zone and none of them answered. For any other error, as when the walk
// got no answer before it reached their zone, or when prev is nil, it
// returns nil.
func failedNameservers(
err error,
prev *state.HostnameState,
) map[string]*resolver.NameserverResponse {
var noAnswer *resolver.NoAnswerError
if !errors.As(err, &noAnswer) || prev == nil {
return nil
}
saved := slices.Sorted(maps.Keys(prev.RecordsByNameserver))
if !slices.Equal(noAnswer.Nameservers, saved) {
return nil
}
results := make(map[string]*resolver.NameserverResponse)
for _, ns := range saved {
results[ns] = &resolver.NameserverResponse{
Nameserver: ns,
Records: make(map[string][]string),
Status: resolver.StatusError,
Error: err.Error(),
}
}
return results
} }
// updateHostnameState finishes a check of hostname from newState, built // updateHostnameState finishes a check of hostname from newState, built
@@ -859,10 +775,9 @@ func (w *Watcher) detectRecordChanges(
// detectNSDisappearances sends one NS Change listing the nameservers of // detectNSDisappearances sends one NS Change listing the nameservers of
// the previous check that are no longer those of the zone hostname is // the previous check that are no longer those of the zone hostname is
// in. For a configured apex domain it leaves out those of the domain's // in. For a configured apex domain it leaves out those of the domain's
// saved delegation, which detectNSChanges notifies when they are removed // delegation on the previous check, which detectNSChanges notifies when
// from it. checkDomain saves a new delegation only after this runs, and // they are removed from it; checkDomain saves the new delegation only
// only together with the records, so the saved delegation and the // after this runs.
// nameservers in prev come from the same check.
func (w *Watcher) detectNSDisappearances( func (w *Watcher) detectNSDisappearances(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -1052,8 +967,7 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
// Phase 3: Remove port state entries that no longer have // Phase 3: Remove port state entries that no longer have
// any hostname referencing them, and certificate entries for // any hostname referencing them, and certificate entries for
// an address whose port 443 is closed or that their name no // an address their name no longer has.
// longer has.
w.cleanupStalePorts(associations) w.cleanupStalePorts(associations)
w.cleanupStaleCertificates() w.cleanupStaleCertificates()
} }
@@ -1074,7 +988,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
ips := w.collectIPs(name) ips := w.collectIPs(name)
for _, ip := range ips { for _, ip := range ips {
for _, port := range monitoredPorts { for _, port := range monitoredPorts {
key := net.JoinHostPort(ip, strconv.Itoa(port)) key := fmt.Sprintf("%s:%d", ip, port)
if assoc[key] == nil { if assoc[key] == nil {
assoc[key] = make(map[string]bool) assoc[key] = make(map[string]bool)
} }
@@ -1099,15 +1013,18 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
return result return result
} }
// parsePortKey splits an "ip:port" key, written by net.JoinHostPort, // parsePortKey splits an "ip:port" key into its components.
// into its components.
func parsePortKey(key string) (string, int) { func parsePortKey(key string) (string, int) {
ip, port, err := net.SplitHostPort(key) lastColon := strings.LastIndex(key, ":")
if err != nil { if lastColon < 0 {
return key, 0 return key, 0
} }
p, err := strconv.Atoi(port) ip := key[:lastColon]
var p int
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
if err != nil { if err != nil {
return ip, 0 return ip, 0
} }
@@ -1137,21 +1054,13 @@ func (w *Watcher) cleanupStalePorts(
} }
// cleanupStaleCertificates removes the certificate entries for an // cleanupStaleCertificates removes the certificate entries for an
// address whose port 443 was found closed, and for an address their // address their name no longer resolves to. An entry saved for a name
// name no longer resolves to. An entry saved for a name none of whose // none of whose nameservers answered is kept: that name's addresses are
// nameservers answered is kept unless its port is closed: that name's // not known, not gone.
// addresses are not known, not gone.
func (w *Watcher) cleanupStaleCertificates() { func (w *Watcher) cleanupStaleCertificates() {
for _, key := range w.state.GetAllCertificateKeys() { for _, key := range w.state.GetAllCertificateKeys() {
ip, hostname := parseCertKey(key) ip, hostname := parseCertKey(key)
ps, ok := w.state.GetPortState(net.JoinHostPort(ip, strconv.Itoa(tlsPort)))
if ok && !ps.Open {
w.state.DeleteCertificateState(key)
continue
}
if slices.Contains(w.collectIPs(hostname), ip) || if slices.Contains(w.collectIPs(hostname), ip) ||
w.noNameserverAnswered(hostname) { w.noNameserverAnswered(hostname) {
continue continue
@@ -1260,7 +1169,7 @@ func (w *Watcher) checkSinglePort(
return return
} }
key := net.JoinHostPort(ip, strconv.Itoa(port)) key := fmt.Sprintf("%s:%d", ip, port)
now := time.Now().UTC() now := time.Now().UTC()
prev, hasPrev := w.state.GetPortState(key) prev, hasPrev := w.state.GetPortState(key)
@@ -1322,7 +1231,7 @@ func (w *Watcher) checkTLSForHostname(
ips := w.collectIPs(hostname) ips := w.collectIPs(hostname)
for _, ip := range ips { for _, ip := range ips {
portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) portKey := fmt.Sprintf("%s:%d", ip, tlsPort)
ps, ok := w.state.GetPortState(portKey) ps, ok := w.state.GetPortState(portKey)
if !ok || !ps.Open { if !ok || !ps.Open {
@@ -1346,7 +1255,7 @@ func (w *Watcher) checkTLSCert(
return return
} }
certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
now := time.Now().UTC() now := time.Now().UTC()
prev, hasPrev := w.state.GetCertificateState(certKey) prev, hasPrev := w.state.GetCertificateState(certKey)
+8 -89
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"os" "os"
"slices" "slices"
"strings" "strings"
@@ -140,22 +139,6 @@ func (m *mockNotifier) getNotifications() []notification {
return result return result
} }
// cancelOnNotify is a mockNotifier that calls cancel at each
// notification, as if shutdown cut the check short there.
type cancelOnNotify struct {
*mockNotifier
cancel context.CancelFunc
}
func (n cancelOnNotify) SendNotification(
ctx context.Context,
title, message, priority string,
) {
n.mockNotifier.SendNotification(ctx, title, message, priority)
n.cancel()
}
// --- Helpers to build a Watcher and run its checks against live DNS --- // --- Helpers to build a Watcher and run its checks against live DNS ---
type testDeps struct { type testDeps struct {
@@ -478,54 +461,6 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
} }
} }
// TestFirstCheckAfterLoadingFileWithoutSections loads a state file
// holding {}, and one that leaves out the certificates section, and runs
// the first check. It saves domain, hostname, port and certificate
// entries; a section loaded as nil made it panic.
func TestFirstCheckAfterLoadingFileWithoutSections(t *testing.T) {
t.Parallel()
tests := []struct {
name string
file string
}{
{"no sections", `{}`},
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain}
_, deps := runChecks(t, cfg, func(deps *testDeps) {
err := os.WriteFile(cfg.StatePath(), []byte(tt.file), 0o600)
if err != nil {
t.Fatalf("writing the state file: %v", err)
}
err = deps.state.Load()
if err != nil {
t.Fatalf("loading the state file: %v", err)
}
})
snap := deps.state.GetSnapshot()
if len(snap.Domains) == 0 || len(snap.Hostnames) == 0 ||
len(snap.Ports) == 0 || len(snap.Certificates) == 0 {
t.Errorf(
"saved %d domain, %d hostname, %d port and %d "+
"certificate entries, want at least one of each",
len(snap.Domains), len(snap.Hostnames),
len(snap.Ports), len(snap.Certificates),
)
}
})
}
}
func TestNSChangeDetection(t *testing.T) { func TestNSChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
@@ -624,10 +559,8 @@ func TestDomainThatDoesNotExist(t *testing.T) {
// codeberg.page servers, are saved. Those are testSmallDomain's two // codeberg.page servers, are saved. Those are testSmallDomain's two
// nameservers; github.io, the zone of the README's example, has eight. // nameservers; github.io, the zone of the README's example, has eight.
// The previous check saved a delegation to oldNS1, now withdrawn, and // The previous check saved a delegation to oldNS1, now withdrawn, and
// records asked at oldNS1. A first check is cut short at its first // records asked at oldNS1: the NS Change of its delegation removes
// notification, as shutdown would, and a second runs in full: across // oldNS1, and no other NS Change lists it again.
// them the NS Change of its delegation removes oldNS1, and no other NS
// Change lists it again.
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) { func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
t.Parallel() t.Parallel()
@@ -639,19 +572,9 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
var deps *testDeps var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
deps = newTestDeps(t, cfg) var w *watcher.Watcher
firstCtx, cancel := context.WithCancel(ctx) w, deps = newTestWatcher(t, cfg)
defer cancel()
w := watcher.NewForTest(
cfg,
deps.state,
resolver.NewFromLogger(slog.Default()),
deps.portChecker,
deps.tlsChecker,
cancelOnNotify{mockNotifier: deps.notifier, cancel: cancel},
)
deps.state.SetDomainState(domain, &state.DomainState{ deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: []string{oldNS1}, Nameservers: []string{oldNS1},
@@ -662,8 +585,6 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
}, },
)) ))
w.RunOnce(firstCtx)
err := checkOnce(ctx, w, deps) err := checkOnce(ctx, w, deps)
// A domain saved as not existing has no records to wait for; // A domain saved as not existing has no records to wait for;
@@ -825,8 +746,7 @@ func TestPortStateChange(t *testing.T) {
hs, _ := deps.state.GetHostnameState(testHost) hs, _ := deps.state.GetHostnameState(testHost)
assertNotified( assertNotified(
t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"), t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning",
"warning",
) )
} }
@@ -1033,12 +953,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
// Port and TLS checks must use the addresses this check found. // Port and TLS checks must use the addresses this check found.
for _, ip := range addresses(snap.Hostnames[testHost]) { for _, ip := range addresses(snap.Hostnames[testHost]) {
portKey := net.JoinHostPort(ip, "80") if _, ok := snap.Ports[ip+":80"]; !ok {
if _, ok := snap.Ports[portKey]; !ok { t.Error("port check used stale DNS: missing " + ip + ":80")
t.Error("port check used stale DNS: missing " + portKey)
} }
certKey := net.JoinHostPort(ip, "443") + ":" + testHost certKey := ip + ":443:" + testHost
if _, ok := snap.Certificates[certKey]; !ok { if _, ok := snap.Certificates[certKey]; !ok {
t.Error("TLS check used stale DNS: missing " + certKey) t.Error("TLS check used stale DNS: missing " + certKey)
} }