Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb7d56cd9a | ||
|
|
82836b41fd | ||
|
|
af81f2ac76 | ||
|
|
56c4395a39 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user