Compare commits
3 Commits
main
...
aa5907c248
| Author | SHA1 | Date | |
|---|---|---|---|
| aa5907c248 | |||
| 84d63f749f | |||
|
|
48bedaf579 |
@@ -1,6 +0,0 @@
|
||||
.git/
|
||||
bin/
|
||||
*.md
|
||||
LICENSE
|
||||
.editorconfig
|
||||
.gitignore
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -6,4 +6,4 @@ jobs:
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-28
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- run: script/cibuild
|
||||
- run: docker build .
|
||||
|
||||
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Repository Rules
|
||||
|
||||
Last Updated 2026-01-08
|
||||
|
||||
These rules MUST be followed at all times, it is very important.
|
||||
|
||||
* Never use `git add -A` - add specific changes to a deliberate commit. A
|
||||
commit should contain one change. After each change, make a commit with a
|
||||
good one-line summary.
|
||||
|
||||
* NEVER modify the linter config without asking first.
|
||||
|
||||
* NEVER modify tests to exclude special cases or otherwise get them to pass
|
||||
without asking first. In almost all cases, the code should be changed,
|
||||
NOT the tests. If you think the test needs to be changed, make your case
|
||||
for that and ask for permission to proceed, then stop. You need explicit
|
||||
user approval to modify existing tests. (You do not need user approval
|
||||
for writing NEW tests.)
|
||||
|
||||
* When linting, assume the linter config is CORRECT, and that each item
|
||||
output by the linter is something that legitimately needs fixing in the
|
||||
code.
|
||||
|
||||
* When running tests, use `make test`.
|
||||
|
||||
* Before commits, run `make check`. This runs `make lint` and `make test`
|
||||
and `make check-fmt`. Any issues discovered MUST be resolved before
|
||||
committing unless explicitly told otherwise.
|
||||
|
||||
* When fixing a bug, write a failing test for the bug FIRST. Add
|
||||
appropriate logging to the test to ensure it is written correctly. Commit
|
||||
that. Then go about fixing the bug until the test passes (without
|
||||
modifying the test further). Then commit that.
|
||||
|
||||
* When adding a new feature, do the same - implement a test first (TDD). It
|
||||
doesn't have to be super complex. Commit the test, then commit the
|
||||
feature.
|
||||
|
||||
* When adding a new feature, use a feature branch. When the feature is
|
||||
completely finished and the code is up to standards (passes `make check`)
|
||||
then and only then can the feature branch be merged into `main` and the
|
||||
branch deleted.
|
||||
|
||||
* Write godoc documentation comments for all exported types and functions as
|
||||
you go along.
|
||||
|
||||
* ALWAYS be consistent in naming. If you name something one thing in one
|
||||
place, name it the EXACT SAME THING in another place.
|
||||
|
||||
* Be descriptive and specific in naming. `wl` is bad;
|
||||
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
|
||||
`MaxConnectionsPerHost` is good.
|
||||
|
||||
* This is not prototype or teaching code - this is designed for production.
|
||||
Any security issues (such as denial of service) or other web
|
||||
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
|
||||
|
||||
* As this is production code, no stubbing of implementations unless
|
||||
specifically instructed. We need working implementations.
|
||||
|
||||
* Avoid vendoring deps unless specifically instructed to. NEVER commit
|
||||
the vendor directory, NEVER commit compiled binaries. If these
|
||||
directories or files exist, add them to .gitignore (and commit the
|
||||
.gitignore) if they are not already in there. Keep the entire git
|
||||
repository (with history) small - under 20MiB, unless you specifically
|
||||
must commit larger files (e.g. test fixture example media files). Only
|
||||
OUR source code and immediately supporting files (such as test examples)
|
||||
goes into the repo/history.
|
||||
1225
CONVENTIONS.md
Normal file
1225
CONVENTIONS.md
Normal file
File diff suppressed because it is too large
Load Diff
45
Makefile
45
Makefile
@@ -1,44 +1,37 @@
|
||||
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker
|
||||
.PHONY: all build lint fmt test check clean
|
||||
|
||||
BINARY := dnswatcher
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
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).
|
||||
BUILDARCH := $(shell go env GOARCH)
|
||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
|
||||
all: check build
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher
|
||||
|
||||
test:
|
||||
@script/test
|
||||
|
||||
lint:
|
||||
@script/lint
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
fmt:
|
||||
@script/fmt
|
||||
gofmt -s -w .
|
||||
goimports -w .
|
||||
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
test:
|
||||
go test -v -race -cover ./...
|
||||
|
||||
# Check runs all validation without making changes
|
||||
# Used by CI and Docker build - fails if anything is wrong
|
||||
check:
|
||||
@script/check
|
||||
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
@echo "==> Checking formatting..."
|
||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||
@echo "==> Running linter..."
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
@echo "==> Running tests..."
|
||||
go test -v -race ./...
|
||||
@echo "==> Building..."
|
||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
|
||||
@echo "==> All checks passed!"
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
124
README.md
124
README.md
@@ -1,10 +1,9 @@
|
||||
# dnswatcher
|
||||
|
||||
dnswatcher is a 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.
|
||||
|
||||
dnswatcher watches configured DNS domains and hostnames for changes, monitors TCP
|
||||
dnswatcher is a production DNS and infrastructure monitoring daemon written in
|
||||
Go. It watches configured DNS domains and hostnames for changes, monitors TCP
|
||||
port availability, tracks TLS certificate expiry, and delivers real-time
|
||||
notifications via Slack, Mattermost, and/or ntfy webhooks.
|
||||
|
||||
@@ -52,6 +51,10 @@ without requiring an external database.
|
||||
responding again.
|
||||
- **Inconsistency detected**: Two nameservers that previously agreed
|
||||
now return different record sets for the same hostname.
|
||||
- **Inconsistency resolved**: Nameservers that previously disagreed
|
||||
are now back in agreement.
|
||||
- **Empty response**: A nameserver that previously returned records
|
||||
now returns an authoritative empty response (NODATA/NXDOMAIN).
|
||||
|
||||
### TCP Port Monitoring
|
||||
|
||||
@@ -106,8 +109,8 @@ includes:
|
||||
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
||||
- **NS inconsistencies**: Which nameservers disagree, what each one
|
||||
returned, which hostname affected.
|
||||
- **Port changes**: Which IP:port, old state, new state, all associated
|
||||
hostnames.
|
||||
- **Port changes**: Which IP:port, old state, new state, associated
|
||||
hostname.
|
||||
- **TLS expiry warnings**: Which certificate, days remaining, CN,
|
||||
issuer, associated hostname and IP.
|
||||
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated
|
||||
@@ -124,42 +127,16 @@ includes:
|
||||
- State is written atomically (write to temp file, then rename) to prevent
|
||||
corruption.
|
||||
|
||||
### Web Dashboard
|
||||
|
||||
dnswatcher includes an unauthenticated, read-only web dashboard at the
|
||||
root URL (`/`). It displays:
|
||||
|
||||
- **Summary counts** for monitored domains, hostnames, ports, and
|
||||
certificates.
|
||||
- **Domains** with their discovered nameservers.
|
||||
- **Hostnames** with per-nameserver DNS records and status.
|
||||
- **Ports** with open/closed state and associated hostnames.
|
||||
- **TLS certificates** with CN, issuer, expiry, and status.
|
||||
- **Recent alerts** (last 100 notifications sent since the process
|
||||
started), displayed in reverse chronological order.
|
||||
|
||||
Every data point shows its age (e.g. "5m ago") so you can tell at a
|
||||
glance how fresh the information is. The page auto-refreshes every 30
|
||||
seconds.
|
||||
|
||||
The dashboard intentionally does not expose any configuration details
|
||||
such as webhook URLs, notification endpoints, or API tokens.
|
||||
|
||||
All assets (CSS) are embedded in the binary and served from the
|
||||
application itself. The dashboard makes zero external HTTP requests —
|
||||
no CDN dependencies or third-party resources are loaded at runtime.
|
||||
|
||||
### HTTP API
|
||||
|
||||
dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||
|
||||
| Endpoint | Description |
|
||||
|---------------------------------------|--------------------------------|
|
||||
| `GET /` | Web dashboard (HTML) |
|
||||
| `GET /s/...` | Static assets (embedded CSS) |
|
||||
| `GET /.well-known/healthcheck` | Health check (JSON) |
|
||||
| `GET /health` | Health check (JSON, legacy) |
|
||||
| `GET /health` | Health check (JSON) |
|
||||
| `GET /api/v1/status` | Current monitoring state |
|
||||
| `GET /api/v1/domains` | Configured domains and status |
|
||||
| `GET /api/v1/hostnames` | Configured hostnames and status|
|
||||
| `GET /metrics` | Prometheus metrics (optional) |
|
||||
|
||||
---
|
||||
@@ -171,7 +148,7 @@ cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
|
||||
|
||||
internal/
|
||||
config/config.go Viper-based configuration
|
||||
globals/globals.go Build-time variables (version)
|
||||
globals/globals.go Build-time variables (version, arch)
|
||||
logger/logger.go slog structured logging (TTY detection)
|
||||
healthcheck/healthcheck.go Health check service
|
||||
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
|
||||
@@ -219,7 +196,7 @@ the following precedence (highest to lowest):
|
||||
|---------------------------------|--------------------------------------------|-------------|
|
||||
| `PORT` | HTTP listen port | `8080` |
|
||||
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
|
||||
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
|
||||
| `DNSWATCHER_DATA_DIR` | Directory for state file | `./data` |
|
||||
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
|
||||
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
|
||||
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
|
||||
@@ -231,25 +208,17 @@ the following precedence (highest to lowest):
|
||||
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
|
||||
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
|
||||
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
|
||||
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
||||
|
||||
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
||||
monitoring targets are configured. A monitoring daemon with nothing to monitor
|
||||
is a misconfiguration, so dnswatcher fails fast with a clear error message
|
||||
rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated
|
||||
list of DNS names before starting.
|
||||
|
||||
### Example `.env`
|
||||
|
||||
```sh
|
||||
PORT=8080
|
||||
DNSWATCHER_DEBUG=false
|
||||
DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
|
||||
DNSWATCHER_DATA_DIR=./data
|
||||
DNSWATCHER_TARGETS=example.com,example.org,www.example.com,api.example.com,mail.example.org
|
||||
DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx
|
||||
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
||||
DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts
|
||||
DNSWATCHER_SEND_TEST_NOTIFICATION=true
|
||||
```
|
||||
|
||||
---
|
||||
@@ -320,12 +289,12 @@ not as a merged view, to enable inconsistency detection.
|
||||
"ports": {
|
||||
"93.184.216.34:80": {
|
||||
"open": true,
|
||||
"hostnames": ["www.example.com"],
|
||||
"hostname": "www.example.com",
|
||||
"lastChecked": "2026-02-19T12:00:00Z"
|
||||
},
|
||||
"93.184.216.34:443": {
|
||||
"open": true,
|
||||
"hostnames": ["www.example.com"],
|
||||
"hostname": "www.example.com",
|
||||
"lastChecked": "2026-02-19T12:00:00Z"
|
||||
}
|
||||
},
|
||||
@@ -349,35 +318,11 @@ tracks reachability:
|
||||
|-------------|-------------------------------------------------|
|
||||
| `ok` | Query succeeded, records are current |
|
||||
| `error` | Query failed (timeout, SERVFAIL, network error) |
|
||||
| `nxdomain` | Authoritative NXDOMAIN response |
|
||||
| `nodata` | Authoritative empty response (NODATA) |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
```sh
|
||||
@@ -385,16 +330,17 @@ make build # Build binary to bin/dnswatcher
|
||||
make test # Run tests with race detector
|
||||
make lint # Run golangci-lint
|
||||
make fmt # Format code
|
||||
make check # Run all checks (test, lint, fmt-check)
|
||||
make check # Run all checks (format, lint, test, build)
|
||||
make clean # Remove build artifacts
|
||||
```
|
||||
|
||||
### Build-Time Variables
|
||||
|
||||
Version is injected via `-ldflags`:
|
||||
Version and architecture are injected via `-ldflags`:
|
||||
|
||||
```sh
|
||||
go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher
|
||||
go build -ldflags "-X main.Version=$(git describe --tags --always) \
|
||||
-X main.Buildarch=$(go env GOARCH)" ./cmd/dnswatcher
|
||||
```
|
||||
|
||||
---
|
||||
@@ -408,7 +354,6 @@ docker run -d \
|
||||
-v dnswatcher-data:/var/lib/dnswatcher \
|
||||
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
||||
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
||||
-e DNSWATCHER_SEND_TEST_NOTIFICATION=true \
|
||||
dnswatcher
|
||||
```
|
||||
|
||||
@@ -421,15 +366,9 @@ docker run -d \
|
||||
triggering change notifications).
|
||||
2. **Initial check**: Immediately perform all DNS, port, and TLS checks
|
||||
on startup.
|
||||
3. **Periodic checks** (DNS always runs first):
|
||||
- DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also
|
||||
re-run before every TLS check cycle to ensure fresh IPs.
|
||||
- Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes.
|
||||
- TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after
|
||||
DNS completes.
|
||||
- Port and TLS checks always use freshly resolved IP addresses from
|
||||
the DNS phase that immediately precedes them — never stale IPs
|
||||
from a previous cycle.
|
||||
3. **Periodic checks**:
|
||||
- DNS and port checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h).
|
||||
- TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h).
|
||||
4. **On change detection**: Send notifications to all configured
|
||||
endpoints, update in-memory state, persist to disk.
|
||||
5. **Shutdown**: Persist final state to disk, complete in-flight
|
||||
@@ -446,18 +385,7 @@ docker run -d \
|
||||
|
||||
## Project Structure
|
||||
|
||||
Follows the conventions defined in `REPO_POLICIES.md`, adapted from the
|
||||
Follows the conventions defined in `CONVENTIONS.md`, adapted from the
|
||||
[upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx
|
||||
for dependency injection, go-chi for HTTP routing, slog for logging, and
|
||||
Viper for configuration.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
License has not yet been chosen for this project. Pending decision by the
|
||||
author (MIT, GPL, or WTFPL).
|
||||
|
||||
## Author
|
||||
|
||||
[@sneak](https://sneak.berlin)
|
||||
|
||||
408
REPO_POLICIES.md
408
REPO_POLICIES.md
@@ -1,408 +0,0 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-07-06
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||
`make docker`, and `make hooks` (installs pre-commit hook). A model 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.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
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
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
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
|
||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||
Dockerfile already runs `make check`, a successful build implies all checks
|
||||
pass.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||
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
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
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.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
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 force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`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:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **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?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sneak](https://sneak.berlin).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
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.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||
`install-precommit`)
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- `.gitea/workflows/check.yml`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
145
TODO.md
145
TODO.md
@@ -1,145 +0,0 @@
|
||||
# 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-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,13 +25,15 @@ import (
|
||||
//
|
||||
//nolint:gochecknoglobals // build-time variables
|
||||
var (
|
||||
Appname = "dnswatcher"
|
||||
Version string
|
||||
Appname = "dnswatcher"
|
||||
Version string
|
||||
Buildarch string
|
||||
)
|
||||
|
||||
func main() {
|
||||
globals.SetAppname(Appname)
|
||||
globals.SetVersion(Version)
|
||||
globals.SetBuildarch(Buildarch)
|
||||
|
||||
fx.New(
|
||||
fx.Provide(
|
||||
|
||||
@@ -23,11 +23,6 @@ const (
|
||||
defaultTLSExpiryWarning = 7
|
||||
)
|
||||
|
||||
// ErrNoTargets is returned when no monitoring targets are configured.
|
||||
var ErrNoTargets = errors.New(
|
||||
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
|
||||
)
|
||||
|
||||
// Params contains dependencies for Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -38,24 +33,23 @@ type Params struct {
|
||||
|
||||
// Config holds application configuration.
|
||||
type Config struct {
|
||||
Port int
|
||||
Debug bool
|
||||
DataDir string
|
||||
Domains []string
|
||||
Hostnames []string
|
||||
SlackWebhook string
|
||||
MattermostWebhook string
|
||||
NtfyTopic string
|
||||
DNSInterval time.Duration
|
||||
TLSInterval time.Duration
|
||||
TLSExpiryWarning int
|
||||
SentryDSN string
|
||||
MaintenanceMode bool
|
||||
MetricsUsername string
|
||||
MetricsPassword string
|
||||
SendTestNotification bool
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
Port int
|
||||
Debug bool
|
||||
DataDir string
|
||||
Domains []string
|
||||
Hostnames []string
|
||||
SlackWebhook string
|
||||
MattermostWebhook string
|
||||
NtfyTopic string
|
||||
DNSInterval time.Duration
|
||||
TLSInterval time.Duration
|
||||
TLSExpiryWarning int
|
||||
SentryDSN string
|
||||
MaintenanceMode bool
|
||||
MetricsUsername string
|
||||
MetricsPassword string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Config instance from environment and config files.
|
||||
@@ -94,7 +88,7 @@ func setupViper(name string) {
|
||||
|
||||
viper.SetDefault("PORT", defaultPort)
|
||||
viper.SetDefault("DEBUG", false)
|
||||
viper.SetDefault("DATA_DIR", "/var/lib/"+name)
|
||||
viper.SetDefault("DATA_DIR", "./data")
|
||||
viper.SetDefault("TARGETS", "")
|
||||
viper.SetDefault("SLACK_WEBHOOK", "")
|
||||
viper.SetDefault("MATTERMOST_WEBHOOK", "")
|
||||
@@ -106,7 +100,6 @@ func setupViper(name string) {
|
||||
viper.SetDefault("MAINTENANCE_MODE", false)
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("SEND_TEST_NOTIFICATION", false)
|
||||
}
|
||||
|
||||
func buildConfig(
|
||||
@@ -139,50 +132,34 @@ func buildConfig(
|
||||
tlsInterval = defaultTLSInterval
|
||||
}
|
||||
|
||||
domains, hostnames, err := parseAndValidateTargets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Port: viper.GetInt("PORT"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
DataDir: viper.GetString("DATA_DIR"),
|
||||
Domains: domains,
|
||||
Hostnames: hostnames,
|
||||
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
||||
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||
DNSInterval: dnsInterval,
|
||||
TLSInterval: tlsInterval,
|
||||
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
|
||||
params: params,
|
||||
log: log,
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseAndValidateTargets() ([]string, []string, error) {
|
||||
domains, hostnames, err := ClassifyTargets(
|
||||
parseCSV(viper.GetString("TARGETS")),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"invalid targets configuration: %w", err,
|
||||
)
|
||||
return nil, fmt.Errorf("invalid targets configuration: %w", err)
|
||||
}
|
||||
|
||||
if len(domains) == 0 && len(hostnames) == 0 {
|
||||
return nil, nil, ErrNoTargets
|
||||
cfg := &Config{
|
||||
Port: viper.GetInt("PORT"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
DataDir: viper.GetString("DATA_DIR"),
|
||||
Domains: domains,
|
||||
Hostnames: hostnames,
|
||||
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
||||
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||
DNSInterval: dnsInterval,
|
||||
TLSInterval: tlsInterval,
|
||||
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
params: params,
|
||||
log: log,
|
||||
}
|
||||
|
||||
return domains, hostnames, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseCSV(input string) []string {
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// newTestParams creates config.Params suitable for testing
|
||||
// without requiring the fx dependency injection framework.
|
||||
func newTestParams(t *testing.T) config.Params {
|
||||
t.Helper()
|
||||
|
||||
g := &globals.Globals{
|
||||
Appname: "dnswatcher",
|
||||
Version: "test",
|
||||
}
|
||||
|
||||
l, err := logger.New(nil, logger.Params{Globals: g})
|
||||
require.NoError(t, err, "failed to create logger")
|
||||
|
||||
return config.Params{
|
||||
Globals: g,
|
||||
Logger: l,
|
||||
}
|
||||
}
|
||||
|
||||
// These tests exercise viper global state and MUST NOT use
|
||||
// t.Parallel(). Each test resets viper for isolation.
|
||||
|
||||
func TestNew_DefaultValues(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
assert.False(t, cfg.Debug)
|
||||
assert.Equal(t, "/var/lib/dnswatcher", cfg.DataDir)
|
||||
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
||||
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
|
||||
assert.Equal(t, 7, cfg.TLSExpiryWarning)
|
||||
assert.False(t, cfg.MaintenanceMode)
|
||||
assert.Empty(t, cfg.SlackWebhook)
|
||||
assert.Empty(t, cfg.MattermostWebhook)
|
||||
assert.Empty(t, cfg.NtfyTopic)
|
||||
assert.Empty(t, cfg.SentryDSN)
|
||||
assert.Empty(t, cfg.MetricsUsername)
|
||||
assert.Empty(t, cfg.MetricsPassword)
|
||||
assert.False(t, cfg.SendTestNotification)
|
||||
}
|
||||
|
||||
func TestNew_EnvironmentOverrides(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("PORT", "9090")
|
||||
t.Setenv("DNSWATCHER_DEBUG", "true")
|
||||
t.Setenv("DNSWATCHER_DATA_DIR", "/tmp/test-data")
|
||||
t.Setenv("DNSWATCHER_DNS_INTERVAL", "30m")
|
||||
t.Setenv("DNSWATCHER_TLS_INTERVAL", "6h")
|
||||
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "14")
|
||||
t.Setenv("DNSWATCHER_SLACK_WEBHOOK", "https://hooks.slack.com/t")
|
||||
t.Setenv("DNSWATCHER_MATTERMOST_WEBHOOK", "https://mm.test/hooks/t")
|
||||
t.Setenv("DNSWATCHER_NTFY_TOPIC", "https://ntfy.sh/test")
|
||||
t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1")
|
||||
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "true")
|
||||
t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin")
|
||||
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret")
|
||||
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "true")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 9090, cfg.Port)
|
||||
assert.True(t, cfg.Debug)
|
||||
assert.Equal(t, "/tmp/test-data", cfg.DataDir)
|
||||
assert.Equal(t, 30*time.Minute, cfg.DNSInterval)
|
||||
assert.Equal(t, 6*time.Hour, cfg.TLSInterval)
|
||||
assert.Equal(t, 14, cfg.TLSExpiryWarning)
|
||||
assert.Equal(t, "https://hooks.slack.com/t", cfg.SlackWebhook)
|
||||
assert.Equal(t, "https://mm.test/hooks/t", cfg.MattermostWebhook)
|
||||
assert.Equal(t, "https://ntfy.sh/test", cfg.NtfyTopic)
|
||||
assert.Equal(t, "https://sentry.test/1", cfg.SentryDSN)
|
||||
assert.True(t, cfg.MaintenanceMode)
|
||||
assert.Equal(t, "admin", cfg.MetricsUsername)
|
||||
assert.Equal(t, "secret", cfg.MetricsPassword)
|
||||
assert.True(t, cfg.SendTestNotification)
|
||||
}
|
||||
|
||||
func TestNew_NoTargetsError(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "")
|
||||
|
||||
_, err := config.New(nil, newTestParams(t))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, config.ErrNoTargets)
|
||||
}
|
||||
|
||||
func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", " , , ")
|
||||
|
||||
_, err := config.New(nil, newTestParams(t))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, config.ErrNoTargets)
|
||||
}
|
||||
|
||||
func TestNew_InvalidDNSInterval_FallsBackToDefault(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DNS_INTERVAL", "banana")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, time.Hour, cfg.DNSInterval,
|
||||
"invalid DNS interval should fall back to 1h default")
|
||||
}
|
||||
|
||||
func TestNew_InvalidTLSInterval_FallsBackToDefault(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_TLS_INTERVAL", "notaduration")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 12*time.Hour, cfg.TLSInterval,
|
||||
"invalid TLS interval should fall back to 12h default")
|
||||
}
|
||||
|
||||
func TestNew_BothIntervalsInvalid(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DNS_INTERVAL", "xyz")
|
||||
t.Setenv("DNSWATCHER_TLS_INTERVAL", "abc")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
||||
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
|
||||
}
|
||||
|
||||
func TestNew_DebugEnablesDebugLogging(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DEBUG", "true")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, cfg.Debug)
|
||||
}
|
||||
|
||||
func TestNew_PortEnvNotPrefixed(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("PORT", "3000")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3000, cfg.Port,
|
||||
"PORT env should work without DNSWATCHER_ prefix")
|
||||
}
|
||||
|
||||
func TestNew_TargetClassification(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS",
|
||||
"example.com,www.example.com,api.example.com,example.org")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
// example.com and example.org are apex domains
|
||||
assert.Len(t, cfg.Domains, 2)
|
||||
// www.example.com and api.example.com are hostnames
|
||||
assert.Len(t, cfg.Hostnames, 2)
|
||||
}
|
||||
|
||||
func TestNew_InvalidTargetPublicSuffix(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "co.uk")
|
||||
|
||||
_, err := config.New(nil, newTestParams(t))
|
||||
require.Error(t, err, "public suffix should be rejected")
|
||||
}
|
||||
|
||||
func TestNew_EmptyAppnameDefaultsToDnswatcher(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
|
||||
g := &globals.Globals{Appname: "", Version: "test"}
|
||||
|
||||
l, err := logger.New(nil, logger.Params{Globals: g})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.New(
|
||||
nil, config.Params{Globals: g, Logger: l},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 8080, cfg.Port,
|
||||
"defaults should load when appname is empty")
|
||||
}
|
||||
|
||||
func TestNew_TargetsWithWhitespace(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", " example.com , www.example.com ")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
|
||||
"whitespace around targets should be trimmed")
|
||||
}
|
||||
|
||||
func TestNew_TargetsWithTrailingComma(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com,")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
|
||||
"trailing comma should be ignored")
|
||||
}
|
||||
|
||||
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DNS_INTERVAL", "5s")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5*time.Second, cfg.DNSInterval)
|
||||
}
|
||||
|
||||
func TestStatePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dataDir string
|
||||
want string
|
||||
}{
|
||||
{"default", "/var/lib/dnswatcher", "/var/lib/dnswatcher/state.json"},
|
||||
{"absolute", "/var/lib/dw", "/var/lib/dw/state.json"},
|
||||
{"nested", "/opt/app/data", "/opt/app/data/state.json"},
|
||||
{"empty", "", "/state.json"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{DataDir: tt.dataDir}
|
||||
assert.Equal(t, tt.want, cfg.StatePath())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package config
|
||||
|
||||
// ParseCSVForTest exports parseCSV for use in external tests.
|
||||
func ParseCSVForTest(input string) []string {
|
||||
return parseCSV(input)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
)
|
||||
|
||||
func TestParseCSV(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"empty string", "", nil},
|
||||
{"single value", "a", []string{"a"}},
|
||||
{"multiple values", "a,b,c", []string{"a", "b", "c"}},
|
||||
{"whitespace trimmed", " a , b ", []string{"a", "b"}},
|
||||
{"trailing comma", "a,b,", []string{"a", "b"}},
|
||||
{"leading comma", ",a,b", []string{"a", "b"}},
|
||||
{"consecutive commas", "a,,b", []string{"a", "b"}},
|
||||
{"all empty segments", ",,,", nil},
|
||||
{"whitespace only", " , , ", nil},
|
||||
{"tabs", "\ta\t,\tb\t", []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := config.ParseCSVForTest(tt.input)
|
||||
require.Len(t, got, len(tt.want))
|
||||
|
||||
for i, w := range tt.want {
|
||||
assert.Equal(t, w, got[i])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,17 @@ import (
|
||||
//
|
||||
//nolint:gochecknoglobals // Required for ldflags injection at build time
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
appname string
|
||||
version string
|
||||
mu sync.RWMutex
|
||||
appname string
|
||||
version string
|
||||
buildarch string
|
||||
)
|
||||
|
||||
// Globals holds build-time variables for dependency injection.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
}
|
||||
|
||||
// New creates a new Globals instance from package-level variables.
|
||||
@@ -29,8 +31,9 @@ func New(_ fx.Lifecycle) (*Globals, error) {
|
||||
defer mu.RUnlock()
|
||||
|
||||
return &Globals{
|
||||
Appname: appname,
|
||||
Version: version,
|
||||
Appname: appname,
|
||||
Version: version,
|
||||
Buildarch: buildarch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -49,3 +52,11 @@ func SetVersion(ver string) {
|
||||
|
||||
version = ver
|
||||
}
|
||||
|
||||
// SetBuildarch sets the build architecture.
|
||||
func SetBuildarch(arch string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
buildarch = arch
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
//go:embed templates/dashboard.html
|
||||
var dashboardFS embed.FS
|
||||
|
||||
// Time unit constants for relative time calculations.
|
||||
const (
|
||||
secondsPerMinute = 60
|
||||
minutesPerHour = 60
|
||||
hoursPerDay = 24
|
||||
)
|
||||
|
||||
// newDashboardTemplate parses the embedded dashboard HTML
|
||||
// template with helper functions.
|
||||
func newDashboardTemplate() *template.Template {
|
||||
funcs := template.FuncMap{
|
||||
"relTime": relTime,
|
||||
"joinStrings": joinStrings,
|
||||
"formatRecords": formatRecords,
|
||||
"expiryDays": expiryDays,
|
||||
}
|
||||
|
||||
return template.Must(
|
||||
template.New("dashboard.html").
|
||||
Funcs(funcs).
|
||||
ParseFS(dashboardFS, "templates/dashboard.html"),
|
||||
)
|
||||
}
|
||||
|
||||
// dashboardData is the data passed to the dashboard template.
|
||||
type dashboardData struct {
|
||||
Snapshot state.Snapshot
|
||||
Alerts []notify.AlertEntry
|
||||
StateAge string
|
||||
GeneratedAt string
|
||||
}
|
||||
|
||||
// HandleDashboard returns the dashboard page handler.
|
||||
func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
||||
tmpl := newDashboardTemplate()
|
||||
|
||||
return func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
snap := h.state.GetSnapshot()
|
||||
alerts := h.notifyHistory.Recent()
|
||||
|
||||
data := dashboardData{
|
||||
Snapshot: snap,
|
||||
Alerts: alerts,
|
||||
StateAge: relTime(snap.LastUpdated),
|
||||
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
writer.Header().Set(
|
||||
"Content-Type", "text/html; charset=utf-8",
|
||||
)
|
||||
|
||||
err := tmpl.Execute(writer, data)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"dashboard template error",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// relTime returns a human-readable relative time string such
|
||||
// as "2 minutes ago" or "never" for zero times.
|
||||
func relTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
|
||||
d := time.Since(t)
|
||||
if d < 0 {
|
||||
return "just now"
|
||||
}
|
||||
|
||||
seconds := int(math.Round(d.Seconds()))
|
||||
if seconds < secondsPerMinute {
|
||||
return fmt.Sprintf("%ds ago", seconds)
|
||||
}
|
||||
|
||||
minutes := seconds / secondsPerMinute
|
||||
if minutes < minutesPerHour {
|
||||
return fmt.Sprintf("%dm ago", minutes)
|
||||
}
|
||||
|
||||
hours := minutes / minutesPerHour
|
||||
if hours < hoursPerDay {
|
||||
return fmt.Sprintf(
|
||||
"%dh %dm ago", hours, minutes%minutesPerHour,
|
||||
)
|
||||
}
|
||||
|
||||
days := hours / hoursPerDay
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%dd %dh ago", days, hours%hoursPerDay,
|
||||
)
|
||||
}
|
||||
|
||||
// joinStrings joins a string slice with a separator.
|
||||
func joinStrings(items []string, sep string) string {
|
||||
return strings.Join(items, sep)
|
||||
}
|
||||
|
||||
// formatRecords formats a map of record type → values into a
|
||||
// compact display string.
|
||||
func formatRecords(records map[string][]string) string {
|
||||
if len(records) == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
for rtype, values := range records {
|
||||
for _, v := range values {
|
||||
parts = append(parts, rtype+": "+v)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// expiryDays returns the number of days until the given time,
|
||||
// rounded down. Returns 0 if already expired.
|
||||
func expiryDays(t time.Time) int {
|
||||
d := time.Until(t).Hours() / hoursPerDay
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return int(d)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||
)
|
||||
|
||||
func TestRelTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dur time.Duration
|
||||
want string
|
||||
}{
|
||||
{"zero", 0, "never"},
|
||||
{"seconds", 30 * time.Second, "30s ago"},
|
||||
{"minutes", 5 * time.Minute, "5m ago"},
|
||||
{"hours", 2*time.Hour + 15*time.Minute, "2h 15m ago"},
|
||||
{"days", 48*time.Hour + 3*time.Hour, "2d 3h ago"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var input time.Time
|
||||
if tt.dur > 0 {
|
||||
input = time.Now().Add(-tt.dur)
|
||||
}
|
||||
|
||||
got := handlers.RelTime(input)
|
||||
if got != tt.want {
|
||||
t.Errorf(
|
||||
"RelTime(%v) = %q, want %q",
|
||||
tt.dur, got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryDays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 10 days from now.
|
||||
future := time.Now().Add(10 * 24 * time.Hour)
|
||||
|
||||
days := handlers.ExpiryDays(future)
|
||||
if days < 9 || days > 10 {
|
||||
t.Errorf("expected ~10 days, got %d", days)
|
||||
}
|
||||
|
||||
// Already expired.
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
days = handlers.ExpiryDays(past)
|
||||
if days != 0 {
|
||||
t.Errorf("expected 0 for expired, got %d", days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := handlers.FormatRecords(nil)
|
||||
if got != "-" {
|
||||
t.Errorf("expected -, got %q", got)
|
||||
}
|
||||
|
||||
got = handlers.FormatRecords(map[string][]string{
|
||||
"A": {"1.2.3.4"},
|
||||
})
|
||||
|
||||
if got != "A: 1.2.3.4" {
|
||||
t.Errorf("unexpected format: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import "time"
|
||||
|
||||
// RelTime exports relTime for testing.
|
||||
func RelTime(t time.Time) string {
|
||||
return relTime(t)
|
||||
}
|
||||
|
||||
// ExpiryDays exports expiryDays for testing.
|
||||
func ExpiryDays(t time.Time) int {
|
||||
return expiryDays(t)
|
||||
}
|
||||
|
||||
// FormatRecords exports formatRecords for testing.
|
||||
func FormatRecords(records map[string][]string) string {
|
||||
return formatRecords(records)
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Handlers.
|
||||
@@ -22,29 +20,23 @@ type Params struct {
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
State *state.State
|
||||
Notify *notify.Service
|
||||
}
|
||||
|
||||
// Handlers provides HTTP request handlers.
|
||||
type Handlers struct {
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
globals *globals.Globals
|
||||
hc *healthcheck.Healthcheck
|
||||
state *state.State
|
||||
notifyHistory *notify.AlertHistory
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
globals *globals.Globals
|
||||
hc *healthcheck.Healthcheck
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
||||
return &Handlers{
|
||||
log: params.Logger.Get(),
|
||||
params: ¶ms,
|
||||
globals: params.Globals,
|
||||
hc: params.Healthcheck,
|
||||
state: params.State,
|
||||
notifyHistory: params.Notify.History(),
|
||||
log: params.Logger.Get(),
|
||||
params: ¶ms,
|
||||
globals: params.Globals,
|
||||
hc: params.Healthcheck,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,217 +2,22 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// statusDomainInfo holds status information for a monitored domain.
|
||||
type statusDomainInfo struct {
|
||||
Nameservers []string `json:"nameservers"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// statusHostnameNSInfo holds per-nameserver status for a hostname.
|
||||
type statusHostnameNSInfo struct {
|
||||
Records map[string][]string `json:"records"`
|
||||
Status string `json:"status"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// statusHostnameInfo holds status information for a monitored hostname.
|
||||
type statusHostnameInfo struct {
|
||||
Nameservers map[string]*statusHostnameNSInfo `json:"nameservers"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// statusPortInfo holds status information for a monitored port.
|
||||
type statusPortInfo struct {
|
||||
Open bool `json:"open"`
|
||||
Hostnames []string `json:"hostnames"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// statusCertificateInfo holds status information for a TLS certificate.
|
||||
type statusCertificateInfo struct {
|
||||
CommonName string `json:"commonName"`
|
||||
Issuer string `json:"issuer"`
|
||||
NotAfter time.Time `json:"notAfter"`
|
||||
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
|
||||
Status string `json:"status"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// statusCounts holds summary counts of monitored resources.
|
||||
type statusCounts struct {
|
||||
Domains int `json:"domains"`
|
||||
Hostnames int `json:"hostnames"`
|
||||
Ports int `json:"ports"`
|
||||
PortsOpen int `json:"portsOpen"`
|
||||
Certificates int `json:"certificates"`
|
||||
CertsOK int `json:"certificatesOk"`
|
||||
CertsError int `json:"certificatesError"`
|
||||
}
|
||||
|
||||
// statusResponse is the full /api/v1/status response.
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
Counts statusCounts `json:"counts"`
|
||||
Domains map[string]*statusDomainInfo `json:"domains"`
|
||||
Hostnames map[string]*statusHostnameInfo `json:"hostnames"`
|
||||
Ports map[string]*statusPortInfo `json:"ports"`
|
||||
Certificates map[string]*statusCertificateInfo `json:"certificates"`
|
||||
}
|
||||
|
||||
// HandleStatus returns the monitoring status handler.
|
||||
func (h *Handlers) HandleStatus() http.HandlerFunc {
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
return func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
snap := h.state.GetSnapshot()
|
||||
|
||||
resp := buildStatusResponse(snap)
|
||||
|
||||
h.respondJSON(
|
||||
writer, request,
|
||||
resp,
|
||||
&response{Status: "ok"},
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// buildStatusResponse constructs the full status response from
|
||||
// the current monitoring snapshot.
|
||||
func buildStatusResponse(
|
||||
snap state.Snapshot,
|
||||
) *statusResponse {
|
||||
resp := &statusResponse{
|
||||
Status: "ok",
|
||||
LastUpdated: snap.LastUpdated,
|
||||
Domains: make(map[string]*statusDomainInfo),
|
||||
Hostnames: make(map[string]*statusHostnameInfo),
|
||||
Ports: make(map[string]*statusPortInfo),
|
||||
Certificates: make(map[string]*statusCertificateInfo),
|
||||
}
|
||||
|
||||
buildDomains(snap, resp)
|
||||
buildHostnames(snap, resp)
|
||||
buildPorts(snap, resp)
|
||||
buildCertificates(snap, resp)
|
||||
buildCounts(resp)
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func buildDomains(
|
||||
snap state.Snapshot,
|
||||
resp *statusResponse,
|
||||
) {
|
||||
for name, ds := range snap.Domains {
|
||||
ns := make([]string, len(ds.Nameservers))
|
||||
copy(ns, ds.Nameservers)
|
||||
sort.Strings(ns)
|
||||
|
||||
resp.Domains[name] = &statusDomainInfo{
|
||||
Nameservers: ns,
|
||||
LastChecked: ds.LastChecked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildHostnames(
|
||||
snap state.Snapshot,
|
||||
resp *statusResponse,
|
||||
) {
|
||||
for name, hs := range snap.Hostnames {
|
||||
info := &statusHostnameInfo{
|
||||
Nameservers: make(map[string]*statusHostnameNSInfo),
|
||||
LastChecked: hs.LastChecked,
|
||||
}
|
||||
|
||||
for ns, nsState := range hs.RecordsByNameserver {
|
||||
recs := make(map[string][]string, len(nsState.Records))
|
||||
for rtype, vals := range nsState.Records {
|
||||
copied := make([]string, len(vals))
|
||||
copy(copied, vals)
|
||||
recs[rtype] = copied
|
||||
}
|
||||
|
||||
info.Nameservers[ns] = &statusHostnameNSInfo{
|
||||
Records: recs,
|
||||
Status: nsState.Status,
|
||||
LastChecked: nsState.LastChecked,
|
||||
}
|
||||
}
|
||||
|
||||
resp.Hostnames[name] = info
|
||||
}
|
||||
}
|
||||
|
||||
func buildPorts(
|
||||
snap state.Snapshot,
|
||||
resp *statusResponse,
|
||||
) {
|
||||
for key, ps := range snap.Ports {
|
||||
hostnames := make([]string, len(ps.Hostnames))
|
||||
copy(hostnames, ps.Hostnames)
|
||||
sort.Strings(hostnames)
|
||||
|
||||
resp.Ports[key] = &statusPortInfo{
|
||||
Open: ps.Open,
|
||||
Hostnames: hostnames,
|
||||
LastChecked: ps.LastChecked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildCertificates(
|
||||
snap state.Snapshot,
|
||||
resp *statusResponse,
|
||||
) {
|
||||
for key, cs := range snap.Certificates {
|
||||
sans := make([]string, len(cs.SubjectAlternativeNames))
|
||||
copy(sans, cs.SubjectAlternativeNames)
|
||||
|
||||
resp.Certificates[key] = &statusCertificateInfo{
|
||||
CommonName: cs.CommonName,
|
||||
Issuer: cs.Issuer,
|
||||
NotAfter: cs.NotAfter,
|
||||
SubjectAlternativeNames: sans,
|
||||
Status: cs.Status,
|
||||
LastChecked: cs.LastChecked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildCounts(resp *statusResponse) {
|
||||
var portsOpen, certsOK, certsError int
|
||||
|
||||
for _, ps := range resp.Ports {
|
||||
if ps.Open {
|
||||
portsOpen++
|
||||
}
|
||||
}
|
||||
|
||||
for _, cs := range resp.Certificates {
|
||||
switch cs.Status {
|
||||
case "ok":
|
||||
certsOK++
|
||||
case "error":
|
||||
certsError++
|
||||
}
|
||||
}
|
||||
|
||||
resp.Counts = statusCounts{
|
||||
Domains: len(resp.Domains),
|
||||
Hostnames: len(resp.Hostnames),
|
||||
Ports: len(resp.Ports),
|
||||
PortsOpen: portsOpen,
|
||||
Certificates: len(resp.Certificates),
|
||||
CertsOK: certsOK,
|
||||
CertsError: certsError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="bg-slate-950">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="refresh" content="30" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>dnswatcher</title>
|
||||
<link rel="stylesheet" href="/s/css/tailwind.min.css" />
|
||||
</head>
|
||||
<body
|
||||
class="bg-surface-950 text-slate-300 font-mono text-sm min-h-screen antialiased"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
{{/* ---- Header ---- */}}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-teal-400 tracking-tight">
|
||||
dnswatcher
|
||||
</h1>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
state updated {{ .StateAge }} · page generated
|
||||
{{ .GeneratedAt }} UTC · auto-refresh 30s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{/* ---- Summary bar ---- */}}
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8"
|
||||
>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Domains
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Domains }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Hostnames
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Hostnames }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Ports
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Ports }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Certificates
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Certificates }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---- Domains ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Domains
|
||||
</h2>
|
||||
{{ if .Snapshot.Domains }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Domain</th>
|
||||
<th class="py-2 px-3">Nameservers</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $name, $ds := .Snapshot.Domains }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $name }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ joinStrings $ds.Nameservers ", " }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $ds.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No domains configured.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Hostnames ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Hostnames
|
||||
</h2>
|
||||
{{ if .Snapshot.Hostnames }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Hostname</th>
|
||||
<th class="py-2 px-3">NS</th>
|
||||
<th class="py-2 px-3">Status</th>
|
||||
<th class="py-2 px-3">Records</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $name, $hs := .Snapshot.Hostnames }}
|
||||
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $name }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $ns }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if eq $nsr.Status "ok" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>ok</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>{{ $nsr.Status }}</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
||||
>
|
||||
{{ formatRecords $nsr.Records }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $nsr.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No hostnames configured.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Ports ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Ports
|
||||
</h2>
|
||||
{{ if .Snapshot.Ports }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Address</th>
|
||||
<th class="py-2 px-3">State</th>
|
||||
<th class="py-2 px-3">Hostnames</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $key, $ps := .Snapshot.Ports }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $key }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if $ps.Open }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>open</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>closed</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ joinStrings $ps.Hostnames ", " }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $ps.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No port data yet.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Certificates ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Certificates
|
||||
</h2>
|
||||
{{ if .Snapshot.Certificates }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Endpoint</th>
|
||||
<th class="py-2 px-3">Status</th>
|
||||
<th class="py-2 px-3">CN</th>
|
||||
<th class="py-2 px-3">Issuer</th>
|
||||
<th class="py-2 px-3">Expires</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $key, $cs := .Snapshot.Certificates }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $key }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if eq $cs.Status "ok" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>ok</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>{{ $cs.Status }}</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-200">
|
||||
{{ $cs.CommonName }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $cs.Issuer }}
|
||||
</td>
|
||||
<td class="py-2 px-3 whitespace-nowrap">
|
||||
{{ if not $cs.NotAfter.IsZero }}
|
||||
{{ $days := expiryDays $cs.NotAfter }}
|
||||
{{ if lt $days 7 }}
|
||||
<span class="text-red-400 font-medium"
|
||||
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||
({{ $days }}d)</span
|
||||
>
|
||||
{{ else if lt $days 30 }}
|
||||
<span class="text-amber-400"
|
||||
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||
({{ $days }}d)</span
|
||||
>
|
||||
{{ else }}
|
||||
<span class="text-slate-400"
|
||||
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||
({{ $days }}d)</span
|
||||
>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $cs.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No certificate data yet.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Recent Alerts ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Recent Alerts ({{ len .Alerts }})
|
||||
</h2>
|
||||
{{ if .Alerts }}
|
||||
<div class="space-y-2">
|
||||
{{ range .Alerts }}
|
||||
<div
|
||||
class="bg-surface-800 border rounded-lg px-4 py-3 {{ if eq .Priority "error" }}border-red-700/40{{ else if eq .Priority "warning" }}border-amber-700/40{{ else if eq .Priority "success" }}border-teal-700/40{{ else }}border-blue-700/40{{ end }}"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
{{ if eq .Priority "error" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>error</span
|
||||
>
|
||||
{{ else if eq .Priority "warning" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-amber-900/50 text-amber-400 border border-amber-700/30"
|
||||
>warning</span
|
||||
>
|
||||
{{ else if eq .Priority "success" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>success</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-blue-900/50 text-blue-400 border border-blue-700/30"
|
||||
>info</span
|
||||
>
|
||||
{{ end }}
|
||||
<span class="text-slate-200 text-xs font-medium">
|
||||
{{ .Title }}
|
||||
</span>
|
||||
<span class="text-slate-600 text-[11px] ml-auto whitespace-nowrap">
|
||||
{{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC
|
||||
({{ relTime .Timestamp }})
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-slate-400 text-xs whitespace-pre-line pl-0.5"
|
||||
>
|
||||
{{ .Message }}
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No alerts recorded since last restart.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Footer ---- */}}
|
||||
<div
|
||||
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
|
||||
>
|
||||
dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
|
||||
{{ len .Snapshot.Hostnames }} hostnames
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -78,5 +78,6 @@ func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
"buildarch", l.params.Globals.Buildarch,
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NtfyPriority exports ntfyPriority for testing.
|
||||
func NtfyPriority(priority string) string {
|
||||
return ntfyPriority(priority)
|
||||
}
|
||||
|
||||
// SlackColor exports slackColor for testing.
|
||||
func SlackColor(priority string) string {
|
||||
return slackColor(priority)
|
||||
}
|
||||
|
||||
// NewRequestForTest exports newRequest for testing.
|
||||
func NewRequestForTest(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
target *url.URL,
|
||||
body io.Reader,
|
||||
) *http.Request {
|
||||
return newRequest(ctx, method, target, body)
|
||||
}
|
||||
|
||||
// NewTestService creates a Service suitable for unit testing.
|
||||
// It discards log output and uses the given transport.
|
||||
func NewTestService(transport http.RoundTripper) *Service {
|
||||
return &Service{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
transport: transport,
|
||||
history: NewAlertHistory(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetNtfyURL sets the ntfy URL on a Service for testing.
|
||||
func (svc *Service) SetNtfyURL(u *url.URL) {
|
||||
svc.ntfyURL = u
|
||||
}
|
||||
|
||||
// SetSlackWebhookURL sets the Slack webhook URL on a
|
||||
// Service for testing.
|
||||
func (svc *Service) SetSlackWebhookURL(u *url.URL) {
|
||||
svc.slackWebhookURL = u
|
||||
}
|
||||
|
||||
// SetMattermostWebhookURL sets the Mattermost webhook URL on
|
||||
// a Service for testing.
|
||||
func (svc *Service) SetMattermostWebhookURL(u *url.URL) {
|
||||
svc.mattermostWebhookURL = u
|
||||
}
|
||||
|
||||
// SendNtfy exports sendNtfy for testing.
|
||||
func (svc *Service) SendNtfy(
|
||||
ctx context.Context,
|
||||
topicURL *url.URL,
|
||||
title, message, priority string,
|
||||
) error {
|
||||
return svc.sendNtfy(ctx, topicURL, title, message, priority)
|
||||
}
|
||||
|
||||
// SendSlack exports sendSlack for testing.
|
||||
func (svc *Service) SendSlack(
|
||||
ctx context.Context,
|
||||
webhookURL *url.URL,
|
||||
title, message, priority string,
|
||||
) error {
|
||||
return svc.sendSlack(
|
||||
ctx, webhookURL, title, message, priority,
|
||||
)
|
||||
}
|
||||
|
||||
// SetRetryConfig overrides the retry configuration for
|
||||
// testing.
|
||||
func (svc *Service) SetRetryConfig(cfg RetryConfig) {
|
||||
svc.retryConfig = cfg
|
||||
}
|
||||
|
||||
// SetSleepFunc overrides the sleep function so tests can
|
||||
// eliminate real delays.
|
||||
func (svc *Service) SetSleepFunc(
|
||||
fn func(time.Duration) <-chan time.Time,
|
||||
) {
|
||||
svc.sleepFn = fn
|
||||
}
|
||||
|
||||
// DeliverWithRetry exports deliverWithRetry for testing.
|
||||
func (svc *Service) DeliverWithRetry(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
fn func(context.Context) error,
|
||||
) error {
|
||||
return svc.deliverWithRetry(ctx, endpoint, fn)
|
||||
}
|
||||
|
||||
// BackoffDuration exports RetryConfig.backoff for testing.
|
||||
func (rc RetryConfig) BackoffDuration(attempt int) time.Duration {
|
||||
return rc.defaults().backoff(attempt)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxAlertHistory is the maximum number of alerts to retain.
|
||||
const maxAlertHistory = 100
|
||||
|
||||
// AlertEntry represents a single notification that was sent.
|
||||
type AlertEntry struct {
|
||||
Timestamp time.Time
|
||||
Title string
|
||||
Message string
|
||||
Priority string
|
||||
}
|
||||
|
||||
// AlertHistory is a thread-safe ring buffer that stores
|
||||
// the most recent alerts.
|
||||
type AlertHistory struct {
|
||||
mu sync.RWMutex
|
||||
entries [maxAlertHistory]AlertEntry
|
||||
count int
|
||||
index int
|
||||
}
|
||||
|
||||
// NewAlertHistory creates a new empty AlertHistory.
|
||||
func NewAlertHistory() *AlertHistory {
|
||||
return &AlertHistory{}
|
||||
}
|
||||
|
||||
// Add records a new alert entry in the ring buffer.
|
||||
func (h *AlertHistory) Add(entry AlertEntry) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.entries[h.index] = entry
|
||||
h.index = (h.index + 1) % maxAlertHistory
|
||||
|
||||
if h.count < maxAlertHistory {
|
||||
h.count++
|
||||
}
|
||||
}
|
||||
|
||||
// Recent returns the stored alerts in reverse chronological
|
||||
// order (newest first). Returns at most maxAlertHistory entries.
|
||||
func (h *AlertHistory) Recent() []AlertEntry {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
result := make([]AlertEntry, h.count)
|
||||
|
||||
for i := range h.count {
|
||||
// Walk backwards from the most recent entry.
|
||||
idx := (h.index - 1 - i + maxAlertHistory) %
|
||||
maxAlertHistory
|
||||
result[i] = h.entries[idx]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package notify_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||
)
|
||||
|
||||
func TestAlertHistoryEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := notify.NewAlertHistory()
|
||||
|
||||
entries := h.Recent()
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("expected 0 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertHistoryAddAndRecent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := notify.NewAlertHistory()
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
h.Add(notify.AlertEntry{
|
||||
Timestamp: now.Add(-2 * time.Minute),
|
||||
Title: "first",
|
||||
Message: "msg1",
|
||||
Priority: "info",
|
||||
})
|
||||
|
||||
h.Add(notify.AlertEntry{
|
||||
Timestamp: now.Add(-1 * time.Minute),
|
||||
Title: "second",
|
||||
Message: "msg2",
|
||||
Priority: "warning",
|
||||
})
|
||||
|
||||
entries := h.Recent()
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Newest first.
|
||||
if entries[0].Title != "second" {
|
||||
t.Errorf(
|
||||
"expected newest first, got %q", entries[0].Title,
|
||||
)
|
||||
}
|
||||
|
||||
if entries[1].Title != "first" {
|
||||
t.Errorf(
|
||||
"expected oldest second, got %q", entries[1].Title,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertHistoryOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := notify.NewAlertHistory()
|
||||
|
||||
const totalEntries = 110
|
||||
|
||||
// Fill beyond capacity.
|
||||
for i := range totalEntries {
|
||||
h.Add(notify.AlertEntry{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Title: "alert",
|
||||
Message: "msg",
|
||||
Priority: string(rune('0' + i%10)),
|
||||
})
|
||||
}
|
||||
|
||||
entries := h.Recent()
|
||||
|
||||
const maxHistory = 100
|
||||
|
||||
if len(entries) != maxHistory {
|
||||
t.Fatalf(
|
||||
"expected %d entries, got %d",
|
||||
maxHistory, len(entries),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,9 +112,6 @@ type Service struct {
|
||||
ntfyURL *url.URL
|
||||
slackWebhookURL *url.URL
|
||||
mattermostWebhookURL *url.URL
|
||||
history *AlertHistory
|
||||
retryConfig RetryConfig
|
||||
sleepFn func(time.Duration) <-chan time.Time
|
||||
}
|
||||
|
||||
// New creates a new notify Service.
|
||||
@@ -126,7 +123,6 @@ func New(
|
||||
log: params.Logger.Get(),
|
||||
transport: http.DefaultTransport,
|
||||
config: params.Config,
|
||||
history: NewAlertHistory(),
|
||||
}
|
||||
|
||||
if params.Config.NtfyTopic != "" {
|
||||
@@ -171,117 +167,65 @@ func New(
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// History returns the alert history for reading recent alerts.
|
||||
func (svc *Service) History() *AlertHistory {
|
||||
return svc.history
|
||||
}
|
||||
|
||||
// SendNotification sends a notification to all configured
|
||||
// endpoints and records it in the alert history.
|
||||
// endpoints.
|
||||
func (svc *Service) SendNotification(
|
||||
ctx context.Context,
|
||||
title, message, priority string,
|
||||
) {
|
||||
svc.history.Add(AlertEntry{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Title: title,
|
||||
Message: message,
|
||||
Priority: priority,
|
||||
})
|
||||
if svc.ntfyURL != nil {
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
svc.dispatchNtfy(ctx, title, message, priority)
|
||||
svc.dispatchSlack(ctx, title, message, priority)
|
||||
svc.dispatchMattermost(ctx, title, message, priority)
|
||||
}
|
||||
|
||||
func (svc *Service) dispatchNtfy(
|
||||
ctx context.Context,
|
||||
title, message, priority string,
|
||||
) {
|
||||
if svc.ntfyURL == nil {
|
||||
return
|
||||
err := svc.sendNtfy(
|
||||
notifyCtx,
|
||||
svc.ntfyURL,
|
||||
title, message, priority,
|
||||
)
|
||||
if err != nil {
|
||||
svc.log.Error(
|
||||
"failed to send ntfy notification",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
if svc.slackWebhookURL != nil {
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
err := svc.deliverWithRetry(
|
||||
notifyCtx, "ntfy",
|
||||
func(c context.Context) error {
|
||||
return svc.sendNtfy(
|
||||
c, svc.ntfyURL,
|
||||
title, message, priority,
|
||||
)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
svc.log.Error(
|
||||
"failed to send ntfy notification "+
|
||||
"after retries",
|
||||
"error", err,
|
||||
err := svc.sendSlack(
|
||||
notifyCtx,
|
||||
svc.slackWebhookURL,
|
||||
title, message, priority,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (svc *Service) dispatchSlack(
|
||||
ctx context.Context,
|
||||
title, message, priority string,
|
||||
) {
|
||||
if svc.slackWebhookURL == nil {
|
||||
return
|
||||
if err != nil {
|
||||
svc.log.Error(
|
||||
"failed to send slack notification",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
if svc.mattermostWebhookURL != nil {
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
err := svc.deliverWithRetry(
|
||||
notifyCtx, "slack",
|
||||
func(c context.Context) error {
|
||||
return svc.sendSlack(
|
||||
c, svc.slackWebhookURL,
|
||||
title, message, priority,
|
||||
)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
svc.log.Error(
|
||||
"failed to send slack notification "+
|
||||
"after retries",
|
||||
"error", err,
|
||||
err := svc.sendSlack(
|
||||
notifyCtx,
|
||||
svc.mattermostWebhookURL,
|
||||
title, message, priority,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (svc *Service) dispatchMattermost(
|
||||
ctx context.Context,
|
||||
title, message, priority string,
|
||||
) {
|
||||
if svc.mattermostWebhookURL == nil {
|
||||
return
|
||||
if err != nil {
|
||||
svc.log.Error(
|
||||
"failed to send mattermost notification",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
err := svc.deliverWithRetry(
|
||||
notifyCtx, "mattermost",
|
||||
func(c context.Context) error {
|
||||
return svc.sendSlack(
|
||||
c, svc.mattermostWebhookURL,
|
||||
title, message, priority,
|
||||
)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
svc.log.Error(
|
||||
"failed to send mattermost notification "+
|
||||
"after retries",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (svc *Service) sendNtfy(
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Retry defaults.
|
||||
const (
|
||||
// DefaultMaxRetries is the number of additional attempts
|
||||
// after the first failure.
|
||||
DefaultMaxRetries = 5
|
||||
|
||||
// DefaultBaseDelay is the initial delay before the first
|
||||
// retry attempt.
|
||||
DefaultBaseDelay = 1 * time.Second
|
||||
|
||||
// DefaultMaxDelay caps the computed backoff delay.
|
||||
DefaultMaxDelay = 60 * time.Second
|
||||
|
||||
// backoffMultiplier is the exponential growth factor.
|
||||
backoffMultiplier = 2
|
||||
|
||||
// jitterFraction controls the ±random spread applied
|
||||
// to each delay (0.25 = ±25%).
|
||||
jitterFraction = 0.25
|
||||
)
|
||||
|
||||
// RetryConfig holds tuning knobs for the retry loop.
|
||||
// Zero values fall back to the package defaults above.
|
||||
type RetryConfig struct {
|
||||
MaxRetries int
|
||||
BaseDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
}
|
||||
|
||||
// defaults returns a copy with zero fields replaced by
|
||||
// package defaults.
|
||||
func (rc RetryConfig) defaults() RetryConfig {
|
||||
if rc.MaxRetries <= 0 {
|
||||
rc.MaxRetries = DefaultMaxRetries
|
||||
}
|
||||
|
||||
if rc.BaseDelay <= 0 {
|
||||
rc.BaseDelay = DefaultBaseDelay
|
||||
}
|
||||
|
||||
if rc.MaxDelay <= 0 {
|
||||
rc.MaxDelay = DefaultMaxDelay
|
||||
}
|
||||
|
||||
return rc
|
||||
}
|
||||
|
||||
// backoff computes the delay for attempt n (0-indexed) with
|
||||
// jitter. The raw delay is BaseDelay * 2^n, capped at
|
||||
// MaxDelay, then randomised by ±jitterFraction.
|
||||
func (rc RetryConfig) backoff(attempt int) time.Duration {
|
||||
raw := float64(rc.BaseDelay) *
|
||||
math.Pow(backoffMultiplier, float64(attempt))
|
||||
|
||||
if raw > float64(rc.MaxDelay) {
|
||||
raw = float64(rc.MaxDelay)
|
||||
}
|
||||
|
||||
// Apply jitter: uniform in [raw*(1-j), raw*(1+j)].
|
||||
lo := raw * (1 - jitterFraction)
|
||||
hi := raw * (1 + jitterFraction)
|
||||
|
||||
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand
|
||||
|
||||
return time.Duration(jittered)
|
||||
}
|
||||
|
||||
// deliverWithRetry calls fn, retrying on error with
|
||||
// exponential backoff. It logs every failed attempt and
|
||||
// returns the last error if all attempts are exhausted.
|
||||
func (svc *Service) deliverWithRetry(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
fn func(context.Context) error,
|
||||
) error {
|
||||
cfg := svc.retryConfig.defaults()
|
||||
|
||||
var lastErr error
|
||||
|
||||
// attempt 0 is the initial call; attempts 1..MaxRetries
|
||||
// are retries.
|
||||
for attempt := range cfg.MaxRetries + 1 {
|
||||
lastErr = fn(ctx)
|
||||
if lastErr == nil {
|
||||
if attempt > 0 {
|
||||
svc.log.Info(
|
||||
"notification delivered after retry",
|
||||
"endpoint", endpoint,
|
||||
"attempt", attempt+1,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Last attempt — don't sleep, just return.
|
||||
if attempt == cfg.MaxRetries {
|
||||
break
|
||||
}
|
||||
|
||||
delay := cfg.backoff(attempt)
|
||||
|
||||
svc.log.Warn(
|
||||
"notification delivery failed, retrying",
|
||||
"endpoint", endpoint,
|
||||
"attempt", attempt+1,
|
||||
"maxAttempts", cfg.MaxRetries+1,
|
||||
"retryIn", delay,
|
||||
"error", lastErr,
|
||||
)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-svc.sleepFunc(delay):
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// sleepFunc returns a channel that closes after d.
|
||||
// It is a field-level indirection so tests can override it.
|
||||
func (svc *Service) sleepFunc(d time.Duration) <-chan time.Time {
|
||||
if svc.sleepFn != nil {
|
||||
return svc.sleepFn(d)
|
||||
}
|
||||
|
||||
return time.After(d)
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
package notify_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||
)
|
||||
|
||||
// Static test errors (err113).
|
||||
var (
|
||||
errTransient = errors.New("transient failure")
|
||||
errPermanent = errors.New("permanent failure")
|
||||
errFail = errors.New("fail")
|
||||
)
|
||||
|
||||
// instantSleep returns a closed channel immediately, removing
|
||||
// real delays from tests.
|
||||
func instantSleep(_ time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// ── backoff calculation ───────────────────────────────────
|
||||
|
||||
func TestBackoffDurationIncreases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := notify.RetryConfig{
|
||||
MaxRetries: 5,
|
||||
BaseDelay: 1 * time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
}
|
||||
|
||||
prev := time.Duration(0)
|
||||
|
||||
// With jitter the exact value varies, but the trend
|
||||
// should be increasing for the first few attempts.
|
||||
for attempt := range 4 {
|
||||
d := cfg.BackoffDuration(attempt)
|
||||
if d <= 0 {
|
||||
t.Fatalf(
|
||||
"attempt %d: backoff must be positive, got %v",
|
||||
attempt, d,
|
||||
)
|
||||
}
|
||||
|
||||
// Allow jitter to occasionally flatten a step, but
|
||||
// the midpoint (no-jitter) should be strictly higher.
|
||||
midpoint := cfg.BaseDelay * (1 << attempt)
|
||||
if attempt > 0 && midpoint <= prev {
|
||||
t.Fatalf(
|
||||
"midpoint should grow: attempt %d midpoint=%v prev=%v",
|
||||
attempt, midpoint, prev,
|
||||
)
|
||||
}
|
||||
|
||||
prev = midpoint
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffDurationCappedAtMax(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := notify.RetryConfig{
|
||||
MaxRetries: 5,
|
||||
BaseDelay: 1 * time.Second,
|
||||
MaxDelay: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Attempt 10 would be 1024s without capping.
|
||||
d := cfg.BackoffDuration(10)
|
||||
|
||||
// With ±25% jitter on a 5s cap: max is 6.25s.
|
||||
const maxWithJitter = 5*time.Second +
|
||||
5*time.Second/4 +
|
||||
time.Millisecond // rounding margin
|
||||
|
||||
if d > maxWithJitter {
|
||||
t.Errorf(
|
||||
"backoff %v exceeds max+jitter %v",
|
||||
d, maxWithJitter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── deliverWithRetry ──────────────────────────────────────
|
||||
|
||||
func TestDeliverWithRetrySucceedsFirstAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := notify.NewTestService(http.DefaultTransport)
|
||||
svc.SetSleepFunc(instantSleep)
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
err := svc.DeliverWithRetry(
|
||||
context.Background(), "test",
|
||||
func(_ context.Context) error {
|
||||
calls.Add(1)
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 call, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverWithRetryRetriesOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := notify.NewTestService(http.DefaultTransport)
|
||||
svc.SetSleepFunc(instantSleep)
|
||||
svc.SetRetryConfig(notify.RetryConfig{
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
})
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
// Fail twice, then succeed on the third attempt.
|
||||
err := svc.DeliverWithRetry(
|
||||
context.Background(), "test",
|
||||
func(_ context.Context) error {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
return errTransient
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after retries: %v", err)
|
||||
}
|
||||
|
||||
if calls.Load() != 3 {
|
||||
t.Errorf("expected 3 calls, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverWithRetryExhaustsAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := notify.NewTestService(http.DefaultTransport)
|
||||
svc.SetSleepFunc(instantSleep)
|
||||
svc.SetRetryConfig(notify.RetryConfig{
|
||||
MaxRetries: 2,
|
||||
BaseDelay: time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
})
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
err := svc.DeliverWithRetry(
|
||||
context.Background(), "test",
|
||||
func(_ context.Context) error {
|
||||
calls.Add(1)
|
||||
|
||||
return errPermanent
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all retries exhausted")
|
||||
}
|
||||
|
||||
if !errors.Is(err, errPermanent) {
|
||||
t.Errorf("expected permanent failure, got: %v", err)
|
||||
}
|
||||
|
||||
// 1 initial + 2 retries = 3 total.
|
||||
if calls.Load() != 3 {
|
||||
t.Errorf("expected 3 calls, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverWithRetryRespectsContextCancellation(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
svc := notify.NewTestService(http.DefaultTransport)
|
||||
svc.SetRetryConfig(notify.RetryConfig{
|
||||
MaxRetries: 5,
|
||||
BaseDelay: time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Use a blocking sleep so the context cancellation is
|
||||
// the only way out.
|
||||
svc.SetSleepFunc(func(_ time.Duration) <-chan time.Time {
|
||||
return make(chan time.Time) // never fires
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
done <- svc.DeliverWithRetry(
|
||||
ctx, "test",
|
||||
func(_ context.Context) error {
|
||||
return errFail
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
// Wait for the first failure + retry sleep to be
|
||||
// entered, then cancel.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf(
|
||||
"expected context.Canceled, got: %v", err,
|
||||
)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("deliverWithRetry did not return after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// ── integration: SendNotification with retry ──────────────
|
||||
|
||||
func TestSendNotificationRetriesTransientFailure(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
attempts int
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
mu.Lock()
|
||||
attempts++
|
||||
n := attempts
|
||||
mu.Unlock()
|
||||
|
||||
if n <= 2 {
|
||||
w.WriteHeader(
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
svc := newRetryTestService(srv.URL, "ntfy")
|
||||
|
||||
svc.SendNotification(
|
||||
context.Background(),
|
||||
"Retry Test", "body", "warning",
|
||||
)
|
||||
|
||||
waitForCondition(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return attempts >= 3
|
||||
})
|
||||
}
|
||||
|
||||
// newRetryTestService creates a test service with instant
|
||||
// sleep and low retry delays for the named endpoint.
|
||||
func newRetryTestService(
|
||||
rawURL, endpoint string,
|
||||
) *notify.Service {
|
||||
svc := notify.NewTestService(http.DefaultTransport)
|
||||
svc.SetSleepFunc(instantSleep)
|
||||
svc.SetRetryConfig(notify.RetryConfig{
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
})
|
||||
|
||||
u, _ := url.Parse(rawURL)
|
||||
|
||||
switch endpoint {
|
||||
case "ntfy":
|
||||
svc.SetNtfyURL(u)
|
||||
case "slack":
|
||||
svc.SetSlackWebhookURL(u)
|
||||
case "mattermost":
|
||||
svc.SetMattermostWebhookURL(u)
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestSendNotificationAllEndpointsRetrySetup(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
result := newEndpointRetryResult()
|
||||
ntfySrv, slackSrv, mmSrv := newRetryServers(result)
|
||||
|
||||
defer ntfySrv.Close()
|
||||
defer slackSrv.Close()
|
||||
defer mmSrv.Close()
|
||||
|
||||
svc := buildAllEndpointRetryService(
|
||||
ntfySrv.URL, slackSrv.URL, mmSrv.URL,
|
||||
)
|
||||
|
||||
svc.SendNotification(
|
||||
context.Background(),
|
||||
"Multi-Retry", "testing", "error",
|
||||
)
|
||||
|
||||
assertAllEndpointsRetried(t, result)
|
||||
}
|
||||
|
||||
// endpointRetryResult tracks per-endpoint retry state.
|
||||
type endpointRetryResult struct {
|
||||
mu sync.Mutex
|
||||
ntfyAttempts int
|
||||
slackAttempts int
|
||||
mmAttempts int
|
||||
ntfyOK bool
|
||||
slackOK bool
|
||||
mmOK bool
|
||||
}
|
||||
|
||||
func newEndpointRetryResult() *endpointRetryResult {
|
||||
return &endpointRetryResult{}
|
||||
}
|
||||
|
||||
func newRetryServers(
|
||||
r *endpointRetryResult,
|
||||
) (*httptest.Server, *httptest.Server, *httptest.Server) {
|
||||
mk := func(
|
||||
attempts *int, ok *bool,
|
||||
) *httptest.Server {
|
||||
return httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
r.mu.Lock()
|
||||
*attempts++
|
||||
n := *attempts
|
||||
r.mu.Unlock()
|
||||
|
||||
if n == 1 {
|
||||
w.WriteHeader(
|
||||
http.StatusServiceUnavailable,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
*ok = true
|
||||
r.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return mk(&r.ntfyAttempts, &r.ntfyOK),
|
||||
mk(&r.slackAttempts, &r.slackOK),
|
||||
mk(&r.mmAttempts, &r.mmOK)
|
||||
}
|
||||
|
||||
func buildAllEndpointRetryService(
|
||||
ntfyURL, slackURL, mmURL string,
|
||||
) *notify.Service {
|
||||
svc := notify.NewTestService(http.DefaultTransport)
|
||||
svc.SetSleepFunc(instantSleep)
|
||||
svc.SetRetryConfig(notify.RetryConfig{
|
||||
MaxRetries: 3,
|
||||
BaseDelay: time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
})
|
||||
|
||||
nu, _ := url.Parse(ntfyURL)
|
||||
su, _ := url.Parse(slackURL)
|
||||
mu, _ := url.Parse(mmURL)
|
||||
|
||||
svc.SetNtfyURL(nu)
|
||||
svc.SetSlackWebhookURL(su)
|
||||
svc.SetMattermostWebhookURL(mu)
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func assertAllEndpointsRetried(
|
||||
t *testing.T,
|
||||
r *endpointRetryResult,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
waitForCondition(t, func() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
return r.ntfyOK && r.slackOK && r.mmOK
|
||||
})
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.ntfyAttempts < 2 {
|
||||
t.Errorf(
|
||||
"ntfy: expected >= 2 attempts, got %d",
|
||||
r.ntfyAttempts,
|
||||
)
|
||||
}
|
||||
|
||||
if r.slackAttempts < 2 {
|
||||
t.Errorf(
|
||||
"slack: expected >= 2 attempts, got %d",
|
||||
r.slackAttempts,
|
||||
)
|
||||
}
|
||||
|
||||
if r.mmAttempts < 2 {
|
||||
t.Errorf(
|
||||
"mattermost: expected >= 2 attempts, got %d",
|
||||
r.mmAttempts,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendNotificationPermanentFailureLogsError(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
attempts int
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
mu.Lock()
|
||||
attempts++
|
||||
mu.Unlock()
|
||||
|
||||
w.WriteHeader(
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
svc := newRetryTestService(srv.URL, "slack")
|
||||
svc.SetRetryConfig(notify.RetryConfig{
|
||||
MaxRetries: 2,
|
||||
BaseDelay: time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
})
|
||||
|
||||
svc.SendNotification(
|
||||
context.Background(),
|
||||
"Permanent Fail", "body", "error",
|
||||
)
|
||||
|
||||
// 1 initial + 2 retries = 3 total.
|
||||
waitForCondition(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return attempts >= 3
|
||||
})
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/static"
|
||||
)
|
||||
|
||||
// requestTimeout is the maximum duration for handling a request.
|
||||
@@ -25,25 +22,7 @@ func (s *Server) SetupRoutes() {
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(chimw.Timeout(requestTimeout))
|
||||
|
||||
// Dashboard (read-only web UI)
|
||||
s.router.Get("/", s.handlers.HandleDashboard())
|
||||
|
||||
// Static assets (embedded CSS/JS)
|
||||
s.router.Mount(
|
||||
"/s",
|
||||
http.StripPrefix(
|
||||
"/s",
|
||||
http.FileServer(http.FS(static.Static)),
|
||||
),
|
||||
)
|
||||
|
||||
// Health check (standard well-known path)
|
||||
s.router.Get(
|
||||
"/.well-known/healthcheck",
|
||||
s.handlers.HandleHealthCheck(),
|
||||
)
|
||||
|
||||
// Legacy health check (keep for backward compatibility)
|
||||
// Health check
|
||||
s.router.Get("/health", s.handlers.HandleHealthCheck())
|
||||
|
||||
// API v1 routes
|
||||
|
||||
@@ -57,49 +57,10 @@ type HostnameState struct {
|
||||
// PortState holds the monitoring state for a port.
|
||||
type PortState struct {
|
||||
Open bool `json:"open"`
|
||||
Hostnames []string `json:"hostnames"`
|
||||
Hostname string `json:"hostname"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom unmarshaling to handle both
|
||||
// the old single-hostname format and the new multi-hostname
|
||||
// format for backward compatibility with existing state files.
|
||||
func (ps *PortState) UnmarshalJSON(data []byte) error {
|
||||
// Use an alias to prevent infinite recursion.
|
||||
type portStateAlias struct {
|
||||
Open bool `json:"open"`
|
||||
Hostnames []string `json:"hostnames"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
var alias portStateAlias
|
||||
|
||||
err := json.Unmarshal(data, &alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshaling port state: %w", err)
|
||||
}
|
||||
|
||||
ps.Open = alias.Open
|
||||
ps.Hostnames = alias.Hostnames
|
||||
ps.LastChecked = alias.LastChecked
|
||||
|
||||
// If Hostnames is empty, try reading the old single-hostname
|
||||
// format for backward compatibility.
|
||||
if len(ps.Hostnames) == 0 {
|
||||
var old struct {
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
// Best-effort: ignore errors since the main unmarshal
|
||||
// already succeeded.
|
||||
if json.Unmarshal(data, &old) == nil && old.Hostname != "" {
|
||||
ps.Hostnames = []string{old.Hostname}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CertificateState holds TLS certificate monitoring state.
|
||||
type CertificateState struct {
|
||||
CommonName string `json:"commonName"`
|
||||
@@ -302,27 +263,6 @@ func (s *State) GetPortState(key string) (*PortState, bool) {
|
||||
return ps, ok
|
||||
}
|
||||
|
||||
// DeletePortState removes a port state entry.
|
||||
func (s *State) DeletePortState(key string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.snapshot.Ports, key)
|
||||
}
|
||||
|
||||
// GetAllPortKeys returns all port state keys.
|
||||
func (s *State) GetAllPortKeys() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
keys := make([]string, 0, len(s.snapshot.Ports))
|
||||
for k := range s.snapshot.Ports {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// SetCertificateState updates the state for a certificate.
|
||||
func (s *State) SetCertificateState(
|
||||
key string,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,19 +20,3 @@ func NewForTest() *State {
|
||||
config: &config.Config{DataDir: ""},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForTestWithDataDir creates a State backed by the given directory
|
||||
// for tests that need file persistence.
|
||||
func NewForTestWithDataDir(dataDir string) *State {
|
||||
return &State{
|
||||
log: slog.Default(),
|
||||
snapshot: &Snapshot{
|
||||
Version: stateVersion,
|
||||
Domains: make(map[string]*DomainState),
|
||||
Hostnames: make(map[string]*HostnameState),
|
||||
Ports: make(map[string]*PortState),
|
||||
Certificates: make(map[string]*CertificateState),
|
||||
},
|
||||
config: &config.Config{DataDir: dataDir},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,15 +72,13 @@ func New(
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
// Use context.Background() — the fx startup context
|
||||
// expires after startup completes, so deriving from it
|
||||
// would cancel the watcher immediately. The watcher's
|
||||
// lifetime is controlled by w.cancel in OnStop.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
OnStart: func(startCtx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(
|
||||
context.WithoutCancel(startCtx),
|
||||
)
|
||||
w.cancel = cancel
|
||||
|
||||
go w.Run(ctx) //nolint:contextcheck // intentionally not derived from startCtx
|
||||
go w.Run(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -129,7 +127,6 @@ func (w *Watcher) Run(ctx context.Context) {
|
||||
)
|
||||
|
||||
w.RunOnce(ctx)
|
||||
w.maybeSendTestNotification(ctx)
|
||||
|
||||
dnsTicker := time.NewTicker(w.config.DNSInterval)
|
||||
tlsTicker := time.NewTicker(w.config.TLSInterval)
|
||||
@@ -144,16 +141,9 @@ func (w *Watcher) Run(ctx context.Context) {
|
||||
|
||||
return
|
||||
case <-dnsTicker.C:
|
||||
w.runDNSChecks(ctx)
|
||||
|
||||
w.checkAllPorts(ctx)
|
||||
w.runDNSAndPortChecks(ctx)
|
||||
w.saveState()
|
||||
case <-tlsTicker.C:
|
||||
// Run DNS first so TLS checks use freshly
|
||||
// resolved IP addresses, not stale ones from
|
||||
// a previous cycle.
|
||||
w.runDNSChecks(ctx)
|
||||
|
||||
w.runTLSChecks(ctx)
|
||||
w.saveState()
|
||||
}
|
||||
@@ -161,26 +151,10 @@ func (w *Watcher) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// RunOnce performs a single complete monitoring cycle.
|
||||
// DNS checks run first so that port and TLS checks use
|
||||
// freshly resolved IP addresses. Port checks run before
|
||||
// TLS because TLS checks only target IPs with an open
|
||||
// port 443.
|
||||
func (w *Watcher) RunOnce(ctx context.Context) {
|
||||
w.detectFirstRun()
|
||||
|
||||
// Phase 1: DNS resolution must complete first so that
|
||||
// subsequent checks use fresh IP addresses.
|
||||
w.runDNSChecks(ctx)
|
||||
|
||||
// Phase 2: Port checks populate port state that TLS
|
||||
// checks depend on (TLS only targets IPs where port
|
||||
// 443 is open).
|
||||
w.checkAllPorts(ctx)
|
||||
|
||||
// Phase 3: TLS checks use fresh DNS IPs and current
|
||||
// port state.
|
||||
w.runDNSAndPortChecks(ctx)
|
||||
w.runTLSChecks(ctx)
|
||||
|
||||
w.saveState()
|
||||
w.firstRun = false
|
||||
}
|
||||
@@ -197,11 +171,7 @@ func (w *Watcher) detectFirstRun() {
|
||||
}
|
||||
}
|
||||
|
||||
// runDNSChecks performs DNS resolution for all configured domains
|
||||
// and hostnames, updating state with freshly resolved records.
|
||||
// This must complete before port or TLS checks run so those
|
||||
// checks operate on current IP addresses.
|
||||
func (w *Watcher) runDNSChecks(ctx context.Context) {
|
||||
func (w *Watcher) runDNSAndPortChecks(ctx context.Context) {
|
||||
for _, domain := range w.config.Domains {
|
||||
w.checkDomain(ctx, domain)
|
||||
}
|
||||
@@ -209,6 +179,8 @@ func (w *Watcher) runDNSChecks(ctx context.Context) {
|
||||
for _, hostname := range w.config.Hostnames {
|
||||
w.checkHostname(ctx, hostname)
|
||||
}
|
||||
|
||||
w.checkAllPorts(ctx)
|
||||
}
|
||||
|
||||
func (w *Watcher) checkDomain(
|
||||
@@ -476,94 +448,24 @@ func (w *Watcher) detectInconsistencies(
|
||||
}
|
||||
|
||||
func (w *Watcher) checkAllPorts(ctx context.Context) {
|
||||
// Phase 1: Build current IP:port → hostname associations
|
||||
// from fresh DNS data.
|
||||
associations := w.buildPortAssociations()
|
||||
|
||||
// Phase 2: Check each unique IP:port and update state
|
||||
// with the full set of associated hostnames.
|
||||
for key, hostnames := range associations {
|
||||
ip, port := parsePortKey(key)
|
||||
if port == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
w.checkSinglePort(ctx, ip, port, hostnames)
|
||||
for _, hostname := range w.config.Hostnames {
|
||||
w.checkPortsForHostname(ctx, hostname)
|
||||
}
|
||||
|
||||
// Phase 3: Remove port state entries that no longer have
|
||||
// any hostname referencing them.
|
||||
w.cleanupStalePorts(associations)
|
||||
for _, domain := range w.config.Domains {
|
||||
w.checkPortsForHostname(ctx, domain)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPortAssociations constructs a map from IP:port keys to
|
||||
// the sorted set of hostnames currently resolving to that IP.
|
||||
func (w *Watcher) buildPortAssociations() map[string][]string {
|
||||
assoc := make(map[string]map[string]bool)
|
||||
|
||||
allNames := make(
|
||||
[]string, 0,
|
||||
len(w.config.Hostnames)+len(w.config.Domains),
|
||||
)
|
||||
allNames = append(allNames, w.config.Hostnames...)
|
||||
allNames = append(allNames, w.config.Domains...)
|
||||
|
||||
for _, name := range allNames {
|
||||
ips := w.collectIPs(name)
|
||||
for _, ip := range ips {
|
||||
for _, port := range monitoredPorts {
|
||||
key := fmt.Sprintf("%s:%d", ip, port)
|
||||
if assoc[key] == nil {
|
||||
assoc[key] = make(map[string]bool)
|
||||
}
|
||||
|
||||
assoc[key][name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make(map[string][]string, len(assoc))
|
||||
for key, set := range assoc {
|
||||
hostnames := make([]string, 0, len(set))
|
||||
for h := range set {
|
||||
hostnames = append(hostnames, h)
|
||||
}
|
||||
|
||||
sort.Strings(hostnames)
|
||||
|
||||
result[key] = hostnames
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parsePortKey splits an "ip:port" key into its components.
|
||||
func parsePortKey(key string) (string, int) {
|
||||
lastColon := strings.LastIndex(key, ":")
|
||||
if lastColon < 0 {
|
||||
return key, 0
|
||||
}
|
||||
|
||||
ip := key[:lastColon]
|
||||
|
||||
var p int
|
||||
|
||||
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
|
||||
if err != nil {
|
||||
return ip, 0
|
||||
}
|
||||
|
||||
return ip, p
|
||||
}
|
||||
|
||||
// cleanupStalePorts removes port state entries that are no
|
||||
// longer referenced by any hostname in the current DNS data.
|
||||
func (w *Watcher) cleanupStalePorts(
|
||||
currentAssociations map[string][]string,
|
||||
func (w *Watcher) checkPortsForHostname(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
) {
|
||||
for _, key := range w.state.GetAllPortKeys() {
|
||||
if _, exists := currentAssociations[key]; !exists {
|
||||
w.state.DeletePortState(key)
|
||||
ips := w.collectIPs(hostname)
|
||||
|
||||
for _, ip := range ips {
|
||||
for _, port := range monitoredPorts {
|
||||
w.checkSinglePort(ctx, ip, port, hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,7 +502,7 @@ func (w *Watcher) checkSinglePort(
|
||||
ctx context.Context,
|
||||
ip string,
|
||||
port int,
|
||||
hostnames []string,
|
||||
hostname string,
|
||||
) {
|
||||
result, err := w.portCheck.CheckPort(ctx, ip, port)
|
||||
if err != nil {
|
||||
@@ -625,8 +527,8 @@ func (w *Watcher) checkSinglePort(
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hosts: %s\nAddress: %s\nPort now %s",
|
||||
strings.Join(hostnames, ", "), key, stateStr,
|
||||
"Host: %s\nAddress: %s\nPort now %s",
|
||||
hostname, key, stateStr,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
@@ -639,7 +541,7 @@ func (w *Watcher) checkSinglePort(
|
||||
|
||||
w.state.SetPortState(key, &state.PortState{
|
||||
Open: result.Open,
|
||||
Hostnames: hostnames,
|
||||
Hostname: hostname,
|
||||
LastChecked: now,
|
||||
})
|
||||
}
|
||||
@@ -855,38 +757,6 @@ func (w *Watcher) saveState() {
|
||||
}
|
||||
}
|
||||
|
||||
// maybeSendTestNotification sends a startup status notification
|
||||
// after the first full scan completes, if SEND_TEST_NOTIFICATION
|
||||
// is enabled. The message is clearly informational ("all ok")
|
||||
// and not an error or anomaly alert.
|
||||
func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
|
||||
if !w.config.SendTestNotification {
|
||||
return
|
||||
}
|
||||
|
||||
snap := w.state.GetSnapshot()
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"dnswatcher has started and completed its initial scan.\n"+
|
||||
"Monitoring %d domain(s) and %d hostname(s).\n"+
|
||||
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
|
||||
"All notification channels are working.",
|
||||
len(snap.Domains),
|
||||
len(snap.Hostnames),
|
||||
len(snap.Ports),
|
||||
len(snap.Certificates),
|
||||
)
|
||||
|
||||
w.log.Info("sending startup test notification")
|
||||
|
||||
w.notify.SendNotification(
|
||||
ctx,
|
||||
"✅ dnswatcher startup complete",
|
||||
msg,
|
||||
"success",
|
||||
)
|
||||
}
|
||||
|
||||
// --- Utility functions ---
|
||||
|
||||
func toSet(items []string) map[string]bool {
|
||||
|
||||
@@ -682,191 +682,6 @@ func TestGracefulShutdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func setupHostnameIP(
|
||||
deps *testDeps,
|
||||
hostname, ip string,
|
||||
) {
|
||||
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {ip}},
|
||||
}
|
||||
deps.portChecker.results[ip+":80"] = true
|
||||
deps.portChecker.results[ip+":443"] = true
|
||||
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
|
||||
CommonName: hostname,
|
||||
Issuer: "DigiCert",
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
SubjectAlternativeNames: []string{hostname},
|
||||
}
|
||||
}
|
||||
|
||||
func updateHostnameIP(deps *testDeps, hostname, ip string) {
|
||||
deps.resolver.mu.Lock()
|
||||
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {ip}},
|
||||
}
|
||||
deps.resolver.mu.Unlock()
|
||||
|
||||
deps.portChecker.mu.Lock()
|
||||
deps.portChecker.results[ip+":80"] = true
|
||||
deps.portChecker.results[ip+":443"] = true
|
||||
deps.portChecker.mu.Unlock()
|
||||
|
||||
deps.tlsChecker.mu.Lock()
|
||||
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
|
||||
CommonName: hostname,
|
||||
Issuer: "DigiCert",
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
SubjectAlternativeNames: []string{hostname},
|
||||
}
|
||||
deps.tlsChecker.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.Hostnames = []string{"www.example.com"}
|
||||
|
||||
w, deps := newTestWatcher(t, cfg)
|
||||
|
||||
setupHostnameIP(deps, "www.example.com", "10.0.0.1")
|
||||
|
||||
ctx := t.Context()
|
||||
w.RunOnce(ctx)
|
||||
|
||||
snap := deps.state.GetSnapshot()
|
||||
if _, ok := snap.Ports["10.0.0.1:80"]; !ok {
|
||||
t.Fatal("expected port state for 10.0.0.1:80")
|
||||
}
|
||||
|
||||
// DNS changes to a new IP; port and TLS must pick it up.
|
||||
updateHostnameIP(deps, "www.example.com", "10.0.0.2")
|
||||
|
||||
w.RunOnce(ctx)
|
||||
|
||||
snap = deps.state.GetSnapshot()
|
||||
|
||||
if _, ok := snap.Ports["10.0.0.2:80"]; !ok {
|
||||
t.Error("port check used stale DNS: missing 10.0.0.2:80")
|
||||
}
|
||||
|
||||
certKey := "10.0.0.2:443:www.example.com"
|
||||
if _, ok := snap.Certificates[certKey]; !ok {
|
||||
t.Error("TLS check used stale DNS: missing " + certKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestNotification_Enabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.Domains = []string{"example.com"}
|
||||
cfg.Hostnames = []string{"www.example.com"}
|
||||
cfg.SendTestNotification = true
|
||||
|
||||
w, deps := newTestWatcher(t, cfg)
|
||||
setupBaselineMocks(deps)
|
||||
|
||||
w.RunOnce(t.Context())
|
||||
|
||||
// RunOnce does not send the test notification — it is
|
||||
// sent by Run after RunOnce completes. Call the exported
|
||||
// RunOnce then check that no test notification was sent
|
||||
// (only Run triggers it). We test the full path via Run.
|
||||
notifications := deps.notifier.getNotifications()
|
||||
if len(notifications) != 0 {
|
||||
t.Errorf(
|
||||
"RunOnce should not send test notification, got %d",
|
||||
len(notifications),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestNotification_ViaRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.Domains = []string{"example.com"}
|
||||
cfg.Hostnames = []string{"www.example.com"}
|
||||
cfg.SendTestNotification = true
|
||||
cfg.DNSInterval = 24 * time.Hour
|
||||
cfg.TLSInterval = 24 * time.Hour
|
||||
|
||||
w, deps := newTestWatcher(t, cfg)
|
||||
setupBaselineMocks(deps)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
w.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Wait for the initial scan and test notification.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
<-done
|
||||
|
||||
notifications := deps.notifier.getNotifications()
|
||||
|
||||
found := false
|
||||
|
||||
for _, n := range notifications {
|
||||
if n.Priority == "success" &&
|
||||
n.Title == "✅ dnswatcher startup complete" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf(
|
||||
"expected startup test notification, got: %v",
|
||||
notifications,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestNotification_Disabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.Domains = []string{"example.com"}
|
||||
cfg.Hostnames = []string{"www.example.com"}
|
||||
cfg.SendTestNotification = false
|
||||
cfg.DNSInterval = 24 * time.Hour
|
||||
cfg.TLSInterval = 24 * time.Hour
|
||||
|
||||
w, deps := newTestWatcher(t, cfg)
|
||||
setupBaselineMocks(deps)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
w.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
<-done
|
||||
|
||||
notifications := deps.notifier.getNotifications()
|
||||
|
||||
for _, n := range notifications {
|
||||
if n.Title == "✅ dnswatcher startup complete" {
|
||||
t.Error(
|
||||
"test notification should not be sent when disabled",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSFailureAndRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/bootstrap: install all dependencies needed to build and develop
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes nothing is present.
|
||||
# golangci-lint and goimports are installed via `go install` at the same
|
||||
# pinned commits the Dockerfile uses (never "latest").
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-07-07 (same pins as the Dockerfile)
|
||||
# golangci-lint v2.10.1
|
||||
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee"
|
||||
# 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
|
||||
}
|
||||
|
||||
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 (installs into
|
||||
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
|
||||
if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi
|
||||
if missing goimports; then go install "$GOIMPORTS_REF"; fi
|
||||
|
||||
go mod download
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
14
script/check
14
script/check
@@ -1,14 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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 "$@"
|
||||
13
script/fmt
13
script/fmt
@@ -1,13 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/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 "$@"
|
||||
12
script/lint
12
script/lint
@@ -1,12 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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 "$@"
|
||||
13
script/setup
13
script/setup
@@ -1,13 +0,0 @@
|
||||
#!/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 "$@"
|
||||
12
script/test
12
script/test
@@ -1,12 +0,0 @@
|
||||
#!/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 "$@"
|
||||
1
static/css/tailwind.min.css
vendored
1
static/css/tailwind.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,10 +0,0 @@
|
||||
// Package static provides embedded static assets.
|
||||
package static
|
||||
|
||||
import "embed"
|
||||
|
||||
// Static contains the embedded static assets (CSS, JS) served
|
||||
// at the /s/ URL prefix.
|
||||
//
|
||||
//go:embed css
|
||||
var Static embed.FS
|
||||
Reference in New Issue
Block a user