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==