1 Commits
Author SHA1 Message Date
sneak 9fb70396e9 build: re-vendor canonical files from prompts dd4027b (closes #257)
check / check (push) Canceled after 0s
The canonical files are fetched whole from sneak/prompts at dd4027b.
Kept after the canonical content: the livednstest deny entry in
.golangci.yml, /bin in .dockerignore, and this repo's own .gitignore
lines. The workflow keeps its concurrency block and
persist-credentials: false. Lint and test are phases of the Dockerfile
(golangci-lint v2.14.0; tests on the Debian Go 1.25.7 image with the
same flags); the build stage depends on both and stamps the version the
canonical way. Dockerfile.lint is gone; the prettier stages of
Dockerfile.fmt moved into the Dockerfile, so Markdown formatting still
runs in Docker. Every scripted docker build passes --no-cache;
script/cibuild bootstraps, runs script/check, then builds the image.
script/fmt-check absorbs fmt-check-go and fmt-check-markdown.

Model: opus-5-5
2026-10-06 01:03:55 +00:00
18 changed files with 137 additions and 1083 deletions
-3
View File
@@ -10,6 +10,3 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
[*.go]
indent_style = tab
-3
View File
@@ -47,9 +47,6 @@ node_modules/
[iI][dD]_[eE][dD]25519_[sS][kK] [iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, kept after the canonical content above. # This repository's own entries, kept after the canonical content above.
*.log
*.out
*.test
bin/ bin/
vendor/ vendor/
data/ data/
-4
View File
@@ -18,10 +18,6 @@ RUN golangci-lint run --config .golangci.yml ./...
# it ends, because the first run already failed. # it ends, because the first run already failed.
# golang 1.25.7-trixie, 2026-10-06 # golang 1.25.7-trixie, 2026-10-06
FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test
# The file permission tests skip themselves as root, so the tests run as
# an ordinary user, whose home directory holds Go's build cache.
RUN useradd --create-home tester
USER tester
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
+13 -53
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.
@@ -100,16 +99,11 @@ notification endpoint set, changes show only on the dashboard; see
records, stored per nameserver. Their changes are notified as a hostname's records, stored per nameserver. Their changes are notified as a hostname's
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
address change, in a message that starts `Domain:` where a hostname's starts address change, in a message that starts `Domain:` where a hostname's starts
`Hostname:`. A nameserver removed from the domain's delegation gets only the `Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
NS change notification above. A domain listed in record change or inconsistency notification. A domain with no delegation of
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency its own has these records asked at the servers of the zone it is in, as a
notification. A domain with no delegation of its own has these records asked hostname has. A domain that does not exist has none: they are not asked for,
at the servers of the zone it is in, as a hostname has. A nameserver the and those saved by an earlier check are removed without a notification.
domain's records were asked at on the previous check but not on this one gets
a hostname's NS change notification (see below), unless it was in the domain's
delegation on the previous check, as when that delegation is withdrawn. A
domain that does not exist has none: they are not asked for, and those saved
by an earlier check are removed without a notification.
### DNS Hostname Monitoring (Subdomains) ### DNS Hostname Monitoring (Subdomains)
@@ -162,10 +156,6 @@ notification endpoint set, changes show only on the dashboard; see
- **NS recovery**: A previously-unreachable nameserver starts responding - **NS recovery**: A previously-unreachable nameserver starts responding
again. Its records are not compared with those from before it failed, so a again. Its records are not compared with those from before it failed, so a
change made while it was failing is not reported as a record change. change made while it was failing is not reported as a record change.
- **NS change**: A nameserver of the zone the hostname is in on the previous
check is not one now, whether or not it answered. One notification lists
every such nameserver on a `Removed:` line. Nothing is sent for a
nameserver that joins the zone.
- **Inconsistency detected**: Two nameservers return different record sets - **Inconsistency detected**: Two nameservers return different record sets
for the same hostname and did not already differ on the previous check. for the same hostname and did not already differ on the previous check.
Every pair of nameservers is compared. The alert is sent once for each Every pair of nameservers is compared. The alert is sent once for each
@@ -223,14 +213,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
@@ -253,9 +237,7 @@ includes:
- **DNS record changes**: Which hostname or domain, which nameserver, what - **DNS record changes**: Which hostname or domain, which nameserver, what
record type, old values, new values. record type, old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added to or removed - **DNS NS changes**: Which domain, which nameservers were added/removed.
from its delegation. For a hostname, or a domain with no delegation of its
own, which nameservers left the zone it is in.
- **NS address changes**: Which domain, which nameserver, its old and new - **NS address changes**: Which domain, which nameserver, its old and new
addresses. addresses.
- **CNAME address changes**: Which hostname or domain, the old and new addresses - **CNAME address changes**: Which hostname or domain, the old and new addresses
@@ -300,10 +282,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
@@ -443,7 +423,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) | `""` |
@@ -457,7 +437,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
@@ -483,16 +463,6 @@ or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If either is
set to anything else, including a bare number or a zero or negative duration, set to anything else, including a bare number or a zero or negative duration,
dnswatcher refuses to start with an error naming the variable and the value. dnswatcher refuses to start with an error naming the variable and the value.
**`PORT` and `DNSWATCHER_TLS_EXPIRY_WARNING`** take a whole number: 1 to 65535
for `PORT`, and a number of days, 1 or more, for
`DNSWATCHER_TLS_EXPIRY_WARNING`. `DNSWATCHER_DEBUG`,
`DNSWATCHER_MAINTENANCE_MODE` and `DNSWATCHER_SEND_TEST_NOTIFICATION` take
`true` or `false`; `1`, `t`, `T`, `TRUE` and `True` also mean true, and `0`,
`f`, `F`, `FALSE` and `False` also mean false. An unset or empty variable means
the default. Any other value, such as `PORT=http`,
`DNSWATCHER_TLS_EXPIRY_WARNING=7d` or `DNSWATCHER_DEBUG=yes`, makes dnswatcher
refuse to start with an error naming the variable and the value.
**`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it **`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it
is set, a panic in an HTTP request handler is sent to Sentry, and the request is set, a panic in an HTTP request handler is sent to Sentry, and the request
still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry: still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry:
@@ -666,20 +636,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
+6 -26
View File
@@ -10,37 +10,15 @@
# 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-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;
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
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`
is read cannot crash the process (closes #261).
- 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint - 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint
and test are phases of the `Dockerfile`, every scripted build uncached (closes and test are phases of the `Dockerfile`, every scripted build uncached (closes
#257). #257).
@@ -205,4 +183,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
+14 -126
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
@@ -25,9 +24,6 @@ const (
defaultTLSExpiryWarning = 7 defaultTLSExpiryWarning = 7
) )
// maxPort is the highest TCP port number.
const maxPort = 65535
// ErrNoTargets is returned when no monitoring targets are configured. // ErrNoTargets is returned when no monitoring targets are configured.
var ErrNoTargets = errors.New( var ErrNoTargets = errors.New(
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable", "no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
@@ -44,30 +40,6 @@ var ErrInvalidInterval = errors.New(
// lists a name that is not in DNSWATCHER_TARGETS. // lists a name that is not in DNSWATCHER_TARGETS.
var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS") var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS")
// ErrInvalidPort is returned when PORT is set but is not a whole number
// from 1 to 65535. An empty value counts as unset and means the default.
var ErrInvalidPort = errors.New("port must be a whole number from 1 to 65535")
// ErrInvalidTLSExpiryWarning is returned when DNSWATCHER_TLS_EXPIRY_WARNING
// is set but is not a whole number, 1 or more. An empty value counts as unset
// and means the default.
var ErrInvalidTLSExpiryWarning = errors.New(
"TLS expiry warning must be a whole number of days, 1 or more",
)
// ErrInvalidBool is returned when DNSWATCHER_DEBUG,
// DNSWATCHER_MAINTENANCE_MODE or DNSWATCHER_SEND_TEST_NOTIFICATION is set
// but is not a value strconv.ParseBool accepts. An empty value counts as
// unset and means the default.
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 +87,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
@@ -159,9 +127,16 @@ func buildConfig(
log *slog.Logger, log *slog.Logger,
params *Params, params *Params,
) (*Config, error) { ) (*Config, error) {
err := readConfigFile(log) err := viper.ReadInConfig()
if err != nil { if err != nil {
return nil, err var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
log.Error("config file malformed", "error", err)
return nil, fmt.Errorf(
"config file malformed: %w", err,
)
}
} }
dnsInterval, err := parseInterval("DNS_INTERVAL") dnsInterval, err := parseInterval("DNS_INTERVAL")
@@ -186,34 +161,9 @@ func buildConfig(
return nil, err return nil, err
} }
port, err := parsePort()
if err != nil {
return nil, err
}
tlsExpiryWarning, err := parseTLSExpiryWarning()
if err != nil {
return nil, err
}
debug, err := parseBool("DEBUG")
if err != nil {
return nil, err
}
maintenanceMode, err := parseBool("MAINTENANCE_MODE")
if err != nil {
return nil, err
}
sendTestNotification, err := parseBool("SEND_TEST_NOTIFICATION")
if err != nil {
return nil, err
}
cfg := &Config{ cfg := &Config{
Port: port, Port: viper.GetInt("PORT"),
Debug: debug, Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"), DataDir: viper.GetString("DATA_DIR"),
Domains: domains, Domains: domains,
Hostnames: hostnames, Hostnames: hostnames,
@@ -223,12 +173,12 @@ func buildConfig(
NtfyTopic: viper.GetString("NTFY_TOPIC"), NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval, DNSInterval: dnsInterval,
TLSInterval: tlsInterval, TLSInterval: tlsInterval,
TLSExpiryWarning: tlsExpiryWarning, TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: maintenanceMode, MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SendTestNotification: sendTestNotification, SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
params: params, params: params,
log: log, log: log,
} }
@@ -236,21 +186,6 @@ func buildConfig(
return cfg, nil return cfg, nil
} }
// readConfigFile reads the config file, if there is one.
func readConfigFile(log *slog.Logger) error {
err := viper.ReadInConfig()
if err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
log.Error("config file malformed", "error", err)
return fmt.Errorf("config file malformed: %w", err)
}
}
return nil
}
// parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A // parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A
// value that does not parse, or is zero or negative, is an error naming the // value that does not parse, or is zero or negative, is an error naming the
// variable and the value; an unset variable has its default from setupViper. // variable and the value; an unset variable has its default from setupViper.
@@ -267,53 +202,6 @@ func parseInterval(key string) (time.Duration, error) {
return interval, nil return interval, nil
} }
// parsePort reads PORT, which has no DNSWATCHER_ prefix, as a port number. A
// value that is not a whole number from 1 to 65535 is an error naming the
// variable and the value; an unset variable has its default from setupViper.
func parsePort() (int, error) {
value := viper.GetString("PORT")
port, err := strconv.Atoi(value)
if err != nil || port < 1 || port > maxPort {
return 0, fmt.Errorf("invalid PORT %q: %w", value, ErrInvalidPort)
}
return port, nil
}
// parseTLSExpiryWarning reads DNSWATCHER_TLS_EXPIRY_WARNING as a number of
// days. A value that is not a whole number, 1 or more, is an error naming the
// variable and the value; an unset variable has its default from setupViper.
func parseTLSExpiryWarning() (int, error) {
value := viper.GetString("TLS_EXPIRY_WARNING")
days, err := strconv.Atoi(value)
if err != nil || days < 1 {
return 0, fmt.Errorf(
"invalid DNSWATCHER_TLS_EXPIRY_WARNING %q: %w",
value, ErrInvalidTLSExpiryWarning,
)
}
return days, nil
}
// parseBool reads the DNSWATCHER_-prefixed setting key as true or false. A
// value strconv.ParseBool does not accept is an error naming the variable and
// the value; an unset variable has its default from setupViper.
func parseBool(key string) (bool, error) {
value := viper.GetString(key)
result, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf(
"invalid DNSWATCHER_%s %q: %w", key, value, ErrInvalidBool,
)
}
return result, nil
}
func parseAndValidateTargets() ([]string, []string, error) { func parseAndValidateTargets() ([]string, []string, error) {
domains, hostnames, err := ClassifyTargets( domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")), parseCSV(viper.GetString("TARGETS")),
+2 -111
View File
@@ -116,7 +116,7 @@ func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
} }
// TestNew_InvalidIntervalStopsStartup checks values that must stop startup; // TestNew_InvalidIntervalStopsStartup checks values that must stop startup;
// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault check that an // TestNew_DefaultValues and TestNew_EmptyIntervalMeansDefault check that an
// unset or empty interval means the default. // unset or empty interval means the default.
func TestNew_InvalidIntervalStopsStartup(t *testing.T) { func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"} variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"}
@@ -144,107 +144,16 @@ func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
} }
} }
func TestNew_EmptyValueMeansDefault(t *testing.T) { func TestNew_EmptyIntervalMeansDefault(t *testing.T) {
viper.Reset() viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "") t.Setenv("DNSWATCHER_DNS_INTERVAL", "")
t.Setenv("DNSWATCHER_TLS_INTERVAL", "") t.Setenv("DNSWATCHER_TLS_INTERVAL", "")
t.Setenv("PORT", "")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "")
t.Setenv("DNSWATCHER_DEBUG", "")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "")
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "")
cfg, err := config.New(nil, newTestParams(t)) cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, time.Hour, cfg.DNSInterval) assert.Equal(t, time.Hour, cfg.DNSInterval)
assert.Equal(t, 12*time.Hour, cfg.TLSInterval) assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
assert.Equal(t, 8080, cfg.Port)
assert.Equal(t, 7, cfg.TLSExpiryWarning)
assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
assert.False(t, cfg.SendTestNotification)
}
// TestNew_InvalidNumberOrBoolStopsStartup checks values that must stop
// startup; TestNew_EnvironmentOverrides, the two tests after this one and
// TestNew_EmptyValueMeansDefault check values that must not.
func TestNew_InvalidNumberOrBoolStopsStartup(t *testing.T) {
notBools := []string{"yes", "on", "2", "tRUE"}
tests := []struct {
variable string
values []string
want error
}{
{
variable: "PORT",
values: []string{"notaport", "0", "-1", "65536", "8080.5"},
want: config.ErrInvalidPort,
},
{
variable: "DNSWATCHER_TLS_EXPIRY_WARNING",
values: []string{"abc", "7d", "0", "-5"},
want: config.ErrInvalidTLSExpiryWarning,
},
{
variable: "DNSWATCHER_DEBUG",
values: notBools,
want: config.ErrInvalidBool,
},
{
variable: "DNSWATCHER_MAINTENANCE_MODE",
values: notBools,
want: config.ErrInvalidBool,
},
{
variable: "DNSWATCHER_SEND_TEST_NOTIFICATION",
values: notBools,
want: config.ErrInvalidBool,
},
}
for _, tt := range tests {
for _, value := range tt.values {
t.Run(tt.variable+"="+value, func(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv(tt.variable, value)
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, tt.want)
require.ErrorContains(t, err, tt.variable)
require.ErrorContains(t, err, strconv.Quote(value))
})
}
}
}
func TestNew_LowestNumbersAndOtherSpellingsOfTrue(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "1")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "1")
t.Setenv("DNSWATCHER_DEBUG", "1")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "T")
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "True")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 1, cfg.Port)
assert.Equal(t, 1, cfg.TLSExpiryWarning)
assert.True(t, cfg.Debug)
assert.True(t, cfg.MaintenanceMode)
assert.True(t, cfg.SendTestNotification)
}
func TestNew_HighestPort(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "65535")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 65535, cfg.Port)
} }
func TestNew_DebugEnablesDebugLogging(t *testing.T) { func TestNew_DebugEnablesDebugLogging(t *testing.T) {
@@ -354,24 +263,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")
+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 -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)
}
}
+4 -52
View File
@@ -4,10 +4,8 @@ package state
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -216,24 +214,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 +252,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,35 +265,17 @@ 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
} }
// GetSnapshot returns a copy of the current snapshot, with copies of // GetSnapshot returns a copy of the current snapshot.
// its maps, so that a caller can range over them while the state is
// written. The entries in the maps are not copied: an entry saved in the
// state must be replaced, never changed in place.
func (s *State) GetSnapshot() Snapshot { func (s *State) GetSnapshot() Snapshot {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
snap := *s.snapshot return *s.snapshot
snap.Domains = maps.Clone(snap.Domains)
snap.Hostnames = maps.Clone(snap.Hostnames)
snap.Ports = maps.Clone(snap.Ports)
snap.Certificates = maps.Clone(snap.Certificates)
return snap
} }
// SetDomainState updates the state for a domain. // SetDomainState updates the state for a domain.
+8 -160
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()
@@ -1094,8 +995,7 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
} }
} }
// TestGetSnapshot_ReturnsCopy verifies that the maps in the snapshot // TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy.
// GetSnapshot returns are not the state's own.
func TestGetSnapshot_ReturnsCopy(t *testing.T) { func TestGetSnapshot_ReturnsCopy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1105,67 +1005,15 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
snap := s.GetSnapshot() snap := s.GetSnapshot()
const added = "added.example.com" // Mutate the returned snapshot's map.
snap.Domains["mutated.example.com"] = &state.DomainState{
snap.Domains[added] = &state.DomainState{} Nameservers: []string{"ns1.mutated.com."},
snap.Hostnames[added] = &state.HostnameState{}
snap.Ports[added] = &state.PortState{}
snap.Certificates[added] = &state.CertificateState{}
if _, ok := s.GetDomainState(added); ok {
t.Error("the snapshot's Domains map is the state's own")
} }
if _, ok := s.GetHostnameState(added); ok { // The original domain data should still be accessible.
t.Error("the snapshot's Hostnames map is the state's own") _, ok := s.GetDomainState("example.com")
} if !ok {
t.Error("original domain should still exist")
if _, ok := s.GetPortState(added); ok {
t.Error("the snapshot's Ports map is the state's own")
}
if _, ok := s.GetCertificateState(added); ok {
t.Error("the snapshot's Certificates map is the state's own")
}
}
// TestGetSnapshot_WhileWritten ranges over snapshots while another
// goroutine writes the state, as the dashboard does while a check runs.
// Under -race it fails when a snapshot's maps are the state's own.
func TestGetSnapshot_WhileWritten(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
const writes = 1000
written := make(chan struct{})
go func() {
defer close(written)
for range writes {
s.SetDomainState("example.com", &state.DomainState{})
s.SetHostnameState(testHostname, &state.HostnameState{})
s.SetPortState(testIPv4+":443", &state.PortState{})
s.SetCertificateState(
testIPv4+":443:"+testHostname, &state.CertificateState{},
)
}
}()
for {
select {
case <-written:
return
default:
}
// json.Marshal ranges over each of the snapshot's maps.
_, err := json.Marshal(s.GetSnapshot())
if err != nil {
t.Fatal(err)
}
} }
} }
-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.
-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)
}
}
+12 -91
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
@@ -73,19 +74,21 @@ b.ns.example.net.: 192.0.2.2`,
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) { func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) notifier := &mockNotifier{}
cfg.Domains = []string{domain} w := watcher.NewForTest(
&config.Config{Domains: []string{domain}},
deps := newTestDeps(t, cfg) nil, nil, nil, nil, notifier,
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier) )
// nsA's address changes, which also makes it differ from nsC; nsB // nsA's address changes, which also makes it differ from nsC; nsB
// fails; nsC answers again. // fails; nsC answers again; nsD is gone.
nsD := "d.ns.example.net."
w.DetectHostnameChanges(t.Context(), domain, w.DetectHostnameChanges(t.Context(), domain,
saved(map[string]*state.NameserverRecordState{ saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}), nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}), nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(), nsC: failed(),
nsD: answered(map[string][]string{"A": {ip1}}),
}), }),
saved(map[string]*state.NameserverRecordState{ saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip2}}), nsA: answered(map[string][]string{"A": {ip2}}),
@@ -99,17 +102,18 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Context(), domain, cnameState(ip1), cnameState(ip2), t.Context(), domain, cnameState(ip1), cnameState(ip2),
) )
// NS Failure is sent for nsB failing and for nsD being gone.
want := map[string]int{ want := map[string]int{
"Record Change": 1, "Record Change": 1,
"Inconsistency": 1, "Inconsistency": 1,
"NS Failure": 1, "NS Failure": 2,
"NS Recovery": 1, "NS Recovery": 1,
"CNAME Address Change": 1, "CNAME Address Change": 1,
} }
sent := make(map[string]int) sent := make(map[string]int)
for _, n := range deps.notifier.getNotifications() { for _, n := range notifier.getNotifications() {
kind, _, _ := strings.Cut(n.Title, ":") kind, _, _ := strings.Cut(n.Title, ":")
sent[kind]++ sent[kind]++
@@ -124,89 +128,6 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
} }
} }
// nsB, which answered, and nsC, which did not, are no longer among the
// nameservers. One NS Change lists both, for the hostname and for the
// configured apex domain with no delegation of its own. Nothing is sent
// for the domain whose delegation on the previous check listed them:
// the check of its delegation sends its NS Change.
func TestRemovedNameserversAreAnNSChange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
// The domain's nameservers saved by the previous check.
delegation []string
// The NS Change sent, or "" for none.
want string
}{
{
name: "hostname",
target: host,
want: `Hostname: www.example.net
Removed: b.ns.example.net., c.ns.example.net.`,
},
{
name: "domain with no delegation of its own",
target: domain,
delegation: []string{},
want: `Domain: example.net
Removed: b.ns.example.net., c.ns.example.net.`,
},
{
name: "domain whose delegation listed them",
target: domain,
delegation: []string{nsA, nsB, nsC},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil, nil, nil, deps.notifier,
)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: tt.delegation,
})
prev := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
})
current := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
})
w.DetectHostnameChanges(t.Context(), tt.target, prev, current)
got := deps.notifier.getNotifications()
if tt.want == "" {
if len(got) != 0 {
t.Errorf("sent %v, want nothing", got)
}
return
}
title := "NS Change: " + tt.target
if len(got) != 1 || got[0].Title != title ||
got[0].Message != tt.want {
t.Errorf("sent %v, want one %q with message:\n%s",
got, title, tt.want)
}
})
}
}
// The startup notification counts the configured domains and hostnames, // The startup notification counts the configured domains and hostnames,
// although the state's hostnames also hold the apex domain's own // although the state's hostnames also hold the apex domain's own
// records. Nothing is looked up: the watcher has no resolver. // records. Nothing is looked up: the watcher has no resolver.
+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)
}
}
}
+47 -90
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)
@@ -329,20 +322,6 @@ func (w *Watcher) checkDomain(
ctx, nameservers, prevAddresses, ctx, nameservers, prevAddresses,
) )
// 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.
// 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.
// Notifications about them name it as a domain (see nameLine). When
// their lookup fails, the check fails as when the NS lookup does, so
// the saved delegation and the saved records come from the same
// check, as detectNSDisappearances needs.
if nxdomain {
w.state.DeleteHostnameState(domain)
} else if !w.checkHostname(ctx, domain) {
return
}
if hasPrev && !w.firstRun { if hasPrev && !w.firstRun {
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers) w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses) w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
@@ -354,6 +333,19 @@ func (w *Watcher) checkDomain(
NXDomain: nxdomain, NXDomain: nxdomain,
LastChecked: now, LastChecked: now,
}) })
// 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.
if nxdomain {
w.state.DeleteHostnameState(domain)
return
}
// The apex domain's records are also checked and saved as a
// hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine).
w.checkHostname(ctx, domain)
} }
func (w *Watcher) detectNSChanges( func (w *Watcher) detectNSChanges(
@@ -464,12 +456,10 @@ func (w *Watcher) detectNSAddressChanges(
} }
} }
// checkHostname checks hostname's records and reports whether it saved
// them; it does not when their lookup fails.
func (w *Watcher) checkHostname( func (w *Watcher) checkHostname(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) 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(
@@ -479,7 +469,7 @@ func (w *Watcher) checkHostname(
"error", err, "error", err,
) )
return false return
} }
prev, _ := w.state.GetHostnameState(hostname) prev, _ := w.state.GetHostnameState(hostname)
@@ -487,8 +477,6 @@ func (w *Watcher) checkHostname(
w.updateHostnameState( w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()), ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
) )
return true
} }
// updateHostnameState finishes a check of hostname from newState, built // updateHostnameState finishes a check of hostname from newState, built
@@ -784,53 +772,28 @@ func (w *Watcher) detectRecordChanges(
} }
} }
// detectNSDisappearances sends one NS Change listing the nameservers of
// 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
// saved delegation, which detectNSChanges notifies when they are removed
// from it. checkDomain saves a new delegation only after this runs, and
// only together with the records, so the saved delegation and the
// records in prev come from the same check.
func (w *Watcher) detectNSDisappearances( func (w *Watcher) detectNSDisappearances(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev, current *state.HostnameState, prev, current *state.HostnameState,
) { ) {
var delegation []string for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK {
if w.isDomain(hostname) { continue
if domainState, ok := w.state.GetDomainState(hostname); ok {
delegation = domainState.Nameservers
} }
msg := fmt.Sprintf(
"%s\nNameserver: %s disappeared",
w.nameLine(hostname), ns,
)
w.notify.SendNotification(
ctx,
"NS Failure: "+hostname,
msg,
"error",
)
} }
var removed []string
for ns := range prev.RecordsByNameserver {
_, ok := current.RecordsByNameserver[ns]
if !ok && !slices.Contains(delegation, ns) {
removed = append(removed, ns)
}
}
if len(removed) == 0 {
return
}
slices.Sort(removed)
msg := fmt.Sprintf(
"%s\nRemoved: %s",
w.nameLine(hostname),
strings.Join(removed, ", "),
)
w.notify.SendNotification(
ctx,
"NS Change: "+hostname,
msg,
"warning",
)
} }
// detectNSFailures notifies when a nameserver that answered on the // detectNSFailures notifies when a nameserver that answered on the
@@ -980,8 +943,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()
} }
@@ -1002,7 +964,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)
} }
@@ -1027,15 +989,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
} }
@@ -1065,21 +1030,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
@@ -1188,7 +1145,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)
@@ -1250,7 +1207,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 {
@@ -1274,7 +1231,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)
+9 -118
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()
@@ -623,11 +558,6 @@ func TestDomainThatDoesNotExist(t *testing.T) {
// nameservers and without nxdomain, and its records, asked at the // nameservers and without nxdomain, and its records, asked at the
// 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
// records asked at oldNS1. A first check is cut short at its first
// notification, as shutdown would, and a second runs in full: across
// 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,30 +569,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{
Nameservers: []string{oldNS1},
})
deps.state.SetHostnameState(domain, saved(
map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}),
},
))
w.RunOnce(firstCtx)
err := checkOnce(ctx, w, deps) err := checkOnce(ctx, w, deps)
@@ -684,22 +593,6 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
if _, ok := deps.state.GetHostnameState(domain); !ok { if _, ok := deps.state.GetHostnameState(domain); !ok {
t.Errorf("no records saved for %s", domain) t.Errorf("no records saved for %s", domain)
} }
title := "NS Change: " + domain
want := "Domain: " + domain + "\nAdded: \nRemoved: " + oldNS1
var changes []notification
for _, n := range deps.notifier.getNotifications() {
if n.Title == title {
changes = append(changes, n)
}
}
if len(changes) != 1 || changes[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s",
changes, title, want)
}
} }
func TestNSAddressChangeDetection(t *testing.T) { func TestNSAddressChangeDetection(t *testing.T) {
@@ -825,8 +718,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 +925,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)
} }
@@ -1158,7 +1049,7 @@ func TestSendTestNotification_Disabled(t *testing.T) {
} }
} }
func TestNSChangeAndRecovery(t *testing.T) { func TestNSFailureAndRecovery(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
@@ -1168,7 +1059,7 @@ func TestNSChangeAndRecovery(t *testing.T) {
// The saved state has every nameserver live DNS lists as one that // The saved state has every nameserver live DNS lists as one that
// did not answer, and, as answering, one that live DNS does not // did not answer, and, as answering, one that live DNS does not
// list, which the check then finds removed. // list, which then disappears.
_, deps := runChecks(t, cfg, func(deps *testDeps) { _, deps := runChecks(t, cfg, func(deps *testDeps) {
byNameserver := map[string]*state.NameserverRecordState{ byNameserver := map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}), oldNS1: answered(map[string][]string{"A": {oldIP}}),
@@ -1180,7 +1071,7 @@ func TestNSChangeAndRecovery(t *testing.T) {
deps.state.SetHostnameState(testHost, saved(byNameserver)) deps.state.SetHostnameState(testHost, saved(byNameserver))
}) })
assertNotified(t, deps, "NS Change: "+testHost, "warning") assertNotified(t, deps, "NS Failure: "+testHost, "error")
assertNotified(t, deps, "NS Recovery: "+testHost, "success") assertNotified(t, deps, "NS Recovery: "+testHost, "success")
// A nameserver that did not answer has no records to compare, so // A nameserver that did not answer has no records to compare, so