README: correct claims the code does not bear out (closes #108)
check / check (push) Failing after 2m12s
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
This commit was merged in pull request #205.
This commit is contained in:
@@ -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
|
||||||
@@ -125,13 +125,16 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
### TCP Port Monitoring
|
### TCP Port Monitoring
|
||||||
|
|
||||||
- For every configured domain and hostname, constructs a deduplicated list of
|
- For every configured domain and hostname, constructs a deduplicated list of
|
||||||
all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution
|
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
|
||||||
across all authoritative nameservers.
|
nameservers returned. A CNAME is not followed: a name whose CNAME points into
|
||||||
|
another zone usually has no addresses here, so its ports and certificate are
|
||||||
|
not checked.
|
||||||
- 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
|
||||||
@@ -139,14 +142,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.
|
||||||
@@ -178,15 +181,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
|
||||||
@@ -230,7 +243,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
|
||||||
|
|
||||||
@@ -321,8 +343,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` |
|
||||||
|
|
||||||
@@ -373,13 +395,15 @@ 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.
|
||||||
|
|
||||||
@@ -388,10 +412,13 @@ 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.
|
|
||||||
|
|
||||||
For hostname monitoring, the resolver follows CNAME chains (with a depth limit
|
CNAME chains are followed (with a depth limit to prevent loops) only to find the
|
||||||
to prevent loops) before collecting terminal A/AAAA records.
|
addresses of nameservers. A watched name's records are stored as its nameservers
|
||||||
|
return them, CNAME included, without following it.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -473,12 +500,17 @@ 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.
|
||||||
|
|
||||||
|
A port entry in the older format, with one `hostname` instead of the `hostnames`
|
||||||
|
list, loads as a list of that one name.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
@@ -616,9 +648,10 @@ 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.
|
nameservers at all, the addresses an earlier check saved for the name 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,
|
||||||
@@ -667,29 +700,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.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
|
- 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).
|
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 +131,5 @@ 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
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user