1 Commits
Author SHA1 Message Date
sneak 927ee136a9 Serve Prometheus metrics at /metrics behind basic auth (closes #94)
check / check (push) Successful in 3m17s
With METRICS_USERNAME and METRICS_PASSWORD both set, the backend
records request metrics through go-http-metrics in a registry of its
own, with Go's runtime and process metrics, and serves them at
GET /metrics behind basic auth with those credentials; nginx passes
/metrics to it. With neither set there is no such route; one alone
stops the start with an error naming both.

Only requests that reach the health check or POST /api/v1/reports are
recorded, not every request as the conventions show: the labels are
path and method, which clients can make up without end. So
POST /api/v1/reports is registered by its full path, not inside a
route group.

Deviation: go get and go mod tidy ran directly; no entrypoint adds a Go
dependency yet (#45).

Model: opus-5-5
2026-10-04 02:08:45 +00:00
14 changed files with 33 additions and 107 deletions
+1 -2
View File
@@ -1,7 +1,6 @@
backend/
dist/ dist/
node_modules/ node_modules/
tmp/ tmp/
yarn.lock yarn.lock
.claude/ .claude/
# The org standard file, copied verbatim; backend/script/lint checks its sha256.
backend/.golangci.yml
+3 -7
View File
@@ -1,5 +1,5 @@
.PHONY: bootstrap setup dev build test lint fmt fmt-check check \ .PHONY: bootstrap setup dev test lint fmt fmt-check check frontend-check \
frontend-check frontend-viewport-test docker hooks frontend-viewport-test docker hooks
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -13,11 +13,7 @@ setup:
@script/setup @script/setup
dev: dev:
@script/dev yarn dev
# The frontend only; backend/Makefile's build target builds the Go server.
build:
@script/build
test: test:
@script/test @script/test
+3 -8
View File
@@ -46,10 +46,6 @@ halves, so the root `make check` fails if either one is broken. We provide:
both linters in Docker both linters in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the - `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook git pre-commit hook
- `script/dev` — run the Vite dev server, which proxies `/api` to a locally
running `netwatch-server`
- `script/build` — build the frontend for production into `dist/`;
`backend/script/build` builds the Go server
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run `script/frontend-test`, then `backend/script/test`, the - `script/test` — run `script/frontend-test`, then `backend/script/test`, the
backend's Go tests with the race detector and coverage backend's Go tests with the race detector and coverage
@@ -65,8 +61,7 @@ halves, so the root `make check` fails if either one is broken. We provide:
- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it - `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint` runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
builds builds
- `script/frontend-fmt` — format everything prettier understands (writes), the - `script/frontend-fmt` — format everything prettier understands (writes)
markdown in `backend/` included
- `script/frontend-fmt-check` — check prettier formatting (read-only) - `script/frontend-fmt-check` — check prettier formatting (read-only)
- `script/frontend-check` — run `script/frontend-test` and - `script/frontend-check` — run `script/frontend-test` and
`script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has `script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has
@@ -255,8 +250,8 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
the backend records Prometheus metrics of its requests and serves them at the backend records Prometheus metrics of its requests and serves them at
`/metrics` on the container port, to requests with this user name and `/metrics` on the container port, to requests with this user name and
password as their basic auth credentials. With neither set, there are no password as their basic auth credentials. With neither set, there are no
metrics and `/metrics` is not found. One set without the other, or a user metrics and `/metrics` is not found. One set without the other stops the
name containing `:`, stops the container container
- **Health check:** the image's `HEALTHCHECK` requests - **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless `/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless
both nginx and the backend answer. upaas reads the container's health 60 both nginx and the backend answer. upaas reads the container's health 60
+1 -12
View File
@@ -29,23 +29,12 @@ latest run passes.
Go's runtime and process metrics, at `GET /metrics` behind basic auth with Go's runtime and process metrics, at `GET /metrics` behind basic auth with
those credentials; nginx passes `/metrics` to it as it does `/api/`. With those credentials; nginx passes `/metrics` to it as it does `/api/`. With
neither set there are no metrics and `/metrics` is 404; one without the other neither set there are no metrics and `/metrics` is 404; one without the other
stops the start with an error naming both, and so does a `METRICS_USERNAME` stops the start with an error naming both. Only requests that reach the health
containing `:`, with an error naming it. Only requests that reach the health
check or `POST /api/v1/reports` are recorded, not `/metrics` itself and not check or `POST /api/v1/reports` are recorded, not `/metrics` itself and not
every request as `GO_HTTP_SERVER_CONVENTIONS.md` shows, because the labels are every request as `GO_HTTP_SERVER_CONVENTIONS.md` shows, because the labels are
the request's path and method, which clients can make up without end. For the request's path and method, which clients can make up without end. For
that, `POST /api/v1/reports` is now registered by its full path instead of that, `POST /api/v1/reports` is now registered by its full path instead of
inside a `/api/v1` route group; it answers as before inside a `/api/v1` route group; it answers as before
- 2026-10-04: `script/` and `Makefile` follow the org models (issue #28):
`make dev` shims to the new `script/dev`, the Vite dev server, and the new
`make build` to `script/build`, the frontend production build.
`.prettierignore` no longer leaves out `backend/`, so `make fmt` and
`make fmt-check` cover `backend/README.md`; it leaves out
`backend/.golangci.yml` by name, the org standard file whose sha256
`backend/script/lint` checks. `script/install-precommit` and the date on
`script/bootstrap`'s pins are the org model again; `script/bootstrap`,
`script/fmt` and `script/fmt-check` each say in a comment why they differ from
it
- 2026-10-04: a frontend build on Node 26 or newer, such as `make test` on a - 2026-10-04: a frontend build on Node 26 or newer, such as `make test` on a
host with Node 26, no longer prints Node's warning that `module.register()` is host with Node 26, no longer prints Node's warning that `module.register()` is
deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never
+20 -23
View File
@@ -32,16 +32,17 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
`test`, `fmt` and `fmt-check`: `test`, `fmt` and `fmt-check`:
- `script/build` — compile the static `netwatch-server` binary with its version - `script/build` — compile the static `netwatch-server` binary with its version
stamped in. The version is `VERSION` from the environment; when that is unset stamped in. The version is `VERSION` from the environment;
or empty, it falls back to `git describe` inside a git checkout, then to `dev` when that is unset or empty, it falls back to `git describe` inside a git
checkout, then to `dev`
- `script/test` — run the Go tests with the race detector and coverage. Go's - `script/test` — run the Go tests with the race detector and coverage. Go's
`-timeout 30s` bounds the tests, not their compile. If they fail, they run `-timeout 30s` bounds the tests, not their compile. If they fail, they run
again with `-v` for the details, and the script fails. The race detector needs again with `-v` for the details, and the script fails. The race detector needs
a C compiler a C compiler
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run - `script/lint` — check `.golangci.yml` against its pinned sha256, then run
golangci-lint. It runs inside the golangci-lint image of the lint stage of the golangci-lint. It runs inside the golangci-lint image of the lint stage of
root `Dockerfile`; from a checkout, run `make lint` at the repo root, which the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
builds that stage which builds that stage
- `script/fmt` — format the Go sources (writes) - `script/fmt` — format the Go sources (writes)
- `script/fmt-check` — check Go formatting (read-only) - `script/fmt-check` — check Go formatting (read-only)
- `script/run` — build and run the server locally - `script/run` — build and run the server locally
@@ -60,9 +61,8 @@ flushes them to compressed files on disk for later analysis.
## Design ## Design
The server is structured as an `fx`-wired Go application under The server is structured as an `fx`-wired Go application under `cmd/netwatch-server/`.
`cmd/netwatch-server/`. Internal packages in `internal/` follow standard Go Internal packages in `internal/` follow standard Go project layout:
project layout:
- **`config`**: Loads configuration from environment variables and config files - **`config`**: Loads configuration from environment variables and config files
via Viper. via Viper.
@@ -91,12 +91,11 @@ project layout:
| `METRICS_USERNAME` | empty | Basic auth user name for `/metrics`; see [Metrics](#metrics) | | `METRICS_USERNAME` | empty | Basic auth user name for `/metrics`; see [Metrics](#metrics) |
| `METRICS_PASSWORD` | empty | Basic auth password for `/metrics`; see [Metrics](#metrics) | | `METRICS_PASSWORD` | empty | Basic auth password for `/metrics`; see [Metrics](#metrics) |
`TRUSTED_PROXIES` defaults to `TRUSTED_PROXIES` defaults to `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`.
`127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. The loopback The loopback entries cover a reverse proxy on the same host. A request whose
entries cover a reverse proxy on the same host. A request whose direct peer is direct peer is outside this set has its forwarded headers ignored, and the
outside this set has its forwarded headers ignored, and the direct peer is direct peer is logged and rate-limited instead. The container image does not use
logged and rate-limited instead. The container image does not use this default; this default; see [Container image](#container-image).
see [Container image](#container-image).
A variable set to a value the server cannot use, such as `PORT=abc`, A variable set to a value the server cannot use, such as `PORT=abc`,
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from `DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
@@ -165,7 +164,8 @@ credentials, so it is bounded instead. Both refusals below answer with the same
to be written fill the cap on their own, and then no file is deleted. At to be written fill the cap on their own, and then no file is deleted. At
start, report files past the cap, as after lowering it, are deleted the same start, report files past the cap, as after lowering it, are deleted the same
way. So the cap is how much of the newest reports is kept: the default of 1 way. So the cap is how much of the newest reports is kept: the default of 1
GiB is small enough for any host; set it to the space you can give `DATA_DIR`. GiB is small enough for any host; set it to the space you can give
`DATA_DIR`.
### CORS ### CORS
@@ -189,14 +189,11 @@ method and status; they also count those requests in progress, and include Go's
runtime and process metrics. No other request is recorded: not those to runtime and process metrics. No other request is recorded: not those to
`/metrics` itself, and not those answered before they reach either route, such `/metrics` itself, and not those answered before they reach either route, such
as a CORS preflight, or a request refused with 404 for a path no route has, 405 as a CORS preflight, or a request refused with 404 for a path no route has, 405
for a method its route does not take, or 413 for declaring a body length over for a method its route does not take, or 413 for a body over the 1 MiB limit.
the 1 MiB limit. A report whose body goes over the limit without declaring its Clients can make up any number of paths and methods, and each would add labels
length reaches the route, is answered 413 there, and is recorded with that to the metrics for as long as the server runs. With neither set, nothing is
status. Clients can make up any number of paths and methods, and each would add recorded and `/metrics` answers 404. One without the other stops the server
labels to the metrics for as long as the server runs. With neither set, nothing from starting, with an error naming both.
is recorded and `/metrics` answers 404. One without the other stops the server
from starting, with an error naming both; so does a `METRICS_USERNAME`
containing `:`, which basic auth cannot carry, with an error naming it.
## TODO ## TODO
-10
View File
@@ -49,10 +49,6 @@ var (
"METRICS_USERNAME and METRICS_PASSWORD must be set together, " + "METRICS_USERNAME and METRICS_PASSWORD must be set together, " +
"or neither", "or neither",
) )
errMetricsUsernameColon = errors.New(
"METRICS_USERNAME must not contain \":\", " +
"which basic auth cannot carry in a user name",
)
) )
// Params defines the dependencies for Config. // Params defines the dependencies for Config.
@@ -193,12 +189,6 @@ func (s *Config) check() error {
return errMetricsCredentials return errMetricsCredentials
} }
// Basic auth splits the credentials at the first ":", so with one
// in the user name every request to /metrics would get 401.
if strings.Contains(s.MetricsUsername, ":") {
return errMetricsUsernameColon
}
return checkOrigins(s.CORSAllowedOrigins) return checkOrigins(s.CORSAllowedOrigins)
} }
-10
View File
@@ -119,16 +119,6 @@ func TestMetricsCredentialsGoTogether(t *testing.T) {
} }
} }
// TestMetricsUsernameMustNotContainColon: basic auth splits the
// credentials at the first ":", so such a user name would get 401 on
// every request to /metrics.
func TestMetricsUsernameMustNotContainColon(t *testing.T) {
t.Setenv("METRICS_USERNAME", "prom:etheus")
t.Setenv("METRICS_PASSWORD", "secret")
requireConfigError(t, "METRICS_USERNAME")
}
// TestCORSAllowedOriginsMustBeOrigins: "*" would let every origin in, // TestCORSAllowedOriginsMustBeOrigins: "*" would let every origin in,
// and an entry that is not a plain origin would match no page. // and an entry that is not a plain origin would match no page.
func TestCORSAllowedOriginsMustBeOrigins(t *testing.T) { func TestCORSAllowedOriginsMustBeOrigins(t *testing.T) {
+1 -3
View File
@@ -17,13 +17,11 @@
# #
# golangci-lint is not installed: make lint runs it in Docker, which # golangci-lint is not installed: make lint runs it in Docker, which
# this script does not install either. # this script does not install either.
#
# Unlike the org model: Go and gcc for backend/, a newer node for eslint.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06 # Pinned versions, 2026-07-07
NODE_VERSION="22.17.0" NODE_VERSION="22.17.0"
# The oldest node the frontend's dependencies accept: the "engines" # The oldest node the frontend's dependencies accept: the "engines"
# field of eslint 10.12.0, the most demanding of them, asks for 22.13.0 # field of eslint 10.12.0, the most demanding of them, asks for 22.13.0
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# script/build: build the frontend for production into dist/. The Go
# backend is built by backend/script/build.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn build
}
main "$@"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# script/dev: run the frontend's Vite dev server. It proxies /api to a
# netwatch-server running locally (see vite.config.js).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn dev
}
main "$@"
-1
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/fmt: format the whole repo (writes): prettier over everything # script/fmt: format the whole repo (writes): prettier over everything
# it understands, then gofmt over the Go backend. # it understands, then gofmt over the Go backend.
# The org model formats only markdown; this repo also has JS and Go.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
-1
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting across the whole repo (read-only). # script/fmt-check: check formatting across the whole repo (read-only).
# Same scope as script/fmt, but fails instead of writing. # Same scope as script/fmt, but fails instead of writing.
# The org model checks only markdown; this repo also has JS and Go.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# script/frontend-fmt: format the frontend and every other file prettier # script/frontend-fmt: format the frontend and every other file prettier
# understands, repo-wide (writes), the markdown in backend/ included. # understands, repo-wide (writes). backend/ is in .prettierignore; Go
# Prettier does not read Go; backend/script/fmt formats the Go sources. # sources are formatted by backend/script/fmt.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+2 -2
View File
@@ -8,8 +8,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
hook=".git/hooks/pre-commit" hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x .git/hooks/pre-commit chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit" echo "pre-commit hook installed: runs script/precommit"
} }