1 Commits
Author SHA1 Message Date
sneak 369f5cea1a metrics: rate limit /metrics per client address before Basic Auth (closes #101)
check / check (push) Successful in 1m7s
/metrics is behind a password, and REPO_POLICIES.md requires rate
limiting on password logins. Each client address may now send it 30
requests a minute, counted by httprate before Basic Auth, so failed
logins use up the allowance and a request over it gets 429 without
the password being checked. The address is the one the existing
trusted-proxy logic in internal/middleware works out, with IPv6
addresses grouped by /64; an IPv4 address a proxy reports in
IPv6-mapped form counts as the plain IPv4 address. A Prometheus
server scraping every 15 seconds sends 4 requests a minute.

Model: opus-5-5
2026-10-01 19:57:09 +00:00
7 changed files with 18 additions and 44 deletions
+2 -5
View File
@@ -34,11 +34,8 @@ COPY . .
# Run the tests - build fails if any test fails
RUN make test
# Build the binary. .dockerignore leaves out .git, so `git describe` in
# the Makefile cannot find the version here: script/docker passes it as
# --build-arg VERSION, and a build that passes none reports `dev`.
ARG VERSION=dev
RUN make build VERSION="${VERSION}"
# Build the binary
RUN make build
# Runtime stage
# alpine 3.21, 2026-02-28
-2
View File
@@ -1,8 +1,6 @@
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker
BINARY := dnswatcher
# `make build VERSION=...` overrides this; the Dockerfile does so, as the
# image has no .git to describe.
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION)
+5 -9
View File
@@ -489,8 +489,7 @@ them. We provide:
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`, with
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
which runs the tests, run on every invocation, and with the version from
`git describe` passed as `--build-arg VERSION`
which runs the tests, run on every invocation
- `script/cibuild` — CI entrypoint: `docker build` with
`--no-cache-filter=lint,builder`, so the lint stage and the builder stage,
which runs the tests, run on every invocation, because a cached build lints
@@ -512,14 +511,11 @@ make clean # Remove build artifacts
### Build-Time Variables
`make build` sets the version with `-ldflags "-X main.Version=..."`, taking
it from `git describe --tags --always --dirty`, or from `VERSION` when given
on the command line (`make build VERSION=1.2.3`). The version appears in the
startup log and in the health check response.
Version is injected via `-ldflags`:
The Docker image has no `.git`, so the `Dockerfile` takes the version as
`--build-arg VERSION`. `make docker` passes it; a plain `docker build`
passes none, and that image reports `dev`.
```sh
go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher
```
---
+3 -4
View File
@@ -22,10 +22,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
- 2026-10-01: `/metrics` allows each client address 30 requests a minute,
counted before Basic Auth, and answers 429 beyond that (closes #101).
- 2026-10-01: the image built by `make docker` reports the `git describe`
version, not `dev`, and the startup log now shows it (closes #109).
- 2026-10-01: two notify shutdown tests always release the delivery they hold,
so a drain that returns early fails them instead of hanging (closes #176).
- 2026-10-01: `script/install-precommit` asks git for the repository's git
directory, so `make hooks` also works where `.git` is a file (closes #129).
- 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every
@@ -99,6 +95,7 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
https://git.eeqj.de/sneak/dnswatcher/issues/107
- invalid DNS or TLS interval silently replaced by the default:
https://git.eeqj.de/sneak/dnswatcher/issues/177
- images report version `dev`: https://git.eeqj.de/sneak/dnswatcher/issues/109
- trial run of the finished image:
https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs:
@@ -106,6 +103,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
- `goimports` in `make fmt-check`, Markdown formatting:
https://git.eeqj.de/sneak/dnswatcher/issues/119
- final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114
- `internal/notify` shutdown tests hang when a drain returns early:
https://git.eeqj.de/sneak/dnswatcher/issues/176
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- README sections required by policy:
https://git.eeqj.de/sneak/dnswatcher/issues/173
-1
View File
@@ -63,7 +63,6 @@ func main() {
return n
},
),
fx.Invoke(func(l *logger.Logger) { l.Identify() }),
fx.Invoke(func(*server.Server, *watcher.Watcher) {}),
).Run()
}
+7 -14
View File
@@ -143,12 +143,6 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
srv := blockingNtfyServer(entered, release, &served)
defer srv.Close()
// srv.Close waits for the handler, so release it however the
// test ends; otherwise a drain that returns early hangs the
// package instead of failing this test.
releaseHandler := sync.OnceFunc(func() { close(release) })
defer releaseHandler()
topicURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
@@ -173,7 +167,9 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
// delay alone.
start := time.Now()
timer := time.AfterFunc(inFlightHold, releaseHandler)
timer := time.AfterFunc(inFlightHold, func() {
close(release)
})
defer timer.Stop()
ctx, cancel := context.WithTimeout(
@@ -272,7 +268,7 @@ func TestDrainBoundedByContextDeadline(t *testing.T) {
// all never returns here (the delivery is parked in a backoff
// that never fires), so an unbounded drain must fail this
// test promptly instead of hanging the package until the test
// binary's -timeout.
// binary's 30s timeout.
returned := make(chan struct{})
go func() {
@@ -451,11 +447,6 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
srv := blockingNtfyServer(entered, release, &served)
defer srv.Close()
// As in TestDrainWaitsForInFlightDelivery: release the handler
// however the test ends, before srv.Close waits for it.
releaseHandler := sync.OnceFunc(func() { close(release) })
defer releaseHandler()
lifecycle := &recordingLifecycle{}
svc := newNotifyService(t, lifecycle, srv.URL)
@@ -481,7 +472,9 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
t.Fatal("delivery never reached the endpoint")
}
timer := time.AfterFunc(inFlightHold, releaseHandler)
timer := time.AfterFunc(inFlightHold, func() {
close(release)
})
defer timer.Stop()
ctx, cancel := context.WithTimeout(
+1 -9
View File
@@ -12,15 +12,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage cannot find it.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache-filter=lint,builder \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"