Compare commits

...
Author SHA1 Message Date
sneak db933f32a6 build: always install pinned lint tools in script/bootstrap (closes #117)
check / check (push) Successful in 36s
`script/bootstrap` guarded its pinned `go install` calls with
`missing()`, which only tests whether a binary is on `PATH`. On any
machine that already had some `golangci-lint`, the install was skipped
and the commit pin had no effect: a v1.x binary cannot parse this
repo's v2-schema `.golangci.yml`, and a different v2.x can silently
disagree with CI. The same reasoning made the v2.12.2 pin bump inert
on every already-provisioned machine.

Install both pinned tools unconditionally. `go install` at a fixed
commit ref is idempotent and cheap with a warm module cache, so
skipping it saved nothing. The `missing()` presence check is kept for
`git`, `make`, and `go`, which really are system-package presence
checks.

Also warn when `PATH` resolves either tool somewhere other than the
directory `go install` writes to, since a shadowing copy earlier on
`PATH` is what `make lint` and `make fmt` would actually run. This is
a warning, not a failure: the remedy is the user's `PATH`.

The pins themselves are unchanged and still match the Dockerfile.
2026-08-09 14:54:18 +00:00
clawbotandsneak 9347a2838b build: update golangci-lint to v2.12.2 with org-standard v2 config (#96)
check / check (push) Successful in 4s
Updates golangci-lint to v2.12.2 and sets `.golangci.yml` to the org-standard v2-schema config already deployed across the org's repos. The config change is owner-authorized (see #96 (comment) and #96 (comment)); the same file is being landed as canonical via prompts PR #24 (sneak/prompts#24).

## Changes

- **Commit-pinned installs**: golangci-lint pinned to commit `c0d3ddc9cf3faa61a4e378e879ece580256d76e5` (v2.12.2, released 2026-05-06) in `Dockerfile` and `script/bootstrap`.
- **`.golangci.yml` set to the org-standard v2 config** (sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`), byte-identical to the file used across the org's other repos. Settings live under `linters.settings`, so the `lll`/`funlen`/`cyclop`/`dupl` thresholds are actually applied (under the old hybrid file, v2 silently ignored the top-level `linters-settings` block).
- **Lint fixes** required by the now-active thresholds:
  - `goconst`: shared constants for repeated status/priority/DNS-fixture strings in `internal/watcher/watcher.go` and the notify, state, and watcher tests
  - `dupl`: consolidated duplicated ntfy/slack HTTP-error tests and SendNotification endpoint-error tests behind shared helpers in `internal/notify/delivery_test.go`
  - `lll`: wrapped long test table entries and comments in `internal/config/classify_test.go`, `internal/notify/history_test.go`, `internal/state/state_test.go`, `internal/watcher/watcher_test.go`; shortened one inline nolint justification in `internal/notify/retry.go`
- **`TODO.md`**: Completed Steps entry updated in the same commit.
- Rebased onto current `main` (`f79cd98`); the branch is one clean commit.

## Notes

- v2.12 deprecates the `gomodguard` linter in favor of `gomodguard_v2`. The org-standard config does not disable the deprecated linter, so golangci-lint may emit an informational deprecation warning; this is accepted by the owner and does not affect the exit status (this exact config+code combination was CI-green at `dea7e44`).

## Verification

- `make check` exits 0 (fmt-check, tests, lint)
- `make lint`: 0 issues; no deprecation warning surfaced in the runs performed
- sha256 of `.golangci.yml` at HEAD verified equal to `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #96
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 23:15:47 +02:00
clawbotandsneak f79cd98107 docs: document the no-DNS-mocking policy in README (closes #94) (#95)
check / check (push) Successful in 5s
Adds a prominent "No DNS mocking. Ever." section near the top of `README.md`, per owner policy (sneak, 2026-08-07):

- DNS is never mocked in this project — no mock resolvers, fake DNS servers, or stubbed lookups, in tests or anywhere else.
- Tests exercise real iterative resolution against live nameservers by design.
- Flaky live tests are fixed with robustness (retries, multiple nameservers, timeouts) or explicit opt-in gating decided by the owner — never with mocks.
- Contributions introducing DNS mocks will be rejected.

Markdown-only change; matches the README's existing tone and hard-wrap style. `script/fmt` covers Go only, so no formatter output applies to this file. Verified via `script/cibuild` (docker build runs `make check` with the pinned toolchain) — green. A direct local `make check` shows 21 pre-existing `goconst` lint findings that come from a newer local `golangci-lint` (v2.12.2 vs the pinned v2.10.1) and are unrelated to this change.

Related: #93 is being reframed under this policy.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #95
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 22:31:48 +02:00
sneak b72c436fda scripts-to-rule-them-all (#92)
check / check (push) Successful in 4s
Reviewed-on: #92
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:14:32 +02:00
sneak 4463c56490 TODO (#91)
check / check (push) Successful in 5s
Reviewed-on: #91
2026-07-06 21:20:44 +02:00
26 changed files with 1066 additions and 337 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-28 # actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build . - run: script/cibuild
+5 -3
View File
@@ -1,5 +1,9 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -14,8 +18,7 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
linters-settings:
lll: lll:
line-length: 88 line-length: 88
funlen: funlen:
@@ -27,6 +30,5 @@ linters-settings:
threshold: 100 threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
+2 -2
View File
@@ -4,8 +4,8 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4
RUN apk add --no-cache git make gcc musl-dev binutils-gold RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.10.1 # golangci-lint v2.12.2, 2026-08-07
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
# goimports v0.42.0 # goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
+24 -28
View File
@@ -1,48 +1,44 @@
.PHONY: all build lint fmt fmt-check test check clean hooks docker .PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker
BINARY := dnswatcher BINARY := dnswatcher
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md).
all: check build all: check build
bootstrap:
@script/bootstrap
setup:
@script/setup
build: build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher
test:
@script/test
lint: lint:
golangci-lint run --config .golangci.yml ./... @script/lint
fmt: fmt:
gofmt -s -w . @script/fmt
goimports -w .
fmt-check: fmt-check:
@test -z "$$(gofmt -l .)" || (echo "gofmt: files not formatted:" && gofmt -l . && exit 1) @script/fmt-check
test:
go test -v -race -timeout 30s -cover ./...
# Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong
check: check:
@echo "==> Checking formatting..." @script/check
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..." docker:
golangci-lint run --config .golangci.yml ./... @script/docker
@echo "==> Running tests..."
go test -v -race -timeout 30s ./... hooks:
@echo "==> Building..." @script/install-precommit
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
@echo "==> All checks passed!"
clean: clean:
rm -rf bin/ rm -rf bin/
hooks:
@echo '#!/bin/sh' > .git/hooks/pre-commit
@echo 'make check' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."
docker:
docker build .
+47 -1
View File
@@ -17,6 +17,26 @@ without requiring an external database.
--- ---
## No DNS mocking. Ever.
**DNS is never mocked in this project — not in tests, not anywhere else.**
No mock resolvers, no fake DNS servers, no stubbed lookups.
dnswatcher's entire purpose is correct behavior against the real DNS.
Tests exercise real iterative resolution against live nameservers by
design; a test suite that passes against a mock proves nothing about the
one thing this program exists to do.
When live tests are flaky, that is a robustness problem, and it gets
fixed with robustness: retries with backoff, querying multiple
independent nameservers, longer timeouts — or explicit opt-in gating
decided by the project owner. Never with mocks.
Contributions that introduce mocked, faked, or stubbed DNS will be
rejected.
---
## Features ## Features
### DNS Domain Monitoring (Apex Domains) ### DNS Domain Monitoring (Apex Domains)
@@ -352,6 +372,32 @@ tracks reachability:
--- ---
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (go, pinned
golangci-lint and goimports, `go mod download`)
- `script/setup` — make a fresh clone ready for development: bootstrap
plus the git pre-commit hook
- `script/projectname` — print the project name (used for the Docker
image tag)
- `script/test` — run the test suite (race detector, coverage)
- `script/lint` — run golangci-lint
- `script/fmt` — format all code (gofmt -s, goimports)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via
`script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
guard, then `script/check`
- `script/install-precommit` — install the git pre-commit hook
## Building ## Building
```sh ```sh
@@ -359,7 +405,7 @@ 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 make lint # Run golangci-lint
make fmt # Format code make fmt # Format code
make check # Run all checks (format, lint, test, build) make check # Run all checks (test, lint, fmt-check)
make clean # Remove build artifacts make clean # Remove build artifacts
``` ```
+235 -15
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-02-22 last_modified: 2026-07-06
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,10 +34,46 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule. every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets: - Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make hooks` (installs pre-commit hook). A model Makefile is at `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single instead of invoking the underlying tools directly. The Makefile is the single
@@ -57,11 +93,83 @@ style conventions are in separate documents:
as a build step so the build fails if the branch is not green. For non-server as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build `make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`, runs `script/cibuild` (which runs `docker build .`) on push. Since the
a successful build implies all checks pass. Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -69,9 +177,11 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`. HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise - Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
`make lint && make fmt-check`. The Makefile should provide a `make hooks` testing is not possible in the repo, `script/precommit` may skip `script/test`
target to install the pre-commit hook. and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard - All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -82,6 +192,42 @@ style conventions are in separate documents:
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile. Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes. - Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary - `make check` must not modify any files in the repo. Tests may use temporary
@@ -98,6 +244,13 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`. - Never force-push to `main`.
@@ -121,12 +274,76 @@ style conventions are in separate documents:
- Dockerized web services listen on port 8080 by default, overridable with - Dockerized web services listen on port 8080 by default, overridable with
`PORT`. `PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections: - `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose, - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example: category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives "µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
- **Design**: How is the program structured? - **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put - **TODO**: Update meticulously, even between commits. When planning, put
@@ -145,11 +362,11 @@ style conventions are in separate documents:
- Database migrations live in `internal/db/migrations/` and must be embedded in - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations tracking - `000_migration.sql` — contains ONLY the creation of the migrations
table itself. Nothing else. tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema. - `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There - **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
is no installed base to migrate. Edit `001_schema.sql` directly. There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change. - **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release. Never edit existing migrations after release.
@@ -181,6 +398,9 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
+160
View File
@@ -0,0 +1,160 @@
# Workflow
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status
pre-1.0. No git tags. Core resolver work in flight on feature/resolver
(dirty: internal/resolver/resolver_test.go). Local checkout has diverged
from origin: origin/main is 8 commits ahead (watcher orchestrator,
unified TARGETS) and origin/feature/resolver already contains the full
iterative resolver implementation with hermetic mocked tests.
# Next Step
Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig,
.dockerignore, and .gitea/workflows/check.yml, and add the missing
fmt-check, docker, and hooks targets to the Makefile. One commit, then
confirm make check still passes.
# Completed Steps
- 2026-08-09: `script/bootstrap` now installs the pinned `golangci-lint`
and `goimports` unconditionally instead of only when the binary is
absent from `PATH`, so the commit pins actually take effect on
already-provisioned machines; it also warns when `PATH` resolves
either tool to a copy outside the directory `go install` writes to.
The `missing` presence check is retained for `git`, `make`, and `go`
(#117)
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
org-standard v2-schema config used across the org's repos
(owner-authorized; same file is being landed as canonical via prompts
PR #24), with settings under `linters.settings` so the
lll/funlen/cyclop/dupl thresholds apply; fixed the resulting
`goconst`, `dupl`, and `lll` findings; the informational `gomodguard`
deprecation warning under this config is accepted
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-02-20: iterative DNS resolver implemented; tests made hermetic
with mocked DNS (origin/feature/resolver, unmerged)
- 2026-02-20: CI actions and go install refs pinned to commit SHAs;
Gitea Actions workflow for make check (origin/ci/make-check, unmerged)
- 2026-02-20: watcher monitoring orchestrator merged to main (#8)
- 2026-02-20: DOMAINS/HOSTNAMES unified into single TARGETS config (#11)
- 2026-02-19: TCP port connectivity checker, made concurrent with port
validation; gosec G704 SSRF findings fixed without suppression
(feature branches, unmerged)
- 2026-02-19: TLS certificate inspector with no-peer-certificates error
path and IP SANs (feature branch, unmerged)
- 2026-02-19: gosec SSRF and formatting fixes on main
- 2026-02-19: initial scaffold with per-nameserver DNS monitoring model
# Future Steps
Compliance:
- Add README sections required by policy (Description, Getting Started,
Rationale, Design, TODO, License, Author) if any are missing
- Pin Dockerfile base images by sha256 and ensure the Docker build runs
make check
Branch reconciliation:
- Sync local checkout with origin: local main is 8 commits behind
origin/main; local feature/resolver has diverged from
origin/feature/resolver, which already implements the resolver
- Merge in-flight branches to main once green: feature/resolver,
ci/make-check, feature/portcheck-implementation,
feature/tlscheck-implementation
Resolver (plan from untracked TODO.md; largely implemented on
origin/feature/resolver, verify each item before closing):
- Add github.com/miekg/dns dependency
- roots.go: hardcoded IANA root server list (a through m, IPv4/IPv6),
rootServers() returning ip:53 strings
- query.go: low-level query(ctx, server, name, qtype): UDP with TCP
fallback on truncation, RD=0, context respected, 5s per-query timeout,
returns raw *dns.Msg
- trace.go: iterative delegation chasing from roots: referral detection
(NOERROR, empty answer, NS in authority), glue extraction with
bailiwick check, out-of-bailiwick NS resolved with recursion guard,
delegation depth limit (20), retry across nameservers on failure, do
not chase CNAMEs inside trace
- FindAuthoritativeNameservers: NS set via trace, sorted, FQDN
normalized, trailing dot handled; must pass its 9 tests
- QueryNameserver: resolve NS host to IPs, query A/AAAA/CNAME/MX/TXT/
SRV/CAA/NS, build NameserverResponse with status mapping (OK,
NXDomain, NoData, Error), documented record formatting, sorted values,
lame delegation detection; must pass its 16 tests
- QueryAllNameservers: find NS set for parent domain (public suffix
list), query all NS in parallel with bounded concurrency, return map
even when all fail, context cancellation; must pass its 4 tests
- LookupNS: thin wrapper over FindAuthoritativeNameservers, sorted,
identical results; must pass its 3 tests
- ResolveIPAddresses: collect A/AAAA from all NS, follow CNAME chains
with MaxCNAMEDepth, dedupe, sort, NXDOMAIN returns empty slice with
nil error; must pass its 9 tests
- All 39 resolver tests pass, make check green, merge to main
Watcher (internal/watcher/watcher.go):
- Scheduling loop in Run(ctx): initial check on startup, separate
tickers for DNS/port and TLS intervals, persist state via state.Save()
after each cycle, clean shutdown on context cancel
- Domain check: LookupNS, compare to stored state, store silently on
first run, notify with old/new NS lists on change
- Hostname check: QueryAllNameservers, compare per-NS records; notify on
record changes, NS failure, NS recovery, inconsistency detected,
inconsistency resolved, empty response; store silently on first run
- Port check: ResolveIPAddresses, check ports 80 and 443 per IP, notify
on open/closed transitions, handle new and disappeared IPs
- TLS check: for each open IP:443, CheckCertificate; notify on expiry
warning, certificate change (CN/issuer/SANs), TLS failure/recovery
Port checker (internal/portcheck/portcheck.go):
- Tests against known-open ports and RFC documentation IPs
- CheckPort: net.DialTimeout (5s), context respected; (true, nil) open,
(false, nil) closed/timeout/refused, error only for unexpected
failures
TLS checker (internal/tlscheck/tlscheck.go):
- Tests against known public HTTPS servers, verify fields populated
- CheckCertificate: tls.Dial to specific IP:443 with hostname as SNI;
extract subject CN, issuer CN and org, NotAfter, SANs; error on
handshake failure
Notification service (internal/notify/notify.go, Slack/Mattermost/ntfy
backends exist):
- Structured notification types: DNS change, port change, TLS expiry,
TLS change, NS failure, NS recovery, NS inconsistency
- Per-backend formatting: Slack/Mattermost attachment colors (red
failures/expiry, yellow warnings, green recoveries, blue info); ntfy
priorities (urgent failures, high warnings, default changes, low
recoveries); include hostname, nameserver, old/new values, timestamps
HTTP API handlers:
- Wire *state.State and *watcher.Watcher into handler params
- GET /api/v1/status: full state snapshot as JSON
- GET /api/v1/domains: domain states with NS records and last-checked
- GET /api/v1/hostnames: hostname states with per-NS record data
Infrastructure notes (from untracked TODO.md):
- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de
remote intentionally; do not "fix" it
- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix
- Resolver tests originally used live DNS against *.dns.sneak.cloud
(required records documented in the test file header); origin now has
mocked hermetic tests, keep them hermetic
+25 -5
View File
@@ -17,13 +17,33 @@ func TestClassifyDNSName(t *testing.T) {
}{ }{
{name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain}, {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain},
{name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname}, {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname},
{name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain}, {
{name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname}, name: "apex domain multi-part TLD",
input: "example.co.uk",
want: config.DNSNameTypeDomain,
},
{
name: "hostname multi-part TLD",
input: "api.example.co.uk",
want: config.DNSNameTypeHostname,
},
{name: "public suffix itself", input: "co.uk", wantErr: true}, {name: "public suffix itself", input: "co.uk", wantErr: true},
{name: "empty string", input: "", wantErr: true}, {name: "empty string", input: "", wantErr: true},
{name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname}, {
{name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain}, name: "deeply nested hostname",
{name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname}, input: "a.b.c.example.com",
want: config.DNSNameTypeHostname,
},
{
name: "trailing dot stripped",
input: "example.com.",
want: config.DNSNameTypeDomain,
},
{
name: "uppercase normalized",
input: "WWW.Example.COM",
want: config.DNSNameTypeHostname,
},
} }
for _, tt := range tests { for _, tt := range tests {
+119 -138
View File
@@ -25,6 +25,20 @@ const (
colorDefault = "#6c757d" colorDefault = "#6c757d"
) )
// Priority strings used across multiple tests.
const (
prioError = "error"
prioWarning = "warning"
prioSuccess = "success"
prioInfo = "info"
prioUnknown = "unknown"
prioDefault = "default"
prioUrgent = "urgent"
)
// testHost is the hostname used in request construction tests.
const testHost = "example.com"
// errSimulated is a static error for transport failures. // errSimulated is a static error for transport failures.
var errSimulated = errors.New("simulated transport failure") var errSimulated = errors.New("simulated transport failure")
@@ -101,13 +115,13 @@ func TestNtfyPriority(t *testing.T) {
input string input string
want string want string
}{ }{
{"error", "urgent"}, {prioError, prioUrgent},
{"warning", "high"}, {prioWarning, "high"},
{"success", "default"}, {prioSuccess, prioDefault},
{"info", "low"}, {prioInfo, "low"},
{"", "default"}, {"", prioDefault},
{"unknown", "default"}, {prioUnknown, prioDefault},
{"critical", "default"}, {"critical", prioDefault},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -134,12 +148,12 @@ func TestSlackColor(t *testing.T) {
input string input string
want string want string
}{ }{
{"error", colorError}, {prioError, colorError},
{"warning", colorWarning}, {prioWarning, colorWarning},
{"success", colorSuccess}, {prioSuccess, colorSuccess},
{"info", colorInfo}, {prioInfo, colorInfo},
{"", colorDefault}, {"", colorDefault},
{"unknown", colorDefault}, {prioUnknown, colorDefault},
{"critical", colorDefault}, {"critical", colorDefault},
} }
@@ -165,7 +179,7 @@ func TestNewRequest(t *testing.T) {
target := &url.URL{ target := &url.URL{
Scheme: "https", Scheme: "https",
Host: "example.com", Host: testHost,
Path: "/webhook", Path: "/webhook",
} }
body := bytes.NewBufferString("hello") body := bytes.NewBufferString("hello")
@@ -187,9 +201,9 @@ func TestNewRequest(t *testing.T) {
) )
} }
if req.Host != "example.com" { if req.Host != testHost {
t.Errorf( t.Errorf(
"Host = %q, want %q", req.Host, "example.com", "Host = %q, want %q", req.Host, testHost,
) )
} }
@@ -217,7 +231,7 @@ func TestNewRequestPreservesContext(t *testing.T) {
ctxKey("k"), ctxKey("k"),
"v", "v",
) )
target := &url.URL{Scheme: "https", Host: "example.com"} target := &url.URL{Scheme: "https", Host: testHost}
req := notify.NewRequestForTest( req := notify.NewRequestForTest(
ctx, http.MethodGet, target, http.NoBody, ctx, http.MethodGet, target, http.NoBody,
@@ -289,10 +303,10 @@ func TestSendNtfyHeaders(t *testing.T) {
) )
} }
if captured.priority != "urgent" { if captured.priority != prioUrgent {
t.Errorf( t.Errorf(
"Priority header = %q, want %q", "Priority header = %q, want %q",
captured.priority, "urgent", captured.priority, prioUrgent,
) )
} }
@@ -311,10 +325,10 @@ func TestSendNtfyAllPriorities(t *testing.T) {
input string input string
want string want string
}{ }{
{"error", "urgent"}, {prioError, prioUrgent},
{"warning", "high"}, {prioWarning, "high"},
{"success", "default"}, {prioSuccess, prioDefault},
{"info", "low"}, {prioInfo, "low"},
} }
for _, tc := range priorities { for _, tc := range priorities {
@@ -356,56 +370,69 @@ func TestSendNtfyAllPriorities(t *testing.T) {
} }
} }
func TestSendNtfyClientError(t *testing.T) { // assertSendStatusError verifies that send returns an error
t.Parallel() // wrapping wantErr when the server responds with status.
func assertSendStatusError(
t *testing.T,
status int,
wantErr error,
send func(*notify.Service, *url.URL) error,
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(status)
}), }),
) )
defer srv.Close() defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport) svc := notify.NewTestService(srv.Client().Transport)
topicURL, _ := url.Parse(srv.URL) target, _ := url.Parse(srv.URL)
err := svc.SendNtfy( err := send(svc, target)
context.Background(), topicURL, "t", "m", "info",
)
if err == nil { if err == nil {
t.Fatal("expected error for 403 response") t.Fatalf("expected error for %d response", status)
} }
if !errors.Is(err, notify.ErrNtfyFailed) { if !errors.Is(err, wantErr) {
t.Errorf("error = %v, want ErrNtfyFailed", err) t.Errorf("error = %v, want %v", err, wantErr)
} }
} }
func sendNtfyInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendNtfy(
context.Background(), target, "t", "m", prioInfo,
)
}
func sendSlackInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendSlack(
context.Background(), target, "t", "m", prioInfo,
)
}
func TestSendNtfyClientError(t *testing.T) {
t.Parallel()
assertSendStatusError(
t, http.StatusForbidden,
notify.ErrNtfyFailed, sendNtfyInfo,
)
}
func TestSendNtfyServerError(t *testing.T) { func TestSendNtfyServerError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendStatusError(
http.HandlerFunc( t, http.StatusInternalServerError,
func(w http.ResponseWriter, _ *http.Request) { notify.ErrNtfyFailed, sendNtfyInfo,
w.WriteHeader(http.StatusInternalServerError)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
topicURL, _ := url.Parse(srv.URL)
err := svc.SendNtfy(
context.Background(), topicURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 500 response")
}
if !errors.Is(err, notify.ErrNtfyFailed) {
t.Errorf("error = %v, want ErrNtfyFailed", err)
}
} }
func TestSendNtfySuccess(t *testing.T) { func TestSendNtfySuccess(t *testing.T) {
@@ -550,11 +577,11 @@ func TestSendSlackAllColors(t *testing.T) {
priority string priority string
want string want string
}{ }{
{"error", colorError}, {prioError, colorError},
{"warning", colorWarning}, {prioWarning, colorWarning},
{"success", colorSuccess}, {prioSuccess, colorSuccess},
{"info", colorInfo}, {prioInfo, colorInfo},
{"unknown", colorDefault}, {prioUnknown, colorDefault},
} }
for _, tc := range colors { for _, tc := range colors {
@@ -606,53 +633,19 @@ func TestSendSlackAllColors(t *testing.T) {
func TestSendSlackClientError(t *testing.T) { func TestSendSlackClientError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendStatusError(
http.HandlerFunc( t, http.StatusBadRequest,
func(w http.ResponseWriter, _ *http.Request) { notify.ErrSlackFailed, sendSlackInfo,
w.WriteHeader(http.StatusBadRequest)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 400 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
} }
func TestSendSlackServerError(t *testing.T) { func TestSendSlackServerError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendStatusError(
http.HandlerFunc( t, http.StatusBadGateway,
func(w http.ResponseWriter, _ *http.Request) { notify.ErrSlackFailed, sendSlackInfo,
w.WriteHeader(http.StatusBadGateway)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "error",
)
if err == nil {
t.Fatal("expected error for 502 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
} }
func TestSendSlackNetworkError(t *testing.T) { func TestSendSlackNetworkError(t *testing.T) {
@@ -977,74 +970,62 @@ func TestSendNotificationMattermostOnly(t *testing.T) {
} }
} }
func TestSendNotificationNtfyError(t *testing.T) { // assertSendNotificationTolerates verifies SendNotification
t.Parallel() // neither panics nor blocks when the endpoint configured by
// setURL responds with status.
func assertSendNotificationTolerates(
t *testing.T,
status int,
priority string,
setURL func(*notify.Service, *url.URL),
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(status)
}), }),
) )
defer srv.Close() defer srv.Close()
ntfyURL, _ := url.Parse(srv.URL) target, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport) svc := notify.NewTestService(http.DefaultTransport)
svc.SetNtfyURL(ntfyURL) setURL(svc, target)
// Should not panic or block.
svc.SendNotification( svc.SendNotification(
context.Background(), "t", "m", "error", context.Background(), "t", "m", priority,
) )
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
func TestSendNotificationNtfyError(t *testing.T) {
t.Parallel()
assertSendNotificationTolerates(
t, http.StatusInternalServerError, prioError,
(*notify.Service).SetNtfyURL,
)
}
func TestSendNotificationSlackError(t *testing.T) { func TestSendNotificationSlackError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendNotificationTolerates(
http.HandlerFunc( t, http.StatusForbidden, prioError,
func(w http.ResponseWriter, _ *http.Request) { (*notify.Service).SetSlackWebhookURL,
w.WriteHeader(http.StatusForbidden)
}),
) )
defer srv.Close()
slackURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetSlackWebhookURL(slackURL)
svc.SendNotification(
context.Background(), "t", "m", "error",
)
time.Sleep(100 * time.Millisecond)
} }
func TestSendNotificationMattermostError(t *testing.T) { func TestSendNotificationMattermostError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendNotificationTolerates(
http.HandlerFunc( t, http.StatusBadGateway, prioWarning,
func(w http.ResponseWriter, _ *http.Request) { (*notify.Service).SetMattermostWebhookURL,
w.WriteHeader(http.StatusBadGateway)
}),
) )
defer srv.Close()
mmURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetMattermostWebhookURL(mmURL)
svc.SendNotification(
context.Background(), "t", "m", "warning",
)
time.Sleep(100 * time.Millisecond)
} }
// ── SlackPayload JSON marshaling ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────
+2 -2
View File
@@ -29,14 +29,14 @@ func TestAlertHistoryAddAndRecent(t *testing.T) {
Timestamp: now.Add(-2 * time.Minute), Timestamp: now.Add(-2 * time.Minute),
Title: "first", Title: "first",
Message: "msg1", Message: "msg1",
Priority: "info", Priority: prioInfo,
}) })
h.Add(notify.AlertEntry{ h.Add(notify.AlertEntry{
Timestamp: now.Add(-1 * time.Minute), Timestamp: now.Add(-1 * time.Minute),
Title: "second", Title: "second",
Message: "msg2", Message: "msg2",
Priority: "warning", Priority: prioWarning,
}) })
entries := h.Recent() entries := h.Recent()
+1 -1
View File
@@ -69,7 +69,7 @@ func (rc RetryConfig) backoff(attempt int) time.Duration {
lo := raw * (1 - jitterFraction) lo := raw * (1 - jitterFraction)
hi := raw * (1 + jitterFraction) hi := raw * (1 + jitterFraction)
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand
return time.Duration(jittered) return time.Duration(jittered)
} }
+37 -25
View File
@@ -13,6 +13,16 @@ import (
const testHostname = "www.example.com" const testHostname = "www.example.com"
// Shared fixture values used across tests.
const (
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testAltNS1 = "ns1.test.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
statusError = "error"
)
// populateState fills a State with representative test data across all categories. // populateState fills a State with representative test data across all categories.
func populateState(t *testing.T, s *state.State) { func populateState(t *testing.T, s *state.State) {
t.Helper() t.Helper()
@@ -20,7 +30,7 @@ func populateState(t *testing.T, s *state.State) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
s.SetDomainState("example.com", &state.DomainState{ s.SetDomainState("example.com", &state.DomainState{
Nameservers: []string{"ns1.example.com.", "ns2.example.com."}, Nameservers: []string{testNS1, testNS2},
LastChecked: now, LastChecked: now,
}) })
@@ -31,17 +41,17 @@ func populateState(t *testing.T, s *state.State) {
s.SetHostnameState(testHostname, &state.HostnameState{ s.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.example.com.": { testNS1: {
Records: map[string][]string{ Records: map[string][]string{
"A": {"93.184.216.34"}, "A": {testIPv4},
"AAAA": {"2606:2800:220:1:248:1893:25c8:1946"}, "AAAA": {"2606:2800:220:1:248:1893:25c8:1946"},
}, },
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
"ns2.example.com.": { testNS2: {
Records: map[string][]string{ Records: map[string][]string{
"A": {"93.184.216.34"}, "A": {testIPv4},
}, },
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
@@ -152,13 +162,13 @@ func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
func verifyNS1Records(t *testing.T, hn *state.HostnameState) { func verifyNS1Records(t *testing.T, hn *state.HostnameState) {
t.Helper() t.Helper()
ns1, ok := hn.RecordsByNameserver["ns1.example.com."] ns1, ok := hn.RecordsByNameserver[testNS1]
if !ok { if !ok {
t.Fatal("missing nameserver ns1.example.com.") t.Fatal("missing nameserver ns1.example.com.")
} }
aRecords := ns1.Records["A"] aRecords := ns1.Records["A"]
if len(aRecords) != 1 || aRecords[0] != "93.184.216.34" { if len(aRecords) != 1 || aRecords[0] != testIPv4 {
t.Errorf("ns1 A records: got %v", aRecords) t.Errorf("ns1 A records: got %v", aRecords)
} }
@@ -213,7 +223,8 @@ func TestSaveLoadRoundTrip_Ports(t *testing.T) {
} }
} }
// TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle. // TestSaveLoadRoundTrip_Certificates verifies certificate data
// survives a save/load cycle.
func TestSaveLoadRoundTrip_Certificates(t *testing.T) { func TestSaveLoadRoundTrip_Certificates(t *testing.T) {
t.Parallel() t.Parallel()
@@ -653,7 +664,7 @@ func TestDomainState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
ds := &state.DomainState{ ds := &state.DomainState{
Nameservers: []string{"ns1.test.com."}, Nameservers: []string{testAltNS1},
LastChecked: now, LastChecked: now,
} }
@@ -664,7 +675,7 @@ func TestDomainState_GetSet(t *testing.T) {
t.Fatal("expected true for existing domain") t.Fatal("expected true for existing domain")
} }
if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." { if len(got.Nameservers) != 1 || got.Nameservers[0] != testAltNS1 {
t.Errorf("nameservers: got %v", got.Nameservers) t.Errorf("nameservers: got %v", got.Nameservers)
} }
@@ -674,7 +685,7 @@ func TestDomainState_GetSet(t *testing.T) {
// Overwrite. // Overwrite.
ds2 := &state.DomainState{ ds2 := &state.DomainState{
Nameservers: []string{"ns1.test.com.", "ns2.test.com."}, Nameservers: []string{testAltNS1, "ns2.test.com."},
LastChecked: now.Add(time.Hour), LastChecked: now.Add(time.Hour),
} }
@@ -704,8 +715,8 @@ func TestHostnameState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{ hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.example.com.": { testNS1: {
Records: map[string][]string{"A": {"1.2.3.4"}}, Records: map[string][]string{"A": {testIP}},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
@@ -720,7 +731,7 @@ func TestHostnameState_GetSet(t *testing.T) {
t.Fatal("expected true for existing hostname") t.Fatal("expected true for existing hostname")
} }
nsState, ok := got.RecordsByNameserver["ns1.example.com."] nsState, ok := got.RecordsByNameserver[testNS1]
if !ok { if !ok {
t.Fatal("missing nameserver entry") t.Fatal("missing nameserver entry")
} }
@@ -730,7 +741,7 @@ func TestHostnameState_GetSet(t *testing.T) {
} }
aRecords := nsState.Records["A"] aRecords := nsState.Records["A"]
if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" { if len(aRecords) != 1 || aRecords[0] != testIP {
t.Errorf("A records: got %v", aRecords) t.Errorf("A records: got %v", aRecords)
} }
} }
@@ -869,7 +880,7 @@ func TestCertificateState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
cs := &state.CertificateState{ cs := &state.CertificateState{
Status: "error", Status: statusError,
Error: "connection refused", Error: "connection refused",
LastChecked: now, LastChecked: now,
} }
@@ -893,8 +904,8 @@ func TestCertificateState_ErrorField(t *testing.T) {
t.Fatal("missing certificate after load") t.Fatal("missing certificate after load")
} }
if got.Status != "error" { if got.Status != statusError {
t.Errorf("status: got %q, want %q", got.Status, "error") t.Errorf("status: got %q, want %q", got.Status, statusError)
} }
if got.Error != "connection refused" { if got.Error != "connection refused" {
@@ -912,9 +923,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{ hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.example.com.": { testNS1: {
Records: nil, Records: nil,
Status: "error", Status: statusError,
Error: "SERVFAIL", Error: "SERVFAIL",
LastChecked: now, LastChecked: now,
}, },
@@ -941,9 +952,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
t.Fatal("missing hostname after load") t.Fatal("missing hostname after load")
} }
nsState := got.RecordsByNameserver["ns1.example.com."] nsState := got.RecordsByNameserver[testNS1]
if nsState.Status != "error" { if nsState.Status != statusError {
t.Errorf("status: got %q, want %q", nsState.Status, "error") t.Errorf("status: got %q, want %q", nsState.Status, statusError)
} }
if nsState.Error != "SERVFAIL" { if nsState.Error != "SERVFAIL" {
@@ -1062,7 +1073,8 @@ func TestConcurrentGetSet(t *testing.T) {
wg.Wait() wg.Wait()
} }
// runConcurrentOps performs a series of get/set/delete operations for concurrency testing. // runConcurrentOps performs a series of get/set/delete
// operations for concurrency testing.
func runConcurrentOps(s *state.State, key string, now time.Time) { func runConcurrentOps(s *state.State, key string, now time.Time) {
const iterations = 50 const iterations = 50
@@ -1085,7 +1097,7 @@ func runConcurrentOps(s *state.State, key string, now time.Time) {
s.SetHostnameState(key+".example.com", &state.HostnameState{ s.SetHostnameState(key+".example.com", &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.test.": { "ns1.test.": {
Records: map[string][]string{"A": {"1.2.3.4"}}, Records: map[string][]string{"A": {testIP}},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
+13 -7
View File
@@ -26,6 +26,12 @@ const tlsPort = 443
// hoursPerDay converts days to hours for duration calculations. // hoursPerDay converts days to hours for duration calculations.
const hoursPerDay = 24 const hoursPerDay = 24
// Status values recorded for nameserver and certificate checks.
const (
statusOK = "ok"
statusError = "error"
)
// Params contains dependencies for Watcher. // Params contains dependencies for Watcher.
type Params struct { type Params struct {
fx.In fx.In
@@ -344,7 +350,7 @@ func buildHostnameState(
for ns, recs := range records { for ns, recs := range records {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: recs, Records: recs,
Status: "ok", Status: statusOK,
LastChecked: now, LastChecked: now,
} }
} }
@@ -402,7 +408,7 @@ func (w *Watcher) detectNSDisappearances(
current map[string]map[string][]string, current map[string]map[string][]string,
) { ) {
for ns, prevNS := range prev.RecordsByNameserver { for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current[ns]; ok || prevNS.Status != "ok" { if _, ok := current[ns]; ok || prevNS.Status != statusOK {
continue continue
} }
@@ -421,7 +427,7 @@ func (w *Watcher) detectNSDisappearances(
for ns := range current { for ns := range current {
prevNS, ok := prev.RecordsByNameserver[ns] prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != "error" { if !ok || prevNS.Status != statusError {
continue continue
} }
@@ -705,7 +711,7 @@ func (w *Watcher) handleTLSError(
now time.Time, now time.Time,
err error, err error,
) { ) {
if hasPrev && !w.firstRun && prev.Status == "ok" { if hasPrev && !w.firstRun && prev.Status == statusOK {
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nError: %s", "Host: %s\nIP: %s\nError: %s",
hostname, ip, err, hostname, ip, err,
@@ -721,7 +727,7 @@ func (w *Watcher) handleTLSError(
w.state.SetCertificateState( w.state.SetCertificateState(
certKey, &state.CertificateState{ certKey, &state.CertificateState{
Status: "error", Status: statusError,
Error: err.Error(), Error: err.Error(),
LastChecked: now, LastChecked: now,
}, },
@@ -748,7 +754,7 @@ func (w *Watcher) handleTLSSuccess(
Issuer: cert.Issuer, Issuer: cert.Issuer,
NotAfter: cert.NotAfter, NotAfter: cert.NotAfter,
SubjectAlternativeNames: cert.SubjectAlternativeNames, SubjectAlternativeNames: cert.SubjectAlternativeNames,
Status: "ok", Status: statusOK,
LastChecked: now, LastChecked: now,
}, },
) )
@@ -760,7 +766,7 @@ func (w *Watcher) detectTLSChanges(
prev *state.CertificateState, prev *state.CertificateState,
cert *tlscheck.CertificateInfo, cert *tlscheck.CertificateInfo,
) { ) {
if prev.Status == "error" { if prev.Status == statusError {
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nTLS recovered", "Host: %s\nIP: %s\nTLS recovered",
hostname, ip, hostname, ip,
+108 -97
View File
@@ -18,6 +18,17 @@ import (
// errNotFound is returned when mock data is missing. // errNotFound is returned when mock data is missing.
var errNotFound = errors.New("not found") var errNotFound = errors.New("not found")
// Fixture values shared across tests.
const (
testDomain = "example.com"
testHost = "www.example.com"
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
testIssuer = "DigiCert"
)
// --- Mock implementations --- // --- Mock implementations ---
type mockResolver struct { type mockResolver struct {
@@ -256,8 +267,8 @@ func TestFirstRunBaseline(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"} cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps) setupBaselineMocks(deps)
@@ -269,37 +280,37 @@ func TestFirstRunBaseline(t *testing.T) {
} }
func setupBaselineMocks(deps *testDeps) { func setupBaselineMocks(deps *testDeps) {
deps.resolver.nsRecords["example.com"] = []string{ deps.resolver.nsRecords[testDomain] = []string{
"ns1.example.com.", testNS1,
"ns2.example.com.", testNS2,
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}}, testNS1: {"A": {testIPv4}},
"ns2.example.com.": {"A": {"93.184.216.34"}}, testNS2: {"A": {testIPv4}},
} }
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}}, testNS1: {"A": {testIPv4}},
"ns2.example.com.": {"A": {"93.184.216.34"}}, testNS2: {"A": {testIPv4}},
} }
deps.resolver.ipAddresses["www.example.com"] = []string{ deps.resolver.ipAddresses[testHost] = []string{
"93.184.216.34", testIPv4,
} }
deps.portChecker.results["93.184.216.34:80"] = true deps.portChecker.results["93.184.216.34:80"] = true
deps.portChecker.results["93.184.216.34:443"] = true deps.portChecker.results["93.184.216.34:443"] = true
deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: "www.example.com", CommonName: testHost,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
"www.example.com", testHost,
}, },
} }
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: "example.com", CommonName: testDomain,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
"example.com", testDomain,
}, },
} }
} }
@@ -348,24 +359,24 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"} cfg.Domains = []string{testDomain}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords["example.com"] = []string{ deps.resolver.nsRecords[testDomain] = []string{
"ns1.example.com.", testNS1,
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}}, testNS1: {"A": {testIPv4}},
} }
deps.portChecker.results["93.184.216.34:80"] = true deps.portChecker.results["93.184.216.34:80"] = true
deps.portChecker.results["93.184.216.34:443"] = true deps.portChecker.results["93.184.216.34:443"] = true
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: "example.com", CommonName: testDomain,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
"example.com", testDomain,
}, },
} }
@@ -406,17 +417,17 @@ func TestNSChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"} cfg.Domains = []string{testDomain}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords["example.com"] = []string{ deps.resolver.nsRecords[testDomain] = []string{
"ns1.example.com.", testNS1,
"ns2.example.com.", testNS2,
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
"ns2.example.com.": {"A": {"1.2.3.4"}}, testNS2: {"A": {testIP}},
} }
deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false deps.portChecker.results["1.2.3.4:443"] = false
@@ -425,13 +436,13 @@ func TestNSChangeDetection(t *testing.T) {
w.RunOnce(ctx) w.RunOnce(ctx)
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.nsRecords["example.com"] = []string{ deps.resolver.nsRecords[testDomain] = []string{
"ns1.example.com.", testNS1,
"ns3.example.com.", "ns3.example.com.",
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
"ns3.example.com.": {"A": {"1.2.3.4"}}, "ns3.example.com.": {"A": {testIP}},
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
@@ -459,15 +470,15 @@ func TestRecordChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}}, testNS1: {"A": {testIPv4}},
} }
deps.resolver.ipAddresses["www.example.com"] = []string{ deps.resolver.ipAddresses[testHost] = []string{
"93.184.216.34", testIPv4,
} }
deps.portChecker.results["93.184.216.34:80"] = false deps.portChecker.results["93.184.216.34:80"] = false
deps.portChecker.results["93.184.216.34:443"] = false deps.portChecker.results["93.184.216.34:443"] = false
@@ -476,10 +487,10 @@ func TestRecordChangeDetection(t *testing.T) {
w.RunOnce(ctx) w.RunOnce(ctx)
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.35"}}, testNS1: {"A": {"93.184.216.35"}},
} }
deps.resolver.ipAddresses["www.example.com"] = []string{ deps.resolver.ipAddresses[testHost] = []string{
"93.184.216.35", "93.184.216.35",
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
@@ -501,24 +512,24 @@ func TestPortStateChange(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
} }
deps.resolver.ipAddresses["www.example.com"] = []string{ deps.resolver.ipAddresses[testHost] = []string{
"1.2.3.4", testIP,
} }
deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: "www.example.com", CommonName: testHost,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
"www.example.com", testHost,
}, },
} }
@@ -541,24 +552,24 @@ func TestTLSExpiryWarning(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
} }
deps.resolver.ipAddresses["www.example.com"] = []string{ deps.resolver.ipAddresses[testHost] = []string{
"1.2.3.4", testIP,
} }
deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: "www.example.com", CommonName: testHost,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(3 * 24 * time.Hour), NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
"www.example.com", testHost,
}, },
} }
@@ -592,25 +603,25 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
} }
deps.resolver.ipAddresses["www.example.com"] = []string{ deps.resolver.ipAddresses[testHost] = []string{
"1.2.3.4", testIP,
} }
deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: "www.example.com", CommonName: testHost,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(3 * 24 * time.Hour), NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
"www.example.com", testHost,
}, },
} }
@@ -647,17 +658,17 @@ func TestGracefulShutdown(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"} cfg.Domains = []string{testDomain}
cfg.DNSInterval = 100 * time.Millisecond cfg.DNSInterval = 100 * time.Millisecond
cfg.TLSInterval = 100 * time.Millisecond cfg.TLSInterval = 100 * time.Millisecond
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords["example.com"] = []string{ deps.resolver.nsRecords[testDomain] = []string{
"ns1.example.com.", testNS1,
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
} }
deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false deps.portChecker.results["1.2.3.4:443"] = false
@@ -687,13 +698,13 @@ func setupHostnameIP(
hostname, ip string, hostname, ip string,
) { ) {
deps.resolver.allRecords[hostname] = map[string]map[string][]string{ deps.resolver.allRecords[hostname] = map[string]map[string][]string{
"ns1.example.com.": {"A": {ip}}, testNS1: {"A": {ip}},
} }
deps.portChecker.results[ip+":80"] = true deps.portChecker.results[ip+":80"] = true
deps.portChecker.results[ip+":443"] = true deps.portChecker.results[ip+":443"] = true
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname, CommonName: hostname,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname}, SubjectAlternativeNames: []string{hostname},
} }
@@ -702,7 +713,7 @@ func setupHostnameIP(
func updateHostnameIP(deps *testDeps, hostname, ip string) { func updateHostnameIP(deps *testDeps, hostname, ip string) {
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.allRecords[hostname] = map[string]map[string][]string{ deps.resolver.allRecords[hostname] = map[string]map[string][]string{
"ns1.example.com.": {"A": {ip}}, testNS1: {"A": {ip}},
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
@@ -714,7 +725,7 @@ func updateHostnameIP(deps *testDeps, hostname, ip string) {
deps.tlsChecker.mu.Lock() deps.tlsChecker.mu.Lock()
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname, CommonName: hostname,
Issuer: "DigiCert", Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname}, SubjectAlternativeNames: []string{hostname},
} }
@@ -725,11 +736,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
setupHostnameIP(deps, "www.example.com", "10.0.0.1") setupHostnameIP(deps, testHost, "10.0.0.1")
ctx := t.Context() ctx := t.Context()
w.RunOnce(ctx) w.RunOnce(ctx)
@@ -740,7 +751,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
} }
// DNS changes to a new IP; port and TLS must pick it up. // DNS changes to a new IP; port and TLS must pick it up.
updateHostnameIP(deps, "www.example.com", "10.0.0.2") updateHostnameIP(deps, testHost, "10.0.0.2")
w.RunOnce(ctx) w.RunOnce(ctx)
@@ -760,8 +771,8 @@ func TestSendTestNotification_Enabled(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"} cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
@@ -786,8 +797,8 @@ func TestSendTestNotification_ViaRun(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"} cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true cfg.SendTestNotification = true
cfg.DNSInterval = 24 * time.Hour cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
@@ -833,8 +844,8 @@ func TestSendTestNotification_Disabled(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"} cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = false cfg.SendTestNotification = false
cfg.DNSInterval = 24 * time.Hour cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
@@ -871,16 +882,16 @@ func TestNSFailureAndRecovery(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{"www.example.com"} cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
"ns2.example.com.": {"A": {"1.2.3.4"}}, testNS2: {"A": {testIP}},
} }
deps.resolver.ipAddresses["www.example.com"] = []string{ deps.resolver.ipAddresses[testHost] = []string{
"1.2.3.4", testIP,
} }
deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false deps.portChecker.results["1.2.3.4:443"] = false
@@ -890,8 +901,8 @@ func TestNSFailureAndRecovery(t *testing.T) {
w.RunOnce(ctx) w.RunOnce(ctx)
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}}, testNS1: {"A": {testIP}},
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
+117
View File
@@ -0,0 +1,117 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Base tooling (git, make, go) comes from nix, apt, brew, or
# apk (detected in that order) and is installed only when absent;
# assumes nothing is present. golangci-lint and goimports are always
# (re)installed via `go install` at the same pinned commits the
# Dockerfile uses (never "latest") -- a presence check cannot tell the
# pinned build from an arbitrary one already on PATH, so guarding them
# would make the pins inert. Idempotent either way: running this twice
# succeeds both times and leaves the same result.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07 (same pins as the Dockerfile)
# golangci-lint v2.12.2
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# go_bin_dir: directory `go install` writes binaries to.
go_bin_dir() {
gobin="$(go env GOBIN)"
if [ -n "$gobin" ]; then
echo "$gobin"
else
echo "$(go env GOPATH)/bin"
fi
}
# warn_if_shadowed <tool> <dir>: the pinned build was just installed
# into <dir>. If PATH resolves <tool> anywhere else, that other copy is
# what `make lint` and `make fmt` will actually run, and it is not the
# pinned version. Warn loudly rather than failing, since the fix is the
# user's PATH and not anything this script can do.
warn_if_shadowed() {
resolved="$(command -v "$1" 2>/dev/null || true)"
if [ "$resolved" != "$2/$1" ]; then
echo "bootstrap: WARNING: installed pinned $1 to $2/$1, but PATH" >&2
echo "bootstrap: WARNING: resolves $1 to ${resolved:-(not on PATH)};" >&2
echo "bootstrap: WARNING: put $2 first on PATH or lint results will" >&2
echo "bootstrap: WARNING: not match CI." >&2
fi
}
main() {
cd "$ROOT"
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi
# Lint/format tools, pinned via go install. These are installed
# unconditionally: `command -v` only proves *some* build is on PATH,
# and a wrong golangci-lint either cannot parse our v2-schema
# .golangci.yml at all or silently disagrees with CI. Installing at
# a fixed commit ref is idempotent and cheap with a warm module
# cache, so there is nothing to save by skipping it.
GOBIN_DIR="$(go_bin_dir)"
go install "$GOLANGCI_LINT_REF"
go install "$GOIMPORTS_REF"
warn_if_shadowed golangci-lint "$GOBIN_DIR"
warn_if_shadowed goimports "$GOBIN_DIR"
go mod download
echo "bootstrap complete"
}
main "$@"
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
gofmt -s -w .
goimports -w .
}
main "$@"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "gofmt: files not formatted:" >&2
echo "$files" >&2
exit 1
fi
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run --config .golangci.yml ./...
}
main "$@"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go extra:
# go mod tidy must be a no-op before the checks run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
if ! git diff --exit-code -- go.mod go.sum; then
echo "precommit: go mod tidy changed go.mod/go.sum;" \
"stage the changes and retry" >&2
exit 1
fi
"$SCRIPT_DIR/check"
}
main "$@"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "dnswatcher"
}
main "$@"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v -race -timeout 30s -cover ./...
}
main "$@"