From 4c2932d6d67a553e46f0435b9953487efa9d4bbb Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Fri, 2 Oct 2026 00:19:30 +0200
Subject: [PATCH] fmt: format and check Markdown with prettier in a container
(closes #119)
make fmt and make fmt-check now cover every Markdown file with prettier
(4-space tabs, proseWrap always), as template-app-go does: prettier is
pinned by package.json and yarn.lock and runs in a docker build on a
digest-pinned node image, never on the host. The check is forced to run
with --no-cache-filter, as script/lint is.
script/fmt-check is split into a Go half and a Markdown half because the
Dockerfile lint stage cannot run docker: that stage now runs the Go half
and script/cibuild runs the Markdown half after the build. *.md leaves
.dockerignore so documents reach the build context.
README.md, TESTING.md and TODO.md are reformatted by make fmt; apart from
the README Entrypoints entries, its make fmt line under Building and the
TODO entry, that diff is mechanical.
Model: opus-5-5
---
.dockerignore | 5 +-
.gitignore | 1 +
.prettierignore | 5 +
.prettierrc | 4 +
Dockerfile | 6 +-
Dockerfile.fmt | 55 +++
README.md | 692 +++++++++++++++++++-------------------
TESTING.md | 53 ++-
TODO.md | 21 +-
package.json | 9 +
script/bootstrap | 14 +-
script/cibuild | 13 +-
script/fmt | 50 ++-
script/fmt-check | 23 +-
script/fmt-check-go | 31 ++
script/fmt-check-markdown | 29 ++
yarn.lock | 8 +
17 files changed, 602 insertions(+), 417 deletions(-)
create mode 100644 .prettierignore
create mode 100644 .prettierrc
create mode 100644 Dockerfile.fmt
create mode 100644 package.json
create mode 100755 script/fmt-check-go
create mode 100755 script/fmt-check-markdown
create mode 100644 yarn.lock
diff --git a/.dockerignore b/.dockerignore
index bae18ae..0e93e56 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,9 @@
.git/
bin/
-*.md
+node_modules/
+# No .md may be excluded: Dockerfile.fmt checks every document with
+# prettier, and an exclusion here would drop a file from that check while
+# prettier still reports every file it was handed clean.
LICENSE
.editorconfig
.gitignore
diff --git a/.gitignore b/.gitignore
index 9c22cd8..d0077e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
bin/
+node_modules/
vendor/
data/
.env
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..c8d365b
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,5 @@
+bin/
+data/
+node_modules/
+.claude/
+static/css/tailwind.min.css
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..8af31cd
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "tabWidth": 4,
+ "proseWrap": "always"
+}
diff --git a/Dockerfile b/Dockerfile
index a76cbb6..c289c07 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,9 @@
# Lint stage - fast feedback on lint issues, before the build starts.
# The linter is invoked directly rather than through `make lint`: that
# target shells out to `docker build -f Dockerfile.lint`, and there is
-# no docker daemon inside a docker build.
+# no docker daemon inside a docker build. For the same reason this stage
+# runs only the Go half of `make fmt-check`; script/cibuild runs the
+# markdown half after this build.
# script/cibuild and script/docker name this stage in --no-cache-filter.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
@@ -12,7 +14,7 @@ RUN go mod download
COPY . .
-RUN make fmt-check
+RUN script/fmt-check-go
RUN golangci-lint run --config .golangci.yml ./...
# Build stage
diff --git a/Dockerfile.fmt b/Dockerfile.fmt
new file mode 100644
index 0000000..be7d0ba
--- /dev/null
+++ b/Dockerfile.fmt
@@ -0,0 +1,55 @@
+# prettier over the markdown, in a container, so it is never installed
+# on the host. script/fmt-check-markdown builds the fmt-check stage;
+# script/fmt builds fmt-out and takes the formatted files back.
+
+# node:22-bookworm-slim, 2026-09-05
+FROM node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS nodedeps
+
+# prettier lives outside /src so that a `COPY . .` of the repo cannot
+# overwrite it, and so that node_modules never appears in the tree
+# prettier is about to walk.
+WORKDIR /tools
+
+# package.json pins the version and yarn.lock pins the bytes:
+# --frozen-lockfile installs exactly the lockfile's resolution and fails
+# if package.json disagrees with it, so the tool cannot float between
+# runs. yarn is the one in the image above.
+COPY package.json yarn.lock ./
+RUN yarn install --frozen-lockfile --non-interactive --no-progress
+
+ENV PATH="/tools/node_modules/.bin:${PATH}"
+
+WORKDIR /src
+
+# Read-only markdown check. Must match $stage in
+# script/fmt-check-markdown.
+FROM nodedeps AS fmt-check
+
+COPY . .
+
+# --config, not discovery: a .prettierrc that failed to arrive would
+# otherwise leave prettier on its defaults, where proseWrap is "preserve"
+# and every wrap this check exists to enforce passes. Missing the file is
+# a hard error instead. --no-editorconfig for the same reason in reverse:
+# .editorconfig is not in the build context, so honouring it here and on
+# a developer's machine would be two different answers.
+RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md"
+
+# Write path. Not a check: script/fmt builds this and takes the files.
+FROM nodedeps AS fmt
+
+COPY . .
+
+RUN prettier --config .prettierrc --no-editorconfig --write "**/*.md"
+
+# Only the markdown leaves, with its paths intact, so that the export
+# below cannot put anything else back over the caller's working tree.
+RUN mkdir -p /out && cd /src && \
+ find . -name '*.md' -type f -exec cp --parents '{}' /out/ ';'
+
+# Export target: `docker build --target fmt-out --output type=local`
+# writes /out's tree into a directory on the client, which is how
+# script/fmt gets formatted markdown back without a bind mount.
+# Must match $stage in script/fmt.
+FROM scratch AS fmt-out
+COPY --from=fmt /out/ /
diff --git a/README.md b/README.md
index 4e8c13d..c5461fb 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,20 @@
# dnswatcher
-dnswatcher is an MIT-licensed, pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks.
+dnswatcher is an MIT-licensed, pre-1.0 Go daemon by
+[@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability,
+and TLS certificates, delivering real-time change notifications via Slack,
+Mattermost, and ntfy webhooks.
-> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice.
+> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without
+> notice.
-dnswatcher watches configured DNS domains and hostnames for changes, monitors TCP
-port availability, tracks TLS certificate expiry, and delivers real-time
+dnswatcher watches configured DNS domains and hostnames for changes, monitors
+TCP port availability, tracks TLS certificate expiry, and delivers real-time
notifications via Slack, Mattermost, and/or ntfy webhooks.
It performs all DNS resolution itself via iterative (non-recursive) queries,
-tracing from root nameservers to authoritative servers directly—never relying
-on upstream recursive resolvers.
+tracing from root nameservers to authoritative servers directly—never relying on
+upstream recursive resolvers.
State is persisted to a local JSON file so that monitoring survives restarts
without requiring an external database.
@@ -19,21 +23,20 @@ without requiring an external database.
## No DNS mocking. Ever.
-**DNS is never mocked in this project — not in tests, not anywhere else.**
-No mock resolvers, no fake DNS servers, no stubbed lookups.
+**DNS is never mocked in this project — not in tests, not anywhere else.** No
+mock resolvers, no fake DNS servers, no stubbed lookups.
-dnswatcher's entire purpose is correct behavior against the real DNS.
-Tests exercise real iterative resolution against live nameservers by
-design; a test suite that passes against a mock proves nothing about the
-one thing this program exists to do.
+dnswatcher's entire purpose is correct behavior against the real DNS. Tests
+exercise real iterative resolution against live nameservers by design; a test
+suite that passes against a mock proves nothing about the one thing this program
+exists to do.
-When live tests are flaky, that is a robustness problem, and it gets
-fixed with robustness: retries with backoff, querying multiple
-independent nameservers, longer timeouts — or explicit opt-in gating
-decided by the project owner. Never with mocks.
+When live tests are flaky, that is a robustness problem, and it gets fixed with
+robustness: retries with backoff, querying multiple independent nameservers,
+longer timeouts — or explicit opt-in gating decided by the project owner. Never
+with mocks.
-Contributions that introduce mocked, faked, or stubbed DNS will be
-rejected.
+Contributions that introduce mocked, faked, or stubbed DNS will be rejected.
---
@@ -46,126 +49,123 @@ rejected.
- Every **1 hour**, performs a full iterative trace from root servers to
discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently.
-- Stores the NS record set as observed by the delegation chain, and the
- IPv4 and IPv6 addresses each nameserver's name resolves to.
+- Stores the NS record set as observed by the delegation chain, and the IPv4 and
+ IPv6 addresses each nameserver's name resolves to.
- Any change triggers a notification:
- - NS added to or removed from the delegation.
- - NS address change: a nameserver that stays in the delegation
- resolves to different addresses than on the previous check. A
- nameserver added or removed gets only the NS change notification.
- When the lookup of a nameserver's addresses fails or finds none,
- its previous addresses are kept and nothing is sent.
+ - NS added to or removed from the delegation.
+ - NS address change: a nameserver that stays in the delegation resolves to
+ different addresses than on the previous check. A nameserver added or
+ removed gets only the NS change notification. When the lookup of a
+ nameserver's addresses fails or finds none, its previous addresses are
+ kept and nothing is sent.
### DNS Hostname Monitoring (Subdomains)
-- Accepts a list of DNS hostnames (subdomains, distinguished from apex
- domains via the Public Suffix List).
+- Accepts a list of DNS hostnames (subdomains, distinguished from apex domains
+ via the Public Suffix List).
- Every **1 hour**, performs a full iterative trace to discover the
- 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).
-- Queries **each** authoritative nameserver independently for **all**
- record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
-- Stores results **per nameserver**. The state for a hostname is not a
- merged view — it is a map from nameserver to record set.
-- DNS names inside record values (CNAME, MX, SRV and NS targets) are
- stored in lower case, because names are case-insensitive and
- nameservers may answer in any letter case. TXT and CAA values keep
- their letter case; they are not lower-cased.
-- Any observable change in any nameserver's response triggers a
- notification. This includes:
- - **Record change**: A nameserver returns different records than it
- did on the previous check (additions, removals, value changes).
- - **NS query failure**: A nameserver that previously responded
- becomes unreachable (timeout, SERVFAIL, REFUSED, network error).
- This is distinct from "responded with no records": a nameserver
- that answers NXDOMAIN or with no records has responded. The alert
- is sent once, on the check where it starts failing. A failing
- nameserver gives no records, so it is not reported as a record
- change or compared for inconsistency. A nameserver that is already
- failing on the first check that sees it is recorded silently.
- - **NS recovery**: A previously-unreachable nameserver starts
- responding again. Its records are not compared with those from
- before it failed, so a change made while it was failing is not
- reported as a record change.
- - **Inconsistency detected**: Two nameservers return different record
- sets for the same hostname and did not already differ on the previous
- check. Every pair of nameservers is compared. The alert is sent once
- for each such pair, on the check where they start to disagree, and not
- again while they keep disagreeing, including after a restart. A
- nameserver that was not in the previous check (newly added, or back
- after dropping out), or 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 again.
+ 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).
+- Queries **each** authoritative nameserver independently for **all** record
+ types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
+- Stores results **per nameserver**. The state for a hostname is not a merged
+ view — it is a map from nameserver to record set.
+- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
+ lower case, because names are case-insensitive and nameservers may answer in
+ any letter case. TXT and CAA values keep their letter case; they are not
+ lower-cased.
+- Any observable change in any nameserver's response triggers a notification.
+ This includes:
+ - **Record change**: A nameserver returns different records than it did on
+ the previous check (additions, removals, value changes).
+ - **NS query failure**: A nameserver that previously responded becomes
+ unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct
+ from "responded with no records": a nameserver that answers NXDOMAIN or
+ with no records has responded. The alert is sent once, on the check where
+ it starts failing. A failing nameserver gives no records, so it is not
+ reported as a record change or compared for inconsistency. A nameserver
+ that is already failing on the first check that sees it is recorded
+ silently.
+ - **NS recovery**: A previously-unreachable nameserver starts responding
+ again. Its records are not compared with those from before it failed, so a
+ change made while it was failing is not reported as a record change.
+ - **Inconsistency detected**: Two nameservers return different record sets
+ for the same hostname and did not already differ on the previous check.
+ Every pair of nameservers is compared. The alert is sent once for each
+ such pair, on the check where they start to disagree, and not again while
+ they keep disagreeing, including after a restart. A nameserver that was
+ not in the previous check (newly added, or back after dropping out), or
+ 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
+ again.
### TCP Port Monitoring
-- For every configured domain and hostname, constructs a deduplicated list
- of all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain
- resolution across all authoritative nameservers.
+- For every configured domain and hostname, constructs a deduplicated list of
+ all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution
+ across all authoritative nameservers.
- Checks TCP connectivity on ports **80** and **443** for each IP address.
- Every **1 hour**, re-checks all ports.
- Any change in port availability triggers a notification:
- - Port transitioned from open to closed (or vice versa).
- - New IP appeared (from DNS change) and its port state was recorded.
- - IP disappeared (from DNS change) — noted in the DNS change
- notification; port state for that IP is removed.
+ - Port transitioned from open to closed (or vice versa).
+ - New IP appeared (from DNS change) and its port state was recorded.
+ - IP disappeared (from DNS change) — noted in the DNS change notification;
+ port state for that IP is removed.
### TLS Certificate Monitoring
-- Every **12 hours**, for each IP address listening on port 443, connects
- via TLS using the correct SNI hostname.
+- Every **12 hours**, for each IP address listening on port 443, connects via
+ TLS using the correct SNI hostname.
- Records the certificate's Subject CN, SANs, issuer, and expiry date.
- Any change triggers a notification:
- - Certificate is expiring within **7 days** (warning, repeated each
- check until renewed or expired).
- - Certificate CN, issuer, or SANs changed (replacement detected,
- reports old and new values).
- - TLS connection failure to a previously-reachable IP:443 (handshake
- error, timeout, connection refused after previously succeeding).
- - TLS recovery: a previously-failing IP:443 now completes a
- handshake again.
+ - Certificate is expiring within **7 days** (warning, repeated each check
+ until renewed or expired).
+ - Certificate CN, issuer, or SANs changed (replacement detected, reports old
+ and new values).
+ - TLS connection failure to a previously-reachable IP:443 (handshake error,
+ timeout, connection refused after previously succeeding).
+ - TLS recovery: a previously-failing IP:443 now completes a handshake again.
### Notifications
**Every observable state change produces a notification.** dnswatcher is
-designed as a real-time change feed — degradations, failures, recoveries,
-and routine changes are all reported equally.
+designed as a real-time change feed — degradations, failures, recoveries, and
+routine changes are all reported equally.
Supported notification backends:
-| Backend | Configuration | Payload Format |
-|----------------|--------------------------|------------------------------|
-| **Slack** | Incoming Webhook URL | Attachments with color |
-| **Mattermost** | Incoming Webhook URL | Slack-compatible attachments |
-| **ntfy** | Topic URL (e.g. `https://ntfy.sh/mytopic`) | Title + body + priority |
+| Backend | Configuration | Payload Format |
+| -------------- | ------------------------------------------ | ---------------------------- |
+| **Slack** | Incoming Webhook URL | Attachments with color |
+| **Mattermost** | Incoming Webhook URL | Slack-compatible attachments |
+| **ntfy** | Topic URL (e.g. `https://ntfy.sh/mytopic`) | Title + body + priority |
All configured endpoints receive every notification. Notification content
includes:
-- **DNS record changes**: Which hostname, which nameserver, what record
- type, old values, new values.
+- **DNS record changes**: Which hostname, which nameserver, what record type,
+ old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added/removed.
-- **NS address changes**: Which domain, which nameserver, its old and
- new addresses.
-- **NS query failures**: Which nameserver failed, error type (timeout,
- SERVFAIL, REFUSED, network error), which hostname/domain affected.
+- **NS address changes**: Which domain, which nameserver, its old and new
+ addresses.
+- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
+ REFUSED, network error), which hostname/domain affected.
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
-- **NS inconsistencies**: Which nameservers disagree, what each one
- returned, which hostname affected.
+- **NS inconsistencies**: Which nameservers disagree, what each one returned,
+ which hostname affected.
- **Port changes**: Which IP:port, old state, new state, all associated
hostnames.
-- **TLS expiry warnings**: Which certificate, days remaining, CN,
- issuer, associated hostname and IP.
-- **TLS certificate changes**: Old and new CN/issuer/SANs, associated
- hostname and IP.
+- **TLS expiry warnings**: Which certificate, days remaining, CN, issuer,
+ associated hostname and IP.
+- **TLS certificate changes**: Old and new CN/issuer/SANs, associated hostname
+ and IP.
- **TLS connection failures/recoveries**: Which IP:port, error details,
associated hostname.
### State Management
-- All monitoring state is kept in memory and persisted to a JSON file on
- disk (`DATA_DIR/state.json`).
+- All monitoring state is kept in memory and persisted to a JSON file on disk
+ (`DATA_DIR/state.json`).
- State is loaded on startup to resume monitoring without triggering
false-positive change notifications.
- State is written atomically (write to temp file, then rename) to prevent
@@ -173,41 +173,39 @@ includes:
### Web Dashboard
-dnswatcher includes an unauthenticated, read-only web dashboard at the
-root URL (`/`). It displays:
+dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
+(`/`). It displays:
-- **Summary counts** for monitored domains, hostnames, ports, and
- certificates.
+- **Summary counts** for monitored domains, hostnames, ports, and certificates.
- **Domains** with their discovered nameservers.
- **Hostnames** with per-nameserver DNS records and status.
- **Ports** with open/closed state and associated hostnames.
- **TLS certificates** with CN, issuer, expiry, and status.
-- **Recent alerts** (last 100 notifications sent since the process
- started), displayed in reverse chronological order.
+- **Recent alerts** (last 100 notifications sent since the process started),
+ displayed in reverse chronological order.
-Every data point shows its age (e.g. "5m ago") so you can tell at a
-glance how fresh the information is. The page auto-refreshes every 30
-seconds.
+Every data point shows its age (e.g. "5m ago") so you can tell at a glance how
+fresh the information is. The page auto-refreshes every 30 seconds.
-The dashboard intentionally does not expose any configuration details
-such as webhook URLs, notification endpoints, or API tokens.
+The dashboard intentionally does not expose any configuration details such as
+webhook URLs, notification endpoints, or API tokens.
-All assets (CSS) are embedded in the binary and served from the
-application itself. The dashboard makes zero external HTTP requests —
-no CDN dependencies or third-party resources are loaded at runtime.
+All assets (CSS) are embedded in the binary and served from the application
+itself. The dashboard makes zero external HTTP requests — no CDN dependencies or
+third-party resources are loaded at runtime.
### HTTP API
dnswatcher exposes a lightweight HTTP API for operational visibility:
-| Endpoint | Description |
-|---------------------------------------|--------------------------------|
-| `GET /` | Web dashboard (HTML) |
-| `GET /s/...` | Static assets (embedded CSS) |
-| `GET /.well-known/healthcheck` | Health check (JSON) |
-| `GET /health` | Health check (JSON, legacy) |
-| `GET /api/v1/status` | Current monitoring state |
-| `GET /metrics` | Prometheus metrics (optional) |
+| Endpoint | Description |
+| ------------------------------ | ----------------------------- |
+| `GET /` | Web dashboard (HTML) |
+| `GET /s/...` | Static assets (embedded CSS) |
+| `GET /.well-known/healthcheck` | Health check (JSON) |
+| `GET /health` | Health check (JSON, legacy) |
+| `GET /api/v1/status` | Current monitoring state |
+| `GET /metrics` | Prometheus metrics (optional) |
#### Server timeouts
@@ -216,7 +214,7 @@ constants in `internal/server/server.go`, not configurable via environment
variables.
| Timeout | Value | Purpose |
-|---------------------|-------|-----------------------------------------------|
+| ------------------- | ----- | --------------------------------------------- |
| `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) |
| `ReadTimeout` | 15s | Bounds the whole request read, headers + body |
| `WriteTimeout` | 75s | Bounds handler execution plus response flush |
@@ -224,20 +222,20 @@ variables.
These are distinct from the 60s per-request handler budget applied by
`chimw.Timeout` in `internal/server/routes.go`, which cancels the request
-context but does not touch the socket. `WriteTimeout` is deliberately
-larger than that budget: the write deadline is armed once request headers
-are read, so a smaller value would sever the connection before a handler
-using its full budget could respond. `IdleTimeout` exceeds common
-Prometheus scrape intervals so the scraper reuses its connection.
+context but does not touch the socket. `WriteTimeout` is deliberately larger
+than that budget: the write deadline is armed once request headers are read, so
+a smaller value would sever the connection before a handler using its full
+budget could respond. `IdleTimeout` exceeds common Prometheus scrape intervals
+so the scraper reuses its connection.
### Security Headers
Every response — the dashboard, the static assets under `/s/...`, the
-healthchecks, the JSON API, and `/metrics` — carries the following
-headers, set by a global middleware:
+healthchecks, the JSON API, and `/metrics` — carries the following headers, set
+by a global middleware:
| Header | Value |
-|-----------------------------|---------------------------------------|
+| --------------------------- | ------------------------------------- |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
| `Content-Security-Policy` | see below |
| `X-Frame-Options` | `DENY` |
@@ -254,21 +252,21 @@ form-action 'none'; frame-ancestors 'none'
```
The dashboard ships no JavaScript (the 30-second refresh is a
-``), no inline styles, no inline event
-handlers, and no images; its only subresource is the embedded stylesheet
-at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The
-policy therefore needs neither `unsafe-inline` nor `unsafe-eval`.
+``), no inline styles, no inline event handlers, and
+no images; its only subresource is the embedded stylesheet at
+`/s/css/tailwind.min.css`, which `style-src 'self'` permits. The policy
+therefore needs neither `unsafe-inline` nor `unsafe-eval`.
`frame-ancestors 'none'` is the primary anti-framing control, with
`X-Frame-Options: DENY` retained as the legacy fallback.
HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is
-expected to run behind a TLS-terminating reverse proxy, and the browser
-must still be told to enforce HTTPS end to end, so the header is never
-gated on whether the request itself arrived over TLS.
+expected to run behind a TLS-terminating reverse proxy, and the browser must
+still be told to enforce HTTPS end to end, so the header is never gated on
+whether the request itself arrived over TLS.
`Referrer-Policy: no-referrer` is stricter than the
-`strict-origin-when-cross-origin` baseline: the dashboard has no
-cross-origin navigation needs, and its URL may name internal hosts.
+`strict-origin-when-cross-origin` baseline: the dashboard has no cross-origin
+navigation needs, and its URL may name internal hosts.
---
@@ -301,23 +299,23 @@ internal/
### Design Principles
- **No recursive resolvers**: All DNS resolution is performed iteratively,
- tracing from root nameservers through the delegation chain to
- authoritative servers.
+ tracing from root nameservers through the delegation chain to authoritative
+ servers.
- **No external database**: State is persisted as a single JSON file.
- **Dependency injection**: All components are wired via
[uber/fx](https://github.com/uber-go/fx).
-- **Structured logging**: All logs use `log/slog` with JSON output in
- production (TTY detection for development).
-- **Graceful shutdown**: All background goroutines respect context
- cancellation and the fx lifecycle. In-flight notification deliveries
- are drained on shutdown, bounded by the shutdown timeout.
+- **Structured logging**: All logs use `log/slog` with JSON output in production
+ (TTY detection for development).
+- **Graceful shutdown**: All background goroutines respect context cancellation
+ and the fx lifecycle. In-flight notification deliveries are drained on
+ shutdown, bounded by the shutdown timeout.
---
## Configuration
-Configuration is loaded via [Viper](https://github.com/spf13/viper) with
-the following precedence (highest to lowest):
+Configuration is loaded via [Viper](https://github.com/spf13/viper) with the
+following precedence (highest to lowest):
1. Environment variables (prefixed with `DNSWATCHER_`)
2. `.env` file (loaded via godotenv)
@@ -327,46 +325,45 @@ the following precedence (highest to lowest):
### Environment Variables
-| Variable | Description | Default |
-|---------------------------------|--------------------------------------------|-------------|
-| `PORT` | HTTP listen port | `8080` |
-| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
-| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
-| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
-| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
-| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
-| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
-| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` |
-| `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_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
-| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
-| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
-| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
-| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
+| Variable | Description | Default |
+| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- |
+| `PORT` | HTTP listen port | `8080` |
+| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
+| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
+| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
+| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
+| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
+| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
+| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` |
+| `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_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
+| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
+| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
+| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
+| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
monitoring targets are configured. A monitoring daemon with nothing to monitor
is a misconfiguration, so dnswatcher fails fast with a clear error message
-rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated
-list of DNS names before starting.
+rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated list
+of DNS names before starting.
**`/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`
without checking the password. A Prometheus server scraping every 15 seconds
sends 4 a minute. IPv6 addresses in one /64 count as one client. When the
-request comes from a private or loopback address, such as a reverse proxy's,
-the client address is taken from the `X-Real-IP` header the proxy sets, or else
-from `X-Forwarded-For`, as the last address in it that is not private or
-loopback. A proxy that sets neither makes all its clients share one allowance.
+request comes from a private or loopback address, such as a reverse proxy's, the
+client address is taken from the `X-Real-IP` header the proxy sets, or else from
+`X-Forwarded-For`, as the last address in it that is not private or loopback. A
+proxy that sets neither makes all its clients share one allowance.
**`DNSWATCHER_DNS_INTERVAL` and `DNSWATCHER_TLS_INTERVAL`** take a positive
duration: a number followed by a unit such as `s`, `m` or `h`, for example
-`90s`, `30m`, `1h` or `1h30m`. There is no unit for days; write `24h`. An
-unset or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If
-either is set to anything else, including a bare number or a zero or negative
-duration, dnswatcher refuses to start with an error naming the variable and
-the value.
+`90s`, `30m`, `1h` or `1h30m`. There is no unit for days; write `24h`. An unset
+or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If either is
+set to anything else, including a bare number or a zero or negative duration,
+dnswatcher refuses to start with an error naming the variable and the value.
**`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it
is set, a panic in an HTTP request handler is sent to Sentry, and the request
@@ -392,107 +389,107 @@ DNSWATCHER_SEND_TEST_NOTIFICATION=true
## DNS Resolution Strategy
-dnswatcher never uses the system's configured recursive resolver. Instead,
-it performs full iterative resolution:
+dnswatcher never uses the system's configured recursive resolver. Instead, it
+performs full iterative resolution:
-1. **Root servers**: Starts from the IANA root nameserver list (hardcoded,
- with periodic refresh).
+1. **Root servers**: Starts from the IANA root nameserver list (hardcoded, with
+ periodic refresh).
2. **TLD delegation**: Queries root servers for the TLD NS records.
-3. **Domain delegation**: Queries TLD nameservers for the domain's NS
- records.
-4. **Authoritative query**: Queries all discovered authoritative
- nameservers directly for the requested records.
+3. **Domain delegation**: Queries TLD nameservers for the domain's NS records.
+4. **Authoritative query**: Queries all discovered authoritative nameservers
+ directly for the requested records.
This approach ensures:
+
- Independence from any upstream resolver's cache or filtering.
-- Ability to detect split-horizon or inconsistent responses across
- authoritative servers.
+- Ability to detect split-horizon or inconsistent responses across authoritative
+ servers.
- Visibility into the full delegation chain.
-For hostname monitoring, the resolver follows CNAME chains (with a
-depth limit to prevent loops) before collecting terminal A/AAAA records.
+For hostname monitoring, the resolver follows CNAME chains (with a depth limit
+to prevent loops) before collecting terminal A/AAAA records.
---
## State File Format
The state file (`DATA_DIR/state.json`) contains the complete monitoring
-snapshot. Hostname records are stored **per authoritative nameserver**,
-not as a merged view, to enable inconsistency detection.
+snapshot. Hostname records are stored **per authoritative nameserver**, not as a
+merged view, to enable inconsistency detection.
```json
{
- "version": 1,
- "lastUpdated": "2026-02-19T12:00:00Z",
- "domains": {
- "example.com": {
- "nameservers": ["ns1.example.com.", "ns2.example.com."],
- "nameserverAddresses": {
- "ns1.example.com.": ["192.0.2.53", "2001:db8::53"],
- "ns2.example.com.": ["198.51.100.53"]
- },
- "lastChecked": "2026-02-19T12:00:00Z"
- }
- },
- "hostnames": {
- "www.example.com": {
- "recordsByNameserver": {
- "ns1.example.com.": {
- "records": {
- "A": ["93.184.216.34"],
- "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"]
- },
- "status": "ok",
- "lastChecked": "2026-02-19T12:00:00Z"
- },
- "ns2.example.com.": {
- "records": {
- "A": ["93.184.216.34"],
- "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"]
- },
- "status": "ok",
- "lastChecked": "2026-02-19T12:00:00Z"
+ "version": 1,
+ "lastUpdated": "2026-02-19T12:00:00Z",
+ "domains": {
+ "example.com": {
+ "nameservers": ["ns1.example.com.", "ns2.example.com."],
+ "nameserverAddresses": {
+ "ns1.example.com.": ["192.0.2.53", "2001:db8::53"],
+ "ns2.example.com.": ["198.51.100.53"]
+ },
+ "lastChecked": "2026-02-19T12:00:00Z"
}
- },
- "lastChecked": "2026-02-19T12:00:00Z"
- }
- },
- "ports": {
- "93.184.216.34:80": {
- "open": true,
- "hostnames": ["www.example.com"],
- "lastChecked": "2026-02-19T12:00:00Z"
},
- "93.184.216.34:443": {
- "open": true,
- "hostnames": ["www.example.com"],
- "lastChecked": "2026-02-19T12:00:00Z"
+ "hostnames": {
+ "www.example.com": {
+ "recordsByNameserver": {
+ "ns1.example.com.": {
+ "records": {
+ "A": ["93.184.216.34"],
+ "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"]
+ },
+ "status": "ok",
+ "lastChecked": "2026-02-19T12:00:00Z"
+ },
+ "ns2.example.com.": {
+ "records": {
+ "A": ["93.184.216.34"],
+ "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"]
+ },
+ "status": "ok",
+ "lastChecked": "2026-02-19T12:00:00Z"
+ }
+ },
+ "lastChecked": "2026-02-19T12:00:00Z"
+ }
+ },
+ "ports": {
+ "93.184.216.34:80": {
+ "open": true,
+ "hostnames": ["www.example.com"],
+ "lastChecked": "2026-02-19T12:00:00Z"
+ },
+ "93.184.216.34:443": {
+ "open": true,
+ "hostnames": ["www.example.com"],
+ "lastChecked": "2026-02-19T12:00:00Z"
+ }
+ },
+ "certificates": {
+ "93.184.216.34:443:www.example.com": {
+ "commonName": "www.example.com",
+ "issuer": "DigiCert TLS RSA SHA256 2020 CA1",
+ "notAfter": "2027-01-15T23:59:59Z",
+ "subjectAlternativeNames": ["www.example.com"],
+ "status": "ok",
+ "lastChecked": "2026-02-19T06:00:00Z"
+ }
}
- },
- "certificates": {
- "93.184.216.34:443:www.example.com": {
- "commonName": "www.example.com",
- "issuer": "DigiCert TLS RSA SHA256 2020 CA1",
- "notAfter": "2027-01-15T23:59:59Z",
- "subjectAlternativeNames": ["www.example.com"],
- "status": "ok",
- "lastChecked": "2026-02-19T06:00:00Z"
- }
- }
}
```
-The `status` field for each per-nameserver entry and certificate entry
-tracks reachability:
+The `status` field for each per-nameserver entry and certificate entry tracks
+reachability:
-| Status | Meaning |
-|-------------|------------------------------------------------------------|
-| `ok` | Query succeeded, records are current |
-| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
+| Status | Meaning |
+| ------- | -------------------------------------------------------- |
+| `ok` | Query succeeded, records are current |
+| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
-A nameserver that answers NXDOMAIN or with no records has status `ok` and
-empty `records`. A nameserver whose query failed has status `error`, empty
-`records`, and the reason in `error`.
+A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
+`records`. A nameserver whose query failed has status `error`, empty `records`,
+and the reason in `error`.
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
resolves to. A state file without it loads, and the next check fills it in
@@ -505,31 +502,38 @@ without a notification.
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
-development workflow, and the Makefile targets are thin shims that call
-them. We provide:
+development workflow, and the Makefile targets are thin shims that call them. We
+provide:
-- `script/bootstrap` — install all dependencies (go, `go mod download`).
- It does not install golangci-lint: see `script/lint` below.
-- `script/setup` — make a fresh clone ready for development: bootstrap
- plus the git pre-commit hook
-- `script/projectname` — print the project name (used for the Docker
- image tag)
-- `script/test` — run the test suite (race detector, coverage). Caching
- is waived for testing, exactly as it is for linting: `-count=1`
- forces every invocation to execute, because the suite queries live
- DNS and a cached pass queries nothing. Failures are rerun with `-v`
- automatically, and the build fails even if that rerun passes.
+- `script/bootstrap` — install all dependencies (go, `go mod download`). It does
+ not install golangci-lint or prettier: both run in Docker, see `script/lint`
+ and `script/fmt` below.
+- `script/setup` — make a fresh clone ready for development: bootstrap plus the
+ git pre-commit hook
+- `script/projectname` — print the project name (used for the Docker image tag)
+- `script/test` — run the test suite (race detector, coverage). Caching is
+ waived for testing, exactly as it is for linting: `-count=1` forces every
+ invocation to execute, because the suite queries live DNS and a cached pass
+ queries nothing. Failures are rerun with `-v` automatically, and the build
+ fails even if that rerun passes.
- `script/lint` — run golangci-lint, always inside Docker: it builds
- `Dockerfile.lint`, which COPYs the repo into the digest-pinned
- `golangci-lint` image and lints as a build step, so a successful
- build is a clean lint. The linter is never installed or run on the
- host, and Docker is the only prerequisite. Caching is waived for
- linting: the lint stage is forced to execute on every run with
- `--no-cache-filter`, because a cached build lints nothing.
-- `script/fmt` — format all code (gofmt -s, goimports). goimports runs
- with `go run` at a pinned commit, never from your `PATH`.
-- `script/fmt-check` — check formatting (read-only) with the same tools,
- failing on any file `script/fmt` would change
+ `Dockerfile.lint`, which COPYs the repo into the digest-pinned `golangci-lint`
+ image and lints as a build step, so a successful build is a clean lint. The
+ linter is never installed or run on the host, and Docker is the only
+ prerequisite. Caching is waived for linting: the lint stage is forced to
+ execute on every run with `--no-cache-filter`, because a cached build lints
+ nothing.
+- `script/fmt` — format all code (gofmt -s, goimports) and all Markdown
+ (prettier). goimports runs with `go run` at a pinned commit, never from your
+ `PATH`. prettier runs inside Docker, built from `Dockerfile.fmt` on a
+ digest-pinned node image, at the version pinned by `package.json` and
+ `yarn.lock`; it is never installed on the host.
+- `script/fmt-check` — check formatting (read-only) with the same tools, failing
+ on any file `script/fmt` would change. It runs the two scripts below.
+- `script/fmt-check-go` — the gofmt and goimports half, on the host. The
+ `Dockerfile` lint stage runs it.
+- `script/fmt-check-markdown` — the prettier half, inside Docker, forced to
+ execute on every run with `--no-cache-filter`
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`, with
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
@@ -538,9 +542,9 @@ them. We provide:
- `script/cibuild` — CI entrypoint: `docker build` with
`--no-cache-filter=lint,builder`, so the lint stage and the builder stage,
which runs the tests, run on every invocation, because a cached build lints
- nothing and queries no DNS
-- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
- guard, then `script/check`
+ nothing and queries no DNS; then `script/fmt-check-markdown`
+- `script/precommit` — run by the git pre-commit hook; `go mod tidy` guard, then
+ `script/check`
- `script/install-precommit` — install the git pre-commit hook
## Building
@@ -549,21 +553,21 @@ them. We provide:
make build # Build binary to bin/dnswatcher
make test # Run tests with race detector
make lint # Run golangci-lint in Docker (requires docker)
-make fmt # Format code
+make fmt # Format code and Markdown (requires docker)
make check # Run all checks (test, lint, fmt-check)
make clean # Remove build artifacts
```
### Build-Time Variables
-`make build` sets the version with `-ldflags "-X main.Version=..."`, taking
-it from `git describe --tags --always --dirty`, or from `VERSION` when given
-on the command line (`make build VERSION=1.2.3`). The version appears in the
-startup log and in the health check response.
+`make build` sets the version with `-ldflags "-X main.Version=..."`, taking it
+from `git describe --tags --always --dirty`, or from `VERSION` when given on the
+command line (`make build VERSION=1.2.3`). The version appears in the startup
+log and in the health check response.
The Docker image has no `.git`, so the `Dockerfile` takes the version as
-`--build-arg VERSION`. `make docker` passes it; a plain `docker build`
-passes none, and that image reports `dev`.
+`--build-arg VERSION`. `make docker` passes it; a plain `docker build` passes
+none, and that image reports `dev`.
---
@@ -584,84 +588,80 @@ docker run -d \
## Running under upaas
-[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this
-repository's `Dockerfile` and runs it. The app needs:
+[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this repository's
+`Dockerfile` and runs it. The app needs:
-- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
- `prod` pull request is a deploy.
-- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
- the state file lives.
-- **Network and port:** the dashboard is unauthenticated and shows every
- watched name and recent alert, and upaas publishes every mapped port on
- all interfaces of the host
- ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a
- port mapping to container port `8080` only if the dashboard should be
- public. Otherwise add none: set the app's Docker network in upaas to
- your reverse proxy's Docker network, and the proxy reaches the app at
- `upaas-` followed by the app name, port `8080`.
-- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list
- of the domains and hostnames to watch. dnswatcher refuses to start
- without it.
+- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to `prod`
+ pull request is a deploy.
+- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where the
+ state file lives.
+- **Network and port:** the dashboard is unauthenticated and shows every watched
+ name and recent alert, and upaas publishes every mapped port on all interfaces
+ of the host ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)).
+ Add a port mapping to container port `8080` only if the dashboard should be
+ public. Otherwise add none: set the app's Docker network in upaas to your
+ reverse proxy's Docker network, and the proxy reaches the app at `upaas-`
+ followed by the app name, port `8080`.
+- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list of the
+ domains and hostnames to watch. dnswatcher refuses to start without it.
- **Recommended environment:** at least one notification endpoint
(`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`,
- `DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the
- dashboard. `DNSWATCHER_METRICS_USERNAME` and
- `DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth.
+ `DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the dashboard.
+ `DNSWATCHER_METRICS_USERNAME` and `DNSWATCHER_METRICS_PASSWORD` serve
+ `/metrics` behind basic auth.
- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to
- `/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every
- setting comes from the environment; the image holds no config file.
-- **Health check:** the image's own, which requests
- `/.well-known/healthcheck` every 10 seconds. upaas reads the
- container's health 60 seconds after a deploy and marks the deploy
- failed unless it is `healthy`.
+ `/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every setting
+ comes from the environment; the image holds no config file.
+- **Health check:** the image's own, which requests `/.well-known/healthcheck`
+ every 10 seconds. upaas reads the container's health 60 seconds after a deploy
+ and marks the deploy failed unless it is `healthy`.
---
## Monitoring Lifecycle
-1. **Startup**: Check that the data directory can be written, and exit
- with an error naming it if not. Load state from disk. If no state
- file exists, start with empty state (first check will establish
- baseline without triggering change notifications).
-2. **Initial check**: Immediately perform all DNS, port, and TLS checks
- on startup.
+1. **Startup**: Check that the data directory can be written, and exit with an
+ error naming it if not. Load state from disk. If no state file exists, start
+ with empty state (first check will establish baseline without triggering
+ change notifications).
+2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
+ startup.
3. **Periodic checks** (DNS always runs first):
- - DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also
- re-run before every TLS check cycle to ensure fresh IPs.
- - Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes.
- - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after
- DNS completes.
- - Port and TLS checks always use freshly resolved IP addresses from
- the DNS phase that immediately precedes them — never stale IPs
- from a previous cycle.
-4. **On change detection**: Send notifications to all configured
- endpoints, update in-memory state, persist to disk.
-5. **Shutdown**: The watcher stops checking and saves the final state
- to disk, and shutdown waits for that save before it goes on. Then it
- waits for in-flight notification deliveries to complete. Both waits
- share the fx shutdown timeout (15s by default): deliveries still
- retrying against an unreachable endpoint when that expires are
- abandoned, and the number abandoned is logged at warn level rather
- than dropped silently. Notifications generated after shutdown has
- begun are refused and logged, so a late burst cannot extend the
- shutdown. A DNS lookup, port check or TLS check that shutdown cuts
- short saves nothing and sends no notification.
+ - DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also re-run
+ before every TLS check cycle to ensure fresh IPs.
+ - Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes.
+ - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after DNS
+ completes.
+ - Port and TLS checks always use freshly resolved IP addresses from the DNS
+ phase that immediately precedes them — never stale IPs from a previous
+ cycle.
+4. **On change detection**: Send notifications to all configured endpoints,
+ update in-memory state, persist to disk.
+5. **Shutdown**: The watcher stops checking and saves the final state to disk,
+ and shutdown waits for that save before it goes on. Then it waits for
+ in-flight notification deliveries to complete. Both waits share the fx
+ shutdown timeout (15s by default): deliveries still retrying against an
+ unreachable endpoint when that expires are abandoned, and the number
+ abandoned is logged at warn level rather than dropped silently. Notifications
+ generated after shutdown has begun are refused and logged, so a late burst
+ cannot extend the shutdown. A DNS lookup, port check or TLS check that
+ shutdown cuts short saves nothing and sends no notification.
---
## Planned Future Features (Post-1.0)
-- **DNSSEC validation**: Validate the DNSSEC chain of trust during
- iterative resolution and report DNSSEC failures as notifications.
+- **DNSSEC validation**: Validate the DNSSEC chain of trust during iterative
+ resolution and report DNSSEC failures as notifications.
---
## Project Structure
Follows the conventions defined in `REPO_POLICIES.md`, adapted from the
-[upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx
-for dependency injection, go-chi for HTTP routing, slog for logging, and
-Viper for configuration.
+[upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx for
+dependency injection, go-chi for HTTP routing, slog for logging, and Viper for
+configuration.
---
diff --git a/TESTING.md b/TESTING.md
index 6b4f3d8..3971309 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -2,44 +2,43 @@
## DNS Resolution Tests
-DNS is never mocked in this project, not in tests and not anywhere
-else; see the README section "No DNS mocking. Ever." Every test that
-looks something up in DNS **MUST** query live DNS servers, never a
-stand-in. Logic that works on record data, such as comparing or
-formatting records, may be tested on that data directly with no
-lookup.
+DNS is never mocked in this project, not in tests and not anywhere else; see the
+README section "No DNS mocking. Ever." Every test that looks something up in DNS
+**MUST** query live DNS servers, never a stand-in. Logic that works on record
+data, such as comparing or formatting records, may be tested on that data
+directly with no lookup.
### Rationale
-The resolver performs iterative resolution from root nameservers through
-the full delegation chain. Mocked responses cannot faithfully represent
-the variety of real-world DNS behavior (truncation, referrals, glue
-records, DNSSEC, varied response times, EDNS, etc.). Testing against
-real servers ensures the resolver works correctly in production.
+The resolver performs iterative resolution from root nameservers through the
+full delegation chain. Mocked responses cannot faithfully represent the variety
+of real-world DNS behavior (truncation, referrals, glue records, DNSSEC, varied
+response times, EDNS, etc.). Testing against real servers ensures the resolver
+works correctly in production.
### Constraints
- Tests hit real DNS infrastructure and require network access
-- Test duration depends on network conditions; timeout tuning keeps
- the suite within the 60-second target
-- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
- plus processing margin
+- Test duration depends on network conditions; timeout tuning keeps the suite
+ within the 60-second target
+- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus
+ processing margin
- Root server fan-out is limited to reduce parallel query load
-- Live lookups that expect an answer go through `internal/livednstest`,
- which limits how many run at once in a test binary and retries a
- lookup that got none
-- Flaky failures from transient network issues are acceptable and
- should be investigated as potential resolver bugs, not papered over
- with mocks or skip flags
+- Live lookups that expect an answer go through `internal/livednstest`, which
+ limits how many run at once in a test binary and retries a lookup that got
+ none
+- Flaky failures from transient network issues are acceptable and should be
+ investigated as potential resolver bugs, not papered over with mocks or skip
+ flags
### What NOT to do
-- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`,
- no stand-in for the watcher's `DNSResolver`, no fake DNS server, no
- canned responses
+- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`, no
+ stand-in for the watcher's `DNSResolver`, no fake DNS server, no canned
+ responses
- **Do not add `-short` flags** to skip slow tests
- **Do not increase `-timeout`** to hide hanging queries
-- **Do not remove `-count=1` from `script/test`** — Go's test cache
- replays a previous run's output without querying anything, so a
- cached pass is not evidence that live resolution works
+- **Do not remove `-count=1` from `script/test`** — Go's test cache replays a
+ previous run's output without querying anything, so a cached pass is not
+ evidence that live resolution works
- **Do not modify linter configuration** to suppress findings
diff --git a/TODO.md b/TODO.md
index 897bda6..a7e12ce 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,12 +1,12 @@
# Workflow
-* branch (from `next`)
-* do the work in Next Step
-* move Next Step to the top of Completed Steps
-* move the top item of Future Steps into Next Step
-* commit (`TODO.md` changes in the same commit as the work)
-* push
-* open a PR against `next`
+- branch (from `next`)
+- do the work in Next Step
+- move Next Step to the top of Completed Steps
+- move the top item of Future Steps into Next Step
+- commit (`TODO.md` changes in the same commit as the work)
+- push
+- open a PR against `next`
# Status
@@ -15,11 +15,12 @@ on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Next Step
-trial run of the finished image:
-https://git.eeqj.de/sneak/dnswatcher/issues/149
+trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps
+- 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run
+ in Docker at the version pinned by `yarn.lock` (closes #119).
- 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both
format scripts run `goimports` at its pinned commit, not from `PATH` (#119).
- 2026-10-01: a hostname is queried at the servers of the zone it is in, found
@@ -114,8 +115,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66
-- Markdown formatting with prettier:
- https://git.eeqj.de/sneak/dnswatcher/issues/119
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- README sections required by policy:
https://git.eeqj.de/sneak/dnswatcher/issues/173
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..53eca1f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "dnswatcher-tooling",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Pins the prettier that script/fmt and script/fmt-check run against this repo's markdown. Not a JavaScript project; nothing here is imported, published, or shipped.",
+ "devDependencies": {
+ "prettier": "3.9.6"
+ }
+}
diff --git a/script/bootstrap b/script/bootstrap
index a463d0a..dd76db4 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -3,11 +3,12 @@
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present.
-# goimports is not installed here: script/fmt and script/fmt-check run
-# it with `go run` at a pinned commit.
+# goimports is not installed here: script/fmt and script/fmt-check-go
+# run it with `go run` at a pinned commit.
# The linter is NOT installed here: golangci-lint runs via docker only
# (script/lint), pinned by image digest, so its only prerequisite is a
-# working docker.
+# working docker. Nor is prettier: script/fmt and
+# script/fmt-check-markdown run it in a container from Dockerfile.fmt.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -66,11 +67,12 @@ main() {
if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi
- # Linting runs via docker only (script/lint). Warn, don't fail:
- # everything except `make lint` works without it.
+ # Linting and the markdown formatter run via docker only. Warn,
+ # don't fail: building and testing work without it.
if missing docker; then
echo "bootstrap: WARNING: docker not found; install it to" \
- "run make lint and make docker." >&2
+ "run make lint, make fmt, make fmt-check, make check" \
+ "and make docker." >&2
fi
go mod download
diff --git a/script/cibuild b/script/cibuild
index 29bea03..ad4ed76 100755
--- a/script/cibuild
+++ b/script/cibuild
@@ -1,18 +1,23 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
-# make fmt-check and golangci-lint; its builder stage runs make test
-# and make build.
+# the Go half of make fmt-check and golangci-lint; its builder stage
+# runs make test and make build. The markdown half of make fmt-check
+# runs after that build, as its own build of Dockerfile.fmt, because
+# there is no docker inside a docker build.
#
# --no-cache-filter=lint,builder runs both stages on every invocation;
# otherwise an unchanged tree is served from the layer cache and passes
-# without linting or querying live DNS.
+# without linting or querying live DNS. script/fmt-check-markdown busts
+# its own cache the same way.
set -eu
-ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --no-cache-filter=lint,builder .
+ "$SCRIPT_DIR/fmt-check-markdown"
}
main "$@"
diff --git a/script/fmt b/script/fmt
index a0b28e6..d64453a 100755
--- a/script/fmt
+++ b/script/fmt
@@ -1,19 +1,65 @@
#!/bin/sh
-# script/fmt: format all files (writes).
+# script/fmt: format all files (writes). Go with gofmt and goimports on
+# the host, markdown with the prettier pinned by Dockerfile.fmt.
#
# goimports runs with `go run` at a pinned commit, never from PATH, so
# every machine formats with the same version and nothing installs it.
+#
+# The markdown pass is a `docker build --output type=local` rather than a
+# `docker run -v`, so it needs no bind mount and behaves the same against
+# a remote daemon; the formatted documents come back out of the build and
+# are copied over the tree here.
+#
+# Unlike script/fmt-check-markdown this does not bust the cache: it is
+# not a gate, and any edit to a document changes the COPY layer above the
+# prettier step, so a cached result is a result over this exact tree.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
-# goimports v0.42.0, 2026-08-07. Must match script/fmt-check.
+# goimports v0.42.0, 2026-08-07. Must match script/fmt-check-go.
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
+# Must match the export stage name in Dockerfile.fmt.
+stage=fmt-out
+
+die() {
+ echo "script/fmt: $*" >&2
+ exit 1
+}
+
main() {
cd "$ROOT"
gofmt -s -w .
go run "$GOIMPORTS_REF" -w .
+
+ tmp="$(mktemp -d "${TMPDIR:-/tmp}/dnswatcher-fmt.XXXXXX")"
+ trap 'rm -rf "$tmp"' EXIT INT TERM
+
+ docker build \
+ --target "$stage" \
+ --output "type=local,dest=$tmp/out" \
+ -f Dockerfile.fmt .
+
+ # An empty export means prettier was handed nothing, which must not
+ # read as "already formatted".
+ (cd "$tmp/out" && find . -type f -name '*.md') |
+ sed 's|^\./||' | LC_ALL=C sort >"$tmp/files"
+
+ [ -s "$tmp/files" ] ||
+ die "the formatting build produced no markdown; the build" \
+ "context reached prettier empty"
+
+ # Copied only where the bytes differ, so an already-formatted tree
+ # keeps its timestamps and says nothing.
+ while IFS= read -r f; do
+ [ -n "$f" ] || continue
+ if [ -f "$f" ] && cmp -s "$tmp/out/$f" "$f"; then
+ continue
+ fi
+ cp "$tmp/out/$f" "$f"
+ echo "prettier: reformatted $f"
+ done <"$tmp/files"
}
main "$@"
diff --git a/script/fmt-check b/script/fmt-check
index 40911dd..c5fb94f 100755
--- a/script/fmt-check
+++ b/script/fmt-check
@@ -1,27 +1,14 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same tools and scope
-# as script/fmt, but fails instead of writing.
+# as script/fmt, but fails instead of writing: the Go on the host, the
+# markdown with prettier in a container.
set -eu
-ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
-
-# goimports v0.42.0, 2026-08-07. Must match script/fmt.
-GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
- cd "$ROOT"
- files="$(gofmt -s -l .)"
- if [ -n "$files" ]; then
- echo "gofmt: files not formatted:" >&2
- echo "$files" >&2
- exit 1
- fi
- files="$(go run "$GOIMPORTS_REF" -l .)"
- if [ -n "$files" ]; then
- echo "goimports: files not formatted:" >&2
- echo "$files" >&2
- exit 1
- fi
+ "$SCRIPT_DIR/fmt-check-go"
+ "$SCRIPT_DIR/fmt-check-markdown"
}
main "$@"
diff --git a/script/fmt-check-go b/script/fmt-check-go
new file mode 100755
index 0000000..fa21fc5
--- /dev/null
+++ b/script/fmt-check-go
@@ -0,0 +1,31 @@
+#!/bin/sh
+# script/fmt-check-go: fail unless every Go source is formatted the way
+# script/fmt would leave it, and name the files that are not. Read-only.
+#
+# Its own script because the Dockerfile's lint stage runs this half
+# alone: there is no docker inside a docker build to run the markdown
+# half in.
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+
+# goimports v0.42.0, 2026-08-07. Must match script/fmt.
+GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
+
+main() {
+ cd "$ROOT"
+ files="$(gofmt -s -l .)"
+ if [ -n "$files" ]; then
+ echo "gofmt: files not formatted:" >&2
+ echo "$files" >&2
+ exit 1
+ fi
+ files="$(go run "$GOIMPORTS_REF" -l .)"
+ if [ -n "$files" ]; then
+ echo "goimports: files not formatted:" >&2
+ echo "$files" >&2
+ exit 1
+ fi
+}
+
+main "$@"
diff --git a/script/fmt-check-markdown b/script/fmt-check-markdown
new file mode 100755
index 0000000..e849b59
--- /dev/null
+++ b/script/fmt-check-markdown
@@ -0,0 +1,29 @@
+#!/bin/sh
+# script/fmt-check-markdown: fail unless every .md is formatted the way
+# script/fmt would leave it. Read-only.
+#
+# prettier is never installed on the host: it runs in a container built
+# from Dockerfile.fmt, pinned by package.json and yarn.lock.
+# --no-cache-filter is here for the reason script/lint gives: a cached
+# build checks nothing.
+#
+# Its own script because script/cibuild runs this half alone, after the
+# Dockerfile's lint stage has checked the Go.
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+
+# Must match the markdown check stage name in Dockerfile.fmt.
+stage=fmt-check
+
+main() {
+ cd "$ROOT"
+ docker build \
+ --progress=plain \
+ --no-cache-filter="$stage" \
+ --target "$stage" \
+ -f Dockerfile.fmt \
+ .
+}
+
+main "$@"
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 0000000..8f3c21a
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,8 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+prettier@3.9.6:
+ version "3.9.6"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
+ integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==