4 Commits
Author SHA1 Message Date
sneak bb7d56cd9a watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Failing after 2m4s
When a watched name's nameservers answer with a CNAME and no address,
the DNS check asks ResolveIPAddresses for the name, which looks it up
again and follows the chain, and saves the addresses at its end in the
hostname state as cnameAddresses. The port and TLS checks use them. A
change in them is notified as a CNAME address change, also from or to
none. A state file without the field loads them as not known (nil), so
its first check sends nothing for them. When following fails, or none
of the name's nameservers answered, the last check's addresses are
kept. The domain check now runs the hostname check for the apex
instead of a copy of it.

Model: opus-5-5
2026-10-02 01:35:53 +00:00
clawbot 82836b41fd resolver: try servers in a random order on each resolution (closes #138)
check / check (push) Successful in 1m42s
Every resolution walked the root servers in a fixed order, so
a.root-servers.net got every first query and its timeouts were paid on
every lookup. Each list of servers the resolver walks is now walked in a
random order from rand.Shuffle, chosen anew each time; a server that does
not reply, refuses, or gives an error reply or a referral that leads no
closer is still passed over for the next. When a referral names a zone's
nameservers without their addresses, all of them are now looked up, not
only the first that resolves, so the zone is not given up because the
first nameserver whose address was found gave no usable reply. No test
fails if the walk stops shuffling: which server a live query reached is
not observable.

Model: opus-5-5
2026-10-02 03:26:06 +02:00
clawbot af81f2ac76 README: correct claims the code does not bear out (closes #108)
check / check (push) Failing after 2m12s
Checked every README claim against the code on next and fixed the ones
that were wrong or missing: what /metrics serves and when, what
DNSWATCHER_MAINTENANCE_MODE does, CORS on the public routes,
notification retries and the in-memory alert history, the certificate
error field and old port entries in the state file, and the Design
tree's missing files. Also corrected: CNAMEs are not followed for
watched names, the root server list is never refreshed, the NS set is
the delegation from the domain's parent zone, notification contents,
and the system resolver being used for webhooks. Code problems found
are filed separately.

Model: opus-5-5
2026-10-02 03:14:26 +02:00
clawbot 56c4395a39 config: watch a target listed twice only once (closes #207)
check / check (push) Successful in 1m50s
ClassifyTargets kept a name every time it appeared in DNSWATCHER_TARGETS,
so example.com,Example.com. put example.com in the domain list twice and
every check looked it up and checked its certificates twice, sending two
expiry warnings per TLS check (port checks were already grouped by address
and port). It now skips a name it has already kept, comparing after the
lower-casing and trailing-dot removal it already did; the list keeps the
order of first appearance.

Model: opus-5-5
2026-10-02 02:56:11 +02:00
14 changed files with 443 additions and 87 deletions
+119 -54
View File
@@ -12,7 +12,7 @@ dnswatcher watches configured DNS domains and hostnames for changes, monitors
TCP port availability, tracks TLS certificate expiry, and delivers real-time TCP port availability, tracks TLS certificate expiry, and delivers real-time
notifications via Slack, Mattermost, and/or ntfy webhooks. notifications via Slack, Mattermost, and/or ntfy webhooks.
It performs all DNS resolution itself via iterative (non-recursive) queries, It resolves the names it watches itself via iterative (non-recursive) queries,
tracing from root nameservers to authoritative servers directly—never relying on tracing from root nameservers to authoritative servers directly—never relying on
upstream recursive resolvers. upstream recursive resolvers.
@@ -69,14 +69,14 @@ notification endpoint set, changes show only on the dashboard; see
- Accepts a list of DNS domain names (apex domains, identified via the - Accepts a list of DNS domain names (apex domains, identified via the
[Public Suffix List](https://publicsuffix.org/)). [Public Suffix List](https://publicsuffix.org/)).
- Every **1 hour**, performs a full iterative trace from root servers to - Every **1 hour** by default, performs a full iterative trace from root servers
discover all authoritative nameservers (NS records) for each domain. to discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently. - Queries **every** discovered authoritative nameserver independently.
- Stores the NS record set as observed by the delegation chain, and the IPv4 and - Stores the domain's NS record set, as its parent zone's servers delegate it,
IPv6 addresses each nameserver's name resolves to. and the IPv4 and IPv6 addresses each nameserver's name resolves to.
- Any change triggers a notification: - Any change triggers a notification:
- NS added to or removed from the delegation. - NS added to or removed from that set.
- NS address change: a nameserver that stays in the delegation resolves to - NS address change: a nameserver that stays in the set resolves to
different addresses than on the previous check. A nameserver added or different addresses than on the previous check. A nameserver added or
removed gets only the NS change notification. When the lookup of a removed gets only the NS change notification. When the lookup of a
nameserver's addresses fails or finds none, its previous addresses are nameserver's addresses fails or finds none, its previous addresses are
@@ -86,7 +86,7 @@ notification endpoint set, changes show only on the dashboard; see
- Accepts a list of DNS hostnames (subdomains, distinguished from apex domains - Accepts a list of DNS hostnames (subdomains, distinguished from apex domains
via the Public Suffix List). via the Public Suffix List).
- Every **1 hour**, performs a full iterative trace to discover the - Every **1 hour** by default, performs a full iterative trace to discover the
authoritative nameservers of the zone the hostname is in, which is not always authoritative nameservers of the zone the hostname is in, which is not always
its last two labels (a name under `co.uk`, or in a delegated subdomain). its last two labels (a name under `co.uk`, or in a delegated subdomain).
- Queries **each** authoritative nameserver independently for **all** record - Queries **each** authoritative nameserver independently for **all** record
@@ -121,12 +121,15 @@ notification endpoint set, changes show only on the dashboard; see
failed on it, and answers differently is reported on the check where it failed on it, and answers differently is reported on the check where it
answers. If a pair agrees again and later disagrees, the alert is sent answers. If a pair agrees again and later disagrees, the alert is sent
again. again.
- **CNAME address change**: For a name whose nameservers answer with a CNAME - **CNAME address change**: The addresses at the end of a name's CNAME chain
and no address, the addresses at the end of its CNAME chain differ from differ from those of the previous check. They are found when its
those of the previous check, including when there are none now. Nothing is nameservers answer with a CNAME and no address; a name that answers with
sent when the previous check saved none, or when the previous addresses an address has none. A change from or to no addresses is sent too, as when
were kept because the chain could not be followed or none of the name's a name moves between A records and a CNAME. Nothing is sent when the
nameservers answered. previous addresses were kept because the chain could not be followed or
none of the name's nameservers answered. The first check after loading a
state file without `cnameAddresses` sends nothing: it saves the addresses
it finds for the next check to compare.
### TCP Port Monitoring ### TCP Port Monitoring
@@ -138,10 +141,11 @@ notification endpoint set, changes show only on the dashboard; see
none of the name's nameservers answered, the addresses the last check found at none of the name's nameservers answered, the addresses the last check found at
its end are used. its end are used.
- Checks TCP connectivity on ports **80** and **443** for each IP address. - Checks TCP connectivity on ports **80** and **443** for each IP address.
- Every **1 hour**, re-checks all ports. - Every **1 hour** by default, re-checks all ports.
- Any change in port availability triggers a notification: - Any change in port availability triggers a notification:
- Port transitioned from open to closed (or vice versa). - Port transitioned from open to closed (or vice versa).
- New IP appeared (from DNS change) and its port state was recorded. - New IP appeared (from DNS change): its port state is recorded without a
port notification; the DNS change notification shows the new address.
- IP disappeared (from DNS change) — noted in the DNS change notification; - IP disappeared (from DNS change) — noted in the DNS change notification;
port state for that IP is removed. When none of a name's nameservers port state for that IP is removed. When none of a name's nameservers
answered, its addresses are not known, so the port state saved for them is answered, its addresses are not known, so the port state saved for them is
@@ -149,14 +153,14 @@ notification endpoint set, changes show only on the dashboard; see
### TLS Certificate Monitoring ### TLS Certificate Monitoring
- Every **12 hours**, for each IP address listening on port 443, connects via - Every **12 hours** by default, for each IP address listening on port 443,
TLS using the correct SNI hostname. connects via TLS using the correct SNI hostname.
- Records the certificate's Subject CN, SANs, issuer, and expiry date. - Records the certificate's Subject CN, SANs, issuer, and expiry date.
- Any change triggers a notification: - Any change triggers a notification:
- Certificate is expiring within **7 days** (warning, repeated each check - Certificate is expiring within **7 days** by default (warning, repeated
until renewed or expired). each check until renewed or expired).
- Certificate CN, issuer, or SANs changed (replacement detected, reports old - Certificate CN, issuer, or SANs changed (replacement detected, reports old
and new values). 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, connection refused after previously succeeding). timeout, connection refused after previously succeeding).
- TLS recovery: a previously-failing IP:443 now completes a handshake again. - TLS recovery: a previously-failing IP:443 now completes a handshake again.
@@ -190,15 +194,25 @@ includes:
- **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned, - **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname affected. which hostname affected.
- **Port changes**: Which IP:port, old state, new state, all associated - **Port changes**: Which IP:port, its new state, all associated hostnames.
hostnames. - **TLS expiry warnings**: Expiry date and days remaining, CN, associated
- **TLS expiry warnings**: Which certificate, days remaining, CN, issuer, hostname and IP.
associated hostname and IP. - **TLS certificate changes**: Old and new CN and issuer, associated hostname
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated hostname and IP. A change to the SANs alone is notified, but the SANs are not listed.
and IP.
- **TLS connection failures/recoveries**: Which IP:port, error details, - **TLS connection failures/recoveries**: Which IP:port, error details,
associated hostname. associated hostname.
Each endpoint is sent each notification on its own, in the background. A
delivery that fails (a network error, no reply within 10 seconds, or an HTTP
status of 400 or more) is retried up to 5 times: the first retry after about 1
second, each wait after that twice as long up to 60 seconds, every wait varied
at random by up to 25%. A delivery still failing after that is logged and
dropped.
The last 100 notifications, delivered or not, are kept in memory for the
dashboard's Recent alerts. They are not saved to the state file, so a restart
clears them.
### State Management ### State Management
- All monitoring state is kept in memory and persisted to a JSON file on disk - All monitoring state is kept in memory and persisted to a JSON file on disk
@@ -242,7 +256,16 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
| `GET /.well-known/healthcheck` | Health check (JSON) | | `GET /.well-known/healthcheck` | Health check (JSON) |
| `GET /health` | Health check (JSON, legacy) | | `GET /health` | Health check (JSON, legacy) |
| `GET /api/v1/status` | Current monitoring state | | `GET /api/v1/status` | Current monitoring state |
| `GET /metrics` | Prometheus metrics (optional) | | `GET /metrics` | Prometheus metrics, see below |
`/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind
Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
process, and counts of `/metrics` requests); dnswatcher records no metrics of
its own.
Every route but `/metrics` may be read from a page on any origin: a cross-origin
`GET` gets `Access-Control-Allow-Origin: *`. Only `GET` is allowed cross-origin,
and without credentials. `/metrics` sends no CORS headers.
#### Server timeouts #### Server timeouts
@@ -333,8 +356,8 @@ following precedence (highest to lowest):
| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` | | `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` |
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` | | `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | | `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` | | `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` | | `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | | `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` |
@@ -342,7 +365,8 @@ following precedence (highest to lowest):
monitoring targets are configured. A monitoring daemon with nothing to monitor monitoring targets are configured. A monitoring daemon with nothing to monitor
is a misconfiguration, so dnswatcher fails fast with a clear error message is a misconfiguration, so dnswatcher fails fast with a clear error message
rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated list rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated list
of DNS names before starting. of DNS names before starting. A name listed more than once, in any letter case
or with a trailing dot, is watched once.
**`/metrics` is rate limited.** Each client address may send it 30 requests a **`/metrics` is rate limited.** Each client address may send it 30 requests a
minute, failed logins included; beyond that it answers `429 Too Many Requests` minute, failed logins included; beyond that it answers `429 Too Many Requests`
@@ -384,22 +408,30 @@ DNSWATCHER_SEND_TEST_NOTIFICATION=true
## DNS Resolution Strategy ## DNS Resolution Strategy
dnswatcher never uses the system's configured recursive resolver. Instead, it dnswatcher never uses the system's configured recursive resolver for the names
performs full iterative resolution: it watches. Instead, it performs full iterative resolution:
1. **Root servers**: Starts from the IANA root nameserver list (hardcoded, with 1. **Root servers**: Starts from the IPv4 addresses of the 13 root servers,
periodic refresh). built into the binary; the list is not refreshed.
2. **TLD delegation**: Queries root servers for the TLD NS records. 2. **TLD delegation**: Queries root servers for the TLD NS records.
3. **Domain delegation**: Queries TLD nameservers for the domain's NS records. 3. **Domain delegation**: Queries TLD nameservers for the domain's NS records.
The delegation they give, from the domain's parent zone, is the domain's NS
record set.
4. **Authoritative query**: Queries all discovered authoritative nameservers 4. **Authoritative query**: Queries all discovered authoritative nameservers
directly for the requested records. directly for the requested records.
In steps 2 and 3 the servers are asked one at a time in a random order, chosen
anew each time, so no one root server gets every first query. A server that does
not reply, refuses the query, or gives an error reply such as SERVFAIL or a
referral that leads no closer to the name is passed over for the next one. When
a referral names a zone's nameservers without their addresses, the addresses of
all of them are looked up, so that each can be asked.
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.
- Visibility into the full delegation chain.
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 CNAME chain is followed included. When they return a CNAME and no address, the CNAME chain is followed
@@ -407,6 +439,9 @@ included. When they return a CNAME and no address, the CNAME chain is followed
the port and TLS checks use those addresses. Nameservers' addresses are found the port and TLS checks use those addresses. Nameservers' addresses are found
the same way. the same way.
Sending a notification or a Sentry report is the one use of the system's
resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
--- ---
## State File Format ## State File Format
@@ -449,6 +484,7 @@ merged view, to enable inconsistency detection.
"lastChecked": "2026-02-19T12:00:00Z" "lastChecked": "2026-02-19T12:00:00Z"
} }
}, },
"cnameAddresses": [],
"lastChecked": "2026-02-19T12:00:00Z" "lastChecked": "2026-02-19T12:00:00Z"
} }
}, },
@@ -487,17 +523,23 @@ reachability:
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
`records`. A nameserver whose query failed, or that only referred it to other `records`. A nameserver whose query failed, or that only referred it to other
nameservers, has status `error`, empty `records`, and the reason in `error`. nameservers, has status `error`, empty `records`, and the reason in `error`. A
certificate entry whose TLS connection or handshake failed likewise has status
`error`, the reason in `error`, and the certificate fields left empty or zero.
`nameserverAddresses` lists, by nameserver, the sorted addresses its name `nameserverAddresses` lists, by nameserver, the sorted addresses its name
resolves to. A state file without it loads, and the next check fills it in resolves to. A state file without it loads, and the next check fills it in
without a notification. without a notification.
`cnameAddresses` lists the sorted addresses at the end of a hostname's CNAME `cnameAddresses` lists the sorted addresses at the end of a hostname's CNAME
chain, found when its nameservers answered with a CNAME and no address, and kept chain, found when its nameservers answered with a CNAME and no address; it is
from the previous check when the chain cannot be followed or none of the name's empty when they answered with an address. When the chain cannot be followed, or
nameservers answered. It is left out otherwise. A state file without it loads, none of the name's nameservers answered, the previous check's list is kept, or
and the next check fills it in without a notification. `null` when no earlier check saved one. A state file without it loads, and the
first check after that saves it without a notification.
A port entry in the older format, with one `hostname` instead of the `hostnames`
list, loads as a list of that one name.
--- ---
@@ -636,11 +678,12 @@ docker run -d \
- Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes. - Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes.
- TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after DNS - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after DNS
completes. completes.
- Port and TLS checks always use freshly resolved IP addresses from the DNS - Port and TLS checks use the IP addresses found by the DNS phase that
phase that immediately precedes them — never stale IPs from a previous immediately precedes them. When that phase cannot find a name's
cycle, with one exception: when a name's CNAME chain cannot be followed, nameservers at all, the addresses an earlier check saved for the name are
or none of the name's nameservers answered, the addresses the previous used. When it cannot follow a name's CNAME chain, or none of the name's
cycle found at the end of the chain are used. nameservers answered, the addresses an earlier check found at the end of
the chain are used.
4. **On change detection**: Send notifications to all configured endpoints, 4. **On change detection**: Send notifications to all configured endpoints,
update in-memory state, persist to disk. update in-memory state, persist to disk.
5. **Shutdown**: The watcher stops checking and saves the final state to disk, 5. **Shutdown**: The watcher stops checking and saves the final state to disk,
@@ -689,29 +732,51 @@ file, so it survives a restart without an external database.
cmd/dnswatcher/main.go Entry point (uber/fx bootstrap) cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
internal/ internal/
config/config.go Viper-based configuration config/
config.go Viper-based configuration
classify.go Splits targets into domains and hostnames
(Public Suffix List)
globals/globals.go Build-time variables (version) globals/globals.go Build-time variables (version)
logger/logger.go slog structured logging (TTY detection) logger/logger.go slog structured logging (TTY detection)
healthcheck/healthcheck.go Health check service healthcheck/healthcheck.go Health check service
middleware/middleware.go HTTP middleware (logging, CORS, security middleware/middleware.go HTTP middleware (logging, CORS, security
headers, metrics auth and rate limit) headers, metrics auth and rate limit)
handlers/handlers.go HTTP request handlers handlers/
handlers.go Shared handler setup and JSON responses
dashboard.go Web dashboard
templates/dashboard.html Dashboard template (embedded)
status.go /api/v1/status
healthcheck.go Health check handler
server/ server/
server.go HTTP server lifecycle server.go HTTP server lifecycle
routes.go Route definitions routes.go Route definitions
state/state.go JSON file state persistence state/state.go JSON file state persistence
resolver/resolver.go Iterative DNS resolution engine resolver/
resolver.go Resolver setup and query status values
iterative.go Iterative DNS resolution engine
dns_client.go UDP and TCP DNS clients
errors.go Resolver errors
portcheck/portcheck.go TCP port connectivity checker portcheck/portcheck.go TCP port connectivity checker
tlscheck/tlscheck.go TLS certificate inspector tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy) notify/
watcher/watcher.go Main monitoring orchestrator and scheduler notify.go Notification service (Slack, Mattermost, ntfy)
retry.go Delivery retries with backoff
history.go Last 100 notifications, for the dashboard
shutdown.go Waits for deliveries at shutdown
watcher/
watcher.go Main monitoring orchestrator and scheduler
interfaces.go The resolver, checkers and notifier it uses
livednstest/livednstest.go Retry and concurrency limit for tests livednstest/livednstest.go Retry and concurrency limit for tests
against live DNS (imported only by tests) against live DNS (imported only by tests)
static/
static.go Embeds the CSS served under /s/
css/tailwind.min.css Dashboard stylesheet
``` ```
### Design Principles ### Design Principles
- **No recursive resolvers**: All DNS resolution is performed iteratively, - **No recursive resolvers**: The watched names are resolved iteratively,
tracing from root nameservers through the delegation chain to authoritative tracing from root nameservers through the delegation chain to authoritative
servers. servers.
- **No external database**: State is persisted as a single JSON file. - **No external database**: State is persisted as a single JSON file.
+7 -3
View File
@@ -19,8 +19,14 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-01: a watched name whose nameservers answer with a CNAME and no - 2026-10-02: a watched name whose nameservers answer with a CNAME and no
address gets port and TLS checks at the end of its CNAME chain (closes #203). address gets port and TLS checks at the end of its CNAME chain (closes #203).
- 2026-10-02: the resolver tries root servers, and every other server list it
walks, in a random order each time, not always from the top (closes #138).
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
letter case or with a trailing dot, is watched once (closes #207).
- 2026-10-01: README checked against the code and corrected: metrics, CORS,
notification retries, CNAMEs, state file fields, Design tree (closes #108).
- 2026-10-01: a certificate within the expiry warning period is warned about on - 2026-10-01: a certificate within the expiry warning period is warned about on
every TLS check, where some checks used to skip it at random (closes #204). every TLS check, where some checks used to skip it at random (closes #204).
- 2026-10-01: a domain's NS set is its delegation from the parent zone's - 2026-10-01: a domain's NS set is its delegation from the parent zone's
@@ -129,6 +135,4 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs: - 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66 https://git.eeqj.de/sneak/dnswatcher/issues/66
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 - review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
+7 -2
View File
@@ -57,17 +57,22 @@ func ClassifyDNSName(name string) (DNSNameType, error) {
// ClassifyTargets splits a list of DNS names into apex domains and // ClassifyTargets splits a list of DNS names into apex domains and
// hostnames using the Public Suffix List. It returns an error if any // hostnames using the Public Suffix List. It returns an error if any
// name cannot be classified. // name cannot be classified. A name given more than once, in any letter
// case or with a trailing dot, is kept once.
func ClassifyTargets(targets []string) ([]string, []string, error) { func ClassifyTargets(targets []string) ([]string, []string, error) {
var domains, hostnames []string var domains, hostnames []string
seen := make(map[string]bool)
for _, t := range targets { for _, t := range targets {
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(t), ".")) normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(t), "."))
if normalized == "" { if normalized == "" || seen[normalized] {
continue continue
} }
seen[normalized] = true
typ, classErr := ClassifyDNSName(normalized) typ, classErr := ClassifyDNSName(normalized)
if classErr != nil { if classErr != nil {
return nil, nil, classErr return nil, nil, classErr
+24
View File
@@ -1,6 +1,7 @@
package config_test package config_test
import ( import (
"slices"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/config"
@@ -93,6 +94,29 @@ func TestClassifyTargets(t *testing.T) {
} }
} }
func TestClassifyTargetsKeepsEachNameOnce(t *testing.T) {
t.Parallel()
domains, hostnames, err := config.ClassifyTargets([]string{
"example.org",
"Example.org.",
"www.example.org",
"EXAMPLE.ORG",
"WWW.Example.org.",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !slices.Equal(domains, []string{"example.org"}) {
t.Errorf("domains = %v, want [example.org]", domains)
}
if !slices.Equal(hostnames, []string{"www.example.org"}) {
t.Errorf("hostnames = %v, want [www.example.org]", hostnames)
}
}
func TestClassifyTargetsRejectsPublicSuffix(t *testing.T) { func TestClassifyTargetsRejectsPublicSuffix(t *testing.T) {
t.Parallel() t.Parallel()
+5 -5
View File
@@ -9,11 +9,11 @@
// //
// 1. Bounded concurrency. Tests run in parallel and the build hosts // 1. Bounded concurrency. Tests run in parallel and the build hosts
// have many cores, so without a limit every test starts its own // have many cores, so without a limit every test starts its own
// iterative resolution at the same instant and they all hit the // iterative resolution at the same instant and they all send their
// first root server within a few milliseconds of each other. Root // first queries to the root servers within a few milliseconds of
// servers rate-limit that, which shows up as a different arbitrary // each other. Root servers rate-limit that, which shows up as a
// subset of tests failing on each run. Run caps how many live // different arbitrary subset of tests failing on each run. Run caps
// operations are in flight at once in one test binary. // how many live operations are in flight at once in one test binary.
// //
// 2. Retry with exponential backoff. Each live operation gets several // 2. Retry with exponential backoff. Each live operation gets several
// attempts with its own timeout. An attempt is retried when it // attempts with its own timeout. An attempt is retried when it
+21
View File
@@ -36,3 +36,24 @@ func (r *Resolver) QueryEachNS(
) (map[string]*NameserverResponse, error) { ) (map[string]*NameserverResponse, error) {
return r.queryEachNS(ctx, nameservers, hostname) return r.queryEachNS(ctx, nameservers, hostname)
} }
// ResolveNSIPs exports resolveNSIPs for testing.
func (r *Resolver) ResolveNSIPs(
ctx context.Context,
nsNames []string,
) []string {
return r.resolveNSIPs(ctx, nsNames)
}
// RootServerList exports rootServerList for testing.
func RootServerList() []string {
return rootServerList()
}
// Shuffled exports shuffled for testing.
func Shuffled(
servers []string,
shuffle func(n int, swap func(i, j int)),
) []string {
return shuffled(servers, shuffle)
}
+26 -8
View File
@@ -4,7 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2"
"net" "net"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -259,9 +261,25 @@ func (r *Resolver) followDelegation(
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// queryServers asks servers, the servers of zone, about name until one // shuffled returns a copy of servers in the order shuffle puts them
// gives a usable reply. A server that times out, refuses or gives a // in. The resolver passes rand.Shuffle, so each time it walks a list of
// reply that is not usable is passed over for the next. // servers it starts at a random one, and no one server gets every
// first query.
func shuffled(
servers []string,
shuffle func(n int, swap func(i, j int)),
) []string {
order := slices.Clone(servers)
shuffle(len(order), func(i, j int) {
order[i], order[j] = order[j], order[i]
})
return order
}
// queryServers asks servers, the servers of zone, about name in a random
// order until one gives a usable reply. A server that times out, refuses
// or gives a reply that is not usable is passed over for the next.
func (r *Resolver) queryServers( func (r *Resolver) queryServers(
ctx context.Context, ctx context.Context,
servers []string, servers []string,
@@ -271,7 +289,7 @@ func (r *Resolver) queryServers(
) (*dns.Msg, error) { ) (*dns.Msg, error) {
var lastErr error var lastErr error
for _, ip := range servers { for _, ip := range shuffled(servers, rand.Shuffle) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
@@ -340,6 +358,10 @@ func nsSetFrom(resp *dns.Msg, domain string) []string {
return extractNSSet(resp.Answer) return extractNSSet(resp.Answer)
} }
// resolveNSIPs returns the addresses of every nameserver in nsNames
// whose name resolves, for a referral that carries none. The walk can
// then go on to the zone's other nameservers when one gives no usable
// reply.
func (r *Resolver) resolveNSIPs( func (r *Resolver) resolveNSIPs(
ctx context.Context, ctx context.Context,
nsNames []string, nsNames []string,
@@ -351,10 +373,6 @@ func (r *Resolver) resolveNSIPs(
if err == nil { if err == nil {
ips = append(ips, resolved...) ips = append(ips, resolved...)
} }
if len(ips) > 0 {
break
}
} }
return ips return ips
+29
View File
@@ -1,6 +1,8 @@
package resolver_test package resolver_test
import ( import (
"math/rand/v2"
"slices"
"testing" "testing"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -235,3 +237,30 @@ func TestExtractRecordValue_LetterCase(t *testing.T) {
}) })
} }
} }
// TestShuffled shuffles the root servers with many seeds. Every order
// must hold each root server once, so each is tried before a
// resolution fails; each root server must come first for some seed, so
// no one root server gets every first query; and the list passed in
// must be left as it was.
func TestShuffled(t *testing.T) {
t.Parallel()
const seeds = 1000
roots := resolver.RootServerList()
before := slices.Clone(roots)
first := make(map[string]bool)
for seed := range uint64(seeds) {
rng := rand.New(rand.NewPCG(seed, 0)) //nolint:gosec // seeded on purpose
order := resolver.Shuffled(roots, rng.Shuffle)
assert.ElementsMatch(t, roots, order)
first[order[0]] = true
}
assert.Len(t, first, len(roots))
assert.Equal(t, before, roots)
}
+34
View File
@@ -383,3 +383,37 @@ func liveResolveIPsAllowingEmpty(
return out return out
} }
// liveResolveNSIPs looks up the addresses of the nameservers named
// names, retrying until there are at least atLeast of them: a name
// whose lookup got no reply is left out of the result, not an error.
func liveResolveNSIPs(
t *testing.T,
r *resolver.Resolver,
names []string,
atLeast int,
) []string {
t.Helper()
var out []string
livednstest.Retry(
t,
"ResolveNSIPs("+strings.Join(names, ", ")+")",
func(ctx context.Context) error {
ips := r.ResolveNSIPs(ctx, names)
if len(ips) < atLeast {
return fmt.Errorf(
"%w: %d addresses, expected at least %d",
livednstest.ErrNoAnswer, len(ips), atLeast,
)
}
out = ips
return nil
},
)
return out
}
+22
View File
@@ -139,6 +139,28 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
} }
} }
// TestResolveNSIPs_EveryNameserver looks up the addresses of two of
// google.com's nameservers together, as the walk does when a referral
// names a zone's nameservers without their addresses, and compares them
// with each looked up alone. Together they must give the addresses of
// both, not only of the first that resolves, so that when one gives no
// usable reply the walk goes on to the other.
func TestResolveNSIPs_EveryNameserver(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
names := []string{"ns3.google.com.", "ns4.google.com."}
want := make([]string, 0, len(names))
for _, name := range names {
want = append(want, liveResolveNSIPs(t, r, []string{name}, 1)...)
}
got := liveResolveNSIPs(t, r, names, len(want))
assert.ElementsMatch(t, want, got)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// QueryNameserver tests // QueryNameserver tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
+3 -2
View File
@@ -55,10 +55,11 @@ type NameserverRecordState struct {
// HostnameState holds per-nameserver monitoring state for a hostname. // HostnameState holds per-nameserver monitoring state for a hostname.
// CNAMEAddresses holds the sorted addresses at the end of the name's // CNAMEAddresses holds the sorted addresses at the end of the name's
// CNAME chain, found when its nameservers answered with a CNAME and no // CNAME chain, found when its nameservers answered with a CNAME and no
// address; it is empty otherwise. // address; it is empty otherwise. It is nil when they are not known: a
// state file written before it existed loads with it nil.
type HostnameState struct { type HostnameState struct {
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"` RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
CNAMEAddresses []string `json:"cnameAddresses,omitempty"` CNAMEAddresses []string `json:"cnameAddresses"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
+89
View File
@@ -188,6 +188,95 @@ func TestLoadStateFromBeforeNameserverAddresses(t *testing.T) {
} }
} }
// TestSaveLoadRoundTrip_CNAMEAddresses checks that no addresses at the
// end of a hostname's CNAME chain load as an empty list, and addresses
// that are not known load as nil: the watcher tells the two apart.
func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) {
t.Parallel()
dir := t.TempDir()
s := state.NewForTestWithDataDir(dir)
want := map[string][]string{
"cname.example.com": {testIP},
"none.example.com": {},
"not-known.example.com": nil,
}
for name, addresses := range want {
s.SetHostnameState(name, &state.HostnameState{
CNAMEAddresses: addresses,
})
}
err := s.Save()
if err != nil {
t.Fatalf("Save() error: %v", err)
}
loaded := state.NewForTestWithDataDir(dir)
err = loaded.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
for name, addresses := range want {
hs, ok := loaded.GetHostnameState(name)
if !ok {
t.Fatalf("missing hostname %s", name)
}
if !reflect.DeepEqual(hs.CNAMEAddresses, addresses) {
t.Errorf(
"%s: loaded %#v, want %#v",
name, hs.CNAMEAddresses, addresses,
)
}
}
}
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
// before the addresses at the end of a hostname's CNAME chain were
// saved. They load as not known (nil), not as none.
func TestLoadStateFromBeforeCNAMEAddresses(t *testing.T) {
t.Parallel()
dir := t.TempDir()
data := []byte(`{
"version": 1,
"lastUpdated": "2026-02-19T12:00:00Z",
"hostnames": {
"www.example.com": {
"recordsByNameserver": {},
"lastChecked": "2026-02-19T12:00:00Z"
}
}
}`)
err := os.WriteFile(filepath.Join(dir, "state.json"), data, 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)
}
hs, ok := s.GetHostnameState(testHostname)
if !ok {
t.Fatal("missing hostname " + testHostname)
}
if hs.CNAMEAddresses != nil {
t.Errorf("CNAME addresses: got %#v, want nil", hs.CNAMEAddresses)
}
}
// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle. // TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) { func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
t.Parallel() t.Parallel()
+42 -3
View File
@@ -112,12 +112,13 @@ func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) {
// cnameState builds the state a check leaves behind for a name whose // cnameState builds the state a check leaves behind for a name whose
// nameserver answered with a CNAME and no address, when following the // nameserver answered with a CNAME and no address, when following the
// CNAME found these addresses. // CNAME found these addresses, which may be none.
func cnameState(addresses ...string) *state.HostnameState { func cnameState(addresses ...string) *state.HostnameState {
hs := hostnameState(map[string]map[string][]string{ hs := hostnameState(map[string]map[string][]string{
nsA: {"CNAME": {"target.example.org."}}, nsA: {"CNAME": {"target.example.org."}},
}) })
hs.CNAMEAddresses = addresses
hs.CNAMEAddresses = append([]string{}, addresses...)
return hs return hs
} }
@@ -125,6 +126,11 @@ func cnameState(addresses ...string) *state.HostnameState {
func TestCNAMEAddressChangeAlerts(t *testing.T) { func TestCNAMEAddressChangeAlerts(t *testing.T) {
t.Parallel() t.Parallel()
// A state file written before the addresses were saved loads with
// them nil.
olderStateFile := cnameState()
olderStateFile.CNAMEAddresses = nil
// Each case is the state saved by the previous check and by the // Each case is the state saved by the previous check and by the
// current one. The name's records are the same in both. // current one. The name's records are the same in both.
tests := []struct { tests := []struct {
@@ -152,9 +158,13 @@ func TestCNAMEAddressChangeAlerts(t *testing.T) {
"no address at the end of the chain now", "no address at the end of the chain now",
cnameState(ip1), cnameState(), 1, cnameState(ip1), cnameState(), 1,
}, },
{
"addresses at the end of the chain again",
cnameState(), cnameState(ip1), 1,
},
{ {
"state file from before addresses were saved", "state file from before addresses were saved",
cnameState(), cnameState(ip1), 0, olderStateFile, cnameState(ip1), 0,
}, },
} }
@@ -197,3 +207,32 @@ func TestCNAMEAddressChangeAlertNamesHostnameAndAddresses(t *testing.T) {
t.Errorf("sent %v, want %v", got, want) t.Errorf("sent %v, want %v", got, want)
} }
} }
// TestNameMovedFromARecordsToCNAMEAlerts checks a name that answers
// with an A record and then with a CNAME whose chain ends in ip2. The
// second check is notified as a CNAME address change from no addresses,
// beside the record change. Nothing is looked up: the watcher has no
// resolver.
func TestNameMovedFromARecordsToCNAMEAlerts(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
prev := hostnameState(map[string]map[string][]string{
nsA: {"A": {ip1}},
})
w.ResolveCNAMEAddresses(t.Context(), host, prev, nil)
w.DetectHostnameChanges(t.Context(), host, prev, cnameState(ip2))
title := "CNAME Address Change: " + host
message := "Hostname: " + host + "\nOld: \nNew: " + ip2
got := notifier.getNotifications()
if !slices.ContainsFunc(got, func(n notification) bool {
return n.Title == title && n.Message == message
}) {
t.Errorf("sent %v, want %q with %q among them", got, title, message)
}
}
+15 -10
View File
@@ -394,11 +394,11 @@ func (w *Watcher) checkHostname(
// resolveCNAMEAddresses saves in current the addresses at the end of // resolveCNAMEAddresses saves in current the addresses at the end of
// hostname's CNAME chain, when the nameservers' answers in current hold // hostname's CNAME chain, when the nameservers' answers in current hold
// a CNAME and no address. ResolveIPAddresses looks the name up again // a CNAME and no address, and an empty list otherwise.
// and follows the chain. The addresses saved in prev, which may be nil, // ResolveIPAddresses looks the name up again and follows the chain. The
// are kept when none of the name's nameservers answered, and when the // addresses saved in prev, which may be nil, are kept when none of the
// chain cannot be followed, as when no nameserver of a zone in it // name's nameservers answered, and when the chain cannot be followed, as
// answers. // when no nameserver of a zone in it answers.
func (w *Watcher) resolveCNAMEAddresses( func (w *Watcher) resolveCNAMEAddresses(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -409,6 +409,9 @@ func (w *Watcher) resolveCNAMEAddresses(
prevAddresses = prev.CNAMEAddresses prevAddresses = prev.CNAMEAddresses
} }
// Empty, not nil: nil means the addresses are not known.
current.CNAMEAddresses = []string{}
answered := false answered := false
hasCNAME := false hasCNAME := false
@@ -451,7 +454,9 @@ func (w *Watcher) resolveCNAMEAddresses(
return return
} }
current.CNAMEAddresses = ips // Appended to the empty list, so a chain that ends in no address is
// saved as empty, not nil.
current.CNAMEAddresses = append(current.CNAMEAddresses, ips...)
} }
// buildHostnameState saves each nameserver's response. A nameserver // buildHostnameState saves each nameserver's response. A nameserver
@@ -502,16 +507,16 @@ func (w *Watcher) detectHostnameChanges(
// detectCNAMEAddressChanges notifies when the addresses at the end of // detectCNAMEAddressChanges notifies when the addresses at the end of
// hostname's CNAME chain differ from those the previous check saved, // hostname's CNAME chain differ from those the previous check saved,
// including when there are none now. When the previous check saved // including a change from or to none. When the previous addresses are
// none, as in a state file from before they were saved, nothing is // not known (nil), as on the first check after loading a state file
// compared. // written before they were saved, nothing is compared.
func (w *Watcher) detectCNAMEAddressChanges( func (w *Watcher) detectCNAMEAddressChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev, current *state.HostnameState, prev, current *state.HostnameState,
) { ) {
old, cur := prev.CNAMEAddresses, current.CNAMEAddresses old, cur := prev.CNAMEAddresses, current.CNAMEAddresses
if len(old) == 0 || sliceEqual(old, cur) { if old == nil || sliceEqual(old, cur) {
return return
} }