Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54c0439be7 | ||
|
|
d2f154b2cf | ||
|
|
4c2932d6d6 |
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
.git/
|
.git/
|
||||||
bin/
|
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
|
LICENSE
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
bin/
|
bin/
|
||||||
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
data/
|
data/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
.claude/
|
||||||
|
static/css/tailwind.min.css
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
+4
-2
@@ -1,7 +1,9 @@
|
|||||||
# Lint stage - fast feedback on lint issues, before the build starts.
|
# Lint stage - fast feedback on lint issues, before the build starts.
|
||||||
# The linter is invoked directly rather than through `make lint`: that
|
# The linter is invoked directly rather than through `make lint`: that
|
||||||
# target shells out to `docker build -f Dockerfile.lint`, and there is
|
# 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.
|
# script/cibuild and script/docker name this stage in --no-cache-filter.
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
@@ -12,7 +14,7 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make fmt-check
|
RUN script/fmt-check-go
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
|
|||||||
@@ -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/ /
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
# dnswatcher
|
# 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
|
dnswatcher watches configured DNS domains and hostnames for changes, monitors
|
||||||
port availability, tracks TLS certificate expiry, and delivers real-time
|
TCP port availability, tracks TLS certificate expiry, and delivers real-time
|
||||||
notifications via Slack, Mattermost, and/or ntfy webhooks.
|
notifications via Slack, Mattermost, and/or ntfy webhooks.
|
||||||
|
|
||||||
It performs all DNS resolution itself via iterative (non-recursive) queries,
|
It performs all DNS resolution itself via iterative (non-recursive) queries,
|
||||||
tracing from root nameservers to authoritative servers directly—never relying
|
tracing from root nameservers to authoritative servers directly—never relying on
|
||||||
on upstream recursive resolvers.
|
upstream recursive resolvers.
|
||||||
|
|
||||||
State is persisted to a local JSON file so that monitoring survives restarts
|
State is persisted to a local JSON file so that monitoring survives restarts
|
||||||
without requiring an external database.
|
without requiring an external database.
|
||||||
@@ -19,21 +23,20 @@ without requiring an external database.
|
|||||||
|
|
||||||
## No DNS mocking. Ever.
|
## No DNS mocking. Ever.
|
||||||
|
|
||||||
**DNS is never mocked in this project — not in tests, not anywhere else.**
|
**DNS is never mocked in this project — not in tests, not anywhere else.** No
|
||||||
No mock resolvers, no fake DNS servers, no stubbed lookups.
|
mock resolvers, no fake DNS servers, no stubbed lookups.
|
||||||
|
|
||||||
dnswatcher's entire purpose is correct behavior against the real DNS.
|
dnswatcher's entire purpose is correct behavior against the real DNS. Tests
|
||||||
Tests exercise real iterative resolution against live nameservers by
|
exercise real iterative resolution against live nameservers by design; a test
|
||||||
design; a test suite that passes against a mock proves nothing about the
|
suite that passes against a mock proves nothing about the one thing this program
|
||||||
one thing this program exists to do.
|
exists to do.
|
||||||
|
|
||||||
When live tests are flaky, that is a robustness problem, and it gets
|
When live tests are flaky, that is a robustness problem, and it gets fixed with
|
||||||
fixed with robustness: retries with backoff, querying multiple
|
robustness: retries with backoff, querying multiple independent nameservers,
|
||||||
independent nameservers, longer timeouts — or explicit opt-in gating
|
longer timeouts — or explicit opt-in gating decided by the project owner. Never
|
||||||
decided by the project owner. Never with mocks.
|
with mocks.
|
||||||
|
|
||||||
Contributions that introduce mocked, faked, or stubbed DNS will be
|
Contributions that introduce mocked, faked, or stubbed DNS will be rejected.
|
||||||
rejected.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,128 +49,125 @@ rejected.
|
|||||||
- Every **1 hour**, performs a full iterative trace from root servers to
|
- Every **1 hour**, performs a full iterative trace from root servers to
|
||||||
discover all authoritative nameservers (NS records) for each domain.
|
discover all authoritative nameservers (NS records) for each domain.
|
||||||
- Queries **every** discovered authoritative nameserver independently.
|
- Queries **every** discovered authoritative nameserver independently.
|
||||||
- Stores the NS record set as observed by the delegation chain, and the
|
- Stores the NS record set as observed by the delegation chain, and the IPv4 and
|
||||||
IPv4 and IPv6 addresses each nameserver's name resolves to.
|
IPv6 addresses each nameserver's name resolves to.
|
||||||
- Any change triggers a notification:
|
- Any change triggers a notification:
|
||||||
- NS added to or removed from the delegation.
|
- NS added to or removed from the delegation.
|
||||||
- NS address change: a nameserver that stays in the delegation
|
- NS address change: a nameserver that stays in the delegation resolves to
|
||||||
resolves to different addresses than on the previous check. A
|
different addresses than on the previous check. A nameserver added or
|
||||||
nameserver added or removed gets only the NS change notification.
|
removed gets only the NS change notification. When the lookup of a
|
||||||
When the lookup of a nameserver's addresses fails or finds none,
|
nameserver's addresses fails or finds none, its previous addresses are
|
||||||
its previous addresses are kept and nothing is sent.
|
kept and nothing is sent.
|
||||||
|
|
||||||
### DNS Hostname Monitoring (Subdomains)
|
### DNS Hostname Monitoring (Subdomains)
|
||||||
|
|
||||||
- Accepts a list of DNS hostnames (subdomains, distinguished from apex
|
- Accepts a list of DNS hostnames (subdomains, distinguished from apex domains
|
||||||
domains via the Public Suffix List).
|
via the Public Suffix List).
|
||||||
- Every **1 hour**, performs a full iterative trace to discover the
|
- Every **1 hour**, performs a full iterative trace to discover the
|
||||||
authoritative nameservers of the zone the hostname is in, which is not
|
authoritative nameservers of the zone the hostname is in, which is not always
|
||||||
always its last two labels (a name under `co.uk`, or in a delegated
|
its last two labels (a name under `co.uk`, or in a delegated subdomain).
|
||||||
subdomain).
|
- Queries **each** authoritative nameserver independently for **all** record
|
||||||
- Queries **each** authoritative nameserver independently for **all**
|
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||||
record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
- Stores results **per nameserver**. The state for a hostname is not a merged
|
||||||
- Stores results **per nameserver**. The state for a hostname is not a
|
view — it is a map from nameserver to record set.
|
||||||
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
|
||||||
- DNS names inside record values (CNAME, MX, SRV and NS targets) are
|
lower case, because names are case-insensitive and nameservers may answer in
|
||||||
stored in lower case, because names are case-insensitive and
|
any letter case. TXT and CAA values keep their letter case; they are not
|
||||||
nameservers may answer in any letter case. TXT and CAA values keep
|
lower-cased.
|
||||||
their letter case; they are not lower-cased.
|
- Any observable change in any nameserver's response triggers a notification.
|
||||||
- Any observable change in any nameserver's response triggers a
|
This includes:
|
||||||
notification. This includes:
|
- **Record change**: A nameserver returns different records than it did on
|
||||||
- **Record change**: A nameserver returns different records than it
|
the previous check (additions, removals, value changes).
|
||||||
did on the previous check (additions, removals, value changes).
|
- **NS query failure**: A nameserver that previously responded becomes
|
||||||
- **NS query failure**: A nameserver that previously responded
|
unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct
|
||||||
becomes unreachable (timeout, SERVFAIL, REFUSED, network error).
|
from "responded with no records": a nameserver that answers NXDOMAIN or
|
||||||
This is distinct from "responded with no records": a nameserver
|
with no records has responded. The alert is sent once, on the check where
|
||||||
that answers NXDOMAIN or with no records has responded. The alert
|
it starts failing. A failing nameserver gives no records, so it is not
|
||||||
is sent once, on the check where it starts failing. A failing
|
reported as a record change or compared for inconsistency. A nameserver
|
||||||
nameserver gives no records, so it is not reported as a record
|
that is already failing on the first check that sees it is recorded
|
||||||
change or compared for inconsistency. A nameserver that is already
|
silently.
|
||||||
failing on the first check that sees it is recorded silently.
|
- **NS recovery**: A previously-unreachable nameserver starts responding
|
||||||
- **NS recovery**: A previously-unreachable nameserver starts
|
again. Its records are not compared with those from before it failed, so a
|
||||||
responding again. Its records are not compared with those from
|
change made while it was failing is not reported as a record change.
|
||||||
before it failed, so a change made while it was failing is not
|
- **Inconsistency detected**: Two nameservers return different record sets
|
||||||
reported as a record change.
|
for the same hostname and did not already differ on the previous check.
|
||||||
- **Inconsistency detected**: Two nameservers return different record
|
Every pair of nameservers is compared. The alert is sent once for each
|
||||||
sets for the same hostname and did not already differ on the previous
|
such pair, on the check where they start to disagree, and not again while
|
||||||
check. Every pair of nameservers is compared. The alert is sent once
|
they keep disagreeing, including after a restart. A nameserver that was
|
||||||
for each such pair, on the check where they start to disagree, and not
|
not in the previous check (newly added, or back after dropping out), or
|
||||||
again while they keep disagreeing, including after a restart. A
|
failed on it, and answers differently is reported on the check where it
|
||||||
nameserver that was not in the previous check (newly added, or back
|
answers. If a pair agrees again and later disagrees, the alert is sent
|
||||||
after dropping out), or failed on it, and answers differently is
|
again.
|
||||||
reported on the check where it answers. If a pair agrees again and
|
|
||||||
later disagrees, the alert is sent again.
|
|
||||||
|
|
||||||
### TCP Port Monitoring
|
### TCP Port Monitoring
|
||||||
|
|
||||||
- For every configured domain and hostname, constructs a deduplicated list
|
- For every configured domain and hostname, constructs a deduplicated list of
|
||||||
of all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain
|
all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution
|
||||||
resolution across all authoritative nameservers.
|
across all authoritative nameservers.
|
||||||
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
||||||
- Every **1 hour**, re-checks all ports.
|
- Every **1 hour**, re-checks all ports.
|
||||||
- Any change in port availability triggers a notification:
|
- Any change in port availability triggers a notification:
|
||||||
- Port transitioned from open to closed (or vice versa).
|
- Port transitioned from open to closed (or vice versa).
|
||||||
- New IP appeared (from DNS change) and its port state was recorded.
|
- New IP appeared (from DNS change) and its port state was recorded.
|
||||||
- IP disappeared (from DNS change) — noted in the DNS change
|
- IP disappeared (from DNS change) — noted in the DNS change notification;
|
||||||
notification; port state for that IP is removed. When none of a name's
|
port state for that IP is removed. When none of a name's nameservers
|
||||||
nameservers answered, its addresses are not known, so the port state saved
|
answered, its addresses are not known, so the port state saved for them is
|
||||||
for them is kept.
|
kept.
|
||||||
|
|
||||||
### TLS Certificate Monitoring
|
### TLS Certificate Monitoring
|
||||||
|
|
||||||
- Every **12 hours**, for each IP address listening on port 443, connects
|
- Every **12 hours**, for each IP address listening on port 443, connects via
|
||||||
via TLS using the correct SNI hostname.
|
TLS using the correct SNI hostname.
|
||||||
- Records the certificate's Subject CN, SANs, issuer, and expiry date.
|
- Records the certificate's Subject CN, SANs, issuer, and expiry date.
|
||||||
- Any change triggers a notification:
|
- Any change triggers a notification:
|
||||||
- Certificate is expiring within **7 days** (warning, repeated each
|
- Certificate is expiring within **7 days** (warning, repeated each check
|
||||||
check until renewed or expired).
|
until renewed or expired).
|
||||||
- Certificate CN, issuer, or SANs changed (replacement detected,
|
- Certificate CN, issuer, or SANs changed (replacement detected, reports old
|
||||||
reports old and new values).
|
and new values).
|
||||||
- TLS connection failure to a previously-reachable IP:443 (handshake
|
- TLS connection failure to a previously-reachable IP:443 (handshake error,
|
||||||
error, timeout, connection refused after previously succeeding).
|
timeout, connection refused after previously succeeding).
|
||||||
- TLS recovery: a previously-failing IP:443 now completes a
|
- TLS recovery: a previously-failing IP:443 now completes a handshake again.
|
||||||
handshake again.
|
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
**Every observable state change produces a notification.** dnswatcher is
|
**Every observable state change produces a notification.** dnswatcher is
|
||||||
designed as a real-time change feed — degradations, failures, recoveries,
|
designed as a real-time change feed — degradations, failures, recoveries, and
|
||||||
and routine changes are all reported equally.
|
routine changes are all reported equally.
|
||||||
|
|
||||||
Supported notification backends:
|
Supported notification backends:
|
||||||
|
|
||||||
| Backend | Configuration | Payload Format |
|
| Backend | Configuration | Payload Format |
|
||||||
|----------------|--------------------------|------------------------------|
|
| -------------- | ------------------------------------------ | ---------------------------- |
|
||||||
| **Slack** | Incoming Webhook URL | Attachments with color |
|
| **Slack** | Incoming Webhook URL | Attachments with color |
|
||||||
| **Mattermost** | Incoming Webhook URL | Slack-compatible attachments |
|
| **Mattermost** | Incoming Webhook URL | Slack-compatible attachments |
|
||||||
| **ntfy** | Topic URL (e.g. `https://ntfy.sh/mytopic`) | Title + body + priority |
|
| **ntfy** | Topic URL (e.g. `https://ntfy.sh/mytopic`) | Title + body + priority |
|
||||||
|
|
||||||
All configured endpoints receive every notification. Notification content
|
All configured endpoints receive every notification. Notification content
|
||||||
includes:
|
includes:
|
||||||
|
|
||||||
- **DNS record changes**: Which hostname, which nameserver, what record
|
- **DNS record changes**: Which hostname, which nameserver, what record type,
|
||||||
type, old values, new values.
|
old values, new values.
|
||||||
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
||||||
- **NS address changes**: Which domain, which nameserver, its old and
|
- **NS address changes**: Which domain, which nameserver, its old and new
|
||||||
new addresses.
|
addresses.
|
||||||
- **NS query failures**: Which nameserver failed, error type (timeout,
|
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
||||||
SERVFAIL, REFUSED, network error), which hostname/domain affected.
|
REFUSED, network error), which hostname/domain affected.
|
||||||
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
||||||
- **NS inconsistencies**: Which nameservers disagree, what each one
|
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
|
||||||
returned, which hostname affected.
|
which hostname affected.
|
||||||
- **Port changes**: Which IP:port, old state, new state, all associated
|
- **Port changes**: Which IP:port, old state, new state, all associated
|
||||||
hostnames.
|
hostnames.
|
||||||
- **TLS expiry warnings**: Which certificate, days remaining, CN,
|
- **TLS expiry warnings**: Which certificate, days remaining, CN, issuer,
|
||||||
issuer, associated hostname and IP.
|
associated hostname and IP.
|
||||||
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated
|
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated hostname
|
||||||
hostname and IP.
|
and IP.
|
||||||
- **TLS connection failures/recoveries**: Which IP:port, error details,
|
- **TLS connection failures/recoveries**: Which IP:port, error details,
|
||||||
associated hostname.
|
associated hostname.
|
||||||
|
|
||||||
### State Management
|
### State Management
|
||||||
|
|
||||||
- All monitoring state is kept in memory and persisted to a JSON file on
|
- All monitoring state is kept in memory and persisted to a JSON file on disk
|
||||||
disk (`DATA_DIR/state.json`).
|
(`DATA_DIR/state.json`).
|
||||||
- State is loaded on startup to resume monitoring without triggering
|
- State is loaded on startup to resume monitoring without triggering
|
||||||
false-positive change notifications.
|
false-positive change notifications.
|
||||||
- State is written atomically (write to temp file, then rename) to prevent
|
- State is written atomically (write to temp file, then rename) to prevent
|
||||||
@@ -175,41 +175,39 @@ includes:
|
|||||||
|
|
||||||
### Web Dashboard
|
### Web Dashboard
|
||||||
|
|
||||||
dnswatcher includes an unauthenticated, read-only web dashboard at the
|
dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
|
||||||
root URL (`/`). It displays:
|
(`/`). It displays:
|
||||||
|
|
||||||
- **Summary counts** for monitored domains, hostnames, ports, and
|
- **Summary counts** for monitored domains, hostnames, ports, and certificates.
|
||||||
certificates.
|
|
||||||
- **Domains** with their discovered nameservers.
|
- **Domains** with their discovered nameservers.
|
||||||
- **Hostnames** with per-nameserver DNS records and status.
|
- **Hostnames** with per-nameserver DNS records and status.
|
||||||
- **Ports** with open/closed state and associated hostnames.
|
- **Ports** with open/closed state and associated hostnames.
|
||||||
- **TLS certificates** with CN, issuer, expiry, and status.
|
- **TLS certificates** with CN, issuer, expiry, and status.
|
||||||
- **Recent alerts** (last 100 notifications sent since the process
|
- **Recent alerts** (last 100 notifications sent since the process started),
|
||||||
started), displayed in reverse chronological order.
|
displayed in reverse chronological order.
|
||||||
|
|
||||||
Every data point shows its age (e.g. "5m ago") so you can tell at a
|
Every data point shows its age (e.g. "5m ago") so you can tell at a glance how
|
||||||
glance how fresh the information is. The page auto-refreshes every 30
|
fresh the information is. The page auto-refreshes every 30 seconds.
|
||||||
seconds.
|
|
||||||
|
|
||||||
The dashboard intentionally does not expose any configuration details
|
The dashboard intentionally does not expose any configuration details such as
|
||||||
such as webhook URLs, notification endpoints, or API tokens.
|
webhook URLs, notification endpoints, or API tokens.
|
||||||
|
|
||||||
All assets (CSS) are embedded in the binary and served from the
|
All assets (CSS) are embedded in the binary and served from the application
|
||||||
application itself. The dashboard makes zero external HTTP requests —
|
itself. The dashboard makes zero external HTTP requests — no CDN dependencies or
|
||||||
no CDN dependencies or third-party resources are loaded at runtime.
|
third-party resources are loaded at runtime.
|
||||||
|
|
||||||
### HTTP API
|
### HTTP API
|
||||||
|
|
||||||
dnswatcher exposes a lightweight HTTP API for operational visibility:
|
dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|---------------------------------------|--------------------------------|
|
| ------------------------------ | ----------------------------- |
|
||||||
| `GET /` | Web dashboard (HTML) |
|
| `GET /` | Web dashboard (HTML) |
|
||||||
| `GET /s/...` | Static assets (embedded CSS) |
|
| `GET /s/...` | Static assets (embedded CSS) |
|
||||||
| `GET /.well-known/healthcheck` | Health check (JSON) |
|
| `GET /.well-known/healthcheck` | Health check (JSON) |
|
||||||
| `GET /health` | Health check (JSON, legacy) |
|
| `GET /health` | Health check (JSON, legacy) |
|
||||||
| `GET /api/v1/status` | Current monitoring state |
|
| `GET /api/v1/status` | Current monitoring state |
|
||||||
| `GET /metrics` | Prometheus metrics (optional) |
|
| `GET /metrics` | Prometheus metrics (optional) |
|
||||||
|
|
||||||
#### Server timeouts
|
#### Server timeouts
|
||||||
|
|
||||||
@@ -218,7 +216,7 @@ constants in `internal/server/server.go`, not configurable via environment
|
|||||||
variables.
|
variables.
|
||||||
|
|
||||||
| Timeout | Value | Purpose |
|
| Timeout | Value | Purpose |
|
||||||
|---------------------|-------|-----------------------------------------------|
|
| ------------------- | ----- | --------------------------------------------- |
|
||||||
| `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) |
|
| `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) |
|
||||||
| `ReadTimeout` | 15s | Bounds the whole request read, headers + body |
|
| `ReadTimeout` | 15s | Bounds the whole request read, headers + body |
|
||||||
| `WriteTimeout` | 75s | Bounds handler execution plus response flush |
|
| `WriteTimeout` | 75s | Bounds handler execution plus response flush |
|
||||||
@@ -226,20 +224,20 @@ variables.
|
|||||||
|
|
||||||
These are distinct from the 60s per-request handler budget applied by
|
These are distinct from the 60s per-request handler budget applied by
|
||||||
`chimw.Timeout` in `internal/server/routes.go`, which cancels the request
|
`chimw.Timeout` in `internal/server/routes.go`, which cancels the request
|
||||||
context but does not touch the socket. `WriteTimeout` is deliberately
|
context but does not touch the socket. `WriteTimeout` is deliberately larger
|
||||||
larger than that budget: the write deadline is armed once request headers
|
than that budget: the write deadline is armed once request headers are read, so
|
||||||
are read, so a smaller value would sever the connection before a handler
|
a smaller value would sever the connection before a handler using its full
|
||||||
using its full budget could respond. `IdleTimeout` exceeds common
|
budget could respond. `IdleTimeout` exceeds common Prometheus scrape intervals
|
||||||
Prometheus scrape intervals so the scraper reuses its connection.
|
so the scraper reuses its connection.
|
||||||
|
|
||||||
### Security Headers
|
### Security Headers
|
||||||
|
|
||||||
Every response — the dashboard, the static assets under `/s/...`, the
|
Every response — the dashboard, the static assets under `/s/...`, the
|
||||||
healthchecks, the JSON API, and `/metrics` — carries the following
|
healthchecks, the JSON API, and `/metrics` — carries the following headers, set
|
||||||
headers, set by a global middleware:
|
by a global middleware:
|
||||||
|
|
||||||
| Header | Value |
|
| Header | Value |
|
||||||
|-----------------------------|---------------------------------------|
|
| --------------------------- | ------------------------------------- |
|
||||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
|
||||||
| `Content-Security-Policy` | see below |
|
| `Content-Security-Policy` | see below |
|
||||||
| `X-Frame-Options` | `DENY` |
|
| `X-Frame-Options` | `DENY` |
|
||||||
@@ -256,21 +254,21 @@ form-action 'none'; frame-ancestors 'none'
|
|||||||
```
|
```
|
||||||
|
|
||||||
The dashboard ships no JavaScript (the 30-second refresh is a
|
The dashboard ships no JavaScript (the 30-second refresh is a
|
||||||
`<meta http-equiv="refresh">`), no inline styles, no inline event
|
`<meta http-equiv="refresh">`), no inline styles, no inline event handlers, and
|
||||||
handlers, and no images; its only subresource is the embedded stylesheet
|
no images; its only subresource is the embedded stylesheet at
|
||||||
at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The
|
`/s/css/tailwind.min.css`, which `style-src 'self'` permits. The policy
|
||||||
policy therefore needs neither `unsafe-inline` nor `unsafe-eval`.
|
therefore needs neither `unsafe-inline` nor `unsafe-eval`.
|
||||||
`frame-ancestors 'none'` is the primary anti-framing control, with
|
`frame-ancestors 'none'` is the primary anti-framing control, with
|
||||||
`X-Frame-Options: DENY` retained as the legacy fallback.
|
`X-Frame-Options: DENY` retained as the legacy fallback.
|
||||||
|
|
||||||
HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is
|
HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is
|
||||||
expected to run behind a TLS-terminating reverse proxy, and the browser
|
expected to run behind a TLS-terminating reverse proxy, and the browser must
|
||||||
must still be told to enforce HTTPS end to end, so the header is never
|
still be told to enforce HTTPS end to end, so the header is never gated on
|
||||||
gated on whether the request itself arrived over TLS.
|
whether the request itself arrived over TLS.
|
||||||
|
|
||||||
`Referrer-Policy: no-referrer` is stricter than the
|
`Referrer-Policy: no-referrer` is stricter than the
|
||||||
`strict-origin-when-cross-origin` baseline: the dashboard has no
|
`strict-origin-when-cross-origin` baseline: the dashboard has no cross-origin
|
||||||
cross-origin navigation needs, and its URL may name internal hosts.
|
navigation needs, and its URL may name internal hosts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -303,23 +301,23 @@ internal/
|
|||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
- **No recursive resolvers**: All DNS resolution is performed iteratively,
|
- **No recursive resolvers**: All DNS resolution is performed iteratively,
|
||||||
tracing from root nameservers through the delegation chain to
|
tracing from root nameservers through the delegation chain to authoritative
|
||||||
authoritative servers.
|
servers.
|
||||||
- **No external database**: State is persisted as a single JSON file.
|
- **No external database**: State is persisted as a single JSON file.
|
||||||
- **Dependency injection**: All components are wired via
|
- **Dependency injection**: All components are wired via
|
||||||
[uber/fx](https://github.com/uber-go/fx).
|
[uber/fx](https://github.com/uber-go/fx).
|
||||||
- **Structured logging**: All logs use `log/slog` with JSON output in
|
- **Structured logging**: All logs use `log/slog` with JSON output in production
|
||||||
production (TTY detection for development).
|
(TTY detection for development).
|
||||||
- **Graceful shutdown**: All background goroutines respect context
|
- **Graceful shutdown**: All background goroutines respect context cancellation
|
||||||
cancellation and the fx lifecycle. In-flight notification deliveries
|
and the fx lifecycle. In-flight notification deliveries are drained on
|
||||||
are drained on shutdown, bounded by the shutdown timeout.
|
shutdown, bounded by the shutdown timeout.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is loaded via [Viper](https://github.com/spf13/viper) with
|
Configuration is loaded via [Viper](https://github.com/spf13/viper) with the
|
||||||
the following precedence (highest to lowest):
|
following precedence (highest to lowest):
|
||||||
|
|
||||||
1. Environment variables (prefixed with `DNSWATCHER_`)
|
1. Environment variables (prefixed with `DNSWATCHER_`)
|
||||||
2. `.env` file (loaded via godotenv)
|
2. `.env` file (loaded via godotenv)
|
||||||
@@ -329,46 +327,45 @@ the following precedence (highest to lowest):
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---------------------------------|--------------------------------------------|-------------|
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
| `PORT` | HTTP listen port | `8080` |
|
| `PORT` | HTTP listen port | `8080` |
|
||||||
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
|
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
|
||||||
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
|
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
|
||||||
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
|
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
|
||||||
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
|
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
|
||||||
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
|
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
|
||||||
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic 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_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_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` |
|
||||||
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
|
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
|
||||||
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
|
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
|
||||||
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
|
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
|
||||||
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
|
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
|
||||||
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
|
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
|
||||||
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
||||||
|
|
||||||
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
||||||
monitoring targets are configured. A monitoring daemon with nothing to monitor
|
monitoring targets are configured. A monitoring daemon with nothing to monitor
|
||||||
is a misconfiguration, so dnswatcher fails fast with a clear error message
|
is a misconfiguration, so dnswatcher fails fast with a clear error message
|
||||||
rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated
|
rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated list
|
||||||
list of DNS names before starting.
|
of DNS names before starting.
|
||||||
|
|
||||||
**`/metrics` is rate limited.** Each client address may send it 30 requests a
|
**`/metrics` is rate limited.** Each client address may send it 30 requests a
|
||||||
minute, failed logins included; beyond that it answers `429 Too Many Requests`
|
minute, failed logins included; beyond that it answers `429 Too Many Requests`
|
||||||
without checking the password. A Prometheus server scraping every 15 seconds
|
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
|
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,
|
request comes from a private or loopback address, such as a reverse proxy's, the
|
||||||
the client address is taken from the `X-Real-IP` header the proxy sets, or else
|
client address is taken from the `X-Real-IP` header the proxy sets, or else from
|
||||||
from `X-Forwarded-For`, as the last address in it that is not private or
|
`X-Forwarded-For`, as the last address in it that is not private or loopback. A
|
||||||
loopback. A proxy that sets neither makes all its clients share one allowance.
|
proxy that sets neither makes all its clients share one allowance.
|
||||||
|
|
||||||
**`DNSWATCHER_DNS_INTERVAL` and `DNSWATCHER_TLS_INTERVAL`** take a positive
|
**`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
|
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
|
`90s`, `30m`, `1h` or `1h30m`. There is no unit for days; write `24h`. An unset
|
||||||
unset or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If
|
or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If either is
|
||||||
either is set to anything else, including a bare number or a zero or negative
|
set to anything else, including a bare number or a zero or negative duration,
|
||||||
duration, dnswatcher refuses to start with an error naming the variable and
|
dnswatcher refuses to start with an error naming the variable and the value.
|
||||||
the value.
|
|
||||||
|
|
||||||
**`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it
|
**`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
|
is set, a panic in an HTTP request handler is sent to Sentry, and the request
|
||||||
@@ -394,107 +391,107 @@ DNSWATCHER_SEND_TEST_NOTIFICATION=true
|
|||||||
|
|
||||||
## DNS Resolution Strategy
|
## DNS Resolution Strategy
|
||||||
|
|
||||||
dnswatcher never uses the system's configured recursive resolver. Instead,
|
dnswatcher never uses the system's configured recursive resolver. Instead, it
|
||||||
it performs full iterative resolution:
|
performs full iterative resolution:
|
||||||
|
|
||||||
1. **Root servers**: Starts from the IANA root nameserver list (hardcoded,
|
1. **Root servers**: Starts from the IANA root nameserver list (hardcoded, with
|
||||||
with periodic refresh).
|
periodic refresh).
|
||||||
2. **TLD delegation**: Queries root servers for the TLD NS records.
|
2. **TLD delegation**: Queries root servers for the TLD NS records.
|
||||||
3. **Domain delegation**: Queries TLD nameservers for the domain's NS
|
3. **Domain delegation**: Queries TLD nameservers for the domain's NS records.
|
||||||
records.
|
4. **Authoritative query**: Queries all discovered authoritative nameservers
|
||||||
4. **Authoritative query**: Queries all discovered authoritative
|
directly for the requested records.
|
||||||
nameservers directly for the requested records.
|
|
||||||
|
|
||||||
This approach ensures:
|
This approach ensures:
|
||||||
|
|
||||||
- Independence from any upstream resolver's cache or filtering.
|
- Independence from any upstream resolver's cache or filtering.
|
||||||
- Ability to detect split-horizon or inconsistent responses across
|
- Ability to detect split-horizon or inconsistent responses across authoritative
|
||||||
authoritative servers.
|
servers.
|
||||||
- Visibility into the full delegation chain.
|
- Visibility into the full delegation chain.
|
||||||
|
|
||||||
For hostname monitoring, the resolver follows CNAME chains (with a
|
For hostname monitoring, the resolver follows CNAME chains (with a depth limit
|
||||||
depth limit to prevent loops) before collecting terminal A/AAAA records.
|
to prevent loops) before collecting terminal A/AAAA records.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## State File Format
|
## State File Format
|
||||||
|
|
||||||
The state file (`DATA_DIR/state.json`) contains the complete monitoring
|
The state file (`DATA_DIR/state.json`) contains the complete monitoring
|
||||||
snapshot. Hostname records are stored **per authoritative nameserver**,
|
snapshot. Hostname records are stored **per authoritative nameserver**, not as a
|
||||||
not as a merged view, to enable inconsistency detection.
|
merged view, to enable inconsistency detection.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"lastUpdated": "2026-02-19T12:00:00Z",
|
"lastUpdated": "2026-02-19T12:00:00Z",
|
||||||
"domains": {
|
"domains": {
|
||||||
"example.com": {
|
"example.com": {
|
||||||
"nameservers": ["ns1.example.com.", "ns2.example.com."],
|
"nameservers": ["ns1.example.com.", "ns2.example.com."],
|
||||||
"nameserverAddresses": {
|
"nameserverAddresses": {
|
||||||
"ns1.example.com.": ["192.0.2.53", "2001:db8::53"],
|
"ns1.example.com.": ["192.0.2.53", "2001:db8::53"],
|
||||||
"ns2.example.com.": ["198.51.100.53"]
|
"ns2.example.com.": ["198.51.100.53"]
|
||||||
},
|
},
|
||||||
"lastChecked": "2026-02-19T12:00:00Z"
|
"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": {
|
"hostnames": {
|
||||||
"open": true,
|
"www.example.com": {
|
||||||
"hostnames": ["www.example.com"],
|
"recordsByNameserver": {
|
||||||
"lastChecked": "2026-02-19T12:00:00Z"
|
"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
|
The `status` field for each per-nameserver entry and certificate entry tracks
|
||||||
tracks reachability:
|
reachability:
|
||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
|-------------|------------------------------------------------------------|
|
| ------- | -------------------------------------------------------- |
|
||||||
| `ok` | Query succeeded, records are current |
|
| `ok` | Query succeeded, records are current |
|
||||||
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
|
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
|
||||||
|
|
||||||
A nameserver that answers NXDOMAIN or with no records has status `ok` and
|
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
|
||||||
empty `records`. A nameserver whose query failed has status `error`, empty
|
`records`. A nameserver whose query failed, or that only referred it to other
|
||||||
`records`, and the reason in `error`.
|
nameservers, has status `error`, empty `records`, and the reason in `error`.
|
||||||
|
|
||||||
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
|
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
|
||||||
resolves to. A state file without it loads, and the next check fills it in
|
resolves to. A state file without it loads, and the next check fills it in
|
||||||
@@ -507,31 +504,38 @@ without a notification.
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call them. We
|
||||||
them. We provide:
|
provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (go, `go mod download`).
|
- `script/bootstrap` — install all dependencies (go, `go mod download`). It does
|
||||||
It does not install golangci-lint: see `script/lint` below.
|
not install golangci-lint or prettier: both run in Docker, see `script/lint`
|
||||||
- `script/setup` — make a fresh clone ready for development: bootstrap
|
and `script/fmt` below.
|
||||||
plus the git pre-commit hook
|
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||||||
- `script/projectname` — print the project name (used for the Docker
|
git pre-commit hook
|
||||||
image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — run the test suite (race detector, coverage). Caching
|
- `script/test` — run the test suite (race detector, coverage). Caching is
|
||||||
is waived for testing, exactly as it is for linting: `-count=1`
|
waived for testing, exactly as it is for linting: `-count=1` forces every
|
||||||
forces every invocation to execute, because the suite queries live
|
invocation to execute, because the suite queries live DNS and a cached pass
|
||||||
DNS and a cached pass queries nothing. Failures are rerun with `-v`
|
queries nothing. Failures are rerun with `-v` automatically, and the build
|
||||||
automatically, and the build fails even if that rerun passes.
|
fails even if that rerun passes.
|
||||||
- `script/lint` — run golangci-lint, always inside Docker: it builds
|
- `script/lint` — run golangci-lint, always inside Docker: it builds
|
||||||
`Dockerfile.lint`, which COPYs the repo into the digest-pinned
|
`Dockerfile.lint`, which COPYs the repo into the digest-pinned `golangci-lint`
|
||||||
`golangci-lint` image and lints as a build step, so a successful
|
image and lints as a build step, so a successful build is a clean lint. The
|
||||||
build is a clean lint. The linter is never installed or run on the
|
linter is never installed or run on the host, and Docker is the only
|
||||||
host, and Docker is the only prerequisite. Caching is waived for
|
prerequisite. Caching is waived for linting: the lint stage is forced to
|
||||||
linting: the lint stage is forced to execute on every run with
|
execute on every run with `--no-cache-filter`, because a cached build lints
|
||||||
`--no-cache-filter`, because a cached build lints nothing.
|
nothing.
|
||||||
- `script/fmt` — format all code (gofmt -s, goimports). goimports runs
|
- `script/fmt` — format all code (gofmt -s, goimports) and all Markdown
|
||||||
with `go run` at a pinned commit, never from your `PATH`.
|
(prettier). goimports runs with `go run` at a pinned commit, never from your
|
||||||
- `script/fmt-check` — check formatting (read-only) with the same tools,
|
`PATH`. prettier runs inside Docker, built from `Dockerfile.fmt` on a
|
||||||
failing on any file `script/fmt` would change
|
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/check` — run test, lint, and fmt-check
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`, with
|
- `script/docker` — build the Docker image tagged via `script/projectname`, with
|
||||||
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
|
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
|
||||||
@@ -540,9 +544,9 @@ them. We provide:
|
|||||||
- `script/cibuild` — CI entrypoint: `docker build` with
|
- `script/cibuild` — CI entrypoint: `docker build` with
|
||||||
`--no-cache-filter=lint,builder`, so the lint stage and the builder stage,
|
`--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
|
which runs the tests, run on every invocation, because a cached build lints
|
||||||
nothing and queries no DNS
|
nothing and queries no DNS; then `script/fmt-check-markdown`
|
||||||
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
|
- `script/precommit` — run by the git pre-commit hook; `go mod tidy` guard, then
|
||||||
guard, then `script/check`
|
`script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -551,21 +555,21 @@ them. We provide:
|
|||||||
make build # Build binary to bin/dnswatcher
|
make build # Build binary to bin/dnswatcher
|
||||||
make test # Run tests with race detector
|
make test # Run tests with race detector
|
||||||
make lint # Run golangci-lint in Docker (requires docker)
|
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 check # Run all checks (test, lint, fmt-check)
|
||||||
make clean # Remove build artifacts
|
make clean # Remove build artifacts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build-Time Variables
|
### Build-Time Variables
|
||||||
|
|
||||||
`make build` sets the version with `-ldflags "-X main.Version=..."`, taking
|
`make build` sets the version with `-ldflags "-X main.Version=..."`, taking it
|
||||||
it from `git describe --tags --always --dirty`, or from `VERSION` when given
|
from `git describe --tags --always --dirty`, or from `VERSION` when given on the
|
||||||
on the command line (`make build VERSION=1.2.3`). The version appears in the
|
command line (`make build VERSION=1.2.3`). The version appears in the startup
|
||||||
startup log and in the health check response.
|
log and in the health check response.
|
||||||
|
|
||||||
The Docker image has no `.git`, so the `Dockerfile` takes the version as
|
The Docker image has no `.git`, so the `Dockerfile` takes the version as
|
||||||
`--build-arg VERSION`. `make docker` passes it; a plain `docker build`
|
`--build-arg VERSION`. `make docker` passes it; a plain `docker build` passes
|
||||||
passes none, and that image reports `dev`.
|
none, and that image reports `dev`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -586,84 +590,80 @@ docker run -d \
|
|||||||
|
|
||||||
## Running under upaas
|
## Running under upaas
|
||||||
|
|
||||||
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this
|
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this repository's
|
||||||
repository's `Dockerfile` and runs it. The app needs:
|
`Dockerfile` and runs it. The app needs:
|
||||||
|
|
||||||
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
|
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to `prod`
|
||||||
`prod` pull request is a deploy.
|
pull request is a deploy.
|
||||||
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
|
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where the
|
||||||
the state file lives.
|
state file lives.
|
||||||
- **Network and port:** the dashboard is unauthenticated and shows every
|
- **Network and port:** the dashboard is unauthenticated and shows every watched
|
||||||
watched name and recent alert, and upaas publishes every mapped port on
|
name and recent alert, and upaas publishes every mapped port on all interfaces
|
||||||
all interfaces of the host
|
of the host ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)).
|
||||||
([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a
|
Add a port mapping to container port `8080` only if the dashboard should be
|
||||||
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
|
||||||
public. Otherwise add none: set the app's Docker network in upaas to
|
reverse proxy's Docker network, and the proxy reaches the app at `upaas-`
|
||||||
your reverse proxy's Docker network, and the proxy reaches the app at
|
followed by the app name, port `8080`.
|
||||||
`upaas-` followed by the app name, port `8080`.
|
- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list of the
|
||||||
- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list
|
domains and hostnames to watch. dnswatcher refuses to start without it.
|
||||||
of the domains and hostnames to watch. dnswatcher refuses to start
|
|
||||||
without it.
|
|
||||||
- **Recommended environment:** at least one notification endpoint
|
- **Recommended environment:** at least one notification endpoint
|
||||||
(`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`,
|
(`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`,
|
||||||
`DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the
|
`DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the dashboard.
|
||||||
dashboard. `DNSWATCHER_METRICS_USERNAME` and
|
`DNSWATCHER_METRICS_USERNAME` and `DNSWATCHER_METRICS_PASSWORD` serve
|
||||||
`DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth.
|
`/metrics` behind basic auth.
|
||||||
- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to
|
- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to
|
||||||
`/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every
|
`/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every setting
|
||||||
setting comes from the environment; the image holds no config file.
|
comes from the environment; the image holds no config file.
|
||||||
- **Health check:** the image's own, which requests
|
- **Health check:** the image's own, which requests `/.well-known/healthcheck`
|
||||||
`/.well-known/healthcheck` every 10 seconds. upaas reads the
|
every 10 seconds. upaas reads the container's health 60 seconds after a deploy
|
||||||
container's health 60 seconds after a deploy and marks the deploy
|
and marks the deploy failed unless it is `healthy`.
|
||||||
failed unless it is `healthy`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Monitoring Lifecycle
|
## Monitoring Lifecycle
|
||||||
|
|
||||||
1. **Startup**: Check that the data directory can be written, and exit
|
1. **Startup**: Check that the data directory can be written, and exit with an
|
||||||
with an error naming it if not. Load state from disk. If no state
|
error naming it if not. Load state from disk. If no state file exists, start
|
||||||
file exists, start with empty state (first check will establish
|
with empty state (first check will establish baseline without triggering
|
||||||
baseline without triggering change notifications).
|
change notifications).
|
||||||
2. **Initial check**: Immediately perform all DNS, port, and TLS checks
|
2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
|
||||||
on startup.
|
startup.
|
||||||
3. **Periodic checks** (DNS always runs first):
|
3. **Periodic checks** (DNS always runs first):
|
||||||
- DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also
|
- DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also re-run
|
||||||
re-run before every TLS check cycle to ensure fresh IPs.
|
before every TLS check cycle to ensure fresh IPs.
|
||||||
- Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes.
|
- Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes.
|
||||||
- TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after
|
- TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after DNS
|
||||||
DNS completes.
|
completes.
|
||||||
- Port and TLS checks always use freshly resolved IP addresses from
|
- Port and TLS checks always use freshly resolved IP addresses from the DNS
|
||||||
the DNS phase that immediately precedes them — never stale IPs
|
phase that immediately precedes them — never stale IPs from a previous
|
||||||
from a previous cycle.
|
cycle.
|
||||||
4. **On change detection**: Send notifications to all configured
|
4. **On change detection**: Send notifications to all configured endpoints,
|
||||||
endpoints, update in-memory state, persist to disk.
|
update in-memory state, persist to disk.
|
||||||
5. **Shutdown**: The watcher stops checking and saves the final state
|
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
|
||||||
to disk, and shutdown waits for that save before it goes on. Then it
|
and shutdown waits for that save before it goes on. Then it waits for
|
||||||
waits for in-flight notification deliveries to complete. Both waits
|
in-flight notification deliveries to complete. Both waits share the fx
|
||||||
share the fx shutdown timeout (15s by default): deliveries still
|
shutdown timeout (15s by default): deliveries still retrying against an
|
||||||
retrying against an unreachable endpoint when that expires are
|
unreachable endpoint when that expires are abandoned, and the number
|
||||||
abandoned, and the number abandoned is logged at warn level rather
|
abandoned is logged at warn level rather than dropped silently. Notifications
|
||||||
than dropped silently. Notifications generated after shutdown has
|
generated after shutdown has begun are refused and logged, so a late burst
|
||||||
begun are refused and logged, so a late burst cannot extend the
|
cannot extend the shutdown. A DNS lookup, port check or TLS check that
|
||||||
shutdown. A DNS lookup, port check or TLS check that shutdown cuts
|
shutdown cuts short saves nothing and sends no notification.
|
||||||
short saves nothing and sends no notification.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Planned Future Features (Post-1.0)
|
## Planned Future Features (Post-1.0)
|
||||||
|
|
||||||
- **DNSSEC validation**: Validate the DNSSEC chain of trust during
|
- **DNSSEC validation**: Validate the DNSSEC chain of trust during iterative
|
||||||
iterative resolution and report DNSSEC failures as notifications.
|
resolution and report DNSSEC failures as notifications.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
Follows the conventions defined in `REPO_POLICIES.md`, adapted from the
|
Follows the conventions defined in `REPO_POLICIES.md`, adapted from the
|
||||||
[upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx
|
[upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx for
|
||||||
for dependency injection, go-chi for HTTP routing, slog for logging, and
|
dependency injection, go-chi for HTTP routing, slog for logging, and Viper for
|
||||||
Viper for configuration.
|
configuration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+26
-27
@@ -2,44 +2,43 @@
|
|||||||
|
|
||||||
## DNS Resolution Tests
|
## DNS Resolution Tests
|
||||||
|
|
||||||
DNS is never mocked in this project, not in tests and not anywhere
|
DNS is never mocked in this project, not in tests and not anywhere else; see the
|
||||||
else; see the README section "No DNS mocking. Ever." Every test that
|
README section "No DNS mocking. Ever." Every test that looks something up in DNS
|
||||||
looks something up in DNS **MUST** query live DNS servers, never a
|
**MUST** query live DNS servers, never a stand-in. Logic that works on record
|
||||||
stand-in. Logic that works on record data, such as comparing or
|
data, such as comparing or formatting records, may be tested on that data
|
||||||
formatting records, may be tested on that data directly with no
|
directly with no lookup.
|
||||||
lookup.
|
|
||||||
|
|
||||||
### Rationale
|
### Rationale
|
||||||
|
|
||||||
The resolver performs iterative resolution from root nameservers through
|
The resolver performs iterative resolution from root nameservers through the
|
||||||
the full delegation chain. Mocked responses cannot faithfully represent
|
full delegation chain. Mocked responses cannot faithfully represent the variety
|
||||||
the variety of real-world DNS behavior (truncation, referrals, glue
|
of real-world DNS behavior (truncation, referrals, glue records, DNSSEC, varied
|
||||||
records, DNSSEC, varied response times, EDNS, etc.). Testing against
|
response times, EDNS, etc.). Testing against real servers ensures the resolver
|
||||||
real servers ensures the resolver works correctly in production.
|
works correctly in production.
|
||||||
|
|
||||||
### Constraints
|
### Constraints
|
||||||
|
|
||||||
- Tests hit real DNS infrastructure and require network access
|
- Tests hit real DNS infrastructure and require network access
|
||||||
- Test duration depends on network conditions; timeout tuning keeps
|
- Test duration depends on network conditions; timeout tuning keeps the suite
|
||||||
the suite within the 60-second target
|
within the 60-second target
|
||||||
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
|
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus
|
||||||
plus processing margin
|
processing margin
|
||||||
- Root server fan-out is limited to reduce parallel query load
|
- Root server fan-out is limited to reduce parallel query load
|
||||||
- Live lookups that expect an answer go through `internal/livednstest`,
|
- Live lookups that expect an answer go through `internal/livednstest`, which
|
||||||
which limits how many run at once in a test binary and retries a
|
limits how many run at once in a test binary and retries a lookup that got
|
||||||
lookup that got none
|
none
|
||||||
- Flaky failures from transient network issues are acceptable and
|
- Flaky failures from transient network issues are acceptable and should be
|
||||||
should be investigated as potential resolver bugs, not papered over
|
investigated as potential resolver bugs, not papered over with mocks or skip
|
||||||
with mocks or skip flags
|
flags
|
||||||
|
|
||||||
### What NOT to do
|
### What NOT to do
|
||||||
|
|
||||||
- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`,
|
- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`, no
|
||||||
no stand-in for the watcher's `DNSResolver`, no fake DNS server, no
|
stand-in for the watcher's `DNSResolver`, no fake DNS server, no canned
|
||||||
canned responses
|
responses
|
||||||
- **Do not add `-short` flags** to skip slow tests
|
- **Do not add `-short` flags** to skip slow tests
|
||||||
- **Do not increase `-timeout`** to hide hanging queries
|
- **Do not increase `-timeout`** to hide hanging queries
|
||||||
- **Do not remove `-count=1` from `script/test`** — Go's test cache
|
- **Do not remove `-count=1` from `script/test`** — Go's test cache replays a
|
||||||
replays a previous run's output without querying anything, so a
|
previous run's output without querying anything, so a cached pass is not
|
||||||
cached pass is not evidence that live resolution works
|
evidence that live resolution works
|
||||||
- **Do not modify linter configuration** to suppress findings
|
- **Do not modify linter configuration** to suppress findings
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
* branch (from `next`)
|
- branch (from `next`)
|
||||||
* do the work in Next Step
|
- do the work in Next Step
|
||||||
* move Next Step to the top of Completed Steps
|
- move Next Step to the top of Completed Steps
|
||||||
* move the top item of Future Steps into Next Step
|
- move the top item of Future Steps into Next Step
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
* push
|
- push
|
||||||
* open a PR against `next`
|
- open a PR against `next`
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
@@ -15,13 +15,16 @@ on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
trial run of the finished image:
|
trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||||
https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-01: when none of a configured name's nameservers answered, the port
|
- 2026-10-01: when none of a configured name's nameservers answered, the port
|
||||||
state saved for its addresses is kept, not removed (closes #193).
|
state saved for its addresses is kept, not removed (closes #193).
|
||||||
|
- 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no
|
||||||
|
nameserver of the name's zone answered (closes #190).
|
||||||
|
- 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
|
- 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).
|
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
|
- 2026-10-01: a hostname is queried at the servers of the zone it is in, found
|
||||||
@@ -116,8 +119,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
- 1.0 readiness: run it with a real config and read the logs:
|
- 1.0 readiness: run it with a real config and read the logs:
|
||||||
https://git.eeqj.de/sneak/dnswatcher/issues/66
|
https://git.eeqj.de/sneak/dnswatcher/issues/66
|
||||||
- Markdown formatting with prettier:
|
|
||||||
https://git.eeqj.de/sneak/dnswatcher/issues/119
|
|
||||||
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
|
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
|
||||||
- README sections required by policy:
|
- README sections required by policy:
|
||||||
https://git.eeqj.de/sneak/dnswatcher/issues/173
|
https://git.eeqj.de/sneak/dnswatcher/issues/173
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ var (
|
|||||||
"no authoritative nameservers found",
|
"no authoritative nameservers found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoNameserverAnswered is returned when every nameserver
|
||||||
|
// asked about a name timed out, failed or returned a referral,
|
||||||
|
// so whether the name has addresses is unknown.
|
||||||
|
ErrNoNameserverAnswered = errors.New("no nameserver answered")
|
||||||
|
|
||||||
// ErrCNAMEDepthExceeded is returned when a CNAME chain
|
// ErrCNAMEDepthExceeded is returned when a CNAME chain
|
||||||
// exceeds MaxCNAMEDepth.
|
// exceeds MaxCNAMEDepth.
|
||||||
ErrCNAMEDepthExceeded = errors.New(
|
ErrCNAMEDepthExceeded = errors.New(
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ func ExtractRecordValue(rr dns.RR) string {
|
|||||||
return extractRecordValue(rr)
|
return extractRecordValue(rr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CollectIPs exports collectIPs for testing.
|
||||||
|
func CollectIPs(
|
||||||
|
results map[string]*NameserverResponse,
|
||||||
|
) ([]string, string, error) {
|
||||||
|
return collectIPs(results)
|
||||||
|
}
|
||||||
|
|
||||||
// QueryEachNS exports queryEachNS for testing.
|
// QueryEachNS exports queryEachNS for testing.
|
||||||
func (r *Resolver) QueryEachNS(
|
func (r *Resolver) QueryEachNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ type queryState struct {
|
|||||||
gotSERVFAIL bool
|
gotSERVFAIL bool
|
||||||
gotRefused bool
|
gotRefused bool
|
||||||
gotTimeout bool
|
gotTimeout bool
|
||||||
|
gotReferral bool
|
||||||
netErr error
|
netErr error
|
||||||
hasRecords bool
|
hasRecords bool
|
||||||
}
|
}
|
||||||
@@ -578,6 +579,18 @@ func (r *Resolver) querySingleType(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A reply with no answer that lists other nameservers, from a server
|
||||||
|
// that does not hold the name's zone, is a referral and says nothing
|
||||||
|
// about the name's records. A parent zone's servers send one when
|
||||||
|
// every server of the name's own zone failed and
|
||||||
|
// FindAuthoritativeNameservers moved on to the parent name.
|
||||||
|
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
||||||
|
len(extractNSSet(msg.Ns)) > 0 {
|
||||||
|
state.gotReferral = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
collectAnswerRecords(msg, resp, state)
|
collectAnswerRecords(msg, resp, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +639,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
|||||||
case state.netErr != nil && !state.hasRecords:
|
case state.netErr != nil && !state.hasRecords:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "network error: " + state.netErr.Error()
|
resp.Error = "network error: " + state.netErr.Error()
|
||||||
|
case state.gotReferral && !state.hasRecords:
|
||||||
|
resp.Status = StatusError
|
||||||
|
resp.Error = "server returned a referral"
|
||||||
case !state.hasRecords && !state.gotNXDomain:
|
case !state.hasRecords && !state.gotNXDomain:
|
||||||
resp.Status = StatusNoData
|
resp.Status = StatusNoData
|
||||||
}
|
}
|
||||||
@@ -734,7 +750,9 @@ func (r *Resolver) LookupAllRecords(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
|
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
|
||||||
// addresses, following CNAME chains up to MaxCNAMEDepth.
|
// addresses, following CNAME chains up to MaxCNAMEDepth. When no
|
||||||
|
// nameserver of the name's zone answered, it returns an error rather
|
||||||
|
// than no addresses.
|
||||||
func (r *Resolver) ResolveIPAddresses(
|
func (r *Resolver) ResolveIPAddresses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
@@ -760,7 +778,10 @@ func (r *Resolver) resolveIPWithCNAME(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, cnameTarget := collectIPs(results)
|
ips, cnameTarget, err := collectIPs(results)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolving %s: %w", hostname, err)
|
||||||
|
}
|
||||||
|
|
||||||
if len(ips) == 0 && cnameTarget != "" {
|
if len(ips) == 0 && cnameTarget != "" {
|
||||||
return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1)
|
return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1)
|
||||||
@@ -771,16 +792,28 @@ func (r *Resolver) resolveIPWithCNAME(
|
|||||||
return ips, nil
|
return ips, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectIPs returns the addresses in the nameservers' answers and the
|
||||||
|
// first CNAME target among them. It returns ErrNoNameserverAnswered when
|
||||||
|
// every nameserver timed out, failed or returned a referral: that is not
|
||||||
|
// a name with no addresses.
|
||||||
func collectIPs(
|
func collectIPs(
|
||||||
results map[string]*NameserverResponse,
|
results map[string]*NameserverResponse,
|
||||||
) ([]string, string) {
|
) ([]string, string, error) {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
var ips []string
|
var ips []string
|
||||||
|
|
||||||
var cnameTarget string
|
var cnameTarget string
|
||||||
|
|
||||||
|
answered := false
|
||||||
|
|
||||||
for _, resp := range results {
|
for _, resp := range results {
|
||||||
|
if resp.Status == StatusTimeout || resp.Status == StatusError {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
answered = true
|
||||||
|
|
||||||
if resp.Status == StatusNXDomain {
|
if resp.Status == StatusNXDomain {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -804,5 +837,9 @@ func collectIPs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ips, cnameTarget
|
if !answered {
|
||||||
|
return nil, "", ErrNoNameserverAnswered
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, cnameTarget, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,42 @@ import (
|
|||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestCollectIPs_OneAnswerIsEnough checks that one nameserver answering
|
||||||
|
// NXDOMAIN says the name has no addresses, though the other timed out.
|
||||||
|
func TestCollectIPs_OneAnswerIsEnough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{
|
||||||
|
"ns1.example.": {Status: resolver.StatusTimeout},
|
||||||
|
"ns2.example.": {Status: resolver.StatusNXDomain},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollectIPs_FailedIsNoAnswer checks that nameservers that all have
|
||||||
|
// status error, from a refusal, a server failure, a network error or a
|
||||||
|
// referral, are no answer rather than a name with no addresses.
|
||||||
|
func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{
|
||||||
|
"ns1.example.": {Status: resolver.StatusError},
|
||||||
|
"ns2.example.": {Status: resolver.StatusError},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -633,6 +633,82 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
|
|||||||
assert.NotEmpty(t, resp.Error)
|
assert.NotEmpty(t, resp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCollectIPs_NoNameserverAnswered takes the response of a
|
||||||
|
// nameserver at 192.0.2.1, where nothing answers, as
|
||||||
|
// TestQueryNameserverIP_Timeout does. Addresses collected from
|
||||||
|
// nameservers that all failed to answer are an error, not none.
|
||||||
|
func TestCollectIPs_NoNameserverAnswered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
// The deadline outlasts the first try, as in
|
||||||
|
// TestQueryNameserverIP_Timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(
|
||||||
|
context.Background(), 3*time.Second,
|
||||||
|
)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserverIP(
|
||||||
|
ctx, "unreachable.test.", "192.0.2.1",
|
||||||
|
"example.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{resp.Nameserver: resp},
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollectIPs_ReferralIsNoAnswer asks a root server about
|
||||||
|
// example.com, which the root zone does not hold, so it only refers the
|
||||||
|
// query to the com servers. That reply is no answer, as is a parent
|
||||||
|
// zone's when every server of the name's own zone failed.
|
||||||
|
func TestCollectIPs_ReferralIsNoAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
var resp *resolver.NameserverResponse
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryNameserverIP(a.root-servers.net, example.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
resp, err = r.QueryNameserverIP(
|
||||||
|
ctx, "a.root-servers.net.", "198.41.0.4",
|
||||||
|
"example.com",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timeout or a network error is no reply at all.
|
||||||
|
if resp.Status == resolver.StatusTimeout ||
|
||||||
|
strings.HasPrefix(resp.Error, "network error") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", livednstest.ErrNoAnswer, resp.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, resolver.StatusError, resp.Status)
|
||||||
|
assert.Equal(t, "server returned a referral", resp.Error)
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{resp.Nameserver: resp},
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
|
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -322,10 +322,9 @@ func (w *Watcher) detectNSChanges(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveNameserverAddresses returns the sorted addresses each
|
// resolveNameserverAddresses returns the sorted addresses each
|
||||||
// nameserver's name resolves to. A nameserver whose lookup fails or
|
// nameserver's name resolves to. A nameserver whose lookup fails, as it
|
||||||
// finds no address keeps its addresses from prev: the resolver finds no
|
// does when no nameserver of the name's zone answers, or finds no
|
||||||
// address, without an error, when every server it asks times out, and
|
// address keeps its addresses from prev and is not an address change.
|
||||||
// that is not an address change.
|
|
||||||
func (w *Watcher) resolveNameserverAddresses(
|
func (w *Watcher) resolveNameserverAddresses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nameservers []string,
|
nameservers []string,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-6
@@ -3,11 +3,12 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes nothing is present.
|
# or apk (detected in that order); assumes nothing is present.
|
||||||
# goimports is not installed here: script/fmt and script/fmt-check run
|
# goimports is not installed here: script/fmt and script/fmt-check-go
|
||||||
# it with `go run` at a pinned commit.
|
# run it with `go run` at a pinned commit.
|
||||||
# The linter is NOT installed here: golangci-lint runs via docker only
|
# The linter is NOT installed here: golangci-lint runs via docker only
|
||||||
# (script/lint), pinned by image digest, so its only prerequisite is a
|
# (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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -66,11 +67,12 @@ main() {
|
|||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Linting runs via docker only (script/lint). Warn, don't fail:
|
# Linting and the markdown formatter run via docker only. Warn,
|
||||||
# everything except `make lint` works without it.
|
# don't fail: building and testing work without it.
|
||||||
if missing docker; then
|
if missing docker; then
|
||||||
echo "bootstrap: WARNING: docker not found; install it to" \
|
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
|
fi
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|||||||
+9
-4
@@ -1,18 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
||||||
# make fmt-check and golangci-lint; its builder stage runs make test
|
# the Go half of make fmt-check and golangci-lint; its builder stage
|
||||||
# and make build.
|
# 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;
|
# --no-cache-filter=lint,builder runs both stages on every invocation;
|
||||||
# otherwise an unchanged tree is served from the layer cache and passes
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build --no-cache-filter=lint,builder .
|
docker build --no-cache-filter=lint,builder .
|
||||||
|
"$SCRIPT_DIR/fmt-check-markdown"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+48
-2
@@ -1,19 +1,65 @@
|
|||||||
#!/bin/sh
|
#!/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
|
# goimports runs with `go run` at a pinned commit, never from PATH, so
|
||||||
# every machine formats with the same version and nothing installs it.
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
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"
|
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() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
gofmt -s -w .
|
gofmt -s -w .
|
||||||
go run "$GOIMPORTS_REF" -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 "$@"
|
main "$@"
|
||||||
|
|||||||
+5
-18
@@ -1,27 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/fmt-check: check formatting (read-only). Same tools and scope
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(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() {
|
main() {
|
||||||
cd "$ROOT"
|
"$SCRIPT_DIR/fmt-check-go"
|
||||||
files="$(gofmt -s -l .)"
|
"$SCRIPT_DIR/fmt-check-markdown"
|
||||||
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 "$@"
|
main "$@"
|
||||||
|
|||||||
Executable
+31
@@ -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 "$@"
|
||||||
Executable
+29
@@ -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 "$@"
|
||||||
@@ -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==
|
||||||
Reference in New Issue
Block a user