diff --git a/.dockerignore b/.dockerignore index 416281f..2db6769 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,84 @@ -node_modules -dist -tmp -.DS_Store -*.log +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. .claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repository's own entries, after the shared content above: the +# frontend build, the viewport test's output, the binary `make build` +# writes and the reports `make run` stores, and Go test binaries, +# coverage output and log files at the root. +/dist +/tmp +/netwatch-server +/data +/*.test +/*.out +/*.log diff --git a/.editorconfig b/.editorconfig index 2fe0ce0..de84fae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,9 @@ insert_final_newline = true [Makefile] indent_style = tab + +[*.go] +indent_style = tab + +# This repository's own sections, such as one for another language it +# uses, go below this comment, and a re-vendor keeps them. diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 08c2ebc..2e96965 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,12 +1,20 @@ name: check on: [push] +# Free the shared runner: a new push cancels only the same branch's older run. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: check: runs-on: ubuntu-latest + # Free the shared runner from a hung build. + timeout-minutes: 20 steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - # script/cibuild bootstraps, runs every check and builds the - # image. script/bootstrap links what it installs into - # ~/.local/bin, so that has to be on PATH for the rest. - - run: PATH="$HOME/.local/bin:$PATH" script/cibuild + # script/cibuild needs no token, so none is left in .git/config. + with: + persist-credentials: false + # All history and tags, so git describe finds the version tag. + fetch-depth: 0 + - run: script/cibuild diff --git a/.gitignore b/.gitignore index e676a31..ecadfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,18 +11,56 @@ Thumbs.db .vscode/ *.sublime-* +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + # Node node_modules/ -# Environment / secrets -.env -.env.* -*.pem -*.key +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation at the end of this file, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + +# This repository's own entries, such as its build outputs, go below +# this comment, and a re-vendor keeps them. Anchor a binary built at the +# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`. # Build output dist/ tmp/ +/netwatch-server + +# The reports make run stores, in netwatch-server's default DATA_DIR +/data/ + +# Go test binaries and coverage output +*.test +*.out # Logs *.log diff --git a/backend/.golangci.yml b/.golangci.yml similarity index 95% rename from backend/.golangci.yml rename to .golangci.yml index a7a74c2..32db9fe 100644 --- a/backend/.golangci.yml +++ b/.golangci.yml @@ -17,6 +17,7 @@ linters: disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go @@ -24,6 +25,8 @@ linters: # silenced by disabling that name, not by enabling the successor. - wsl # Deprecated, replaced by wsl_v5 - gomodguard # Deprecated, replaced by gomodguard_v2 + # Misses findings at random in v2.14.0; back once a pinned release fixes it + - canonicalheader settings: lll: line-length: 88 diff --git a/.prettierignore b/.prettierignore index d0374bc..23d67fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,2 @@ -backend/ -dist/ node_modules/ -tmp/ yarn.lock -.claude/ diff --git a/Dockerfile b/Dockerfile index d13e53e..a4e2bfe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,66 +1,154 @@ # The one image netwatch ships: nginx serves the built frontend and -# passes /api/ and /.well-known/healthcheck to netwatch-server, the Go -# backend, which runs in the same container on loopback only. +# passes /api/, /.well-known/healthcheck and /metrics to netwatch-server, +# the Go backend, which runs in the same container on loopback only. # bin/entrypoint.sh starts and watches both. +# +# The lint and test phases are the gates: `make lint` (script/lint) +# builds the lint stage alone and `make test` (script/test) the test +# stage alone, and the two build stages, frontend-build and builder, +# depend on both, so the image cannot be built unless they pass. Each +# phase covers the frontend as well, through a copy from a node stage. +# Inside them each tool is invoked directly, never through make or +# script/, whose lint and test are themselves docker builds. +# +# In every stage ARG CACHEBUST comes right after the steps that install +# system packages and download Go's modules, or right after FROM in a +# stage with neither, and every step that lints, tests or compiles +# comes after it, as does yarn install in the node stages. script/ +# passes a new value on every build, which reruns every RUN step after +# it. Docker keeps the layers above it until the base image, their own +# lines or go.mod and go.sum change. The steps that compile mount Go's +# build cache, and golangci-lint run also mounts golangci-lint's; both +# survive from build to build. -# Lint stage — fast feedback on formatting and lint issues. The -# golangci/golangci-lint image ships Go, gofmt, make and the linter, so -# nothing is installed here. The root make lint builds this stage alone. -# golangci/golangci-lint:v2.12.2 (2026-08-10) -FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint - -WORKDIR /src -COPY backend/go.mod backend/go.sum ./ -RUN go mod download -COPY backend/ . -RUN make fmt-check -RUN make lint - -# Backend build stage -# golang:1.25-alpine (2026-02-27) -FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder - -RUN apk add --no-cache make - -WORKDIR /src - -# Force BuildKit to run the lint stage before proceeding. BuildKit runs -# stages in parallel by default; without this no-op copy a lint failure -# would not gate compilation. -COPY --from=lint /src/go.sum /dev/null - -COPY backend/go.mod backend/go.sum ./ -RUN go mod download -COPY backend/ . - -RUN make test - -# make build is a shim around backend/script/build, the one definition -# of the build command: -# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=... -X main.Buildarch=..." -# That script reads VERSION from the environment, so it is handed over -# there rather than as a make variable. -ARG VERSION=dev -RUN VERSION="${VERSION}" make build - -# Frontend stage +# Frontend lint stage: eslint with the rules in eslint.config.js. The +# lint phase below runs it. # node:22-alpine as of 2026-02-22 -FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend +FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint +ARG CACHEBUST WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile -RUN apk add --no-cache git make COPY . . -# make frontend-check is the frontend half of make check (test + lint + -# fmt-check); its test step is the production yarn build, so this both -# produces dist/ and gates the image on lint/fmt-check/test regressions. -# This node stage has neither Go nor Docker; the lint and builder stages -# above gate the backend half. -RUN make frontend-check +RUN yarn eslint . -# Runtime stage +# Lint phase: golangci-lint over the Go packages with .golangci.yml, +# and eslint through the copy from frontend-lint at the end. The +# golangci/golangci-lint image ships Go and the linter. +# golangci/golangci-lint:v2.14.0, 2026-09-24 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +ARG CACHEBUST +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache/golangci-lint \ + golangci-lint run --config .golangci.yml ./... +# Nothing is wanted from frontend-lint; the copy is what makes this +# phase run it. +COPY --from=frontend-lint /app/yarn.lock /dev/null + +# Frontend test stage: the unit tests in test/unit/. The test phase +# below runs it, and the frontend-build stage starts from it. The tests +# print a dot each; if any fails, they run again with every test listed, +# and the step fails even if that run passes. NODE_OPTIONS chooses the +# reporter because yarn adds its arguments after the test files, where +# node would take a reporter option for one more file. +# node:22-alpine as of 2026-02-22 +FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test +ARG CACHEBUST +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile +COPY . . +RUN NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || \ + { echo "--- Rerunning with every test listed for details ---"; \ + NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test; \ + exit 1; } + +# Test phase: the backend's tests with the race detector and coverage, +# and the frontend's through the copy from the frontend-test stage at +# the end. -race needs cgo and so a C compiler, which the Debian Go image +# ships and the alpine one does not. -timeout 90s is a backstop above +# the 60-second cap on the suite. The rerun with -v only shows details: +# the step fails however it ends, because the first run already failed. +# -count=1 because the mounted cache also holds test results, which go +# test would otherwise replay. +# golang:1.25.7-trixie, 2026-10-07 +FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +ARG CACHEBUST +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + go test -count=1 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -timeout 90s -race -v ./...; exit 1; } +# Nothing is wanted from the frontend-test stage; the copy is what makes +# this phase run its tests. +COPY --from=frontend-test /app/yarn.lock /dev/null + +# Frontend build stage: the production build into dist/, which the +# runtime stage serves, on the yarn packages and source of the +# frontend-test stage. Nothing is wanted from the two phases; the copies +# are what make BuildKit build them first, so this stage cannot run +# unless lint and test passed. +FROM frontend-test AS frontend-build +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +# vite.config.js reads the commit for the page's footer with git. git is +# installed again on every image build, although it comes above +# ARG CACHEBUST, because this stage starts from frontend-test, whose +# steps run again on every build. +RUN apk add --no-cache git +ARG CACHEBUST +RUN yarn build + +# Backend build stage. Nothing is wanted from the two phases; the copies +# are what make BuildKit build them first, so nothing after them can +# run unless lint and test passed. +# golang:1.25-alpine (2026-02-27) +FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder +RUN apk add --no-cache git +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +ARG CACHEBUST +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /src +COPY . . + +# The same go build as in script/build, which builds the binary on the +# host. +# +# The version is the VERSION build argument when one is given, otherwise +# `git describe --tags --always` on the .git in the build context: the +# tag on a tagged commit, tag-N-gHASH on a commit after one, the short +# commit when no tag is reachable. With .git present, a version that is +# still empty, dev or unknown fails the build: git is missing or could +# not read the checkout. +ARG VERSION +RUN --mount=type=cache,target=/root/.cache/go-build \ + version="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$version" in ""|dev|unknown) \ + echo "version is '$version' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.Version=$version" \ + -o netwatch-server ./cmd/netwatch-server/ + +# Runtime stage, and the last one: a plain `docker build .` builds it +# and the stages it copies from, the two phases included. # nginx:stable-alpine as of 2026-02-22 FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab +ARG CACHEBUST # netwatch-server runs as this user, which owns the report directory. # nginx keeps the image's own arrangement: its main process runs as @@ -71,9 +159,9 @@ RUN addgroup -g 1000 -S netwatch && \ # At start-up the nginx image renders every template here into # conf.d; bin/entrypoint.sh says how. RUN rm /etc/nginx/conf.d/default.conf -COPY nginx.conf /etc/nginx/templates/netwatch.conf.template -COPY security-headers.conf /etc/nginx/security-headers.conf -COPY --from=frontend /app/dist /usr/share/nginx/html +COPY configs/nginx.conf /etc/nginx/templates/netwatch.conf.template +COPY configs/security-headers.conf /etc/nginx/security-headers.conf +COPY --from=frontend-build /app/dist /usr/share/nginx/html COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server COPY bin/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/Makefile b/Makefile index eb7bb43..322677a 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ -.PHONY: bootstrap setup dev test lint fmt fmt-check check frontend-check \ - frontend-viewport-test docker hooks +.PHONY: bootstrap setup dev build run clean test lint fmt fmt-check check \ + add-dependency tidy frontend-viewport-test docker hooks # 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). test, lint, fmt, fmt-check and check cover the whole -# repo: the frontend here and the Go backend in backend/. +# repo: the frontend in web/ and the Go backend in cmd/ and internal/. bootstrap: @script/bootstrap @@ -13,7 +13,18 @@ setup: @script/setup dev: - yarn dev + @script/dev + +# The frontend into dist/ and the netwatch-server binary. +build: + @script/build + +# Builds, then runs netwatch-server on port 8080. +run: + @script/run + +clean: + @script/clean test: @script/test @@ -30,14 +41,16 @@ fmt-check: check: @script/check -# The frontend half of check, for Dockerfile's node build stage, which -# has neither Go nor Docker. Use check everywhere else. -frontend-check: - @script/frontend-check +# make add-dependency PACKAGE=@. PACKAGE reaches the +# script through the environment, so the shell never reads it as code. +add-dependency: + @script/add-dependency "$$PACKAGE" + +tidy: + @script/tidy # Responsive-layout verification in a containerised browser. Kept out of -# check: it needs Docker and takes minutes, where make test has to stay -# under 20 seconds. +# check: it takes minutes. frontend-viewport-test: @script/frontend-viewport-test diff --git a/README.md b/README.md index acbb62d..3f51cc2 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,452 @@ -NetWatch is an MIT-licensed JavaScript single-page application by -[@sneak](https://sneak.berlin) that provides real-time network latency -monitoring to common internet hosts, displayed with color-coded figures and -sparkline graphs, served from a static bucket or Docker container. +NetWatch is an MIT-licensed single-page web application by +[@sneak](https://sneak.berlin) that shows the latency from your browser to 26 +well-known internet hosts. The page is JavaScript and does all its measuring in +the browser. A small Go backend, `netwatch-server`, stores the measurements the +page reports. The page and the backend ship together in one Docker image. + +## Rationale + +When a network misbehaves, it helps to see at a glance which hosts answer and +how fast. NetWatch is one page with a latency figure and a small graph for each +host, refreshed every few seconds. The page measures from the browser, so it +shows the path your own traffic takes. It can be served from any static file +host. The backend only stores what the page reports. Without it, the page works +the same and nothing is stored. ## Getting Started ```bash -# Install dependencies -yarn install +# Install the dependencies and the git pre-commit hook +make setup -# Development server -yarn dev +# Serve the page from the Vite dev server +make dev -# Production build -yarn build +# Build the page and netwatch-server, then run the server on port 8080 +make run -# Preview production build -yarn preview +# Run the tests, both linters and the format check +make check -# Docker -docker build -t netwatch . +# Build the page into dist/ and netwatch-server into the repo root +make build + +# Build the Docker image and run it +make docker docker run -p 8080:8080 netwatch ``` -`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running -`netwatch-server` (see `backend/`) receives the reports the page posts. +`make check` and `make docker` need Docker. `make run` starts `netwatch-server` +on port 8080 with its defaults, so the page's reports are stored in +`data/reports`. `make dev` passes the page's `/api` requests there. -## Entrypoints +## The page -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. -The Go backend in `backend/` has its own `script/` directory and shim Makefile -(see [backend/README.md](backend/README.md)). The root scripts cover both -halves, so the root `make check` fails if either one is broken. We provide: +The page shows one row per host: the host's name, its URL, its latest latency in +milliseconds and a graph of its last 100 checks. Below the figure are the +minimum, median, average and maximum of the host's answers in that history, or +`timeout` or `unreachable` after a failed check. A round of checks runs every 3 +seconds by default. The header shows the interval, the history's span, and +whether the page is running or paused. -- `script/bootstrap` — install all dependencies (the pinned node via nvm unless - one new enough for the frontend's dependencies is installed, yarn via - corepack, `yarn install --frozen-lockfile`, the pinned Go unless one at least - as new as `backend/go.mod` asks for is installed, and the Go modules), linking - what it installs itself into `~/.local/bin`, which has to be on `PATH`. It - installs no Go linter and not Docker: `make lint` runs the linter in Docker -- `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 `script/frontend-test`, then the backend's Go tests, both - within one 30-second timeout -- `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by - building the lint stage of `Dockerfile` without the cache -- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/` -- `script/fmt-check` — check formatting (read-only): prettier, then gofmt -- `script/check` — run test, lint, and fmt-check -- `script/frontend-test` — run the production build as the frontend's test (no - unit tests yet) -- `script/frontend-lint` — run prettier in check mode -- `script/frontend-fmt` — format everything prettier understands (writes) -- `script/frontend-fmt-check` — check prettier formatting (read-only) -- `script/frontend-check` — the frontend half of `script/check`, for - `Dockerfile`, whose node build stage has neither Go nor Docker -- `script/frontend-viewport-test` — responsive-layout verification of the built - frontend in a containerised headless Chrome (see - [test/viewport/README.md](test/viewport/README.md)). Not part of - `script/check`: it needs Docker and takes minutes. -- `script/docker` — build the image from `Dockerfile` without the build cache, - tagged `netwatch` via `script/projectname` -- `script/cibuild` — CI entrypoint: runs `script/bootstrap` and `script/check`, - then builds the image as `script/docker` does, without the build cache -- `script/precommit` — run by the git pre-commit hook; runs `script/check` -- `script/install-precommit` — install the git pre-commit hook +### Targets -## Responsive layout +The page checks 26 hosts on the internet, the WAN hosts: -The narrow-viewport layout lives in the `max-width: 768px` media block in -`src/styles.css`. It is verified automatically by `make frontend-viewport-test`, -which drives a digest-pinned headless Chrome against the built `dist/` and -asserts on computed layout at widths derived from that CSS — one pixel either -side of every breakpoint it declares, plus a 320px floor, a desktop baseline and -two landscape sizes. See [test/viewport/README.md](test/viewport/README.md) for -what it covers and what it genuinely cannot. +- `datavi.be`, pinned to the top at start +- the Anthropic API and the OpenAI API +- the AWS Console, the Google Cloud Console and Microsoft Azure +- Cloudflare, Fastly, Akamai, Google, GitHub and Backblaze B2 +- 8 S3 regional endpoints: Cape Town, London, Bahrain, Tokyo, Singapore, Sydney, + Oregon and São Paulo +- 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn, + Hillsboro and Singapore -## Rationale +Two local targets follow under the heading "Local Network", left out of the +summary and the health box. `Local CPE` is the cable modem at +`http://192.168.100.1`. `Local Gateway` is the first of `http://192.168.1.1`, +`http://192.168.0.1`, `http://192.168.8.1` and `http://10.0.0.1` to answer +within 1.5 seconds of the page loading; with no answer, it has no row. Browsers +block a page served from a public address from reaching private addresses. So +the local targets answer only when the page is served from `localhost` or from a +private address. -When debugging network issues, it's useful to have a persistent at-a-glance view -of latency and reachability to multiple well-known internet endpoints. NetWatch -provides this as a zero-dependency SPA that can be deployed anywhere static -files are served, with no backend required. +The host list is written in `web/src/main.js`; there is no setting for it. -## Design +### What a figure measures -The application is a single-page app built with Vite and Tailwind CSS v4. All -code lives in `src/main.js` with a class-based architecture: +A check is a request to the target's URL: `GET`, or `HEAD` for the S3 regions. +The figure is the time from sending the request until the response headers +arrive, in milliseconds. Unlike ping, it includes the time the server takes to +answer. On a new connection it also includes the DNS lookup, the connection +setup and the TLS handshake. Any answer counts as reachable, an error page +included. The browser follows redirects, so a target whose home page redirects +is checked at `/robots.txt` on the same host. -- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis - ticks, etc.) -- **`HostState`**: Per-host state management — history buffer, latency tracking, - status transitions -- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause - state, aggregate stats -- **`SparklineRenderer`**: Canvas 2D sparkline drawing with fixed axes, - color-coded line segments, error regions, and DPR-aware scaling -- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` / - `updateSummary()` / `updateHealthBox()` handle incremental updates -- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, - pushes samples, redraws UI. When paused, pushes blank markers (no probes, no - false outage) -- **`Reporter`**: Posts collected samples to the backend +A check times out after 80% of the interval: 2.4 seconds at the default. A check +that answers after that limit counts as a timeout too. A check that fails with a +network error is recorded as unreachable. The first round after the page loads +is discarded, as its figures include the connection setup. So is the first round +after an interval change. -### Reporting +### Health and summary -Every `reportInterval` (default 60s) the page POSTs a JSON report to the -same-origin path `/api/v1/reports`: a random per-browser `clientId` kept in -`localStorage`, `geo` sent as null, and each host's unreported, non-paused -samples (timestamp, latency, error). A per-host high-water mark makes every -report a delta, so only new samples are sent; the mark advances only on a -delivered report, and while paused nothing is sent. Delivery failure is quiet — -one debug-log line per outage, retried at the next interval, never blocking -probing. The report-building step is a pure function of host state. +Above the rows, a health box sums up the WAN hosts' latest results. A host +counts as failed when its last check timed out or found it unreachable. -### Monitoring targets +- `OFFLINE` (red): more than 10 hosts failed and at most 4 answered +- `DEGRADED` (orange): otherwise, more than 4 hosts failed +- `SLOW` (yellow): otherwise, more than 3 hosts took over 1000 ms +- `HEALTHY` (green): otherwise -- **22 WAN hosts**: datavi.be, Anthropic API, OpenAI API, AWS Console, GCP - Console, Azure, Cloudflare, Fastly, Akamai, GitHub, B2, 7 S3 regional - endpoints (Cape Town, London, Bahrain, Tokyo, Sydney, Oregon, São Paulo), 4 - GCS locational endpoints (Iowa, Belgium, Singapore, Sydney) -- **Local CPE**: Cable modem at 192.168.100.1 (always monitored) -- **Local Gateway**: Auto-detected on startup by probing common default gateway - addresses (192.168.1.1, 192.168.0.1, 192.168.8.1, 10.0.0.1); first responder - wins. Note: modern browsers enforce Private Network Access restrictions that - block public-origin pages from reaching RFC1918 addresses, so local targets - only work when NetWatch is served from localhost or a private address. +Below it, a summary line covers the WAN hosts only: -Local hosts are tracked separately from WAN stats. +- `Reachable`: how many of the 26 answered their last check +- `Now`: the minimum, median, average and maximum of their latest figures +- the history's span, such as `5m`: the minimum and maximum over the whole + history +- `Checks`: the rounds run since the page loaded or the interval changed -### Latency measurement +When no WAN host answers, the page checks 4 of them, picked at random, every +half second. As soon as one answers, a new round starts. -HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with -`performance.now()`. 1-second timeout; anything over 1000ms is clamped to -unreachable. IPv4 only. +### Rows, colors and controls -### Color coding +A figure and its line in the graph take the color of the legend in the footer, +from green under 50 ms to red from 500 ms up; gray is no answer. The graph's +axis ends at 1000 ms; a higher latency is drawn at the top, and the figure still +shows its real value. -| Latency | Color | -| ----------- | ------ | -| < 50ms | Green | -| < 100ms | Lime | -| < 200ms | Yellow | -| < 500ms | Orange | -| >= 500ms | Red | -| Unreachable | Gray | +From time to time, the page sorts the WAN rows by their latest latency: fastest +first, failed last. The pin button at the left of a row keeps the row at the +top; pinned rows are sorted by name. `datavi.be` is pinned at start. -### Output structure +`Pause` stops the checks: the graphs keep scrolling with a gap for each paused +round, and the figures turn gray. The `Interval` menu sets the time between +rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the history and +starts a new round at once. The `Debug log` checkbox in the footer opens a panel +with the page's last 1000 log lines. -``` -dist/ -├── index.html -└── assets/ - ├── index-*.css - └── index-*.js -``` +## Reporting -## Features +Every 60 seconds the page posts its undelivered samples as a report to +`/api/v1/reports` on the origin it was served from. The report is JSON with four +fields: -- Real-time monitoring with 2s update interval and 300s history sparklines -- Health indicator: green (HEALTHY) or red (DEGRADED) based on WAN reachability -- Summary stats: reachable count, min/max/avg latency across WAN hosts only -- Fixed chart axes: Y-axis 0–1000ms, X-axis 0–300s -- Color-coded latency figures and sparkline line segments -- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no - false outage) -- Clickable service URLs -- Canvas-based sparkline rendering with devicePixelRatio scaling -- Zero runtime dependencies: all resources bundled into build artifacts +- `clientId`: a random id, made once per browser and kept in `localStorage`, or + once per session when the browser has no usable storage +- `geo`: always `null` +- `hosts`: one entry per host with new samples: its `name`, `url` and `status`, + and `history`, its samples. A sample has the check's time `t` in Unix + milliseconds, its `latency` (`null` for a failed check) and its `error` + (`"timeout"`, `"unreachable"` or `null`). +- `timestamp`: the time of the report + +Each report carries only the samples no earlier report has delivered. A sample +waits as long as the history holds it: the last 100 rounds of each host. An +interval change clears the history. The cleared samples are kept, the last 100 +of each host, until a report delivers them. Nothing is sent while the page is +paused. A report with no answer after 30 seconds is abandoned, and its samples +go in the next one. So a server that stored them but answered late gets them +twice. + +A failed delivery is quiet, as on a static file host with no backend: one debug +log line when deliveries start failing, one when they recover. The checks go on. ## Deployment -After running `yarn build`, deploy the contents of the `dist/` directory to any -static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or -use the Docker image behind a reverse proxy. +`make docker` builds the image `netwatch` from `Dockerfile`: the whole service +in one container. nginx serves the page and passes `/api/`, +`/.well-known/healthcheck` and `/metrics` to `netwatch-server` in the same +container. The backend listens on `127.0.0.1:8081` only, as user `netwatch` (uid +1000). `bin/entrypoint.sh` starts both and watches them. -The Docker image, built from `Dockerfile`, is the whole service in one -container: nginx serves the built frontend and passes `/api/` and -`/.well-known/healthcheck` to the Go backend, `netwatch-server`, which listens -only inside the container, on `127.0.0.1:8081`. The image: +Before the backend starts, `bin/entrypoint.sh` runs two subcommands of +`netwatch-server`. `check-cidr` checks each `TRUSTED_PROXIES` entry (see +[Running under upaas](#running-under-upaas)). `prepare-data-dir` creates the +report directory and gives `/data`, with the directories, regular files and +symbolic links in it, to user `netwatch`. It changes nothing outside `/data`. -- Listens on port 8080 by default (override with `PORT` env var) -- Takes the client address from `X-Forwarded-For` only on requests from the - reverse proxies named in `TRUSTED_PROXIES`, and by default from none -- Sends access logs to stdout -- Caches static assets with immutable headers -- Sends the security headers `REPO_POLICIES.md` requires on every response, as - `security-headers.conf` sets them, in place of the backend's own -- Stores reports in `DATA_DIR`, `/data/reports` by default, on the `/data` - volume. Before the backend starts, the image creates `DATA_DIR` and gives it - and `/data` to user `netwatch` (uid 1000), which the backend runs as, so a - host directory bind-mounted at `/data` ends up owned by uid 1000 -- Writes buffered reports to disk on `docker stop`, and exits non-zero if nginx - or the backend exits on its own, so the platform restarts it +The image also: -## Running under upaas +- takes the client address from `X-Forwarded-For` only on a request from a proxy + named in `TRUSTED_PROXIES`, and passes that one address to the backend. The + backend is started with `TRUSTED_PROXIES=127.0.0.1/32`, so it trusts nginx + alone. +- writes nginx's access log and the backend's JSON log to stdout +- refuses a request body over 1 MiB with nginx's own 413 error page +- requests `/.well-known/healthcheck` through nginx every 30 seconds, as its + `HEALTHCHECK`. The check fails unless both nginx and the backend answer, and + while report files cannot be written. +- on `docker stop`, writes the waiting reports to disk and exits 0, or non-zero + if that write fails. If nginx or the backend exits on its own, the other is + stopped and the container exits non-zero. -What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs: +### Running under upaas -- **Port:** container port `8080`. -- **Volume:** container path `/data`; the reports are kept in `/data/reports`. -- **Environment variables:** none is required. An empty one counts as unset, and - one set to a value netwatch cannot use stops the container at start, with the - reason in its log. - - `PORT`, default `8080`: the container port, from 1 to 65535. `8081` cannot - be used: the backend listens on it inside the container - - `REPORTS_PER_MINUTE`, default `60`: reports each client address may send a - minute - - `DATA_DIR_MAX_BYTES`, default `1073741824` (1 GiB): the most room the - report files may take - - `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call - the API - - `DEBUG`, default `false`: debug logging - - `DATA_DIR`, default `/data/reports`: leave unset; reports kept outside - `/data` do not survive a redeploy - - `TRUSTED_PROXIES`, default empty: set it to the address the reverse proxy - in front of the container connects from, as an IP address or CIDR; several - are separated by commas. nginx takes the client address from - `X-Forwarded-For` only on a request from one of them, and the rate limit - counts that address. Unset, `X-Forwarded-For` is ignored and every client - behind the proxy shares the proxy's one allowance of `REPORTS_PER_MINUTE`. - Name only addresses nothing but the proxy connects from: any client that - connects from one can write its own `X-Forwarded-For`, and through a port - Docker publishes, every client may connect from the Docker network's - gateway, such as `172.17.0.1`. -- **Health check:** the image's `HEALTHCHECK` requests - `/.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 - seconds after a deploy and fails the deploy unless it is `healthy`. The - container also stops when either process exits. +The [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs: -## Browser Compatibility +- Port: container port `8080`. +- Volume: container path `/data`. +- Environment variables: none is required. Every setting in + [Settings](#settings) applies, with four differences in the image: + - `BIND_ADDRESS` has no effect. The backend listens on `127.0.0.1` inside + the container. + - `PORT` is nginx's public port, from 1 to 65535 and not `8081`, where the + backend listens inside the container. + - `DATA_DIR` must be `/data` or a path below it, written in full, with no + `.` or `..` part and no extra `/`. A path through a symbolic link is + refused unless the link is relative and stays inside `/data`. + - `TRUSTED_PROXIES` names the proxies nginx trusts, as IP addresses or CIDRs + separated by commas; the backend's own setting is fixed to `127.0.0.1/32`. + `bin/entrypoint.sh` gives a lone address a `/32` or `/128` and checks each + entry with `netwatch-server check-cidr`, which refuses what the backend + would refuse. Set and empty, nginx trusts no proxy, and every client + behind a proxy then shares the proxy's one allowance of + `REPORTS_PER_MINUTE`. A client that connects from a trusted address can + write its own `X-Forwarded-For`. A port Docker publishes can bring + connections from the Docker network's gateway, such as `172.17.0.1`, which + the default includes. Where that matters, name only the proxy's own + addresses. +- Health: the image's `HEALTHCHECK`, described above. -Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom -properties. +The container stops at start when a variable holds a value it cannot use. The +reason is in its log. -## Limitations +## Settings -- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses - `no-cors` mode which allows the request but provides opaque responses. Latency - is still measurable based on request timing. -- **Local gateway**: The 192.168.100.1 endpoint requires the host to be - accessible from your network. -- **Network conditions**: Measurements reflect browser-to-endpoint latency, - which includes your local network, ISP, and internet routing. +`netwatch-server` reads its settings from environment variables. It also reads a +`.env` file in the directory it starts in, and a YAML file named +`netwatch-server.yaml` in `/etc/netwatch-server/` or +`~/.config/netwatch-server/`, when one exists. A variable in the environment +wins over a file. + +The server does not start when a variable holds a value it cannot use. Its error +names the variable. An empty variable counts as unset, except `TRUSTED_PROXIES`. + +- `BIND_ADDRESS`, default empty: the IP address to listen on. Empty listens on + every interface. The image sets it to `127.0.0.1`. +- `PORT`, default `8080`: the port to listen on, from 1 to 65535. In the image + this is nginx's port; the backend listens on `8081`. +- `DATA_DIR`, default `./data/reports` (`/data/reports` in the image): the + directory for the report files. The server creates it if it is missing, and + stops at start if it cannot create a file in it. +- `DATA_DIR_MAX_BYTES`, default `1073741824` (1 GiB): the most bytes of report + files kept in `DATA_DIR`, oldest deleted first. A positive whole number. See + [Report limits](#report-limits). +- `REPORTS_PER_MINUTE`, default `60`: how many reports each client address may + send a minute. A positive whole number. See [Report limits](#report-limits). +- `TRUSTED_PROXIES`, default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`: the + reverse proxies whose `X-Forwarded-For` and `X-Real-IP` headers are trusted, + as CIDRs separated by commas. See [Trusted proxies](#trusted-proxies). +- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call the + API, separated by commas. See [CORS](#cors). +- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: the basic auth + credentials for `/metrics`. See [Metrics](#metrics). +- `SENTRY_DSN`, default empty: the DSN of a Sentry project. Each request whose + handling crashes is reported there; the request still gets a 500. +- `DEBUG`, default empty: any non-empty value turns debug logging on, `false` + and `0` included. Every value is accepted. + +### Trusted proxies + +Each `TRUSTED_PROXIES` entry must be a CIDR, such as `127.0.0.1/32`; a lone +address is refused. A list replaces the default; set and empty, the server +trusts no proxy. Loopback is not in the default, so a reverse proxy on the same +host has to be named. + +A request from a peer outside this set has its forwarded headers ignored; the +peer's own address is logged and rate limited. From a peer inside the set, the +client address is the right-most `X-Forwarded-For` entry that is not in the set. +The peer's address is used when every entry is in the set, or when the search +from the right meets an entry that is not an address, such as `unknown`. +`X-Real-IP` is read only when `X-Forwarded-For` is missing or empty. + +### Routes + +- `POST /api/v1/reports` takes a report from the page. It needs no credentials; + [Report limits](#report-limits) bound it instead. +- `GET /.well-known/healthcheck` answers 200 with a JSON object: `appname`, + `version`, `now`, `uptime_seconds`, `uptime_human` and `status`, which is + `"ok"`. While report files cannot be written, it answers 503 with the same + fields and `"status":"error"`. +- `GET /metrics` serves Prometheus metrics, only when `METRICS_USERNAME` and + `METRICS_PASSWORD` are set. See [Metrics](#metrics). + +Every request body is capped at 1 MiB, and every request at 60 seconds. + +### Report storage + +The server keeps the reports it takes in memory and writes them to `DATA_DIR` as +files of JSON lines, compressed with zstd. It writes a new file every minute, +whenever 10 MiB of reports are waiting, and when it stops. With none waiting, it +writes no file. The files are named `reports--.jsonl.zst`; +the timestamp is UTC to the millisecond, so the names sort by time. + +The reports of a failed write, as on a full disk, stay in memory for the next +write to try again. Until a write succeeds, `POST /api/v1/reports` refuses every +report with 500 and the health check answers 503. + +### Report limits + +`POST /api/v1/reports` takes reports from anyone who can reach it, so it is +bounded in four ways. Each refusal by the backend answers with the body +`{"status":"error"}`. + +- JSON only. A report must be sent with `Content-Type: application/json`; any + other content type is refused with 415, and malformed JSON with 400. +- Body limit. A report body may be at most 1 MiB. A request that declares a + longer body, or a report that runs past that, is refused with 413. +- Rate limit. Each client address may send `REPORTS_PER_MINUTE` reports a + minute; past that it gets 429 with `Retry-After: 60`. The page sends one + report a minute from each open tab, so the default of 60 refuses nothing from + up to 30 tabs behind one address. +- Size cap. The report files in `DATA_DIR` may total at most + `DATA_DIR_MAX_BYTES`, counting the files already there at start and the + reports waiting in memory. When a report would take the total past the cap, + the oldest files are deleted to make room. A report is refused with 507 when + deleting the oldest files cannot make room. + +At the defaults, one address can fill the 1 GiB cap with its own reports in +about 17 minutes, and every report stored before them is deleted to make room. + +### CORS + +The page needs no entry here: it calls the API from the origin it is served +from. By default the server sends no CORS headers, and browsers let no other +origin's pages call it. `CORS_ALLOWED_ORIGINS` lists other origins, such as +`https://netwatch.example.com`, whose pages may then call the API with `GET` and +`POST`. Each entry must be a plain origin as browsers send it: `scheme://host`, +with an optional `:port`, no path, no trailing `/` and no `*`. + +### Metrics + +With both `METRICS_USERNAME` and `METRICS_PASSWORD` set, the server serves +Prometheus metrics at `GET /metrics`. A request with those as its basic auth +credentials gets them; any other gets 401. With neither set, `/metrics` +answers 404. The metrics record each request that reaches the health check or +`POST /api/v1/reports`: its duration and response size, labelled with its path, +method and status. Each client address may make 60 requests to `/metrics` a +minute. Past that it gets 429 with `Retry-After: 60`, and its credentials are +not checked. + +### Logging + +The backend logs JSON to stdout, or plain text when stdout is a terminal. Each +request is logged with its method, URL, status, client address and duration. +Every value taken from a request is cut to 128 bytes before it is logged. + +## Design + +The repository holds the page, the backend and the image: + +- `web/`: the page's source. `index.html` loads `src/main.js` and + `src/styles.css`. +- `cmd/netwatch-server/` and `internal/`: the Go backend. `cmd/` holds only + `main`, which calls into `internal/cli`. +- `configs/`: nginx's configuration for the image, `nginx.conf` and + `security-headers.conf`. +- `bin/entrypoint.sh`: the image's entrypoint. +- `Dockerfile`: the lint and test phases, the two build stages and the image. +- `script/`: the development commands; `Makefile` targets call them. +- `test/unit/`: the page's unit tests. `test/viewport/`: the layout test. + +### The page + +The page is built with Vite and Tailwind CSS v4. All its code is in +`web/src/main.js`: + +- `CONFIG`: the timing and display constants. The interval menu sets its + `updateInterval`; the check timeout and the history span follow from it. +- `WAN_HOSTS`: the WAN targets, each with its URL and, for the S3 entries, the + method `HEAD`. +- `HostState` and `AppState`: one host's history and status, and the whole + page's state. +- `measureLatency`: one check. `tick`: one round, which checks every host in + parallel and draws each row as soon as its check ends. +- `SparklineRenderer`: the graphs. `buildUI`, `updateHostRow`, `updateSummary` + and `updateHealthBox`: the page and its parts. +- `Reporter`: builds and posts the reports. `buildReport` is a pure function of + the hosts' state. + +### The backend + +`netwatch-server` is a Go HTTP server. The `fx` library starts and stops its +parts in dependency order. The backend's packages are in `internal/`: + +- `cli`: the command line: the server when given no arguments, or the + `check-cidr` and `prepare-data-dir` subcommands that `bin/entrypoint.sh` runs. +- `config`: reads the settings and refuses values the server cannot use. +- `server`: the router, the routes and the HTTP server. +- `middleware`: the request log, the security headers, CORS, the body limit, the + rate limit, the client address resolution and the metrics. +- `handlers`: the health check and the report route. +- `reportbuf`: the in-memory buffer of reports and the files it writes them to. + It also holds `PrepareDataDir`, which `prepare-data-dir` runs. +- `healthcheck`, `logger` and `globals`: the health status, the logger, and the + name and version the build stamps in. + +## Development + +### Entrypoints + +This repository adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard. The scripts in `script/` are the entrypoints for the development +workflow, and the `Makefile` targets are thin shims that call them. + +- `script/bootstrap`: install the dependencies. It installs the pinned node, + yarn and Go when the ones on `PATH` do not pass its checks, and links them + into `~/.local/bin`, which has to be on `PATH`. It installs no Go linter and + no Docker. +- `script/setup`: `script/bootstrap`, then the git pre-commit hook. +- `script/dev`: run the Vite dev server. +- `script/build`: build the page into `dist/`, then the static `netwatch-server` + binary into the repo root. Its version is `VERSION` from the environment, else + `git describe --tags --always --dirty`, else `dev`. +- `script/run`: `script/build`, then run `netwatch-server` with its defaults. +- `script/clean`: remove what `script/build` writes. +- `script/projectname`: print `netwatch`, the image tag `script/docker` uses. +- `script/yarn`: run yarn under the node `script/bootstrap` would use. +- `script/test`: build the `test` phase of `Dockerfile`, writing no image: the + Go tests with the race detector and coverage, and the page's unit tests. +- `script/lint`: build the `lint` phase of `Dockerfile`, writing no image: + golangci-lint over the Go packages and eslint over the JavaScript. +- `script/fmt`: run the formatters: prettier over the JavaScript, CSS, HTML and + Markdown, then gofmt over `cmd/` and `internal/`. They write the files in + place. +- `script/fmt-check`: check the formatting (read-only), with the same scope as + `script/fmt`. +- `script/check`: `script/test`, `script/lint` and `script/fmt-check`. +- `script/add-dependency`: add a page dependency, or move one to another + version: `make add-dependency PACKAGE=@`. +- `script/tidy`: run `go mod tidy`. +- `script/frontend-test`: run the unit tests in `test/unit/` on the host, with + Node's built-in test runner, then the production build. +- `script/frontend-fmt` and `script/frontend-fmt-check`: the prettier half of + `script/fmt` and `script/fmt-check`. +- `script/frontend-viewport-test`: check the built page's layout in a + containerised headless Chrome, not as part of `script/check`; see + [test/viewport/README.md](test/viewport/README.md). +- `script/docker`: build the image from `Dockerfile`, tagged `netwatch`. +- `script/cibuild`: `script/bootstrap`, `script/check`, then the image build. + The Gitea workflow `.gitea/workflows/check.yml` runs it on every push. +- `script/precommit`: run by the git pre-commit hook; it runs `script/check`. +- `script/install-precommit`: install the git pre-commit hook. `make hooks` + calls it. + +### Tests + +`make test` runs the backend's Go tests and the page's unit tests in Docker. The +unit tests import `web/src/main.js` in Node and call the functions it exports; +no browser is involved. `make frontend-viewport-test` checks the built page's +layout in a headless Chrome; [test/viewport/README.md](test/viewport/README.md) +says what it checks and what it cannot. ## TODO -- Add unit tests -- Add eslint for JS linting (currently lint target runs prettier only) -- Add configurable host list (environment variable or config file) -- Add latency history export (CSV/JSON) -- Add notification/alert when status changes to DEGRADED +The to-do list is [TODO.md](TODO.md): where the work stands, the next step, the +open work, and what has been done. ## License diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index bc2f161..1067698 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-07-06 +last_modified: 2026-10-18 --- This document covers repository structure, tooling, and workflow standards. Code @@ -54,21 +54,37 @@ style conventions are in separate documents: `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@ --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 + installed node only when its major version is the pinned one; otherwise (node + missing, or another major, such as the node 24 the CI runner image ships) it + installs the PINNED node version via nvm and installs yarn and the packages + under it. nvm itself is installed first if missing, from a hash-verified + GitHub release archive (never `curl | sh`), with bash installed as an explicit + prerequisite since nvm requires bash. Only the major version is compared + because the `Dockerfile` stages start from a node image whose exact version is + not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned + via `corepack prepare yarn@ --activate`. Never install "latest" or + "lts"; always exact versions. `script/cibuild` runs the CI build: it changes + to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the + image with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: when + `script/bootstrap` installs node and yarn under nvm it leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs + under another node. The host entrypoints that need yarn — `script/fmt` and + `script/fmt-check` — therefore use the same test as `script/bootstrap`: when + the node on `PATH` has the pinned major version they run the `yarn` on `PATH`, + and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the + pinned node. A runner carrying nothing but docker and git then gets through + `script/check`. 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/`. The README @@ -89,87 +105,290 @@ style conventions are in separate documents: 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. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. -- **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. +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: - The standard pattern for a Go repo Dockerfile is: + ```sh + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target lint --output type=cacheonly . + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target test --output type=cacheonly . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **The gate builds write no image.** With `--output type=cacheonly` the phase + runs and a failing step fails the build, but the result is not exported. + Nothing uses those images, and writing one out is slow: a Go test phase's + image holds the toolchain. A build given neither `--output` nor `-t` writes + an untagged image and leaves it dangling, on every developer host and every + CI runner. `script/cibuild` and `script/docker` build the image that ships + and tag it, so each build replaces the previous image; each assigns the tag + on its own line before the build, so `set -e` stops it where + `script/projectname` fails. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own `TMPDIR`, so the lock is + out of reach. The lint phase does keep golangci-lint's cache between check + builds (below), but it lints every checkout at `/src`, and a finding is + taken from that cache only for the same files, settings and golangci-lint + version. + +- **Any build that runs checks is given a new `CACHEBUST` value.** Docker + invalidates a `COPY` layer only when the copied content changes, so on an + unchanged tree the check `RUN` is served from cache, nothing executes, and the + build still exits 0. Every `docker build` in `script/` therefore passes + `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process + id, so no two builds share a value, not even the three in one + `script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and + `script/docker` are the four, and there is no fifth — `script/check` runs the + two gate phases and `script/fmt-check`, and builds no image of its own. In + every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps + that install system packages and download Go's modules, or right after `FROM` + in a stage with neither, and every step that lints, tests or compiles comes + after it. A step that installs anything else as well, such as a + `script/bootstrap` that also installs node packages, comes after it too. A new + value reruns every `RUN` step after that line; `COPY` steps are compared by + content and need nothing. Docker keeps the layers above the line until the + base image, their own lines or the files they copy change. A stage without the + `ARG` line is served from cache on an unchanged tree, so a repository + re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do + not pass `--no-cache`: it reruns every step too, but it also builds the layers + above the line again and empties every cache mount, so Go's build cache and + golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they + survive between check builds, would start empty every time. A bare + `docker build .` is not evidence that anything ran: a sub-second build + reporting success is a cache hit, not a result. Never invalidate by pruning — + `docker builder prune` and friends destroy a build cache shared with every + other build on the host. When a check is added or changed, prove it works by + planting a defect it must catch and watching the run fail on it, then revert + the defect. A green run alone shows neither that the check ran nor that it + covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: ```dockerfile - # Lint stage — fast feedback on formatting and lint issues + # In every stage ARG CACHEBUST comes right after the steps that install + # system packages and download Go's modules, or right after FROM in a + # stage with neither, and every step that lints, tests or compiles + # comes after it. script/ passes a new value on every build, which + # reruns every RUN step after it. Docker keeps the layers above it + # until the base image, their own lines or go.mod and go.sum change. + # The steps that compile mount Go's build cache, and golangci-lint run + # also mounts golangci-lint's; both survive from build to build. + + # Lint phase # 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 + ARG CACHEBUST COPY . . - RUN make fmt-check - RUN make lint + RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache/golangci-lint \ + golangci-lint run --config .golangci.yml ./... - # Build stage - # golang:1.x-alpine, YYYY-MM-DD - FROM golang@sha256:... AS builder + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. -count=1 because the mounted + # cache also holds test results, which go test would otherwise replay. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test 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 + ARG CACHEBUST COPY . . - RUN make test + RUN --mount=type=cache,target=/root/.cache/go-build \ + go test -count=1 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -timeout 90s -race -v ./...; exit 1; } - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so nothing after them can + # run unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + RUN apk add --no-cache git + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + ARG CACHEBUST + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + COPY . . - # Runtime stage + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN --mount=type=cache,target=/root/.cache/go-build \ + VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one FROM alpine@sha256:... + ARG CACHEBUST 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. + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. + - Check builds keep these, and none of them can hand back a stale result: + - The layers above `ARG CACHEBUST`: the system packages and Go's + modules. Docker installs the packages again when the base image or the + install line changes, and downloads the modules again when `go.mod` or + `go.sum` changes. Go checks every module against `go.sum` as it + downloads it, and no later build can change a kept layer. The modules + are kept in a layer rather than a cache mount because a cache mount is + shared by every build on the host, and Go builds from whatever files + it holds without checking them again (only `go mod verify` does), so a + file changed there would go unnoticed. + - Go's build cache (`/root/.cache/go-build`, where Go keeps it for + root), mounted on the steps that compile: `golangci-lint run`, both + `go test` runs and `go build`. Go reuses a compiled package only when + all its inputs are unchanged. The cache also holds test results, which + is why both `go test` runs carry `-count=1`. + - golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned + image keeps it), mounted on `golangci-lint run`. golangci-lint reuses + a package's findings only when the package's files and those of every + package it imports, `go.mod`, the linters and their settings in + `.golangci.yml`, and the golangci-lint version are all unchanged. - 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 + (e.g. a web frontend compiled in a separate stage), the lint phase 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. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them. The test phase, + on the Debian Go image, installs the libraries its tests need the same + way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so + Docker keeps it until the base image or the install line changes: + + ```dockerfile + FROM golangci/golangci-lint@sha256:... AS lint + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + ARG CACHEBUST + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. + A checkout whose `.git` is a file (a linked worktree, or a repository + checked out as a submodule) is the exception: that file points to a git + directory outside the build context, so the build cannot read the version + and a plain `docker build .` fails; pass the version with + `--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already + do. - 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. + runs `script/cibuild` on push, and checks out the repo as its only other step, + with `persist-credentials: false`: `script/cibuild` needs no token, and + without it the checkout leaves the job's token in `.git/config` for every + later step. The checkout step also sets `fetch-depth: 0`, which fetches the + tags `git describe` needs: by default it clones shallow with no tags, and a + tagged repository's CI build would stamp a bare short commit id. The + workflow's `concurrency` block groups runs by workflow and branch + (`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`, + so a new push cancels the older run on the same branch, queued or running, and + no other: runs for replaced commits do not hold up the shared runner. + `script/cibuild` bootstraps, runs the gate phases, and then builds the image, + so a successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build gets a `CACHEBUST` value of its own and so runs the gate phases a + second time. That is the price of the rule above, and it is worth paying: the + image that ships is built from a run of its own gates rather than from a cache + entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the + shared runner after 20 minutes. That allows for the three Docker builds + described above (the test phase, the lint phase, then the image), each held to + the 5-minute Docker build limit below, plus the bootstrap. A separate workflow + limited to `main` by a `branches` list under `on: push` cannot be checked by + review: to try a change to it, add the feature branch to that list and push, + then remove the branch from the list again before merging. Keep any job in it + that publishes behind `if: github.ref_name == 'main'`, so the run from the + feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -189,14 +408,21 @@ style conventions are in separate documents: 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` must complete in under 60 seconds. That is the hard cap, and a + suite that exceeds it fails. Under 20 seconds is the target. A suite between + 20 and 60 seconds is still green, but the overage must be filed as an + improvement bug against that repo. Add a 90-second timeout to the test + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. -- **`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: +- **The test command 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 command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: ```makefile test: @@ -209,11 +435,25 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 30s -race -cover ./... || \ + @go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 30s -race -v ./...; exit 1; } + go test -count=1 -timeout 90s -race -v ./...; exit 1; } ``` + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. + + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. The directory lasts from one run to the next on a + developer's machine, where this target runs, and in the `test` phase of the + `Dockerfile`, which mounts it to keep compiled packages between check + builds, so the phase's `go test` runs carry `-count=1` too. A new + `CACHEBUST` value (above) is what makes that step run on an unchanged tree. + Python example: ```makefile @@ -239,10 +479,89 @@ style conventions are in separate documents: 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 + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + `node_modules/`, and the repo's own build outputs. Fetch the standard + `.gitignore` from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + a new repo. A repo's `.gitignore` is the standard file followed by the repo's + own entries, such as its binaries; a re-vendor replaces the standard part and + keeps those entries. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # The version and the tag each get their own line: a failing command + # substitution inside an argument does not trip `set -e`, so the inline + # form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + tag="$(script/projectname)" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --build-arg VERSION="$version" \ + -t "$tag" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so the canonical + `.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -258,9 +577,61 @@ style conventions are in separate documents: - 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`. +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Node is the exception to the whole-token comparison: the canonical + `script/bootstrap` compares only its major version, because the node 22 + alpine image the `Dockerfile` stages start from is not the exact pin, and + nvm cannot install a prebuilt node on alpine. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -371,15 +742,21 @@ style conventions are in separate documents: Never edit existing migrations after release. - All repos should have an `.editorconfig` enforcing the project's indentation - settings. + settings: the standard file from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets + tabs for `Makefile` and Go files, followed by the repo's own sections, such as + one for another language it uses. A re-vendor replaces the standard part and + keeps those sections. - 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: + only project-level config files (`README.md`, `AGENTS.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 + - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) @@ -406,3 +783,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/TODO.md b/TODO.md index 8b17dd2..8a964eb 100644 --- a/TODO.md +++ b/TODO.md @@ -1,217 +1,709 @@ # Workflow -- branch (from `main`) +- branch from `next` - 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 +- push the branch and open a PR against `next` # Status -pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI -workflow, and the backend repo standard files are all on `main`. Frontend and -backend are both functional. Working toward the 1.0.0 milestone by closing the -remaining repo-compliance issues on the tracker. +pre-1.0. Release candidates are tagged on `next`, the first being `v1.0.0-rc1`; +`git tag --merged origin/next` lists them. `main` is the stable branch and +`next` the development branch, which every PR targets. The frontend and the Go +backend ship as one Docker image. The Gitea workflow +`.gitea/workflows/check.yml` runs `script/cibuild` on every push, and cancels +the older run on the same branch. Working toward 1.0.0. # Next Step -Confirm the `.gitea/workflows/check.yml` run is green (main always green -policy). The workflow file is already on `main`; what is unverified is that its -latest run passes. +Run `make frontend-viewport-test` in CI as its own step; it is not part of +`make check`, as it needs Docker and takes minutes. # Completed Steps -- 2026-09-29: the container sets up its own data directory (issue #75): - `bin/entrypoint.sh`, still as root, creates `DATA_DIR` if missing and gives it - and `/data` to the `netwatch` user with mode 750 before starting the backend - as that user, so an empty host directory owned by root, or one holding files - from another uid, works with no step on the host. It stops the start instead - when a symbolic link is on the path to `DATA_DIR`, since root would change - whatever the link points to. The `README.md` first-run step that created and - chowned the host directory is gone, and the image no longer sets that - ownership at build time -- 2026-09-29: CI can no longer pass on checks that did not run (issue #37): - `script/cibuild` is now the org model, byte for byte. It runs - `script/bootstrap` and `script/check`, then builds the image with `--no-cache` - and the version from `git describe` as the `VERSION` build argument, where it - used to be a plain `docker build .` whose check steps could come from the - build cache. The workflow puts `~/.local/bin`, where bootstrap links what it - installs, on the step's `PATH`, and bootstrap now installs its pinned node - when the installed one is older than the frontend's dependencies need -- 2026-09-29: `backend/.golangci.yml` re-vendored from `sneak/prompts` (issue - #41): `gomodguard`, deprecated in golangci-lint v2.12.0, is disabled and its - successor `gomodguard_v2` enabled with the org block list, so lint runs print - no deprecation warning. The new file also turns `depguard` on with its - `test-support` rule, which keeps `net/http/httptest` out of non-test code; - netwatch adds no entries of its own to that rule. `backend/script/lint` checks - the new sha256 +- 2026-10-26: `README.md`, `TODO.md` and `test/viewport/README.md` are rewritten + sentence by sentence ([#189](https://git.eeqj.de/sneak/netwatch/issues/189)). + Each sentence reads on the first pass: one idea, under about 30 words, the + verb early. Every statement matches the code on `next`. +- 2026-10-26: a target whose home page answers with a redirect is checked at + `/robots.txt` on the same host, which answers without one + ([#194](https://git.eeqj.de/sneak/netwatch/issues/194)). These are the AWS + Console, Akamai, B2 and the 8 S3 regions. The browser followed each redirect, + so their figures also timed the page it led to. The Google Cloud Console is + checked at `/robots.txt` too, as its home page redirects `curl`'s requests. + The S3 regions are checked with a HEAD request, as Chrome closes their + connection after most GET requests. `README.md` says what a figure includes. +- 2026-10-26: the six Hetzner targets answer wherever the page is served from + ([#191](https://git.eeqj.de/sneak/netwatch/issues/191)). They close the + connection without an answer when the request has a `Referer` header. The + browser sent one unless the page came with the image's + `Referrer-Policy: no-referrer` header, which a static host and `make dev` do + not send. Each check now sets `referrerPolicy: 'no-referrer'`. +- 2026-10-19: the server's `OnStart` hook in `internal/server/server.go` starts + `listenAndServe` in an anonymous function, as the Go style guide asks + ([#162](https://git.eeqj.de/sneak/netwatch/issues/162)). It was + `go s.listenAndServe()`. Nothing the server does changed. +- 2026-10-19: `.gitignore`, `.editorconfig` and `.golangci.yml` are the + sneak/prompts copies at `0653579` + ([#174](https://git.eeqj.de/sneak/netwatch/issues/174)). `.gitignore` is the + standard file followed by this repository's own entries. `.editorconfig` is + the standard file alone, which carries the `[*.go]` section. `.golangci.yml` + disables `canonicalheader`, as the canonical copy does: golangci-lint v2.14.0 + misses its findings at random. +- 2026-10-19: `package.json` has no `version` field and is marked + `"private": true` ([#158](https://git.eeqj.de/sneak/netwatch/issues/158)). It + said `1.0.0`, a release that has not been made, and nothing read it. The git + tag is the one version; the image and `make build` take it from + `git describe --tags`. The package is never published. +- 2026-10-19: Status names `v1.0.0-rc1`, the first release candidate, and says + release candidates are tagged on `next` + ([#157](https://git.eeqj.de/sneak/netwatch/issues/157)). It said there were no + git tags. +- 2026-10-19: `netwatch-server` starts the server only when given no arguments + ([#179](https://git.eeqj.de/sneak/netwatch/issues/179)). `check-cidr CIDR` and + `prepare-data-dir DATA_DIR` behave as before. Any other arguments print a + one-line usage message naming both forms to stderr and exit 2; they used to + start the server. Unit tests cover each case. +- 2026-10-19: `main()` in `cmd/netwatch-server/main.go` is one call into the new + package `internal/cli`, as the Go style guide asks + ([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs + the `check-cidr` and `prepare-data-dir` subcommands, with the same arguments, + messages and exit codes, and otherwise the server with the `fx` wiring. + `Appname` and `Version` stay in `main`, where the build sets `Version`. `main` + gives `cli.Main` stderr and the function that runs the server; the tests + replace both. Unit tests give each subcommand input it takes and input it + refuses. +- 2026-10-19: when `VERSION` is unset or empty, `script/build` stamps the + version from `git describe --tags --always --dirty`, as `script/docker` does + ([#156](https://git.eeqj.de/sneak/netwatch/issues/156)). Without `--tags` it + skipped lightweight tags, so `make run` could report a commit where the image + built from the same tree reported the tag. +- 2026-10-19: `.dockerignore` keeps Go test binaries and coverage output at the + root out of the build context, with `/*.test` and `/*.out` in the repository's + own section ([#155](https://git.eeqj.de/sneak/netwatch/issues/155)). Such + files were copied into the lint, test and build stages. The entries match only + at the root, so the same files in a subdirectory still reach the image. +- 2026-10-19: `.gitea/workflows/check.yml` is the sneak/prompts copy at + `0653579` ([#153](https://git.eeqj.de/sneak/netwatch/issues/153)). Its + checkout step sets `fetch-depth: 0`, so CI clones all history and tags. The + image built from a tagged commit then gets the tag as its version; it got the + short commit. The step also sets `persist-credentials: false`, so the job's + token is not left in `.git/config`. A new push cancels the older run on the + same branch, and a run stops after 20 minutes. +- 2026-10-19: `script/bootstrap` keeps the `yarn` on `PATH` only when + `yarn --version` is exactly `YARN_VERSION`; otherwise it installs that version + ([#154](https://git.eeqj.de/sneak/netwatch/issues/154)). Any yarn used to be + kept. It uses the `node` on `PATH` only when that node has the major version + of `NODE_VERSION` and is at least `NODE_MIN_VERSION`; a node 24 was kept + before. After installing node or yarn it looks the tool up through `PATH` + again and stops if that one fails the same check, as it does for Go. It prints + the node and yarn versions it uses. Every script that runs yarn on the host, + except `script/bootstrap`, runs it through the new `script/yarn`. That script + puts `~/.local/bin` first on `PATH`. It uses the first `node` there when it + passes the same test, and otherwise runs yarn under the pinned node in nvm. + `make build` ran yarn under whatever node was first on `PATH`. +- 2026-10-19: `bin/entrypoint.sh` has each step in a function, and a `main` at + the end calls them in order, as the code style guide's shell section asks + ([#163](https://git.eeqj.de/sneak/netwatch/issues/163)). It is still POSIX sh + with no `set -e`, and does what it did, with the same messages and exit codes. +- 2026-10-19: while report files cannot be written, the health check answers 503 + with `"status":"error"` and the same fields, so the image's `HEALTHCHECK` + reports the container `unhealthy` + ([#142](https://git.eeqj.de/sneak/netwatch/issues/142)). It answered 200 + whatever happened, so the platform saw a healthy container while every report + was refused with 500. It answers 200 at start, before any write has failed, + and again from the first write that succeeds. +- 2026-10-19: report file writes no longer overlap + ([#150](https://git.eeqj.de/sneak/netwatch/issues/150)), so the reports of + failed writes are written in the order they arrived. Two writes that failed at + the same time put their reports back in the wrong order when the older failed + first. One write runs at a time. Each takes the waiting reports only when it + starts, after a failed write has put its own back. The write that `Append` + starts at 10 MiB waits for any write under way, and `Append` starts no other + while it waits. Files written since the start are deleted to make room in the + order they were written, after those found at start. +- 2026-10-19: `DEBUG` unset or empty leaves debug logging off, and any other + value, `false` and `0` included, turns it on, as the code style guide says + ([#159](https://git.eeqj.de/sneak/netwatch/issues/159)). No value of it stops + the start any more. Before, only `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, + `f`, `F`, `FALSE`, `false` and `False` were accepted. `README.md` no longer + gives `false` as its default. +- 2026-10-19: check builds keep Go's modules and compiled packages between runs + ([#118](https://git.eeqj.de/sneak/netwatch/issues/118)), on sneak's rulings in + [prompts#113](https://git.eeqj.de/sneak/prompts/issues/113) and + [prompts#130](https://git.eeqj.de/sneak/prompts/issues/130). + `REPO_POLICIES.md`, `script/test`, `script/lint`, `script/docker` and + `script/cibuild` are the sneak/prompts copies at `0653579`. The scripts pass a + new `CACHEBUST` value where they passed `--no-cache`, and the lint and test + builds write no image. In each stage of `Dockerfile`, `ARG CACHEBUST` comes + after the system packages and the Go module download. Every step that lints, + tests or compiles comes after it, as does the node stages' `yarn install`. + Go's build cache and golangci-lint's are cache mounts, and both `go test` runs + carry `-count=1`. The test phase's Go caches used to be in memory, empty on + every build. +- 2026-10-19: the three unit tests of a discarded check also check that the + host's history is still empty, its status still `pending`, and the console + untouched ([#151](https://git.eeqj.de/sneak/netwatch/issues/151)). The + discarded cases are a round given up, a pause, and the first round. The tests + checked only the row. So a change that recorded the result and skipped only + the row would have kept them passing. +- 2026-10-18: run on its own, the backend stops at start when `DATA_DIR` cannot + be created or read, or when no file can be created in it + ([#148](https://git.eeqj.de/sneak/netwatch/issues/148)). The error names + `DATA_DIR` and its value. The first two cases named only the path, and the + third was found only at the first write of a report file. At start the server + creates a file in `DATA_DIR` and removes it. The report file number is an + `atomic.Int64`, as the Go style guide asks for int types + ([#161](https://git.eeqj.de/sneak/netwatch/issues/161)); file names are + unchanged. +- 2026-10-18: the image's nginx sends the page with `Cache-Control: no-cache` + ([#141](https://git.eeqj.de/sneak/netwatch/issues/141)), so a browser that + keeps a copy checks with the server that it is current before using it again. + The page went out with no `Cache-Control`. So a browser could keep it after a + deploy, ask for files under `/assets/` that the new build no longer has, and + show an empty page. A path that `location /` answers with the page through its + `try_files` fallback gets the same header. `location /` includes the security + headers again, as `/assets/` does, since its own `add_header` drops the + server's. `/assets/` keeps its immutable caching. + `make frontend-viewport-test` fails unless `/` and a path that falls back to + the page carry `Cache-Control: no-cache` and `Content-Security-Policy`. +- 2026-10-18: `README.md` says what the page loses while reports are refused or + the page is paused ([#145](https://git.eeqj.de/sneak/netwatch/issues/145)). It + says how fast one address can replace the stored reports + ([#146](https://git.eeqj.de/sneak/netwatch/issues/146)). It says that in the + image nginx answers a body over 1 MiB itself + ([#147](https://git.eeqj.de/sneak/netwatch/issues/147)). nginx's + `client_max_body_size` is 1 MiB by default, so in the image it answers a + larger body with 413 and its own HTML page, and the backend never sees it. The + reporter, the body limit and nginx are unchanged. +- 2026-10-18: a browser that cannot format the header's clocks still probes + ([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the + local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which + Chrome before 95, Firefox before 91 and Safari before 15.4 reject with an + error. It came before the first round started, so such a browser never checked + a target. The clocks now start last, after the rounds, and an error formatting + them leaves both blank. The footer's color legend reads `>=500ms` for red, + where it read `>500ms` + ([#152](https://git.eeqj.de/sneak/netwatch/issues/152)). +- 2026-10-18: `netwatch-server prepare-data-dir` no longer changes the owner of + a file outside `/data` through a hard link in the volume + ([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A file with a second + name (hard link) keeps its owner, and the start logs its path. Owners are set + on the opened directory, file or link, never by name, so a hard link swapped + in after the check is caught too. A symbolic link is opened without following + it, and the link itself is given to the netwatch user. So the server can still + follow one in a sticky directory that anyone can write to. Devices, named + pipes and sockets keep their owner. Setting the owner on an open file that is + not a directory is built only for Linux. `prepare-data-dir` runs only in the + Linux image, and macOS has neither `O_PATH` nor `AT_EMPTY_PATH`. On any other + system the package still builds, and `prepare-data-dir` fails at the first + file that is not a directory. +- 2026-10-18: from a trusted proxy, the backend takes the client address from + the right-most `X-Forwarded-For` entry that is not in `TRUSTED_PROXIES` + ([#144](https://git.eeqj.de/sneak/netwatch/issues/144)). It took the left-most + entry, which the client writes itself behind a proxy that appends to the + header. So a client could choose the address the rate limit counts and the + request log records. An entry written as an address with a port is read as + that address. The direct peer is used when the header holds only trusted + addresses, or when the search meets an entry that is not an address at all, + such as `unknown`. `X-Real-IP` is read only without `X-Forwarded-For`, and + every `X-Forwarded-For` header line is read, not only the first. The image's + nginx, and the `TRUSTED_PROXIES=127.0.0.1/32` it starts the backend with, are + unchanged. +- 2026-10-18: after an interval change, the summary's Reachable slot reads + `--/--` in white, as at page load, until the second round since the change + ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It read 0/26 in + red from the change on, as no WAN host had a check recorded, for up to two + intervals with the network fine. +- 2026-10-14: stopping the server waits for a report file write already under + way, then runs the final flush + ([#122](https://git.eeqj.de/sneak/netwatch/issues/122)). Such a write is the + periodic flush's, or the one `Append` starts when 10 MiB are waiting. It used + to return at once, and the process exiting cut the write off, losing its + reports. A write under way that fails puts its reports back in memory, so the + final flush writes them. The stop fails, and the process exits non-zero, only + if that flush fails too. +- 2026-10-14: `make test` no longer runs the frontend's production build, which + the tests do not need ([#118](https://git.eeqj.de/sneak/netwatch/issues/118)). + `yarn build` moved out of the stage that runs the frontend's unit tests, now + named `frontend-test`, into a new `frontend-build` stage. That stage starts + from `frontend-test` and, like the `builder` stage, waits on the lint and test + phases, so the image still cannot be built unless they pass. The runtime stage + copies `dist/` from it, and the `git` that `vite.config.js` reads the commit + with is installed there. No build cache was added. +- 2026-10-14: opening the debug log no longer raises the measured latencies + ([#131](https://git.eeqj.de/sneak/netwatch/issues/131)). With the panel open, + each line written drew the panel again with every line before it, up to 1000. + A check that ends while the page draws is timed only when the drawing is done. + Each line is now added at the end of the open panel, and its first line is + dropped once it holds more than 1000. It scrolls to its newest line once a + frame rather than once a line. The whole panel is drawn only when it is + opened. +- 2026-10-14: unset, the backend's own `TRUSTED_PROXIES` trusts `10.0.0.0/8`, + `172.16.0.0/12` and `192.168.0.0/16`, and no longer loopback + ([#129](https://git.eeqj.de/sneak/netwatch/issues/129)). A list replaces that. + Set and empty, it trusts no proxy; it used to get the default, as viper reads + an empty environment variable as unset. A value it cannot parse still stops + the start, and the image still starts the backend with + `TRUSTED_PROXIES=127.0.0.1/32`. +- 2026-10-14: while report files cannot be written, `POST /api/v1/reports` + answers 500, and storing recovers once writing works again, with no restart + ([#123](https://git.eeqj.de/sneak/netwatch/issues/123)). A failed write puts + its reports back in the report buffer, still counted against + `DATA_DIR_MAX_BYTES`, where they used to be lost. The next flush, within a + minute, tries them again. Until a write succeeds, every report is refused and + nothing of it is kept, so the buffer does not grow while writes fail. +- 2026-10-14: a request whose handler panics is logged with status 500 and + counted with `code` 500 in the metrics, not 200 + ([#125](https://git.eeqj.de/sneak/netwatch/issues/125)). `Recoverer` is still + the first middleware. `Write500OnPanic` sits after the request log and after + the metrics. It writes the 500 as the panic passes and lets the panic go on to + Sentry and `Recoverer`. `Recoverer` now writes a 500 only when no status was + written. +- 2026-10-14: with `TRUSTED_PROXIES` unset, the container's nginx trusts the + three private ranges of RFC 1918, `10.0.0.0/8`, `172.16.0.0/12` and + `192.168.0.0/16` ([#119](https://git.eeqj.de/sneak/netwatch/issues/119)), as + sneak ruled on 2026-09-22 for every app. It trusted no proxy before. A list + replaces them, and set and empty it trusts none. The backend inside the + container still trusts only `127.0.0.1/32`. +- 2026-10-14: `POST /api/v1/reports` takes only a report sent with + `Content-Type: application/json`, parameters such as `charset` allowed + ([#126](https://git.eeqj.de/sneak/netwatch/issues/126)). It refuses any other + with 415 and the usual error body, storing nothing. A page on another origin + can send a `text/plain` POST without a CORS preflight, so any site could make + its visitors' browsers store reports. A JSON POST waits for the preflight, + which only an origin in `CORS_ALLOWED_ORIGINS` passes. The page already sent + `application/json`. +- 2026-10-14: changing the interval no longer drops the samples not yet reported + to the backend ([#124](https://git.eeqj.de/sneak/netwatch/issues/124)). The + interval menu resets each host with the new `HostState.reset`, which moves the + history's entries to `clearedHistory`, keeping the last 100 of all it has + cleared. `buildReport` reads both, so the next report has the samples recorded + before the change that no report has delivered. Until its next check, such a + host is sent with the status of its last check, not `pending`. The page's + history still starts over. `web/src/main.js` exports `Reporter` for the tests. +- 2026-10-14: the repo has the layout `REPO_POLICIES.md` gives + ([#30](https://git.eeqj.de/sneak/netwatch/issues/30)). The Go module is at the + root (`go.mod`, `go.sum`, `.golangci.yml`, `cmd/netwatch-server/`, + `internal/`), so its path `sneak.berlin/go/netwatch` names the repository. + `go.mod` ignores `node_modules/`, where npm packages can ship `.go` files that + `make tidy` would otherwise read as this module's packages. The page's source + is in `web/` (`index.html`, `src/`), which `vite.config.js` makes Vite's root; + the build still goes to `dist/`. `nginx.conf` and `security-headers.conf` are + in `configs/`. `backend/` is gone. Its `LICENSE` and `.dockerignore` are + deleted, its `.gitignore` entries are in the root one, and its `README.md` is + folded into the root `README.md`. Its scripts are in the root `script/`: + `script/build` builds the server binary after the frontend, and `script/run` + and `script/clean` came from `backend/script/`. `make test`, `make lint`, + `make fmt` and `make fmt-check` do what its `test`, `lint`, `fmt` and + `fmt-check` did. `script/bootstrap` no longer installs gcc, which only the + host Go tests needed. The image and the page are unchanged. +- 2026-10-07: the files shared from `sneak/prompts` were its copies at commit + `dd4027b` ([#113](https://git.eeqj.de/sneak/netwatch/issues/113)), with this + repository's own entries after the shared content in `.gitignore`, + `.dockerignore` and `.editorconfig`. `make lint` and `make test` each built + one phase of `Dockerfile` without the cache. `lint` ran golangci-lint v2.14.0 + over `backend/` and, through the `frontend-lint` stage, eslint. `test` ran the + Go tests on the Debian Go image and, through the `frontend` stage, the + frontend's unit tests and build. The builder stage waits on both. Without a + `VERSION` build argument it takes the version from `git describe` on the + `.git` in the build context. Neither linter nor the tests run on the host for + `make check`. `backend/.golangci.yml` was no longer checked against a sha256. + prettier formats only the JavaScript, CSS, HTML and Markdown, so the shared + `.golangci.yml` stays as fetched. `script/fmt` and `script/fmt-check` put + `~/.local/bin` on `PATH`. `script/frontend-lint` and `script/frontend-check` + are gone. +- 2026-10-07: the six Hetzner targets answer again, and failed checks are + written to the browser console + ([#114](https://git.eeqj.de/sneak/netwatch/issues/114)). The Hetzner + speed-test servers close the connection without an answer when the URL has a + query string, and every check added `?_cb=` and the time. Checks now fetch + each target's URL as written, with `cache: 'no-store'` as before. Each + recorded check that fails writes one `console.error` line, and the same line + to the debug log. The line gives the target's name and URL, the time, what + failed and how long the request took. A target that answers after failed + checks writes one `console.info` line. Checks the page does not record write + none, so the debug log no longer lists failures in the first round or in the + recovery probe. +- 2026-10-04: the page's footer no longer says "IPv4 only" + ([#111](https://git.eeqj.de/sneak/netwatch/issues/111)). Each check is a + `fetch`, the browser picks IPv4 or IPv6 for each WAN host, and the local + targets are IPv4 addresses. +- 2026-10-04: `README.md`, `TODO.md` and `test/viewport/README.md` say what the + tree does ([#24](https://git.eeqj.de/sneak/netwatch/issues/24)). The README's + Getting Started leads with `make` targets. A new Backend section says what + `netwatch-server` stores, its routes and how the image builds and runs it; it + pointed to `backend/README.md` for its settings. The checks are GET requests. + The 26 WAN hosts, the four health states, the summary's figures and the + features the list lacked are described as the page has them. The README's TODO + section points here. This file's Workflow branches from `next` and opens the + PR against `next`. Status says where the repo stands, and Next Step and Future + Steps hold only open work, linked to its issue where one exists. The viewport + test's README names Node's test runner, not `vitest`. +- 2026-10-04: in `src/main.js`, a target's min, max, median and average latency + come from one list of its answers + ([#102](https://git.eeqj.de/sneak/netwatch/issues/102)). The summary's figures + use the same function, so the median is written once. The latency color limits + are one table in `CONFIG`, read by both the figure's and the sparkline's + color. The health thresholds, the debug log's length and the gateway check's + timeout are `CONFIG` entries too. So are the recovery probe's host count and + interval, how often the rows are sorted, and the delay before the first + sparkline resize. Nothing the page does or shows changed; the footer's color + legend still writes the limits out as text. +- 2026-10-04: password guesses at `/metrics` are rate limited + ([#104](https://git.eeqj.de/sneak/netwatch/issues/104)). Each client address, + resolved through `TRUSTED_PROXIES` as for reports, may make 60 requests to + `/metrics` a minute, counted by `go-chi/httprate` apart from its reports. Past + that it gets 429 and its basic auth credentials are not checked. The limit is + a constant in `internal/server/routes.go`. +- 2026-10-04: the backend reports errors to Sentry + ([#95](https://git.eeqj.de/sneak/netwatch/issues/95)). With `SENTRY_DSN` set, + it sets up `sentry-go` with the release `netwatch-server-` and its version. It + reports each panic in a handler through `sentryhttp`, the last middleware + every request goes through. `sentryhttp` panics again, so the request still + gets the 500 from the panic recovery. On shutdown it waits up to 2 seconds for + Sentry to finish sending. A DSN Sentry refuses stops the start with an error + naming `SENTRY_DSN`. With it empty, Sentry is not set up and nothing is sent + to it. +- 2026-10-04: a target's name and URL and a debug log message show as the + characters they are and are never read as HTML + ([#29](https://git.eeqj.de/sneak/netwatch/issues/29)). A host row escapes the + name and URL it writes into its markup, and the debug log sets each line as + text. `README.md` no longer calls `CONFIG` frozen: the interval menu sets its + `updateInterval`, and the values computed from it follow. `AppState` declares + the recovery probe's two properties. The sparkline axis functions lose the + parameters they did not use, and the comment on a target's history names both + kinds of entry it holds. +- 2026-10-04: a dependency can be added without running yarn or go by hand + ([#45](https://git.eeqj.de/sneak/netwatch/issues/45)). + `make add-dependency PACKAGE=@` shims to the new + `script/add-dependency`, which runs `yarn add --dev`, so `package.json` and + `yarn.lock` change together, then `yarn install --frozen-lockfile`. The same + command moves a package to another version. `make tidy` shims to the new + `script/tidy`, which runs `go mod tidy`. A Go module is added by importing it, + or moved by editing its `require` line, then `make tidy`. `script/bootstrap` + still installs with `--frozen-lockfile`. +- 2026-10-04: the backend serves Prometheus metrics + ([#94](https://git.eeqj.de/sneak/netwatch/issues/94)). With `METRICS_USERNAME` + and `METRICS_PASSWORD` both set, it records request duration and response size + through `go-http-metrics`. It serves them, 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 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` containing `:`. Only + requests that reach the health check or `POST /api/v1/reports` are recorded, + not `/metrics` itself and not every request as `GO_HTTP_SERVER_CONVENTIONS.md` + shows. The labels are the request's path and method, which clients can make up + without end. For that, `POST /api/v1/reports` is registered by its full path + instead of inside a `/api/v1` route group. +- 2026-10-04: `script/` and `Makefile` follow the org models + ([#28](https://git.eeqj.de/sneak/netwatch/issues/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 + left out `backend/`, so `make fmt` and `make fmt-check` covered + `backend/README.md`; it left out `backend/.golangci.yml` by name. + `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 no longer prints Node's + warning that `module.register()` is deprecated + ([#32](https://git.eeqj.de/sneak/netwatch/issues/32)). The build in + `Dockerfile` runs on Node 22, which never printed it. The call was in + `@tailwindcss/node`, which `@tailwindcss/vite` brings in at its own exact + version. tailwind 4.3.1 calls `module.registerHooks()` instead where Node has + it. `yarn.lock` has `@tailwindcss/vite` and `tailwindcss` at 4.3.3, inside the + ranges `package.json` already allowed, and tailwind's own dependencies moved + with them. The built CSS changed only in how it is written out, in tailwind's + Firefox focus-ring rule, and in tailwind's default sans-serif font list, which + the page does not use. +- 2026-10-03: the frontend's unit tests cover what the page computes + ([#21](https://git.eeqj.de/sneak/netwatch/issues/21)). They cover + `humanDuration`, and the latency colours of a figure and of a sparkline either + side of each boundary. They cover a target's min, max, average and median + latency, and each of the four health states either side of its thresholds. + `package.json` has a `test` script, so `yarn run test` and `npm run test` run + them, and `script/frontend-test` runs it. `src/main.js` exports + `humanDuration`, `HostState`, `latencyClass` and `latencyHex` for the tests; + nothing it does changed. +- 2026-10-03: the backend's logs are one stream + ([#27](https://git.eeqj.de/sneak/netwatch/issues/27)). fx logs its own steps + of starting and stopping through the backend's logger. So off a terminal, + every line the backend and fx write is JSON; fx used to write plain text to + stderr. A config file that is found but cannot be read stops the start with + its error, logged as JSON like a bad setting. It used to end in a Go panic. + The backend logs its name, version and architecture once at start. The health + check's uptime keys are `uptime_seconds` and `uptime_human`; its path, content + type, `"status":"ok"` and 200 are unchanged. `SENTRY_DSN`, `METRICS_USERNAME` + and `METRICS_PASSWORD` were still read and unused until + [#94](https://git.eeqj.de/sneak/netwatch/issues/94) and + [#95](https://git.eeqj.de/sneak/netwatch/issues/95) wired them up. +- 2026-10-03: the frontend has a real linter + ([#47](https://git.eeqj.de/sneak/netwatch/issues/47), and item 2 of + [#28](https://git.eeqj.de/sneak/netwatch/issues/28)): `eslint` with its + recommended rules, set in `eslint.config.js`, runs in a new `frontend-lint` + stage of `Dockerfile`. `script/lint` builds the lint stages and runs no linter + on the host. `script/bootstrap` wants node 22.13.0 or newer, as eslint 10 + does. +- 2026-10-03: the tap-target check in `make frontend-viewport-test` expects one + visible pin button per WAN host row + ([#46](https://git.eeqj.de/sneak/netwatch/issues/46)), where it expected at + least 10 of the 26. Pin buttons missing from only some rows now fail it. The + host row count the test gathers, which the `app-rendered` check also reads, + counts only the WAN host rows: the local host rows have no pin button. +- 2026-10-03: each target's row shows its result as soon as its check ends + ([#91](https://git.eeqj.de/sneak/netwatch/issues/91)). Every row waited for + the round's slowest check, up to 24 seconds at a 30-second interval. Every row + is still redrawn, and sorting, the summary, the health box and offline + detection still run, once, when the round's last check ends. So no row reads + "paused" after a pause and resume during the round. A check that ends after + the user pauses or after its round is given up shows nothing. The first round + is still discarded as a whole. +- 2026-10-03: root no longer acts outside `/data` when it prepares `DATA_DIR` + ([#80](https://git.eeqj.de/sneak/netwatch/issues/80)). `bin/entrypoint.sh` + runs `netwatch-server prepare-data-dir`, which refuses a `DATA_DIR` that is + not `/data` or a path below it written in full. Then it creates `DATA_DIR`, + gives `/data` and everything in it to `netwatch` and sets the modes, all + through a Go `os.Root` opened on `/data`. That refuses any path leading out of + `/data`. So no symbolic link, already there or swapped in by a host process + during the start, can make root create or change anything elsewhere. + `DATA_DIR=/etc` no longer gives `/etc` to `netwatch`. The `README.md` section + "Running under upaas" says which values are accepted. +- 2026-10-03: `DATA_DIR_MAX_BYTES` is how much of the report files is kept + ([#54](https://git.eeqj.de/sneak/netwatch/issues/54)). When a report would + take them past it, the oldest report files are deleted to make room, each + deletion logged. At start, files already past it are deleted the same way. A + file still being written is never deleted. A report is refused with 507 only + when the reports waiting to be written fill the cap on their own, and then no + file is deleted. A file that cannot be deleted still counts until the next + start; one already deleted by hand counts as freed. +- 2026-10-03: `backend/script/lint` said what went wrong with its + `.golangci.yml` check ([#34](https://git.eeqj.de/sneak/netwatch/issues/34)). + On a hash mismatch it said to compare the file with the org standard. If they + differed, restore the org standard. If they were the same, the org standard + had changed, so update `GOLANGCI_CONFIG_SHA256` in that script. It used to say + only to restore the file, which loops once the org standard itself has moved. + A missing `.golangci.yml`, and a `sha256sum` that is missing or prints no + hash, each got their own message instead of being reported as a mismatch. +- 2026-10-03: the Go tests run with the race detector and coverage + ([#88](https://git.eeqj.de/sneak/netwatch/issues/88)): + `go test -timeout 30s -race -cover ./...` and, if that fails, the same again + with `-v`, then a failing exit. Go's `-timeout` bounds the tests, not their + compile. The race detector needs a C compiler; the binary is still built with + `CGO_ENABLED=0`. New tests cover the health check's answer, a valid report's + answer, a report file's exact contents, and the flush when the buffer reaches + 10 MiB. +- 2026-10-03: each target check times out after 80% of the refresh interval + ([#78](https://git.eeqj.de/sneak/netwatch/issues/78)), 24 seconds at 30 + seconds, where it was capped at 3 seconds. A round started early gives up the + last round's checks if they are still waiting, so rounds never overlap. A + round starts early after an interval change, or when the recovery probe finds + a target answering. The recovery probe gives up its own checks after half a + second. The frontend has its first unit tests, run by `script/frontend-test` + with Node's built-in test runner. For them, `index.html` links + `src/styles.css`, which `src/main.js` used to import. +- 2026-09-29: the container sets up its own data directory + ([#75](https://git.eeqj.de/sneak/netwatch/issues/75)). `bin/entrypoint.sh`, + still as root, created `DATA_DIR` if missing and gave it and `/data` to the + `netwatch` user with mode 750 before starting the backend as that user. So an + empty host directory owned by root, or one holding files from another uid, + works with no step on the host. It stopped the start when a symbolic link was + on the path to `DATA_DIR`, since root would change whatever the link points + to. The `README.md` first-run step that created and chowned the host directory + is gone, and the image no longer sets that ownership at build time. +- 2026-09-29: CI can no longer pass on checks that did not run + ([#37](https://git.eeqj.de/sneak/netwatch/issues/37)). `script/cibuild` became + the org model, byte for byte. It runs `script/bootstrap` and `script/check`, + then builds the image with the version from `git describe` as the `VERSION` + build argument. It used to be a plain `docker build .` whose check steps could + come from the build cache. The workflow put `~/.local/bin`, where bootstrap + links what it installs, on the step's `PATH`. bootstrap installs its pinned + node when the installed one is older than the frontend's dependencies need. +- 2026-09-29: `backend/.golangci.yml` was re-vendored from `sneak/prompts` + ([#41](https://git.eeqj.de/sneak/netwatch/issues/41)). `gomodguard`, + deprecated in golangci-lint v2.12.0, is disabled and its successor + `gomodguard_v2` enabled with the org block list, so lint runs print no + deprecation warning. The file also turns `depguard` on with its `test-support` + rule, which keeps `net/http/httptest` out of non-test code; netwatch adds no + entries of its own to that rule. - 2026-09-29: nginx sends the security headers `REPO_POLICIES.md` requires on - every response (issue #18), including errors, `/assets/` and what it passes on - from the backend, whose own copies it drops so each header goes out once. They - live in `security-headers.conf`, which `nginx.conf` includes. The content - security policy allows no inline script or style, so the status dot's grey in - `src/main.js` is now a class; `connect-src` is `*` because probed hosts - redirect to others, and the browser checks each redirect against it -- 2026-09-29: the request log is bounded (issue #60): the method, URL, protocol, - `User-Agent`, `Referer`, request ID (which chi takes from the client's - `X-Request-Id` header) and client address it writes are each cut to 128 bytes, - the bound the report handler already used, so one request can no longer put - about 1 MiB per field into a log line. That bound and its helper now live in - the `logger` package, shared by both + every response ([#18](https://git.eeqj.de/sneak/netwatch/issues/18)), errors + and `/assets/` included. On what it passes on from the backend, it drops the + backend's own copies, so each header goes out once. They live in + `security-headers.conf`, which `nginx.conf` includes. The content security + policy allows no inline script or style, so the status dot's grey in + `src/main.js` is a class. `connect-src` is `*` because probed hosts redirect + to others, and the browser checks each redirect against it. +- 2026-09-29: the request log is bounded + ([#60](https://git.eeqj.de/sneak/netwatch/issues/60)). Each field it writes + from the request is cut to 128 bytes, the bound the report handler already + used. The fields are the method, URL, protocol, `User-Agent`, `Referer`, + request ID and client address. chi takes the request ID from the client's + `X-Request-Id` header. One request could put about 1 MiB per field into a log + line. That bound and its helper live in the `logger` package, shared by both. - 2026-09-29: nginx takes the client address from `X-Forwarded-For` only on requests from the reverse proxies named in the container's `TRUSTED_PROXIES` - (issue #64), and by default from none, where it trusted every RFC1918 address - before, so a client could write a new address on each request and escape the - rate limit. `bin/entrypoint.sh` writes one `set_real_ip_from` line per entry - into `/etc/nginx/trusted-proxies.conf`, which `nginx.conf` includes, refusing - an entry that is not an IP address or CIDR, as `netwatch-server check-cidr` - finds; it starts the backend with `TRUSTED_PROXIES=127.0.0.1/32`, since nginx - is its only client -- 2026-09-29: report file names can no longer collide (issue #61): each is + ([#64](https://git.eeqj.de/sneak/netwatch/issues/64)), and at the time by + default from none. It trusted every RFC1918 address before, so a client could + write a new address on each request and escape the rate limit. + `bin/entrypoint.sh` writes one `set_real_ip_from` line per entry into + `/etc/nginx/trusted-proxies.conf`, which `nginx.conf` includes. It refuses an + entry that is not an IP address or CIDR, as `netwatch-server check-cidr` + finds. It starts the backend with `TRUSTED_PROXIES=127.0.0.1/32`, since nginx + is its only client. +- 2026-09-29: report file names can no longer collide + ([#61](https://git.eeqj.de/sneak/netwatch/issues/61)). Each is `reports--.jsonl.zst`, where the number goes up by one for - each file the server starts to write, so two flushes in the same millisecond, - such as a flush for size and the final flush at shutdown, each get a file of - their own instead of the second one failing. A failed write uses up its - number, leaving a gap if the file could not be created and otherwise a file - under that number that may be incomplete. -- 2026-09-29: ready to run under upaas (issue #59): the image has a + each file the server starts to write. So two flushes in the same millisecond + each get a file of their own; the second one used to fail. A flush for size + and the final flush at shutdown can fall in the same millisecond. A failed + write uses up its number. +- 2026-09-29: ready to run under upaas + ([#59](https://git.eeqj.de/sneak/netwatch/issues/59)). The image has a `HEALTHCHECK` that requests `/.well-known/healthcheck` through nginx on the port from `PORT`. The backend no longer reads a bad `PORT` as 0 or a bad - `DEBUG` as false: those, and a `BIND_ADDRESS` that is not an IP address, stop - it from starting with an error naming the variable, as the limits, + `DEBUG` as false. Those, and a `BIND_ADDRESS` that is not an IP address, stop + it from starting with an error naming the variable. The limits, `CORS_ALLOWED_ORIGINS` and, now by name, `TRUSTED_PROXIES` already did. `bin/entrypoint.sh` also refuses a `PORT` outside 1 to 65535, and `8081`, - where the backend listens inside the container, naming `PORT`. `README.md` has - a "Running under upaas" section, whose first-run steps create the host - directory for `/data` owned by uid 1000; the image does not change its owner -- 2026-09-29: nginx listens on `PORT` (issue #26), 8080 when unset or empty: the - nginx image renders `nginx.conf` as a template at container start, filling in - `PORT` and no other variable. `bin/entrypoint.sh` refuses to start when `PORT` - is not digits only. `server_tokens off` keeps the nginx version out of - responses. `script/frontend-viewport-test` renders the template the same way. - Gzip and a `50x.html` error page are not added -- 2026-09-29: bounded the report endpoint (issue #20): `POST /api/v1/reports` - still needs no credentials, but each client address, as resolved through + where the backend listens inside the container, naming `PORT`. `README.md` got + a "Running under upaas" section. +- 2026-09-29: nginx listens on `PORT` + ([#26](https://git.eeqj.de/sneak/netwatch/issues/26)), 8080 when unset or + empty. The nginx image renders `nginx.conf` as a template at container start, + filling in `PORT` and no other variable. `bin/entrypoint.sh` refuses to start + when `PORT` is not digits only. `server_tokens off` keeps the nginx version + out of responses. `script/frontend-viewport-test` renders the template the + same way. Gzip and a `50x.html` error page are not added. +- 2026-09-29: the report endpoint is bounded + ([#20](https://git.eeqj.de/sneak/netwatch/issues/20)). `POST /api/v1/reports` + still needs no credentials. Each client address, as resolved through `TRUSTED_PROXIES`, may send `REPORTS_PER_MINUTE` (default 60) reports a minute, counted by `go-chi/httprate`, and past that gets 429 with - `Retry-After`; the report files in `DATA_DIR`, counted from start with those - already there, may total at most `DATA_DIR_MAX_BYTES` (default 1 GiB), past - which reports get 507; and the wildcard CORS is gone: no CORS headers unless - `CORS_ALLOWED_ORIGINS` lists origins, and an entry that is not a plain + `Retry-After`. The report files in `DATA_DIR`, counted from start with those + already there, may total at most `DATA_DIR_MAX_BYTES` (default 1 GiB); past + that, reports got 507. The wildcard CORS is gone: no CORS headers unless + `CORS_ALLOWED_ORIGINS` lists origins. An entry that is not a plain `scheme://host[:port]` origin, `*` included, stops the server from starting. - Deleting report files frees room only at the next start; pruning is issue #54 -- 2026-09-28: one container image (issue #52): the root `Dockerfile` builds the - only image, and `Dockerfile.backend` is gone. nginx serves the frontend on - port 8080 and proxies `/api/` and `/.well-known/healthcheck` to the backend, - which listens on `127.0.0.1:8081` in the same container; the new + Deleting report files freed room only at the next start, until + [#54](https://git.eeqj.de/sneak/netwatch/issues/54). +- 2026-09-28: one container image + ([#52](https://git.eeqj.de/sneak/netwatch/issues/52)). The root `Dockerfile` + builds the only image, and `Dockerfile.backend` is gone. nginx serves the + frontend on port 8080 and proxies `/api/` and `/.well-known/healthcheck` to + the backend, which listens on `127.0.0.1:8081` in the same container. The new `BIND_ADDRESS` setting sets its listen address. `bin/entrypoint.sh` starts both, passes TERM and INT on to both, and exits non-zero when either exits on its own. The backend runs as user `netwatch` and stores reports on the `/data` - volume. `script/docker` is the org model again -- 2026-09-28: unified the gate (issue #16): the root `make check` covers the Go - backend as well as the frontend, and the pre-commit hook with it; the backend - moved onto scripts-to-rule-them-all (`backend/script/*`, `backend/Makefile` as - shims, its duplicate hook installer removed); `script/cibuild` builds both - images and is the workflow's only build step. The root `make lint` runs - golangci-lint only in Docker, by building the lint stage of - `Dockerfile.backend` without the cache. `script/bootstrap` installs the pinned - Go unless the installed one is at least what `backend/go.mod` asks for, links - what it installs into `~/.local/bin` without replacing anything it did not - create, and installs no linter. Root `make test` runs both halves within one - 30-second timeout. When `VERSION` is unset or empty, the backend binary's - version falls back to `git describe` inside a git checkout, then to `dev` -- 2026-09-28: frontend reporting client (issue #53): a `Reporter` class posts - collected samples to `/api/v1/reports` every `reportInterval` (default 60s) as - a per-host delta, with the report-building step a pure exported function of - host state; a per-host mark advances only on a delivered POST; at most one - report POST is pending at a time and it is abandoned after half the interval, - so a slow POST never overlaps the next report and a mark never moves - backwards; the samples of an abandoned POST are sent again at the next - interval, so a backend that stored them but answered late receives them twice; - the per-browser client id works in insecure (plain-HTTP) contexts; - `vite.config.js` proxies `/api` to the local backend for `yarn dev` -- 2026-09-28: report ingest correctness (issue #23): a storage failure now - returns 500 instead of a false `ok`; oversize bodies return 413 (distinguished - from malformed JSON, which stays 400); a `MaxBodyBytes` middleware caps every - route, not just the report route; the raw attacker-controlled `geo` blob is no - longer logged (only its length), and `client_id`, `timestamp` and decode error - text are length-bounded before logging; a `decodeJSON` handler helper was - added; panic recovery now routes the stack through slog instead of chi's - plain-text stderr; and writing a report file now returns its error, so a - failed final flush on shutdown makes the process exit non-zero instead of - losing the buffered reports silently -- 2026-09-21: shutdown lifecycle correctness. The process now shuts down through - fx instead of `os.Exit`, so every component's `OnStop` runs and buffered - reports are flushed to disk on `SIGTERM` — previously a full flush window of - telemetry was silently lost on every restart. The `http.Server` is now built - before its serving goroutine starts, so shutdown can no longer race or - nil-deref it; a listen failure exits non-zero via `fx.Shutdowner`; `reportbuf` - `OnStop` is idempotent; and `writeTimeout` now exceeds the chi per-request - budget so that budget is actually reachable. Dead `startupTime`, `exitCode`, - and `cancelFunc` fields were removed -- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and - `IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP, - frame/sniff/referrer/permissions headers) registered before CORS, and - trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP` - only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default) + volume. `script/docker` is the org model again. +- 2026-09-28: one gate ([#16](https://git.eeqj.de/sneak/netwatch/issues/16)). + The root `make check` covers the Go backend as well as the frontend, and the + pre-commit hook with it. The backend moved onto scripts-to-rule-them-all: + `backend/script/*`, `backend/Makefile` as shims, its duplicate hook installer + removed. `script/cibuild` built both images and was the workflow's only build + step. The root `make lint` ran golangci-lint only in Docker, by building the + lint stage of `Dockerfile.backend` without the cache. `script/bootstrap` + installed the pinned Go unless the installed one was at least what + `backend/go.mod` asked for. It links what it installs into `~/.local/bin` + without replacing anything it did not create, and installs no linter. When + `VERSION` is unset or empty, the backend binary's version falls back to + `git describe` inside a git checkout, then to `dev`. +- 2026-09-28: frontend reporting client + ([#53](https://git.eeqj.de/sneak/netwatch/issues/53)). A `Reporter` class + posts collected samples to `/api/v1/reports` every `reportInterval` (default + 60s) as a per-host delta. The report-building step is a pure exported function + of host state. A per-host mark advances only on a delivered POST. At most one + report POST is pending at a time, and it is abandoned after half the interval. + So a slow POST never overlaps the next report, and a mark never moves + backwards. The samples of an abandoned POST are sent again at the next + interval, so a backend that stored them but answered late receives them twice. + The per-browser client id works in insecure (plain-HTTP) contexts. + `vite.config.js` proxies `/api` to the local backend for `yarn dev`. +- 2026-09-28: report ingest correctness + ([#23](https://git.eeqj.de/sneak/netwatch/issues/23)). A storage failure + returns 500 instead of a false `ok`. An oversize body returns 413, apart from + malformed JSON, which stays 400. A `MaxBodyBytes` middleware caps every route, + not just the report route. The raw attacker-controlled `geo` blob is no longer + logged, only its length, and `client_id`, `timestamp` and decode error text + are length-bounded before logging. A `decodeJSON` handler helper was added. + Panic recovery routes the stack through slog instead of chi's plain-text + stderr. Writing a report file returns its error, so a failed final flush on + shutdown makes the process exit non-zero instead of losing the buffered + reports silently. +- 2026-09-21: shutdown lifecycle correctness. The process shuts down through fx + instead of `os.Exit`, so every component's `OnStop` runs and buffered reports + are flushed to disk on `SIGTERM`. Before, a full flush window of telemetry was + lost on every restart. The `http.Server` is built before its serving goroutine + starts, so shutdown can no longer race or nil-deref it. A listen failure exits + non-zero via `fx.Shutdowner`. `reportbuf`'s `OnStop` is idempotent. + `writeTimeout` exceeds the chi per-request budget, so that budget is + reachable. Dead `startupTime`, `exitCode` and `cancelFunc` fields were + removed. +- 2026-09-21: backend HTTP hardening + ([#19](https://git.eeqj.de/sneak/netwatch/issues/19)): `ReadHeaderTimeout` and + `IdleTimeout` on the server, and a `SecurityHeaders` middleware (HSTS, tight + CSP, frame, sniff, referrer and permissions headers) registered before CORS. + `X-Forwarded-For` and `X-Real-IP` are honoured only from a `TRUSTED_PROXIES` + allowlist, at the time loopback plus RFC1918 by default. - 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and - moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of - `Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by - digest); the previous config declared `version: "2"` but used v1 schema keys, - so every threshold in it was inert and its green result was meaningless. - `backend/Makefile`'s `lint` target now asserts the config's sha256 against the - canonical file first, so drift from the org standard fails the build instead - of silently degrading to defaults -- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap - target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow - viewports, `#pause-btn`). The pin button's hit area grows via matching - negative margins, so its layout footprint and row density are unchanged -- 2026-08-10: per-host status line wraps below the 768px breakpoint instead of - forcing horizontal page scroll at 320px -- 2026-08-09: `Dockerfile.backend` reworked to the mandated Go multistage - lint-stage pattern: separate `lint` stage on the hash-pinned - `golangci/golangci-lint` image, `COPY --from=lint` stage dependency, - `CGO_ENABLED=0` static build driven by `ARG VERSION`, and no more `COPY .git` -- 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo - root so `root = true` covers the frontend too, and replaced `.gitignore` with - the org model (OS, editor, node, and environment/secrets sections) plus this - repo's `dist/` and `*.log`. `.env`, `.env.*`, `*.pem`, and `*.key` are now - ignored repo-wide, not just under `backend/`. Excluding `.git` from - `.dockerignore` stays deferred: both images read git metadata at build time - (`COPY .git` in `Dockerfile.backend`, `git rev-parse` in `vite.config.js`) -- 2026-08-09: automated responsive-layout harness - (`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP - against the built `dist/`, viewport widths derived from the breakpoints in - `src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). Every - check carries a presence guard so none of them can pass against a page it is - not actually measuring. Found two real layout defects, filed as + moved the pinned golangci-lint from v2.7.2 to v2.12.2; the `lint` stage of + `Dockerfile.backend` pinned the `golangci/golangci-lint:v2.12.2` image by + digest. The previous config declared `version: "2"` but used v1 schema keys, + so every threshold in it was inert and its green result meant nothing. + `backend/Makefile`'s `lint` target asserted the config's sha256 against the + canonical file first, so drift from the org standard failed the build instead + of silently degrading to defaults. +- 2026-08-10: every interactive control meets the 44x44 CSS px minimum tap + target: `.pin-btn`, `#interval-select`, the debug-log label and, on narrow + viewports, `#pause-btn`. The pin button's hit area grows via matching negative + margins, so its layout footprint and row density are unchanged. +- 2026-08-10: the per-host status line wraps below the 768px breakpoint instead + of forcing horizontal page scroll at 320px. +- 2026-08-09: `Dockerfile.backend` was reworked to the Go multistage lint-stage + pattern. It got a separate `lint` stage on the hash-pinned + `golangci/golangci-lint` image, and a `COPY --from=lint` stage dependency. The + build became a `CGO_ENABLED=0` static build driven by `ARG VERSION`, with no + more `COPY .git`. +- 2026-08-09: dotfile compliance. `backend/.editorconfig` moved to the repo root + so `root = true` covers the frontend too. `.gitignore` became the org model + (OS, editor, node, and environment/secrets sections) plus this repo's `dist/` + and `*.log`. `.env`, `.env.*`, `*.pem` and `*.key` are ignored repo-wide, not + just under `backend/`. Excluding `.git` from `.dockerignore` stayed deferred: + both images read git metadata at build time. +- 2026-08-09: automated responsive-layout test (`make frontend-viewport-test`): + a digest-pinned headless Chrome driven over CDP against the built `dist/`, at + viewport widths derived from the breakpoints in `src/styles.css` + ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). The tap-target and + host-row checks each fail when they measured nothing. The overflow, + viewport-edge and clipped-text checks have no such guard of their own and rely + on the `app-rendered` check, which fails the run when the app did not render. + It found two real layout defects, filed as [#42](https://git.eeqj.de/sneak/netwatch/issues/42) and - [#43](https://git.eeqj.de/sneak/netwatch/issues/43) -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - shims, README Entrypoints section + [#43](https://git.eeqj.de/sneak/netwatch/issues/43). +- 2026-07-07: adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile + shims, README Entrypoints section. - 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow and backend repo standard files; backend Dockerfile fixed (Go 1.25, - golangci-lint) and moved to repo root (feat/reportbuf-storage) + golangci-lint) and moved to repo root (`feat/reportbuf-storage`). - 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing - fixes; nginx config extracted; port hardcoded to 8080 + fixes; nginx config extracted; port hardcoded to 8080. - 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix, - S3 Singapore endpoint added + S3 Singapore endpoint added. - 2026-02-23: summary box redesign, host pinning, local and UTC clocks, checks - counter + counter. - 2026-02-23: hosts sorted by latency; GET instead of HEAD for latency; timeout - derived from interval; Hetzner regional endpoints; 3s interval -- 2026-01-29: initial NetWatch network latency monitor + derived from interval; Hetzner regional endpoints; 3s interval. +- 2026-01-29: initial NetWatch network latency monitor. # Future Steps -- Wire `script/frontend-viewport-test` into CI as its own step (deliberately not - part of `make check` today; the decision has real CI-runtime cost and is - tracked separately) -- Compliance top-up as one small commit: add .editorconfig and add the hooks - target to the Makefile -- After merge, confirm .gitea/workflows/check.yml is on main and CI is green - (main always green policy) -- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete - it +- A backend test that posts a report to `POST /api/v1/reports` and checks the + compressed file it is written to +- A backend route that decompresses the stored reports and answers queries on + them +- Prometheus metrics for the backend's in-memory buffer: its size, the number of + flushes and the number of reports +- A configurable host list (an environment variable or a config file) +- Export of the latency history (CSV or JSON) +- A notification when the health status changes to DEGRADED diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index fd4f2b0..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -.DS_Store diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index e48380c..0000000 --- a/backend/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -/netwatch-server -*.log -*.out -*.test -.env -data/ diff --git a/backend/LICENSE b/backend/LICENSE deleted file mode 100644 index 5b83a52..0000000 --- a/backend/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 @sneak (https://sneak.berlin) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/backend/Makefile b/backend/Makefile deleted file mode 100644 index faf9ec5..0000000 --- a/backend/Makefile +++ /dev/null @@ -1,30 +0,0 @@ -# Thin shims; the implementations live in backend/script/ (see the -# Entrypoints section of README.md). There is no check, hooks or docker -# target here: the root Makefile's check covers this directory, its -# hooks target installs the repo's only pre-commit hook, and its docker -# target builds the one image, which contains this backend. - -.PHONY: all build test lint fmt fmt-check run clean - -all: build - -build: - @script/build - -test: - @script/test - -lint: - @script/lint - -fmt: - @script/fmt - -fmt-check: - @script/fmt-check - -run: - @script/run - -clean: - @script/clean diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index 9914eb0..0000000 --- a/backend/README.md +++ /dev/null @@ -1,181 +0,0 @@ -netwatch-server is an MIT-licensed Go HTTP backend by -[@sneak](https://sneak.berlin) that receives telemetry reports from the NetWatch -SPA and persists them as zstd-compressed JSONL files on disk. - -## Getting Started - -From this directory: - -```bash -# Build and run locally -make run -``` - -From the repo root, whose `Dockerfile` builds the one image that ships this -backend behind nginx (see [Container image](#container-image)): - -```bash -# Run tests, lint, and format check over the frontend and this backend -make check - -# Build the image: nginx, the frontend and this backend -make docker -docker run -p 8080:8080 netwatch -``` - -## Entrypoints - -This directory follows the same -[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) -pattern as the repo root: the targets in `backend/Makefile` are thin shims over -`backend/script/`. The root `Dockerfile` runs them, and the root scripts call -`test`, `fmt` and `fmt-check`: - -- `script/build` — compile the static `netwatch-server` binary with its version - and architecture stamped in. The version is `VERSION` from the environment; - 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 under a 30-second timeout -- `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 root `Dockerfile`; from a checkout, run `make lint` at the repo root, - which builds that stage -- `script/fmt` — format the Go sources (writes) -- `script/fmt-check` — check Go formatting (read-only) -- `script/run` — build and run the server locally -- `script/clean` — remove build artifacts - -There is no `check`, `hooks` or `docker` target here: the root `make check` -covers this directory, the root `make hooks` installs the repo's only pre-commit -hook, and the root `make docker` builds the image that contains this backend. - -## Rationale - -The NetWatch frontend collects latency measurements from the browser but has no -way to persist or aggregate them. This backend provides a minimal -`POST /api/v1/reports` endpoint that buffers incoming reports in memory and -flushes them to compressed files on disk for later analysis. - -## Design - -The server is structured as an `fx`-wired Go application under `cmd/netwatch-server/`. -Internal packages in `internal/` follow standard Go project layout: - -- **`config`**: Loads configuration from environment variables and config files - via Viper. -- **`handlers`**: HTTP request handlers for the API (health check, report - ingestion). -- **`reportbuf`**: In-memory buffer that accumulates JSONL report lines and - flushes to zstd-compressed files when the buffer reaches 10 MiB or every 60 - seconds. -- **`server`**: Chi-based HTTP server with middleware wiring and route - registration. -- **`healthcheck`**, **`middleware`**, **`logger`**, **`globals`**: Supporting - infrastructure. - -### Configuration - -| Variable | Default | Description | -| ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- | -| `BIND_ADDRESS` | empty | IP address to listen on; empty listens on every interface | -| `PORT` | `8080` | HTTP listen port | -| `DATA_DIR` | `./data/reports` | Directory for compressed reports | -| `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Largest total size of the report files in `DATA_DIR`; see [Report limits](#report-limits) | -| `DEBUG` | `false` | Enable debug logging | -| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution | -| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) | -| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) | - -`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`. -The loopback entries cover a reverse proxy on the same host. A request whose -direct peer is outside this set has its forwarded headers ignored, and the -direct peer is logged and rate-limited instead. The container image does not use -this default; see [Container image](#container-image). - -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 -starting, with an error naming the variable. An empty variable counts as unset. - -### Container image - -The root `Dockerfile` builds one image in which nginx listens on the public port -8080, serves the frontend, and proxies `/api/` and `/.well-known/healthcheck` to -this server. The image's entrypoint, `bin/entrypoint.sh`, starts the server as -user `netwatch` (uid 1000) with `BIND_ADDRESS=127.0.0.1` and `PORT=8081`, so -only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the -client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on -the `/data` volume; the entrypoint creates it and gives it and `/data` to -`netwatch` before starting the server. nginx replaces the security headers -this server sets with those in the root `security-headers.conf`, so those are -what clients of the image see. - -The container's own `TRUSTED_PROXIES` goes to nginx instead: IP addresses or -CIDRs, separated by commas, of the reverse proxies in front of the container. -nginx takes the client address from `X-Forwarded-For` only on a request from one -of them. Unset or empty, nginx trusts no proxy, and the client address is the -one each request comes from, so every client behind a proxy shares one rate -limit. An entry that is not an IP address or CIDR, such as a hostname or -`1.2.3`, stops the container at start with an error naming `TRUSTED_PROXIES`: -the entrypoint checks each entry with `netwatch-server check-cidr`, which parses -it as this server parses its own `TRUSTED_PROXIES`. - -### Report storage - -Reports are written as `reports--.jsonl.zst` files in -`DATA_DIR`. The timestamp is in UTC to the millisecond, so the names sort by -time. The number starts at 1 when the server starts and goes up by one for each -file the server starts to write, so two files written in the same millisecond -still get different names. A failed write uses up its number, leaving a gap in -the numbers if the file could not be created and otherwise a file under that -number that may be incomplete. Each file contains one JSON object per line, -compressed with zstd. Files are created with `O_EXCL` to prevent overwrites. - -### Report limits - -`POST /api/v1/reports` takes reports from anyone who can reach it, without -credentials, so it is bounded instead. Both refusals below answer with the same -`{"status":"error"}` body as any other error. - -- **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may - send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with - `Retry-After: 60`. The minute slides: reports from the minute before still - count, fading out over the current one, so an address is sure never to be - refused only while it sends at most half of `REPORTS_PER_MINUTE` in any 60 - seconds. The page sends one report a minute from each open tab, so the default - of 60 refuses nothing from up to 30 tabs behind one address, such as a - household or an office sharing it, however their reports bunch up. Report - responses also carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and - `X-RateLimit-Reset` headers. -- **Size cap.** The report files in `DATA_DIR` may total at most - `DATA_DIR_MAX_BYTES`, counting the files already there at start. Reports - waiting in memory count at their uncompressed size until they are written, so - a report that would take the total past the cap is refused with 507, and - nothing of it is stored. Deleting report files frees room only at the next - start, when the files are counted again. The default of 1 GiB is small enough - for any host; set it to the space you can give `DATA_DIR`. - -### CORS - -The page calls the API from the origin it is served from, so by default the -server sends no CORS headers, and browsers let no other origin's pages call it. -To serve the page from elsewhere, list that origin in `CORS_ALLOWED_ORIGINS` -(for example `https://netwatch.example.com`); pages from a listed origin may -`GET` and `POST` with a `Content-Type` header. Each entry must be a plain -origin, `scheme://host` with an optional `:port`, as browsers send it: no path, -not even a trailing `/`, and no `*`. Any other entry stops the server from -starting, with an error naming `CORS_ALLOWED_ORIGINS`. - -## TODO - -- Add integration test that POSTs a report and verifies the compressed output -- Add report decompression/query endpoint -- Add metrics (Prometheus) for buffer size, flush count, report count -- Add retention policy to prune old report files - -## License - -MIT. See [LICENSE](LICENSE). - -## Author - -[@sneak](https://sneak.berlin) diff --git a/backend/cmd/netwatch-server/main.go b/backend/cmd/netwatch-server/main.go deleted file mode 100644 index 01c77a4..0000000 --- a/backend/cmd/netwatch-server/main.go +++ /dev/null @@ -1,58 +0,0 @@ -// Package main is the entry point for netwatch-server. -package main - -import ( - "fmt" - "os" - - "sneak.berlin/go/netwatch/internal/config" - "sneak.berlin/go/netwatch/internal/globals" - "sneak.berlin/go/netwatch/internal/handlers" - "sneak.berlin/go/netwatch/internal/healthcheck" - "sneak.berlin/go/netwatch/internal/logger" - "sneak.berlin/go/netwatch/internal/middleware" - "sneak.berlin/go/netwatch/internal/reportbuf" - "sneak.berlin/go/netwatch/internal/server" - - "go.uber.org/fx" -) - -//nolint:gochecknoglobals // set via ldflags at build time -var ( - Appname = "netwatch-server" - Version string - Buildarch string -) - -func main() { - // "netwatch-server check-cidr CIDR" exits 1, with the error, if - // this server would refuse CIDR in its TRUSTED_PROXIES. - // bin/entrypoint.sh runs it on each entry it gives nginx. - if len(os.Args) == 3 && os.Args[1] == "check-cidr" { - _, err := middleware.ParseTrustedProxies(os.Args[2:]) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - return - } - - globals.Appname = Appname - globals.Version = Version - globals.Buildarch = Buildarch - - fx.New( - fx.Provide( - config.New, - globals.New, - handlers.New, - healthcheck.New, - logger.New, - middleware.New, - reportbuf.New, - server.New, - ), - fx.Invoke(func(*server.Server) {}), - ).Run() -} diff --git a/backend/go.sum b/backend/go.sum deleted file mode 100644 index cac5e22..0000000 --- a/backend/go.sum +++ /dev/null @@ -1,73 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= -github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= -github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= -github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= -go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= -go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= -go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go deleted file mode 100644 index a75e31e..0000000 --- a/backend/internal/config/config_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package config_test - -import ( - "strings" - "testing" - - "sneak.berlin/go/netwatch/internal/config" - "sneak.berlin/go/netwatch/internal/globals" - "sneak.berlin/go/netwatch/internal/logger" - - "go.uber.org/fx" -) - -// requireConfigError builds the config as main does and fails the -// test unless that fails with an error naming setting. It uses -// fx.New, because fxtest.New fails the test itself on an error. -func requireConfigError(t *testing.T, setting string) { - t.Helper() - - app := fx.New( - fx.NopLogger, - fx.Provide(globals.New, logger.New, config.New), - fx.Invoke(func(*config.Config) {}), - ) - - err := app.Err() - if err == nil || !strings.Contains(err.Error(), setting) { - t.Fatalf("config error = %v, want one naming %s", err, setting) - } -} - -// TestSettingsLoadAsGiven: valid values pass the checks and are used -// as given. bin/entrypoint.sh starts the server with these -// BIND_ADDRESS and PORT values. -func TestSettingsLoadAsGiven(t *testing.T) { - t.Setenv("BIND_ADDRESS", "127.0.0.1") - t.Setenv("PORT", "8081") - t.Setenv("DEBUG", "true") - - var cfg *config.Config - - app := fx.New( - fx.NopLogger, - fx.Provide(globals.New, logger.New, config.New), - fx.Populate(&cfg), - ) - - err := app.Err() - if err != nil { - t.Fatalf("config error = %v", err) - } - - if cfg.BindAddress != "127.0.0.1" || cfg.Port != 8081 || !cfg.Debug { - t.Fatalf("BindAddress, Port, Debug = %q, %d, %t; "+ - "want \"127.0.0.1\", 8081, true", - cfg.BindAddress, cfg.Port, cfg.Debug) - } -} - -// TestPortMustBeAPortNumber: viper reads a value that is not a number -// as 0, on which the server would listen on a random port. -func TestPortMustBeAPortNumber(t *testing.T) { - for _, value := range []string{"abc", "0", "65536", "8080.5"} { - t.Run(value, func(t *testing.T) { - t.Setenv("PORT", value) - - requireConfigError(t, "PORT") - }) - } -} - -// TestDebugMustBeTrueOrFalse: viper reads any other value, such as -// "yes", as false. -func TestDebugMustBeTrueOrFalse(t *testing.T) { - t.Setenv("DEBUG", "yes") - - requireConfigError(t, "DEBUG") -} - -// TestBindAddressMustBeAnIPAddress: a host name would be looked up -// only once the server starts listening, and a mistyped one would stop -// it then with an error that does not name the setting. -func TestBindAddressMustBeAnIPAddress(t *testing.T) { - t.Setenv("BIND_ADDRESS", "localhost") - - requireConfigError(t, "BIND_ADDRESS") -} - -// TestReportsPerMinuteMustBePositive: unchecked, zero would panic -// when the routes are built, and a negative rate would lift the -// limit. -func TestReportsPerMinuteMustBePositive(t *testing.T) { - t.Setenv("REPORTS_PER_MINUTE", "0") - - requireConfigError(t, "REPORTS_PER_MINUTE") -} - -// TestDataDirMaxBytesMustBeANumber: viper reads a value that is not -// a number, such as "1GB", as 0, which would refuse every report. -func TestDataDirMaxBytesMustBeANumber(t *testing.T) { - t.Setenv("DATA_DIR_MAX_BYTES", "1GB") - - requireConfigError(t, "DATA_DIR_MAX_BYTES") -} - -// TestCORSAllowedOriginsMustBeOrigins: "*" would let every origin in, -// and an entry that is not a plain origin would match no page. -func TestCORSAllowedOriginsMustBeOrigins(t *testing.T) { - for _, entry := range []string{ - "*", - "https://*.netwatch.example", - "netwatch.example", - "https://netwatch.example/", - } { - t.Run(entry, func(t *testing.T) { - t.Setenv("CORS_ALLOWED_ORIGINS", entry) - - requireConfigError(t, "CORS_ALLOWED_ORIGINS") - }) - } -} diff --git a/backend/internal/handlers/handlers_test.go b/backend/internal/handlers/handlers_test.go deleted file mode 100644 index 5128215..0000000 --- a/backend/internal/handlers/handlers_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package handlers_test - -import ( - "testing" - - _ "sneak.berlin/go/netwatch/internal/handlers" -) - -func TestImport(t *testing.T) { - t.Parallel() - // Compilation check — verifies the package parses - // and all imports resolve. -} diff --git a/backend/internal/handlers/healthcheck.go b/backend/internal/handlers/healthcheck.go deleted file mode 100644 index d71e8bf..0000000 --- a/backend/internal/handlers/healthcheck.go +++ /dev/null @@ -1,11 +0,0 @@ -package handlers - -import "net/http" - -// HandleHealthCheck returns a handler for the health check -// endpoint. -func (s *Handlers) HandleHealthCheck() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - s.respondJSON(w, r, s.hc.Check(), http.StatusOK) - } -} diff --git a/backend/internal/reportbuf/reportbuf.go b/backend/internal/reportbuf/reportbuf.go deleted file mode 100644 index eeb0184..0000000 --- a/backend/internal/reportbuf/reportbuf.go +++ /dev/null @@ -1,309 +0,0 @@ -// Package reportbuf accumulates telemetry reports in memory -// and periodically flushes them to zstd-compressed JSONL files. -package reportbuf - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io/fs" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "time" - - "sneak.berlin/go/netwatch/internal/config" - "sneak.berlin/go/netwatch/internal/logger" - - "github.com/klauspost/compress/zstd" - "go.uber.org/fx" -) - -const ( - flushSizeThreshold = 10 << 20 // 10 MiB - flushInterval = 1 * time.Minute - defaultDataDir = "./data/reports" - dirPerms fs.FileMode = 0o750 - filePerms fs.FileMode = 0o640 - - // Report files are named filePrefix + timestamp + "-" + number + - // fileSuffix; see writeFile. - filePrefix = "reports-" - fileSuffix = ".jsonl.zst" -) - -// ErrFull is returned by Append when storing the report would -// take the report files past the configured maximum size. -var ErrFull = errors.New("report files at their size cap") - -// Params defines the dependencies for Buffer. -type Params struct { - fx.In - - Config *config.Config - Logger *logger.Logger -} - -// Buffer accumulates JSON lines in memory and flushes them -// to zstd-compressed files on disk. -type Buffer struct { - buf bytes.Buffer - dataDir string - done chan struct{} - log *slog.Logger - maxBytes int64 - mu sync.Mutex - // now is the clock report files are named by: time.Now, except - // in tests that need two flushes to share a timestamp. - now func() time.Time - // seq numbers the report files, so that two named in the same - // millisecond still get different names. - seq atomic.Uint64 - stopOnce sync.Once - // usedBytes is what Append checks against maxBytes: the size - // of the report files in dataDir, plus the reports not yet - // written to one at their uncompressed size. - usedBytes int64 -} - -// New creates a Buffer and registers lifecycle hooks to -// manage the data directory and flush goroutine. -func New( - lc fx.Lifecycle, - params Params, -) (*Buffer, error) { - dir := params.Config.DataDir - if dir == "" { - dir = defaultDataDir - } - - b := &Buffer{ - dataDir: dir, - done: make(chan struct{}), - log: params.Logger.Get(), - maxBytes: params.Config.DataDirMaxBytes, - now: time.Now, - } - - lc.Append(fx.Hook{ - OnStart: func(_ context.Context) error { - err := os.MkdirAll(b.dataDir, dirPerms) - if err != nil { - return fmt.Errorf("create data dir: %w", err) - } - - // Report files left by earlier runs count too. - b.usedBytes, err = reportFilesSize(b.dataDir) - if err != nil { - return err - } - - go b.flushLoop() - - return nil - }, - OnStop: func(_ context.Context) error { - // stopOnce makes OnStop idempotent: a second - // invocation must not close an already-closed channel - // (which would panic) or flush again. - var err error - - b.stopOnce.Do(func() { - close(b.done) - err = b.flushLocked() - }) - - // A failed final flush fails the stop, so the process - // exits non-zero. - return err - }, - }) - - return b, nil -} - -// Append marshals v as a single JSON line and appends it to -// the buffer. It stores nothing and returns ErrFull if the line -// would take usedBytes past maxBytes. If the buffer reaches the -// size threshold, it is drained and written to disk -// asynchronously. -func (b *Buffer) Append(v any) error { - line, err := json.Marshal(v) - if err != nil { - return fmt.Errorf("marshal report: %w", err) - } - - lineBytes := int64(len(line)) + 1 // with its newline - - b.mu.Lock() - - if b.usedBytes+lineBytes > b.maxBytes { - b.mu.Unlock() - - return ErrFull - } - - b.usedBytes += lineBytes - b.buf.Write(line) - b.buf.WriteByte('\n') - - if b.buf.Len() >= flushSizeThreshold { - data := b.drainBuf() - b.mu.Unlock() - - go func() { - writeErr := b.writeFile(data) - if writeErr != nil { - b.log.Error("flush reports failed", "error", writeErr) - } - }() - - return nil - } - - b.mu.Unlock() - - return nil -} - -// flushLoop runs a ticker that periodically flushes buffered -// data to disk until the done channel is closed. -func (b *Buffer) flushLoop() { - ticker := time.NewTicker(flushInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - err := b.flushLocked() - if err != nil { - b.log.Error("flush reports failed", "error", err) - } - case <-b.done: - return - } - } -} - -// flushLocked acquires the lock, drains the buffer, and -// writes the data to a compressed file. -func (b *Buffer) flushLocked() error { - b.mu.Lock() - - if b.buf.Len() == 0 { - b.mu.Unlock() - - return nil - } - - data := b.drainBuf() - b.mu.Unlock() - - return b.writeFile(data) -} - -// drainBuf copies the buffer contents and resets it. -// The caller must hold b.mu. -func (b *Buffer) drainBuf() []byte { - data := make([]byte, b.buf.Len()) - copy(data, b.buf.Bytes()) - b.buf.Reset() - - return data -} - -// writeFile creates a timestamped zstd-compressed JSONL file -// in the data directory. -func (b *Buffer) writeFile(data []byte) error { - // The timestamp comes first, so the names sort by time; the number - // after it tells apart files named in the same millisecond. - ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z") - name := fmt.Sprintf("%s%s-%d%s", filePrefix, ts, b.seq.Add(1), fileSuffix) - path := filepath.Join(b.dataDir, name) - - // path is built from the operator-supplied dataDir plus a - // generated timestamp and number, so it carries no external input. - f, err := os.OpenFile( //nolint:gosec // see comment above - path, - os.O_WRONLY|os.O_CREATE|os.O_EXCL, - filePerms, - ) - if err != nil { - return fmt.Errorf("create report file: %w", err) - } - - // Closes the file on the early returns below. The success - // path closes it explicitly to check the error; closing it - // a second time here is harmless. - defer func() { _ = f.Close() }() - - enc, err := zstd.NewWriter(f) - if err != nil { - return fmt.Errorf("create zstd encoder: %w", err) - } - - _, err = enc.Write(data) - if err != nil { - _ = enc.Close() - - return fmt.Errorf("write compressed data: %w", err) - } - - err = enc.Close() - if err != nil { - return fmt.Errorf("close zstd encoder: %w", err) - } - - info, err := f.Stat() - if err != nil { - return fmt.Errorf("stat report file: %w", err) - } - - err = f.Close() - if err != nil { - return fmt.Errorf("close report file: %w", err) - } - - // The reports counted at their uncompressed size while they - // waited; now they count as the file. After a failed write they - // stay counted as they were, which errs toward refusing reports - // early rather than letting the files pass the cap. - b.mu.Lock() - b.usedBytes += info.Size() - int64(len(data)) - b.mu.Unlock() - - return nil -} - -// reportFilesSize returns the total size of the report files in -// dir. -func reportFilesSize(dir string) (int64, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return 0, fmt.Errorf("read data dir: %w", err) - } - - var total int64 - - for _, entry := range entries { - name := entry.Name() - if !strings.HasPrefix(name, filePrefix) || - !strings.HasSuffix(name, fileSuffix) { - continue - } - - info, err := entry.Info() - if err != nil { - return 0, fmt.Errorf("stat report file: %w", err) - } - - total += info.Size() - } - - return total, nil -} diff --git a/backend/internal/reportbuf/reportbuf_test.go b/backend/internal/reportbuf/reportbuf_test.go deleted file mode 100644 index 0b56b7e..0000000 --- a/backend/internal/reportbuf/reportbuf_test.go +++ /dev/null @@ -1,441 +0,0 @@ -package reportbuf_test - -import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "sneak.berlin/go/netwatch/internal/config" - "sneak.berlin/go/netwatch/internal/globals" - "sneak.berlin/go/netwatch/internal/logger" - "sneak.berlin/go/netwatch/internal/reportbuf" - - "github.com/klauspost/compress/zstd" - "go.uber.org/fx" - "go.uber.org/fx/fxtest" -) - -// TestFlushOnShutdown proves the flush-on-shutdown path: a -// report appended after start but before the periodic flush -// window must reach disk when the fx lifecycle stops. This is -// the exact case that silent data loss on restart used to -// destroy. -func TestFlushOnShutdown(t *testing.T) { - dir := t.TempDir() - t.Setenv("DATA_DIR", dir) - - var buf *reportbuf.Buffer - - app := fxtest.New(t, - fx.Provide( - globals.New, - logger.New, - config.New, - reportbuf.New, - ), - fx.Populate(&buf), - ) - - app.RequireStart() - - err := buf.Append(map[string]string{"probe": "shutdown"}) - if err != nil { - t.Fatalf("append report: %v", err) - } - - // RequireStop runs the reportbuf OnStop hook, which is the - // only code path that flushes buffered reports on shutdown. - app.RequireStop() - - if !hasReportFile(t, dir) { - t.Fatal("no report file on disk after shutdown; " + - "the buffered report was lost") - } -} - -// TestFailedFinalFlushFailsStop proves a final flush that cannot -// write its file makes the stop fail, which makes the process -// exit non-zero instead of dropping the buffered reports silently. -func TestFailedFinalFlushFailsStop(t *testing.T) { - dir := t.TempDir() - t.Setenv("DATA_DIR", dir) - - var buf *reportbuf.Buffer - - app := fxtest.New(t, - fx.Provide( - globals.New, - logger.New, - config.New, - reportbuf.New, - ), - fx.Populate(&buf), - ) - - app.RequireStart() - - err := buf.Append(map[string]string{"probe": "shutdown"}) - if err != nil { - t.Fatalf("append report: %v", err) - } - - // Removing the data directory leaves the final flush nowhere to - // write. A read-only directory would not do: tests run as root - // in the backend image, and root ignores the read-only bit. - err = os.RemoveAll(dir) - if err != nil { - t.Fatalf("remove data dir: %v", err) - } - - err = app.Stop(t.Context()) - if !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("stop error = %v, want the final flush's error", err) - } -} - -// startBuffer starts a Buffer through fx, as main does, with the -// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set. -func startBuffer(t *testing.T) *reportbuf.Buffer { - t.Helper() - - var buf *reportbuf.Buffer - - app := fxtest.New(t, - fx.Provide( - globals.New, - logger.New, - config.New, - reportbuf.New, - ), - fx.Populate(&buf), - ) - - app.RequireStart() - t.Cleanup(app.RequireStop) - - return buf -} - -// lineBytes is what one report takes in the buffer: its JSON and a -// newline. -func lineBytes(t *testing.T, report any) int { - t.Helper() - - line, err := json.Marshal(report) - if err != nil { - t.Fatalf("marshal report: %v", err) - } - - return len(line) + 1 -} - -func TestAppendPastCapIsRefused(t *testing.T) { - report := map[string]string{"id": "cap"} - - t.Setenv("DATA_DIR", t.TempDir()) - t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report))) - - buf := startBuffer(t) - - err := buf.Append(report) - if err != nil { - t.Fatalf("report that fills the cap exactly: %v", err) - } - - err = buf.Append(report) - if !errors.Is(err, reportbuf.ErrFull) { - t.Fatalf("report past the cap: error = %v, want ErrFull", err) - } -} - -// TestCapCountsReportFilesAlreadyInDataDir starts on a data -// directory holding a report file from an earlier run, and a file -// that is not a report, which must not count. -func TestCapCountsReportFilesAlreadyInDataDir(t *testing.T) { - const earlierBytes = 100 - - report := map[string]string{"id": "cap"} - dir := t.TempDir() - - writeBytes(t, filepath.Join(dir, "reports-2026-01-01T00-00-00.000Z.jsonl.zst"), - earlierBytes) - writeBytes(t, filepath.Join(dir, "notes.txt"), 10*earlierBytes) - - t.Setenv("DATA_DIR", dir) - t.Setenv("DATA_DIR_MAX_BYTES", - strconv.Itoa(earlierBytes+lineBytes(t, report))) - - buf := startBuffer(t) - - err := buf.Append(report) - if err != nil { - t.Fatalf("report that fills the cap exactly: %v", err) - } - - err = buf.Append(report) - if !errors.Is(err, reportbuf.ErrFull) { - t.Fatalf("report past the cap: error = %v, want ErrFull", err) - } -} - -// TestWrittenReportsCountAtFileSize checks that once reports are -// written, they count as their compressed file, not their -// uncompressed size, which frees room under the cap. -func TestWrittenReportsCountAtFileSize(t *testing.T) { - // Repetitive, so its file is far smaller than its JSON. - report := map[string]string{"id": strings.Repeat("a", 1000)} - size := lineBytes(t, report) - - t.Setenv("DATA_DIR", t.TempDir()) - // Room for the report twice over only if the first one counts - // at its file's size by the time the second arrives. - t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*size-1)) - - buf := startBuffer(t) - - err := buf.Append(report) - if err != nil { - t.Fatalf("first report: %v", err) - } - - err = buf.Flush() - if err != nil { - t.Fatalf("flush: %v", err) - } - - err = buf.Append(report) - if err != nil { - t.Fatalf("second report, after the first was written: %v", err) - } -} - -// TestWrittenReportsKeepCounting writes one report file after another -// under a small cap: each report must be taken while the files on disk -// leave room for it, and refused once they do not. -func TestWrittenReportsKeepCounting(t *testing.T) { - const maxBytes = 200 - - report := map[string]string{"id": "written"} - size := int64(lineBytes(t, report)) - dir := t.TempDir() - - t.Setenv("DATA_DIR", dir) - t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes)) - - buf := startBuffer(t) - - // Every file takes at least a byte, so they fill the cap within - // maxBytes rounds. - for range maxBytes { - used := reportFilesBytes(t, dir) - - err := buf.Append(report) - if used+size > maxBytes { - if !errors.Is(err, reportbuf.ErrFull) { - t.Fatalf("with %d bytes of report files: error = %v, "+ - "want ErrFull", used, err) - } - - return - } - - if err != nil { - t.Fatalf("with %d bytes of report files: %v", used, err) - } - - err = buf.Flush() - if err != nil { - t.Fatalf("flush: %v", err) - } - } - - t.Fatal("the report files never filled the cap") -} - -// TestConcurrentAppendsStopAtCap appends from many goroutines at once -// with room for exactly roomFor reports: exactly that many must be -// taken, which holds only if Append checks and counts each report -// under one lock. -func TestConcurrentAppendsStopAtCap(t *testing.T) { - const ( - roomFor = 5 - senders = 50 - ) - - // Large, so each Append takes long enough for the senders to - // overlap while the cap is reached. - report := map[string]string{"id": strings.Repeat("a", 1_000_000)} - - t.Setenv("DATA_DIR", t.TempDir()) - t.Setenv("DATA_DIR_MAX_BYTES", - strconv.Itoa(roomFor*lineBytes(t, report))) - - buf := startBuffer(t) - - var ( - taken atomic.Int64 - wg sync.WaitGroup - ) - - start := make(chan struct{}) - - for range senders { - wg.Go(func() { - <-start - - err := buf.Append(report) - if err == nil { - taken.Add(1) - } else if !errors.Is(err, reportbuf.ErrFull) { - t.Errorf("append: %v", err) - } - }) - } - - close(start) - wg.Wait() - - if got := taken.Load(); got != roomFor { - t.Fatalf("%d reports taken, want %d", got, roomFor) - } -} - -// TestTwoFlushesInOneMillisecond flushes twice within one millisecond, -// as a flush for size and the final flush at shutdown can: each flush -// must write a file of its own, and the files must hold every report. -func TestTwoFlushesInOneMillisecond(t *testing.T) { - const flushes = 2 - - dir := t.TempDir() - t.Setenv("DATA_DIR", dir) - - buf := startBuffer(t) - buf.StopClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - - for id := 1; id <= flushes; id++ { - err := buf.Append(map[string]int{"id": id}) - if err != nil { - t.Fatalf("append report %d: %v", id, err) - } - - err = buf.Flush() - if err != nil { - t.Fatalf("flush %d: %v", id, err) - } - } - - files := readReportFiles(t, dir) - if len(files) != flushes { - t.Fatalf("%d report files after %d flushes", len(files), flushes) - } - - for id := 1; id <= flushes; id++ { - want := fmt.Sprintf(`{"id":%d}`+"\n", id) - if !slices.Contains(files, want) { - t.Fatalf("no report file holds report %d alone", id) - } - } -} - -// reportFilesBytes returns the total size of the report files in dir. -func reportFilesBytes(t *testing.T, dir string) int64 { - t.Helper() - - paths, err := filepath.Glob(filepath.Join(dir, "reports-*.jsonl.zst")) - if err != nil { - t.Fatalf("list report files: %v", err) - } - - var total int64 - - for _, path := range paths { - info, statErr := os.Stat(path) - if statErr != nil { - t.Fatalf("stat %s: %v", path, statErr) - } - - total += info.Size() - } - - return total -} - -// readReportFiles returns the decompressed contents of each report -// file in dir. -func readReportFiles(t *testing.T, dir string) []string { - t.Helper() - - files := os.DirFS(dir) - - names, err := fs.Glob(files, "reports-*.jsonl.zst") - if err != nil { - t.Fatalf("list report files: %v", err) - } - - dec, err := zstd.NewReader(nil) - if err != nil { - t.Fatalf("create zstd decoder: %v", err) - } - defer dec.Close() - - contents := make([]string, 0, len(names)) - - for _, name := range names { - compressed, readErr := fs.ReadFile(files, name) - if readErr != nil { - t.Fatalf("read %s: %v", name, readErr) - } - - data, decErr := dec.DecodeAll(compressed, nil) - if decErr != nil { - t.Fatalf("decompress %s: %v", name, decErr) - } - - contents = append(contents, string(data)) - } - - return contents -} - -func writeBytes(t *testing.T, path string, n int) { - t.Helper() - - err := os.WriteFile(path, make([]byte, n), 0o600) - if err != nil { - t.Fatalf("write %s: %v", path, err) - } -} - -func hasReportFile(t *testing.T, dir string) bool { - t.Helper() - - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("read data dir: %v", err) - } - - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".jsonl.zst") { - info, statErr := e.Info() - if statErr != nil { - t.Fatalf("stat %s: %v", e.Name(), statErr) - } - - if info.Size() > 0 { - return true - } - } - } - - return false -} diff --git a/backend/internal/server/export_test.go b/backend/internal/server/export_test.go deleted file mode 100644 index 84cdd90..0000000 --- a/backend/internal/server/export_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package server - -// MaxRequestBodyBytes exposes the router-wide body limit to the -// external tests. -const MaxRequestBodyBytes = maxRequestBodyBytes - -// ListenAddr exposes the address the server listens on to the -// external tests. -func (s *Server) ListenAddr() string { - return s.newHTTPServer().Addr -} diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go deleted file mode 100644 index 4c87f59..0000000 --- a/backend/internal/server/routes.go +++ /dev/null @@ -1,41 +0,0 @@ -package server - -import ( - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" -) - -const ( - requestTimeout = 60 * time.Second - - // maxRequestBodyBytes caps every request body. A route group - // can mount s.mw.MaxBodyBytes with a smaller value to lower - // its bound, but cannot raise it: this cap runs first. - maxRequestBodyBytes int64 = 1 << 20 // 1 MiB -) - -// SetupRoutes configures the chi router with middleware and -// all application routes. -func (s *Server) SetupRoutes() { - s.router = chi.NewRouter() - - s.router.Use(s.mw.Recoverer()) - s.router.Use(middleware.RequestID) - s.router.Use(s.mw.Logging()) - s.router.Use(s.mw.SecurityHeaders()) - s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins)) - s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes)) - s.router.Use(middleware.Timeout(requestTimeout)) - - s.router.Get( - "/.well-known/healthcheck", - s.h.HandleHealthCheck(), - ) - - s.router.Route("/api/v1", func(r chi.Router) { - r.With(s.mw.RateLimit(s.params.Config.ReportsPerMinute)). - Post("/reports", s.h.HandleReport()) - }) -} diff --git a/backend/internal/server/routes_test.go b/backend/internal/server/routes_test.go deleted file mode 100644 index 972debd..0000000 --- a/backend/internal/server/routes_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package server_test - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "sneak.berlin/go/netwatch/internal/config" - "sneak.berlin/go/netwatch/internal/globals" - "sneak.berlin/go/netwatch/internal/handlers" - "sneak.berlin/go/netwatch/internal/healthcheck" - "sneak.berlin/go/netwatch/internal/logger" - "sneak.berlin/go/netwatch/internal/middleware" - "sneak.berlin/go/netwatch/internal/reportbuf" - "sneak.berlin/go/netwatch/internal/server" - - "go.uber.org/fx" - "go.uber.org/fx/fxtest" -) - -// newServer builds a Server from the same constructors as main, -// configured from the environment. It is never started, so nothing -// listens. -func newServer(t *testing.T) *server.Server { - t.Helper() - - var srv *server.Server - - app := fxtest.New(t, - fx.Provide( - config.New, - globals.New, - handlers.New, - healthcheck.New, - logger.New, - middleware.New, - reportbuf.New, - server.New, - ), - fx.Populate(&srv), - ) - - err := app.Err() - if err != nil { - t.Fatalf("build server: %v", err) - } - - return srv -} - -// TestReportsAreRateLimited checks that POST /api/v1/reports is -// behind the per-address rate limit, set here to two a minute. -func TestReportsAreRateLimited(t *testing.T) { - t.Setenv("REPORTS_PER_MINUTE", "2") - - srv := newServer(t) - srv.SetupRoutes() - - post := func() int { - rec := httptest.NewRecorder() - req := httptest.NewRequestWithContext(t.Context(), - http.MethodPost, "/api/v1/reports", - strings.NewReader(`{"clientId":"c1","hosts":[]}`), - ) - srv.ServeHTTP(rec, req) - - return rec.Code - } - - for i := range 2 { - if code := post(); code != http.StatusOK { - t.Fatalf("report %d: status = %d, want %d", - i+1, code, http.StatusOK) - } - } - - if code := post(); code != http.StatusTooManyRequests { - t.Fatalf("third report in a minute: status = %d, want %d", - code, http.StatusTooManyRequests) - } -} - -// TestCORSAllowedOriginsReachTheRouter checks that an origin listed in -// CORS_ALLOWED_ORIGINS is allowed by the router, not only when handed -// to the CORS middleware directly. -func TestCORSAllowedOriginsReachTheRouter(t *testing.T) { - const origin = "https://netwatch.example:8443" - - t.Setenv("CORS_ALLOWED_ORIGINS", origin) - - srv := newServer(t) - srv.SetupRoutes() - - // The preflight a browser sends before it POSTs JSON from origin. - rec := httptest.NewRecorder() - req := httptest.NewRequestWithContext(t.Context(), - http.MethodOptions, "/api/v1/reports", http.NoBody) - req.Header.Set("Origin", origin) - req.Header.Set("Access-Control-Request-Method", http.MethodPost) - req.Header.Set("Access-Control-Request-Headers", "content-type") - srv.ServeHTTP(rec, req) - - got := rec.Header().Get("Access-Control-Allow-Origin") - if got != origin { - t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, origin) - } -} - -// TestHealthCheckRejectsOversizeBody sends the health check, which -// never reads its body, a body one byte over the limit. Only the -// router-wide body limit can reject it. -func TestHealthCheckRejectsOversizeBody(t *testing.T) { - t.Parallel() - - srv := newServer(t) - srv.SetupRoutes() - - rec := httptest.NewRecorder() - req := httptest.NewRequestWithContext(t.Context(), - http.MethodGet, "/.well-known/healthcheck", - strings.NewReader( - strings.Repeat("x", int(server.MaxRequestBodyBytes)+1), - ), - ) - - srv.ServeHTTP(rec, req) - - if rec.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("status = %d, want %d", - rec.Code, http.StatusRequestEntityTooLarge) - } - - if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" { - t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n") - } - - got := rec.Header().Get("Content-Type") - if got != "application/json; charset=utf-8" { - t.Errorf("Content-Type = %q, want a JSON content type", got) - } -} diff --git a/backend/script/build b/backend/script/build deleted file mode 100755 index 40d4d01..0000000 --- a/backend/script/build +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -# script/build: compile the static netwatch-server binary into the -# backend project root, with its version and architecture stamped in. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -main() { - cd "$ROOT" - - # VERSION comes from the environment (the root Dockerfile passes its - # ARG VERSION in). Unset or empty, it is git describe, or "dev" where - # there is no git or no repository history. - version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}" - - CGO_ENABLED=0 go build -trimpath \ - -ldflags "-s -w -X main.Version=$version -X main.Buildarch=$(uname -m)" \ - -o netwatch-server ./cmd/netwatch-server/ -} - -main "$@" diff --git a/backend/script/clean b/backend/script/clean deleted file mode 100755 index 1f4e638..0000000 --- a/backend/script/clean +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# script/clean: remove build artifacts. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -main() { - cd "$ROOT" - rm -f netwatch-server -} - -main "$@" diff --git a/backend/script/fmt b/backend/script/fmt deleted file mode 100755 index 9fa78bf..0000000 --- a/backend/script/fmt +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# script/fmt: format the Go sources (writes). -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -main() { - cd "$ROOT" - go fmt ./... -} - -main "$@" diff --git a/backend/script/fmt-check b/backend/script/fmt-check deleted file mode 100755 index 57405d0..0000000 --- a/backend/script/fmt-check +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -# script/fmt-check: check Go formatting (read-only). Same scope as -# script/fmt, but fails instead of writing. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -main() { - cd "$ROOT" - unformatted="$(gofmt -l .)" - if [ -n "$unformatted" ]; then - echo "Files not formatted:" >&2 - echo "$unformatted" >&2 - exit 1 - fi -} - -main "$@" diff --git a/backend/script/lint b/backend/script/lint deleted file mode 100755 index 720b747..0000000 --- a/backend/script/lint +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -# script/lint: run golangci-lint over the backend. This runs inside the -# lint stage of the root Dockerfile, whose digest-pinned golangci-lint -# image provides the linter; nothing installs golangci-lint on the host. -# From a checkout, run `make lint` at the repo root, which builds that -# stage. -# -# .golangci.yml is standardized org-wide and must never be edited here -# (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with -# v1 keys, which left every threshold in the file inert while the build -# stayed green. So the file is first checked against the canonical -# copy's sha256: a local comparison, no network, nothing unpinned. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -GOLANGCI_CONFIG_SHA256="a79b63a254602a5318db5d0e9a06bc71b84bf0c1d896305229d8bfed1d1b1776" - -main() { - cd "$ROOT" - actual="$(sha256sum .golangci.yml | cut -d' ' -f1)" - if [ "$actual" != "$GOLANGCI_CONFIG_SHA256" ]; then - echo ".golangci.yml has drifted from the org standard." >&2 - echo " expected $GOLANGCI_CONFIG_SHA256" >&2 - echo " actual $actual" >&2 - echo "Restore it verbatim from sneak/prompts; do not edit it." >&2 - exit 1 - fi - golangci-lint run ./... -} - -main "$@" diff --git a/backend/script/test b/backend/script/test deleted file mode 100755 index cbfc889..0000000 --- a/backend/script/test +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# script/test: run the backend test suite. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -main() { - cd "$ROOT" - timeout 30 go test ./... -} - -main "$@" diff --git a/bin/entrypoint.sh b/bin/entrypoint.sh index 6f3bdf8..7bae9d4 100755 --- a/bin/entrypoint.sh +++ b/bin/entrypoint.sh @@ -13,30 +13,35 @@ set -u # address and start anyway, and reports a bad port without naming # PORT, so a value that is not a usable port stops the container here, # before either process starts. -export PORT="${PORT:-8080}" -case "$PORT" in - *[!0-9]*) - echo "entrypoint: PORT must be a port number, not '$PORT'" >&2 +check_port() { + export PORT="${PORT:-8080}" + case "$PORT" in + *[!0-9]*) + echo "entrypoint: PORT must be a port number, not '$PORT'" >&2 + exit 1 + ;; + esac + # The length is checked first because, for a number too big for it, + # the shell's test prints an error and is false, so the range checks + # alone would let it through. + if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then + echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2 exit 1 - ;; -esac -# The length is checked first because, for a number too big for it, -# the shell's test prints an error and is false, so the range checks -# alone would let it through. -if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then - echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2 - exit 1 -fi -if [ "$PORT" -eq 8081 ]; then - echo "entrypoint: PORT cannot be 8081, netwatch-server listens there" >&2 - exit 1 -fi + fi + if [ "$PORT" -eq 8081 ]; then + echo "entrypoint: PORT cannot be 8081, netwatch-server" \ + "listens there" >&2 + exit 1 + fi +} # TRUSTED_PROXIES names the reverse proxies in front of the container, # as IP addresses or CIDRs separated by commas. nginx takes the client -# address from X-Forwarded-For only on a request from one of them, so -# unset or empty, it trusts no one. nginx.conf includes the file written -# here, one set_real_ip_from line per entry. +# address from X-Forwarded-For only on a request from one of them. +# Unset, they are the three private ranges of RFC 1918; set and empty, +# nginx trusts no one, which is why the default below follows - and not +# :-. configs/nginx.conf includes the file written here, one +# set_real_ip_from line per entry. # # nginx looks up an entry it cannot read as an address as a hostname, # and trusts what it finds (1.2.3 is found as 1.2.0.3). So each entry @@ -45,70 +50,64 @@ fi # TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one # below, naming the entry as written. set -f keeps a * in an entry from # becoming a list of file names. -TRUSTED_PROXIES="${TRUSTED_PROXIES:-}" -set -f -for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do - case "$proxy" in - */*) cidr="$proxy" ;; - *:*) cidr="$proxy/128" ;; - *) cidr="$proxy/32" ;; - esac - if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then - echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \ - "separated by commas; '$proxy' is neither" >&2 - exit 1 - fi - echo "set_real_ip_from $cidr;" -done > /etc/nginx/trusted-proxies.conf +write_trusted_proxies() { + TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" + set -f + for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do + case "$proxy" in + */*) cidr="$proxy" ;; + *:*) cidr="$proxy/128" ;; + *) cidr="$proxy/32" ;; + esac + if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then + echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \ + "separated by commas; '$proxy' is neither" >&2 + exit 1 + fi + echo "set_real_ip_from $cidr;" + done > /etc/nginx/trusted-proxies.conf +} # netwatch-server keeps its report files in DATA_DIR, on the /data # volume, which may be a host directory owned by root or by another -# uid. Both are given to the netwatch user here, with the mode the -# server gives a directory it creates, so the host directory needs no -# preparing. -# -# chown and chmod, run as root, change whatever a symbolic link on the -# path points to, anywhere in the container, and the netwatch user can -# put one in /data. So the start stops unless readlink -f, which -# follows every link on a path, gives /data and DATA_DIR back as they -# are. It also writes a path in full, so a DATA_DIR with '.', '..' or -# an extra '/' in it is refused too. -export DATA_DIR="${DATA_DIR:-/data/reports}" -mkdir -p "$DATA_DIR" || exit 1 -if [ "$(readlink -f /data)" != /data ] || - [ "$(readlink -f "$DATA_DIR")" != "$DATA_DIR" ]; then - echo "entrypoint: DATA_DIR must be a full path with no '.', '..'," \ - "extra '/' or symbolic link on it or on /data, not '$DATA_DIR'" >&2 - exit 1 -fi -chown -R netwatch:netwatch /data "$DATA_DIR" || exit 1 -chmod 750 /data "$DATA_DIR" || exit 1 - -# A stop signal is only noted here; the loop below acts on it. -stop_requested="" -trap 'stop_requested=yes' TERM INT +# uid. Here, as root, netwatch-server prepare-data-dir creates DATA_DIR +# and gives /data and the directories, regular files and symbolic links +# in it to the netwatch user, so the host directory needs no preparing. +# A file with a second name (hard link) keeps its owner, as that name +# could be outside /data, and its path is logged. It stops the start, +# naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it +# acts on nothing outside /data, whatever symbolic or hard links it +# meets there. +prepare_data_dir() { + export DATA_DIR="${DATA_DIR:-/data/reports}" + netwatch-server prepare-data-dir "$DATA_DIR" || exit 1 +} # netwatch-server runs as the netwatch user and listens on loopback -# only, on a port other than the public one; nginx.conf proxies to this -# address. Its only client is nginx, so it takes the client address -# nginx passes on from 127.0.0.1 alone, whatever TRUSTED_PROXIES the -# container has. The netwatch user has no login shell, hence -s +# only, on a port other than the public one; configs/nginx.conf proxies +# to this address. Its only client is nginx, so it takes the client +# address nginx passes on from 127.0.0.1 alone, whatever TRUSTED_PROXIES +# the container has. The netwatch user has no login shell, hence -s # /bin/sh. busybox su replaces itself with the command instead of # staying on as its parent, so $! is the server's own PID. -BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \ - su -s /bin/sh netwatch -c 'exec netwatch-server' & -backend=$! +start_backend() { + BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \ + su -s /bin/sh netwatch -c 'exec netwatch-server' & + backend=$! +} # nginx starts through the nginx image's own entrypoint, which applies # the image's start-up configuration and then replaces itself with -# nginx. Part of that start-up configuration renders nginx.conf into -# conf.d with nginx listening on PORT. NGINX_ENVSUBST_FILTER limits +# nginx. Part of that start-up configuration renders configs/nginx.conf +# into conf.d with nginx listening on PORT. NGINX_ENVSUBST_FILTER limits # that rendering to PORT: a variable nginx itself uses, such as $uri, # would otherwise be replaced by an environment variable of the same # name. -NGINX_ENVSUBST_FILTER='^PORT$' \ - /docker-entrypoint.sh nginx -g 'daemon off;' & -nginx=$! +start_nginx() { + NGINX_ENVSUBST_FILTER='^PORT$' \ + /docker-entrypoint.sh nginx -g 'daemon off;' & + nginx=$! +} running() { kill -0 "$1" 2>/dev/null @@ -117,26 +116,47 @@ running() { # POSIX sh cannot wait for whichever of two children exits first, so # look once a second. The shell collects a child that has exited while # it runs sleep, and running() is false for that child from then on. -while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do - sleep 1 -done +wait_for_stop_or_exit() { + while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do + sleep 1 + done +} # Stop both, then wait until neither is left. -kill -TERM "$backend" "$nginx" 2>/dev/null -while running "$backend" || running "$nginx"; do - sleep 1 -done - -wait "$backend" -backend_status=$? -wait "$nginx" -nginx_status=$? -echo "entrypoint: netwatch-server exited $backend_status," \ - "nginx exited $nginx_status" +stop_both() { + kill -TERM "$backend" "$nginx" 2>/dev/null + while running "$backend" || running "$nginx"; do + sleep 1 + done +} # Success is a requested stop that both processes exited cleanly from. -if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] && - [ "$nginx_status" -eq 0 ]; then - exit 0 -fi -exit 1 +exit_with_status() { + wait "$backend" + backend_status=$? + wait "$nginx" + nginx_status=$? + echo "entrypoint: netwatch-server exited $backend_status," \ + "nginx exited $nginx_status" + if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] && + [ "$nginx_status" -eq 0 ]; then + exit 0 + fi + exit 1 +} + +main() { + check_port + write_trusted_proxies + prepare_data_dir + # A stop signal is only noted here; wait_for_stop_or_exit acts on it. + stop_requested="" + trap 'stop_requested=yes' TERM INT + start_backend + start_nginx + wait_for_stop_or_exit + stop_both + exit_with_status +} + +main "$@" diff --git a/cmd/netwatch-server/main.go b/cmd/netwatch-server/main.go new file mode 100644 index 0000000..5eefc12 --- /dev/null +++ b/cmd/netwatch-server/main.go @@ -0,0 +1,19 @@ +// Package main is the entry point for netwatch-server; package cli +// does the work. +package main + +import ( + "os" + + "sneak.berlin/go/netwatch/internal/cli" +) + +//nolint:gochecknoglobals // set via ldflags at build time +var ( + Appname = "netwatch-server" + Version string +) + +func main() { + os.Exit(cli.Main(os.Stderr, Appname, Version, os.Args[1:], cli.RunServer)) +} diff --git a/cmd/netwatch-server/main_test.go b/cmd/netwatch-server/main_test.go new file mode 100644 index 0000000..5d9eee5 --- /dev/null +++ b/cmd/netwatch-server/main_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// The tests run main() in a child process, this test binary started +// again with runMainEnv set, because main() can exit its process and +// takes its settings from the environment. +const runMainEnv = "NETWATCH_SERVER_RUN_MAIN" + +// childTimeout bounds each child's whole run; it is killed after it. +const childTimeout = 10 * time.Second + +func TestMain(m *testing.M) { + if os.Getenv(runMainEnv) != "" { + // A SIGTERM that comes before fx catches it is dropped, not fatal. + signal.Notify(make(chan os.Signal, 1), syscall.SIGTERM) + main() + + return + } + + os.Exit(m.Run()) +} + +// TestOutputIsJSON: off a terminal, every line the server writes from +// start to stop is JSON, fx's own lines included. +func TestOutputIsJSON(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), childTimeout) + defer cancel() + + port := freePort(ctx, t) + child, stdout, stderr := startServer(ctx, t, t.TempDir(), port) + + waitForHealthcheck(ctx, t, port) + + // The child drops a SIGTERM that comes before fx catches it (see + // TestMain), so send one every 100ms until the test ends. ctx + // bounds the wait: when it ends, the child is killed. + stop := make(chan struct{}) + defer close(stop) + + go func() { + for { + _ = child.Process.Signal(syscall.SIGTERM) + + select { + case <-stop: + return + case <-time.After(100 * time.Millisecond): + } + } + }() + + err := child.Wait() + if err != nil { + t.Fatalf("server exit = %v, want success", err) + } + + requireJSONLines(t, stdout, stderr) + + if !strings.Contains(stdout.String(), `"msg":"starting"`) { + t.Fatalf("no startup line in stdout:\n%s", stdout) + } +} + +// TestMalformedConfigFileStopsTheStart: a config file the server finds +// but cannot read stops the start, and the error is logged as JSON. +func TestMalformedConfigFileStopsTheStart(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), childTimeout) + defer cancel() + + home := t.TempDir() + dir := filepath.Join(home, ".config", "netwatch-server") + + err := os.MkdirAll(dir, 0o750) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(filepath.Join(dir, "netwatch-server.yaml"), + []byte("PORT: [8080\n"), 0o600) + if err != nil { + t.Fatal(err) + } + + child, stdout, stderr := startServer(ctx, t, home, freePort(ctx, t)) + + err = child.Wait() + if child.ProcessState.ExitCode() != 1 { + t.Fatalf("server exit = %v, want exit status 1", err) + } + + requireJSONLines(t, stdout, stderr) + + if !strings.Contains(stdout.String(), "netwatch-server.yaml") { + t.Fatalf("no error naming the config file in stdout:\n%s", stdout) + } +} + +// TestRefusedSentryDSNStopsTheStart: a SENTRY_DSN that Sentry refuses +// stops the start, and the error, naming SENTRY_DSN, is logged as JSON. +func TestRefusedSentryDSNStopsTheStart(t *testing.T) { + t.Setenv("SENTRY_DSN", "not-a-dsn") + + ctx, cancel := context.WithTimeout(t.Context(), childTimeout) + defer cancel() + + child, stdout, stderr := startServer(ctx, t, t.TempDir(), freePort(ctx, t)) + + err := child.Wait() + if child.ProcessState.ExitCode() != 1 { + t.Fatalf("server exit = %v, want exit status 1", err) + } + + requireJSONLines(t, stdout, stderr) + + if !strings.Contains(stdout.String(), "SENTRY_DSN") { + t.Fatalf("no error naming SENTRY_DSN in stdout:\n%s", stdout) + } +} + +// startServer runs main() in a child process listening on +// 127.0.0.1:port, with home as its HOME and working directory and its +// data directory in home, so it touches nothing outside home. Its +// stdout and stderr go to the two buffers returned, which hold all of +// it once child.Wait returns. The child is killed when ctx ends, and +// killed and reaped when the test ends if nothing waited for it. +func startServer( + ctx context.Context, + t *testing.T, + home, port string, +) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + + child := exec.CommandContext(ctx, self) //nolint:gosec // this test binary + child.Dir = home + child.Env = append(os.Environ(), + runMainEnv+"=1", + "HOME="+home, + "DATA_DIR="+filepath.Join(home, "data"), + "BIND_ADDRESS=127.0.0.1", + "PORT="+port, + ) + child.Stdout = &stdout + child.Stderr = &stderr + + err = child.Start() + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + if child.ProcessState == nil { + _ = child.Process.Kill() + _ = child.Wait() + } + }) + + return child, &stdout, &stderr +} + +// freePort returns a TCP port on 127.0.0.1 that was free a moment ago. +func freePort(ctx context.Context, t *testing.T) string { + t.Helper() + + var lc net.ListenConfig + + l, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + _ = l.Close() + + _, port, err := net.SplitHostPort(l.Addr().String()) + if err != nil { + t.Fatal(err) + } + + return port +} + +// waitForHealthcheck returns once the health check on port answers 200. +func waitForHealthcheck(ctx context.Context, t *testing.T, port string) { + t.Helper() + + url := "http://127.0.0.1:" + port + "/.well-known/healthcheck" + + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return + } + } + + select { + case <-ctx.Done(): + t.Fatalf("health check never answered: %v", err) + case <-time.After(50 * time.Millisecond): + } + } +} + +// requireJSONLines fails the test on each line of outs that is not +// JSON. +func requireJSONLines(t *testing.T, outs ...*bytes.Buffer) { + t.Helper() + + for _, out := range outs { + for line := range strings.Lines(out.String()) { + if !json.Valid([]byte(line)) { + t.Errorf("line is not JSON: %s", line) + } + } + } +} diff --git a/nginx.conf b/configs/nginx.conf similarity index 70% rename from nginx.conf rename to configs/nginx.conf index 3ea963a..ccb8aaf 100644 --- a/nginx.conf +++ b/configs/nginx.conf @@ -18,9 +18,10 @@ server { # The client address comes from X-Forwarded-For only on a request # from the reverse proxies in TRUSTED_PROXIES: bin/entrypoint.sh - # writes one set_real_ip_from line for each into this file, and - # leaves it empty when TRUSTED_PROXIES is unset, so that by default - # the client address is the one each request comes from. + # writes one set_real_ip_from line for each into this file. Unset, + # TRUSTED_PROXIES is the three private ranges of RFC 1918; set and + # empty, the file is empty and the client address is the one each + # request comes from. include /etc/nginx/trusted-proxies.conf; real_ip_header X-Forwarded-For; real_ip_recursive on; @@ -29,8 +30,13 @@ server { access_log /dev/stdout combined; error_log /dev/stderr warn; + # With no-cache, a browser never uses a copy of the page that names + # assets a newer build no longer has without first checking with the + # server that it is current. location / { try_files $uri $uri/ /index.html; + add_header Cache-Control no-cache; + include /etc/nginx/security-headers.conf; } # Cache static assets aggressively @@ -45,7 +51,9 @@ server { # 127.0.0.1:8081. These headers go with every request passed to it. # X-Forwarded-For carries only the client address, as resolved by # the real IP settings above, and not the chain the request came - # with: the backend takes the first entry, which a client can write. + # with. bin/entrypoint.sh starts the backend with + # TRUSTED_PROXIES=127.0.0.1/32, the address nginx connects from, so + # the backend takes that one entry as the client address. proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; @@ -53,7 +61,7 @@ server { # netwatch-server sets the same security headers on its own # responses. Its copies are dropped so that each header goes out - # once, as security-headers.conf sets it. + # once, as configs/security-headers.conf sets it. proxy_hide_header Strict-Transport-Security; proxy_hide_header Content-Security-Policy; proxy_hide_header X-Frame-Options; @@ -68,4 +76,10 @@ server { location = /.well-known/healthcheck { proxy_pass http://127.0.0.1:8081; } + + # The backend's Prometheus metrics, behind its own basic auth. Unless + # METRICS_USERNAME and METRICS_PASSWORD are set, it answers 404. + location = /metrics { + proxy_pass http://127.0.0.1:8081; + } } diff --git a/security-headers.conf b/configs/security-headers.conf similarity index 73% rename from security-headers.conf rename to configs/security-headers.conf index 6602d6e..bfdf8bd 100644 --- a/security-headers.conf +++ b/configs/security-headers.conf @@ -1,5 +1,5 @@ # The security headers REPO_POLICIES.md requires on every response. -# nginx.conf includes this file, which Dockerfile copies to +# configs/nginx.conf includes this file, which Dockerfile copies to # /etc/nginx/security-headers.conf. always sends each header on error # responses too. @@ -8,11 +8,10 @@ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" alway # Scripts and styles load only from the page's own origin. Inline ones # are blocked, style attributes in markup included, so style elements # through classes or element.style. data: images are for the favicon -# in index.html. connect-src is * because the browser checks each probe in -# src/main.js against it, and also every redirect the probe follows, -# and several of those hosts redirect to others; a list of hosts here -# would block those probes. It also covers the reports the page sends -# to its own origin. +# in web/index.html. connect-src is * because the page checks every +# target in web/src/main.js. A list of hosts here would have to change +# with every change to those targets. It also covers the reports the +# page sends to its own origin. add_header Content-Security-Policy "default-src 'self'; connect-src *; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; add_header X-Frame-Options DENY always; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5ff3543 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +// eslint's recommended rules over every JavaScript file in the repo. +// make lint runs it, in the frontend-lint stage of Dockerfile. +import js from "@eslint/js"; +import globals from "globals"; +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + js.configs.recommended, + { + // The page, and facts.js, which the viewport test runs inside it. + files: ["web/src/**/*.js", "test/viewport/facts.js"], + languageOptions: { + globals: { + ...globals.browser, + // vite.config.js defines these when it builds the page. + __COMMIT_HASH__: "readonly", + __COMMIT_FULL__: "readonly", + }, + }, + }, + { + // Run by node: the tests, the viewport harness and these configs. + files: ["test/**/*.js", "*.js"], + ignores: ["test/viewport/facts.js"], + languageOptions: { globals: globals.node }, + }, +]); diff --git a/backend/go.mod b/go.mod similarity index 55% rename from backend/go.mod rename to go.mod index a2462e8..20cca2a 100644 --- a/backend/go.mod +++ b/go.mod @@ -2,21 +2,35 @@ module sneak.berlin/go/netwatch go 1.25.5 +// npm packages can ship .go files, which ./... and all would include. +ignore ./node_modules + require ( + github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 + github.com/getsentry/sentry-go v0.49.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.16.0 github.com/joho/godotenv v1.5.1 - github.com/klauspost/compress v1.18.4 + github.com/klauspost/compress v1.19.1 + github.com/prometheus/client_golang v1.24.1 + github.com/slok/go-http-metrics v0.13.0 github.com/spf13/viper v1.21.0 go.uber.org/fx v1.24.0 + golang.org/x/sys v0.47.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -28,6 +42,6 @@ require ( go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.26.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2aec8c6 --- /dev/null +++ b/go.sum @@ -0,0 +1,107 @@ +github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go= +github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= +github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= +github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8= +github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..0b618c4 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,143 @@ +// Package cli runs netwatch-server from its command line: the +// check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh +// runs before it starts the server, and, given no arguments, the +// server, with the fx wiring. +package cli + +import ( + "fmt" + "io" + "os/user" + + "sneak.berlin/go/netwatch/internal/config" + "sneak.berlin/go/netwatch/internal/globals" + "sneak.berlin/go/netwatch/internal/handlers" + "sneak.berlin/go/netwatch/internal/healthcheck" + "sneak.berlin/go/netwatch/internal/logger" + "sneak.berlin/go/netwatch/internal/middleware" + "sneak.berlin/go/netwatch/internal/reportbuf" + "sneak.berlin/go/netwatch/internal/server" + + "go.uber.org/fx" + "go.uber.org/fx/fxevent" +) + +// usageExitCode is what Main returns for a command line it does not +// understand, the usual exit code for a usage error. +const usageExitCode = 2 + +// Main runs netwatch-server with args, its command line after the +// program's name, and returns the exit code. With no args, Main runs +// the server by calling serve with appname and version, the name and +// version the server logs and reports. With "check-cidr CIDR" or +// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its +// errors to stderr. Any other args get a one-line usage message on +// stderr and exit code 2. main passes os.Stderr and RunServer; the +// tests pass a buffer and a function of their own, so that no test +// starts the server. When serve is RunServer and the server fails to +// start, to listen or to stop, fx's Run exits the process with status 1 +// and Main never returns. +func Main( + stderr io.Writer, + appname, version string, + args []string, + serve func(appname, version string), +) int { + // "netwatch-server check-cidr CIDR" exits 1, with the error, if + // this server would refuse CIDR in its TRUSTED_PROXIES. + // bin/entrypoint.sh runs it on each entry it gives nginx. + if len(args) == 2 && args[0] == "check-cidr" { + return checkCIDR(stderr, args[1]) + } + + // "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready + // for the netwatch user, or exits 1 with the error; see + // reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root + // before it starts this server as that user. + if len(args) == 2 && args[0] == "prepare-data-dir" { + return prepareDataDir(stderr, "/data", args[1], "netwatch") + } + + // Any other command line is refused, so that a mistyped one does + // not start the server. + if len(args) > 0 { + _, _ = fmt.Fprintln(stderr, + "usage: netwatch-server [check-cidr CIDR | prepare-data-dir DATA_DIR]") + + return usageExitCode + } + + serve(appname, version) + + return 0 +} + +// checkCIDR returns 0 if the server would take cidr in its +// TRUSTED_PROXIES, and otherwise writes the error to stderr and +// returns 1. +func checkCIDR(stderr io.Writer, cidr string) int { + _, err := middleware.ParseTrustedProxies([]string{cidr}) + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + + return 1 + } + + return 0 +} + +// prepareDataDir gets dataDir ready for the user named username with +// reportbuf.PrepareDataDir, volume being where the data volume is +// mounted, and returns 0. Otherwise it writes the error to stderr and +// returns 1. +func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int { + owner, err := user.Lookup(username) + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + + return 1 + } + + err = reportbuf.PrepareDataDir(volume, dataDir, owner) + if err != nil { + _, _ = fmt.Fprintf(stderr, "DATA_DIR '%s': %v\n", dataDir, err) + + return 1 + } + + return 0 +} + +// RunServer runs the server until it is told to stop, with appname and +// version as the name and version it logs and reports. If the server +// fails to start, to listen or to stop, fx's Run exits the process +// with status 1 itself. +func RunServer(appname, version string) { + globals.Appname = appname + globals.Version = version + + fx.New( + // fx logs each step of starting and stopping through the + // server's own logger, so off a terminal those lines are + // JSON like every other line. + fx.WithLogger(func(log *logger.Logger) fxevent.Logger { + return &fxevent.SlogLogger{Logger: log.Get()} + }), + fx.Provide( + config.New, + globals.New, + handlers.New, + healthcheck.New, + logger.New, + middleware.New, + reportbuf.New, + server.New, + ), + fx.Invoke( + // First, so the name and version are logged even when + // a setting stops the start. + func(log *logger.Logger) { log.Identify() }, + func(*server.Server) {}, + ), + ).Run() +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..b76428e --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,219 @@ +package cli_test + +import ( + "bytes" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "testing" + + "sneak.berlin/go/netwatch/internal/cli" + "sneak.berlin/go/netwatch/internal/reportbuf" +) + +// TestMainRunsCheckCIDR: Main given check-cidr returns 0, writing +// nothing, for a CIDR the server takes in its TRUSTED_PROXIES, and 1, +// with an error naming TRUSTED_PROXIES and the CIDR, for one it +// refuses, and does not start the server. +func TestMainRunsCheckCIDR(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + + code := cli.Main(&stderr, "netwatch", "test", + []string{"check-cidr", "10.0.0.0/8"}, failIfServerStarts(t)) + if code != 0 || stderr.Len() != 0 { + t.Errorf("check-cidr 10.0.0.0/8: exit %d, stderr %q; want exit 0 and no output", + code, stderr.String()) + } + + stderr.Reset() + + code = cli.Main(&stderr, "netwatch", "test", + []string{"check-cidr", "1.2.3/32"}, failIfServerStarts(t)) + + want := `TRUSTED_PROXIES "1.2.3/32": ` + if code != 1 || !strings.HasPrefix(stderr.String(), want) { + t.Errorf("check-cidr 1.2.3/32: exit %d, stderr %q; want exit 1 and %q...", + code, stderr.String(), want) + } +} + +// TestMainRunsPrepareDataDir: Main given prepare-data-dir runs the +// prepare-data-dir subcommand for the netwatch user, and does not start +// the server. The test image has no netwatch user, so that subcommand +// exits 1 with the error from looking the user up; +// TestPrepareDataDirRefusesPathOutsideVolume checks the path. +func TestMainRunsPrepareDataDir(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + + code := cli.Main(&stderr, "netwatch", "test", + []string{"prepare-data-dir", "/etc"}, failIfServerStarts(t)) + + want := user.UnknownUserError("netwatch").Error() + "\n" + if code != 1 || stderr.String() != want { + t.Errorf("prepare-data-dir /etc: exit %d, stderr %q; want exit 1 and %q", + code, stderr.String(), want) + } +} + +// TestMainRunsServerWithNoArguments: Main given no arguments starts the +// server with the name and version it was given, writes nothing and +// returns 0. +func TestMainRunsServerWithNoArguments(t *testing.T) { + t.Parallel() + + var ( + stderr bytes.Buffer + gotAppname, gotVersion string + ) + + code := cli.Main(&stderr, "netwatch", "test", []string{}, + func(appname, version string) { + gotAppname, gotVersion = appname, version + }) + if code != 0 || stderr.Len() != 0 { + t.Errorf("exit %d, stderr %q; want exit 0 and no output", + code, stderr.String()) + } + + if gotAppname != "netwatch" || gotVersion != "test" { + t.Errorf("server started with %q %q; want %q %q", + gotAppname, gotVersion, "netwatch", "test") + } +} + +// TestMainRefusesOtherCommandLines: Main given a subcommand with too +// few or too many values, or a misspelt subcommand, writes the usage +// message to stderr, returns 2 and does not start the server. +func TestMainRefusesOtherCommandLines(t *testing.T) { + t.Parallel() + + want := "usage: netwatch-server " + + "[check-cidr CIDR | prepare-data-dir DATA_DIR]\n" + + for _, commandLine := range []string{ + "netwatch-server check-cidr", + "netwatch-server check-cidr 192.0.2.0/24 198.51.100.0/24", + "netwatch-server prepare-data-dir", + "netwatch-server prepare-data-dir /data/reports /data/other", + "netwatch-server check-cdir 192.0.2.0/24", + } { + var stderr bytes.Buffer + + // Main takes the command line after the program's name. + args := strings.Fields(commandLine)[1:] + + code := cli.Main(&stderr, "netwatch", "test", args, failIfServerStarts(t)) + if code != 2 || stderr.String() != want { + t.Errorf("%q: exit %d, stderr %q; want exit 2 and %q", + commandLine, code, stderr.String(), want) + } + } +} + +// TestCheckCIDRTakesCIDR: check-cidr exits 0, writing nothing, for a +// CIDR the server takes in its TRUSTED_PROXIES. +func TestCheckCIDRTakesCIDR(t *testing.T) { + t.Parallel() + + for _, cidr := range []string{"10.0.0.0/8", "192.0.2.1/32", "2001:db8::/32"} { + var stderr bytes.Buffer + + code := cli.CheckCIDR(&stderr, cidr) + if code != 0 || stderr.Len() != 0 { + t.Errorf("%q: exit %d, stderr %q; want exit 0 and no output", + cidr, code, stderr.String()) + } + } +} + +// TestCheckCIDRRefusesOtherInput: check-cidr exits 1, with an error +// naming TRUSTED_PROXIES and the input, for anything the server would +// refuse in its TRUSTED_PROXIES, such as 1.2.3/32, which nginx would +// look up as a hostname. +func TestCheckCIDRRefusesOtherInput(t *testing.T) { + t.Parallel() + + for _, cidr := range []string{ + "1.2.3/32", "example.com/32", "10.0.0.0/33", "10.0.0.1", "", + } { + var stderr bytes.Buffer + + code := cli.CheckCIDR(&stderr, cidr) + + want := "TRUSTED_PROXIES " + strconv.Quote(cidr) + ": " + if code != 1 || !strings.HasPrefix(stderr.String(), want) { + t.Errorf("%q: exit %d, stderr %q; want exit 1 and %q...", + cidr, code, stderr.String(), want) + } + } +} + +// TestPrepareDataDirTakesPathInVolume: prepare-data-dir exits 0, +// writing nothing, for a DATA_DIR below the volume, and creates it. +func TestPrepareDataDirTakesPathInVolume(t *testing.T) { + t.Parallel() + + volume := t.TempDir() + dataDir := filepath.Join(volume, "reports") + + var stderr bytes.Buffer + + code := cli.PrepareDataDir(&stderr, volume, dataDir, currentUsername(t)) + if code != 0 || stderr.Len() != 0 { + t.Errorf("exit %d, stderr %q; want exit 0 and no output", + code, stderr.String()) + } + + info, err := os.Stat(dataDir) + if err != nil || !info.IsDir() { + t.Errorf("DATA_DIR not created: %v", err) + } +} + +// TestPrepareDataDirRefusesPathOutsideVolume: prepare-data-dir exits +// 1, with an error naming DATA_DIR and its value, for a DATA_DIR +// outside the volume. +func TestPrepareDataDirRefusesPathOutsideVolume(t *testing.T) { + t.Parallel() + + volume := t.TempDir() + + var stderr bytes.Buffer + + code := cli.PrepareDataDir(&stderr, volume, "/etc", currentUsername(t)) + + want := "DATA_DIR '/etc': " + reportbuf.ErrDataDirOutsideVolume.Error() + "\n" + if code != 1 || stderr.String() != want { + t.Errorf("exit %d, stderr %q; want exit 1 and %q", + code, stderr.String(), want) + } +} + +// currentUsername is the name of the user the test runs as, the only +// owner a test not run as root can give files to. +func currentUsername(t *testing.T) string { + t.Helper() + + current, err := user.Current() + if err != nil { + t.Fatal(err) + } + + return current.Username +} + +// failIfServerStarts is what the tests give Main in place of +// cli.RunServer: it fails the test instead of starting the server. +func failIfServerStarts(t *testing.T) func(appname, version string) { + t.Helper() + + return func(string, string) { + t.Error("Main started the server") + } +} diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..24522b6 --- /dev/null +++ b/internal/cli/export_test.go @@ -0,0 +1,16 @@ +package cli + +import "io" + +// CheckCIDR exposes the check-cidr subcommand to the external tests, +// which read what it writes to stderr. +func CheckCIDR(stderr io.Writer, cidr string) int { + return checkCIDR(stderr, cidr) +} + +// PrepareDataDir exposes the prepare-data-dir subcommand to the +// external tests, which give it a volume and a user of their own in +// place of /data and netwatch. +func PrepareDataDir(stderr io.Writer, volume, dataDir, username string) int { + return prepareDataDir(stderr, volume, dataDir, username) +} diff --git a/backend/internal/config/config.go b/internal/config/config.go similarity index 73% rename from backend/internal/config/config.go rename to internal/config/config.go index a258978..888afa1 100644 --- a/backend/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "math" "net/netip" "net/url" + "os" "strconv" "strings" @@ -21,16 +22,14 @@ import ( ) // defaultTrustedProxies lists the networks whose forwarded -// headers are honoured by default: IPv4 and IPv6 loopback, -// for a reverse proxy on the same host, and the RFC1918 -// ranges. The container image does not use it: +// headers are honoured when TRUSTED_PROXIES is unset: the +// RFC 1918 ranges. The container image does not use it: // bin/entrypoint.sh gives the server 127.0.0.1/32, since // nginx is its only client there. -const defaultTrustedProxies = "127.0.0.1/32,::1/128," + - "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" -// Default limits on stored reports; backend/README.md gives the -// reasons for these values. +// Default limits on stored reports; README.md gives the reasons for +// these values. const ( defaultReportsPerMinute = 60 defaultDataDirMaxBytes = 1 << 30 // 1 GiB @@ -42,8 +41,16 @@ var ( "must be an origin, scheme://host with an optional port", ) errNotPort = errors.New("must be a port number, 1 to 65535") - errNotBool = errors.New("must be true or false") errNotIP = errors.New("must be an IP address, or empty") + + errMetricsCredentials = errors.New( + "METRICS_USERNAME and METRICS_PASSWORD must be set together, " + + "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. @@ -73,7 +80,8 @@ type Config struct { // New loads configuration from env, .env files, and config // files, returning a fully resolved Config. It fails, with an error -// naming the setting, on a value the server cannot use. +// naming the setting, on a value the server cannot use, and on a +// config file it finds but cannot read. func New( _ fx.Lifecycle, params Params, @@ -92,7 +100,7 @@ func New( viper.SetDefault("CORS_ALLOWED_ORIGINS", "") viper.SetDefault("DATA_DIR", "./data/reports") viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes) - viper.SetDefault("DEBUG", "false") + viper.SetDefault("DEBUG", "") // An empty BIND_ADDRESS listens on every interface. viper.SetDefault("BIND_ADDRESS", "") viper.SetDefault("PORT", "8080") @@ -106,23 +114,29 @@ func New( if err != nil { var notFound viper.ConfigFileNotFoundError if !errors.As(err, ¬Found) { - log.Error("config file malformed", "error", err) - panic(err) + return nil, fmt.Errorf("config file %s: %w", + viper.ConfigFileUsed(), err) } } - // Read with strconv: viper's GetInt and GetBool would read a value - // they cannot parse as 0 or false instead of failing. + // Read with strconv: viper's GetInt would read a value it cannot + // parse as 0 instead of failing. port, err := strconv.Atoi(viper.GetString("PORT")) if err != nil || port < 1 || port > math.MaxUint16 { return nil, fmt.Errorf("PORT %q: %w", viper.GetString("PORT"), errNotPort) } - debug, err := strconv.ParseBool(viper.GetString("DEBUG")) - if err != nil { - return nil, fmt.Errorf("DEBUG %q: %w", - viper.GetString("DEBUG"), errNotBool) + // Any non-empty value turns debug logging on, "false" and "0" + // included, as the code style guide says. + debug := viper.GetString("DEBUG") != "" + + // An empty TRUSTED_PROXIES trusts no proxy, but viper reads an + // empty environment variable as unset and would give the default, + // so the environment is read first. + trustedProxies, set := os.LookupEnv("TRUSTED_PROXIES") + if !set { + trustedProxies = viper.GetString("TRUSTED_PROXIES") } s := &Config{ @@ -136,7 +150,7 @@ func New( Port: port, ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"), SentryDSN: viper.GetString("SENTRY_DSN"), - TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")), + TrustedProxies: splitList(trustedProxies), log: log, params: ¶ms, } @@ -155,8 +169,8 @@ func New( } // check fails with an error naming the first setting here whose value -// the server cannot use. New checks PORT and DEBUG as it reads them, -// and the middleware checks TRUSTED_PROXIES as it parses it. +// the server cannot use. New checks PORT as it reads it, and the +// middleware checks TRUSTED_PROXIES as it parses it. func (s *Config) check() error { // viper reads a value that is not a number as 0, so this also // catches a mistyped setting. @@ -177,6 +191,18 @@ func (s *Config) check() error { } } + // The server records and serves metrics only with both set, so + // one alone is a mistake that would otherwise go unnoticed. + if (s.MetricsUsername == "") != (s.MetricsPassword == "") { + 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) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..7396ff0 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,245 @@ +package config_test + +import ( + "os" + "slices" + "strings" + "testing" + + "sneak.berlin/go/netwatch/internal/config" + "sneak.berlin/go/netwatch/internal/globals" + "sneak.berlin/go/netwatch/internal/logger" + + "go.uber.org/fx" +) + +// requireConfigError builds the config as cli.RunServer does and fails +// the test unless that fails with an error naming setting. It uses +// fx.New, because fxtest.New fails the test itself on an error. +func requireConfigError(t *testing.T, setting string) { + t.Helper() + + app := fx.New( + fx.NopLogger, + fx.Provide(globals.New, logger.New, config.New), + fx.Invoke(func(*config.Config) {}), + ) + + err := app.Err() + if err == nil || !strings.Contains(err.Error(), setting) { + t.Fatalf("config error = %v, want one naming %s", err, setting) + } +} + +// requireTrustedProxies builds the config as cli.RunServer does and +// fails the test unless its TrustedProxies are want. +func requireTrustedProxies(t *testing.T, want []string) { + t.Helper() + + var cfg *config.Config + + app := fx.New( + fx.NopLogger, + fx.Provide(globals.New, logger.New, config.New), + fx.Populate(&cfg), + ) + + err := app.Err() + if err != nil { + t.Fatalf("config error = %v", err) + } + + if !slices.Equal(cfg.TrustedProxies, want) { + t.Fatalf("TrustedProxies = %q, want %q", cfg.TrustedProxies, want) + } +} + +// TestTrustedProxiesUnsetTrustsRFC1918: unset, the server trusts the +// RFC 1918 ranges and nothing else, loopback included. +func TestTrustedProxiesUnsetTrustsRFC1918(t *testing.T) { + // t.Setenv first, so the variable is put back as it was after the + // test. + t.Setenv("TRUSTED_PROXIES", "") + + err := os.Unsetenv("TRUSTED_PROXIES") + if err != nil { + t.Fatal(err) + } + + requireTrustedProxies(t, + []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}) +} + +// TestTrustedProxiesEmptyTrustsNothing: set and empty, the server +// trusts no proxy. viper alone would read the empty variable as unset +// and give the default. +func TestTrustedProxiesEmptyTrustsNothing(t *testing.T) { + t.Setenv("TRUSTED_PROXIES", "") + + requireTrustedProxies(t, []string{}) +} + +// TestTrustedProxiesListReplacesDefault: a list is used as given, with +// none of the default ranges added. +func TestTrustedProxiesListReplacesDefault(t *testing.T) { + t.Setenv("TRUSTED_PROXIES", "172.17.0.1/32, 2001:db8::/32") + + requireTrustedProxies(t, []string{"172.17.0.1/32", "2001:db8::/32"}) +} + +// TestSettingsLoadAsGiven: valid values pass the checks and are used +// as given. bin/entrypoint.sh starts the server with these +// BIND_ADDRESS and PORT values. +func TestSettingsLoadAsGiven(t *testing.T) { + t.Setenv("BIND_ADDRESS", "127.0.0.1") + t.Setenv("PORT", "8081") + t.Setenv("DEBUG", "true") + + var cfg *config.Config + + app := fx.New( + fx.NopLogger, + fx.Provide(globals.New, logger.New, config.New), + fx.Populate(&cfg), + ) + + err := app.Err() + if err != nil { + t.Fatalf("config error = %v", err) + } + + if cfg.BindAddress != "127.0.0.1" || cfg.Port != 8081 || !cfg.Debug { + t.Fatalf("BindAddress, Port, Debug = %q, %d, %t; "+ + "want \"127.0.0.1\", 8081, true", + cfg.BindAddress, cfg.Port, cfg.Debug) + } +} + +// TestPortMustBeAPortNumber: viper reads a value that is not a number +// as 0, on which the server would listen on a random port. +func TestPortMustBeAPortNumber(t *testing.T) { + for _, value := range []string{"abc", "0", "65536", "8080.5"} { + t.Run(value, func(t *testing.T) { + t.Setenv("PORT", value) + + requireConfigError(t, "PORT") + }) + } +} + +// TestDebugIsOnForAnyNonEmptyValue: unset or empty, debug logging is +// off; any other value turns it on, "false" and "0" included, and none +// stops the start. +func TestDebugIsOnForAnyNonEmptyValue(t *testing.T) { + for _, tc := range []struct { + name string + value string + unset bool + want bool + }{ + {name: "unset", unset: true, want: false}, + {name: "empty", value: "", want: false}, + {name: "false", value: "false", want: true}, + {name: "0", value: "0", want: true}, + {name: "yes", value: "yes", want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + // t.Setenv first, so the variable is put back as it was + // after the test. + t.Setenv("DEBUG", tc.value) + + if tc.unset { + err := os.Unsetenv("DEBUG") + if err != nil { + t.Fatal(err) + } + } + + var cfg *config.Config + + app := fx.New( + fx.NopLogger, + fx.Provide(globals.New, logger.New, config.New), + fx.Populate(&cfg), + ) + + err := app.Err() + if err != nil { + t.Fatalf("config error = %v", err) + } + + if cfg.Debug != tc.want { + t.Fatalf("Debug = %t, want %t", cfg.Debug, tc.want) + } + }) + } +} + +// TestBindAddressMustBeAnIPAddress: a host name would be looked up +// only once the server starts listening, and a mistyped one would stop +// it then with an error that does not name the setting. +func TestBindAddressMustBeAnIPAddress(t *testing.T) { + t.Setenv("BIND_ADDRESS", "localhost") + + requireConfigError(t, "BIND_ADDRESS") +} + +// TestReportsPerMinuteMustBePositive: unchecked, zero would panic +// when the routes are built, and a negative rate would lift the +// limit. +func TestReportsPerMinuteMustBePositive(t *testing.T) { + t.Setenv("REPORTS_PER_MINUTE", "0") + + requireConfigError(t, "REPORTS_PER_MINUTE") +} + +// TestDataDirMaxBytesMustBeANumber: viper reads a value that is not +// a number, such as "1GB", as 0, which would refuse every report. +func TestDataDirMaxBytesMustBeANumber(t *testing.T) { + t.Setenv("DATA_DIR_MAX_BYTES", "1GB") + + requireConfigError(t, "DATA_DIR_MAX_BYTES") +} + +// TestMetricsCredentialsGoTogether: with only one of the two set, the +// server would quietly serve no metrics, so the start fails, naming +// both. +func TestMetricsCredentialsGoTogether(t *testing.T) { + for _, set := range []string{"METRICS_USERNAME", "METRICS_PASSWORD"} { + t.Run(set, func(t *testing.T) { + t.Setenv("METRICS_USERNAME", "") + t.Setenv("METRICS_PASSWORD", "") + t.Setenv(set, "prometheus") + + requireConfigError(t, "METRICS_USERNAME") + requireConfigError(t, "METRICS_PASSWORD") + }) + } +} + +// 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, +// and an entry that is not a plain origin would match no page. +func TestCORSAllowedOriginsMustBeOrigins(t *testing.T) { + for _, entry := range []string{ + "*", + "https://*.netwatch.example", + "netwatch.example", + "https://netwatch.example/", + } { + t.Run(entry, func(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", entry) + + requireConfigError(t, "CORS_ALLOWED_ORIGINS") + }) + } +} diff --git a/backend/internal/globals/globals.go b/internal/globals/globals.go similarity index 67% rename from backend/internal/globals/globals.go rename to internal/globals/globals.go index 0e3807b..8920243 100644 --- a/backend/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -4,28 +4,24 @@ package globals import "go.uber.org/fx" -//nolint:gochecknoglobals // set from main before fx starts +//nolint:gochecknoglobals // set by cli.RunServer before fx starts var ( // Appname is the application name. Appname string // Version is the git version tag. Version string - // Buildarch is the build architecture. - Buildarch string ) // Globals holds build-time metadata for the application. type Globals struct { - Appname string - Version string - Buildarch string + Appname string + Version string } // New creates a Globals instance from package-level variables. func New(_ fx.Lifecycle) (*Globals, error) { return &Globals{ - Appname: Appname, - Buildarch: Buildarch, - Version: Version, + Appname: Appname, + Version: Version, }, nil } diff --git a/backend/internal/handlers/export_test.go b/internal/handlers/export_test.go similarity index 100% rename from backend/internal/handlers/export_test.go rename to internal/handlers/export_test.go diff --git a/backend/internal/handlers/handlers.go b/internal/handlers/handlers.go similarity index 65% rename from backend/internal/handlers/handlers.go rename to internal/handlers/handlers.go index db51059..f1b5432 100644 --- a/backend/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -5,7 +5,9 @@ package handlers import ( "context" "encoding/json" + "errors" "log/slog" + "mime" "net/http" "sneak.berlin/go/netwatch/internal/globals" @@ -18,6 +20,10 @@ import ( const jsonContentType = "application/json; charset=utf-8" +// errNotJSON is what decodeJSON returns for a request whose +// Content-Type is not application/json. +var errNotJSON = errors.New("content type is not application/json") + // reportAppender is the subset of the report buffer the handlers // depend on. Defining it here keeps the storage failure path // exercisable with a stub in tests. @@ -80,14 +86,25 @@ func (s *Handlers) respondJSON( } } -// decodeJSON decodes the request body into v. The body is -// expected to already be bounded by the body-size middleware, so -// a caller can distinguish an over-limit body from malformed -// JSON by testing the returned error for *http.MaxBytesError. +// decodeJSON decodes the request body into v. It returns errNotJSON, +// reading nothing, unless the request's Content-Type is +// application/json, with or without parameters such as charset. The +// body is expected to already be bounded by the body-size middleware, +// so a caller can distinguish an over-limit body from malformed JSON +// by testing the returned error for *http.MaxBytesError. func (s *Handlers) decodeJSON( _ http.ResponseWriter, r *http.Request, v any, ) error { + // A page on another origin can POST text/plain or a form without + // a CORS preflight, so its visitors' browsers would send it + // whatever the CORS settings. A JSON POST waits for the + // preflight, so only JSON is taken. + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + return errNotJSON + } + return json.NewDecoder(r.Body).Decode(v) } diff --git a/internal/handlers/healthcheck.go b/internal/handlers/healthcheck.go new file mode 100644 index 0000000..04e0f70 --- /dev/null +++ b/internal/handlers/healthcheck.go @@ -0,0 +1,19 @@ +package handlers + +import "net/http" + +// HandleHealthCheck returns a handler for the health check +// endpoint. It answers 200 while the status is "ok" and 503 otherwise, +// so that the image's HEALTHCHECK fails. +func (s *Handlers) HandleHealthCheck() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp := s.hc.Healthcheck() + + code := http.StatusOK + if resp.Status != "ok" { + code = http.StatusServiceUnavailable + } + + s.respondJSON(w, r, resp, code) + } +} diff --git a/internal/handlers/healthcheck_test.go b/internal/handlers/healthcheck_test.go new file mode 100644 index 0000000..ae9192d --- /dev/null +++ b/internal/handlers/healthcheck_test.go @@ -0,0 +1,133 @@ +package handlers_test + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "sneak.berlin/go/netwatch/internal/config" + "sneak.berlin/go/netwatch/internal/globals" + "sneak.berlin/go/netwatch/internal/handlers" + "sneak.berlin/go/netwatch/internal/healthcheck" + "sneak.berlin/go/netwatch/internal/logger" + "sneak.berlin/go/netwatch/internal/reportbuf" + + "go.uber.org/fx/fxtest" +) + +// newStartedHandlers builds Handlers with a real health check for the +// server named in g, and a report buffer in a temporary directory, and +// starts them, which records the time the uptime counts from. +func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers { + t.Helper() + + lc := fxtest.NewLifecycle(t) + + log, err := logger.New(lc, logger.Params{Globals: g}) + if err != nil { + t.Fatalf("logger: %v", err) + } + + buf, err := reportbuf.New(lc, reportbuf.Params{ + Config: &config.Config{DataDir: t.TempDir(), DataDirMaxBytes: 1 << 20}, + Logger: log, + }) + if err != nil { + t.Fatalf("report buffer: %v", err) + } + + hc, err := healthcheck.New(lc, + healthcheck.Params{Buffer: buf, Globals: g, Logger: log}) + if err != nil { + t.Fatalf("health check: %v", err) + } + + h, err := handlers.New(lc, handlers.Params{ + Buffer: buf, Globals: g, Healthcheck: hc, Logger: log, + }) + if err != nil { + t.Fatalf("handlers: %v", err) + } + + lc.RequireStart() + t.Cleanup(lc.RequireStop) + + return h +} + +// TestHandleHealthCheck checks the health check's answer at start, when +// no write of a report file has failed: 200, a JSON content type, and a +// JSON object with exactly the fields of healthcheck.HealthcheckResponse, +// carrying "status":"ok", this server's name and version and an uptime +// counted from its start. The answer while writes fail is checked in +// internal/reportbuf, whose tests can make a write fail. +func TestHandleHealthCheck(t *testing.T) { + t.Parallel() + + g := &globals.Globals{Appname: "netwatch-server", Version: "v1.2.3"} + h := newStartedHandlers(t, g) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodGet, "/.well-known/healthcheck", http.NoBody) + + h.HandleHealthCheck().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + contentType := rec.Header().Get("Content-Type") + if contentType != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", + contentType, "application/json; charset=utf-8") + } + + var body map[string]any + + err := json.Unmarshal(rec.Body.Bytes(), &body) + if err != nil { + t.Fatalf("body not a JSON object: %v (%q)", err, rec.Body.String()) + } + + fields := []string{ + "appname", "now", "status", "uptime_human", "uptime_seconds", "version", + } + if got := slices.Sorted(maps.Keys(body)); !slices.Equal(got, fields) { + t.Fatalf("fields = %v, want %v", got, fields) + } + + for field, want := range map[string]string{ + "appname": g.Appname, "status": "ok", "version": g.Version, + } { + if body[field] != want { + t.Errorf("%s = %v, want %q", field, body[field], want) + } + } + + now, _ := body["now"].(string) + + at, err := time.Parse(time.RFC3339Nano, now) + if err != nil || time.Since(at).Abs() > time.Minute { + t.Errorf("now = %q, want the current time in RFC 3339 (%v)", now, err) + } + + // Started just now, so the uptime is well under a minute. + human, _ := body["uptime_human"].(string) + + uptime, err := time.ParseDuration(human) + if err != nil || uptime > time.Minute { + t.Errorf("uptime_human = %q, want a duration under a minute (%v)", + human, err) + } + + seconds, ok := body["uptime_seconds"].(float64) + if !ok || seconds < 0 || seconds > time.Minute.Seconds() { + t.Errorf("uptime_seconds = %v, want a number of seconds under a minute", + body["uptime_seconds"]) + } +} diff --git a/backend/internal/handlers/report.go b/internal/handlers/report.go similarity index 82% rename from backend/internal/handlers/report.go rename to internal/handlers/report.go index e7dd4e4..a14d835 100644 --- a/backend/internal/handlers/report.go +++ b/internal/handlers/report.go @@ -30,7 +30,7 @@ type report struct { } // HandleReport returns a handler that accepts telemetry -// reports from NetWatch clients. +// reports from NetWatch clients, sent as application/json. func (s *Handlers) HandleReport() http.HandlerFunc { type response struct { Status string `json:"status"` @@ -66,9 +66,15 @@ func (s *Handlers) HandleReport() http.HandlerFunc { } // decodeErrorStatus logs a report decode failure and returns the -// status to send: 413 when the body exceeded the size limit, -// otherwise 400 for malformed JSON. +// status to send: 415 when the report was not sent as JSON, 413 when +// the body exceeded the size limit, otherwise 400 for malformed JSON. func (s *Handlers) decodeErrorStatus(err error) int { + if errors.Is(err, errNotJSON) { + s.log.Warn("report refused: Content-Type is not application/json") + + return http.StatusUnsupportedMediaType + } + var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit) @@ -86,11 +92,12 @@ func (s *Handlers) decodeErrorStatus(err error) int { } // appendErrorStatus logs a failure to store a report and returns -// the status to send: 507 when the report files are at their size -// cap, otherwise 500. +// the status to send: 507 when the reports waiting to be written fill +// the size cap, otherwise 500, as when report files cannot be written. func (s *Handlers) appendErrorStatus(err error) int { if errors.Is(err, reportbuf.ErrFull) { - s.log.Warn("report refused: report files at their size cap") + s.log.Warn("report refused: " + + "reports waiting to be written fill the size cap") return http.StatusInsufficientStorage } diff --git a/backend/internal/handlers/report_test.go b/internal/handlers/report_test.go similarity index 56% rename from backend/internal/handlers/report_test.go rename to internal/handlers/report_test.go index cd94b5c..020ba67 100644 --- a/backend/internal/handlers/report_test.go +++ b/internal/handlers/report_test.go @@ -27,6 +27,17 @@ type stubAppender struct { func (s stubAppender) Append(any) error { return s.err } +// recordingAppender keeps every report the handler stores. +type recordingAppender struct { + reports []any +} + +func (a *recordingAppender) Append(v any) error { + a.reports = append(a.reports, v) + + return nil +} + func newTestHandlers(buf stubAppender, out io.Writer) *handlers.Handlers { return handlers.NewForTest(buf, slog.New(slog.NewJSONHandler(out, nil))) } @@ -46,6 +57,147 @@ func decodeStatus(t *testing.T, body []byte) string { return resp.Status } +// TestHandleReportAcceptsValidReports checks the answer to a valid +// report, one with no hosts and one shaped as the frontend sends them: +// 200 and {"status":"ok"} as JSON. +func TestHandleReportAcceptsValidReports(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + }{ + { + name: "no hosts", + body: `{"clientId":"c1","geo":null,"hosts":[],` + + `"timestamp":"2026-10-03T12:00:00.000Z"}`, + }, + { + name: "a host with a latency and an error sample", + body: `{"clientId":"c1","geo":null,"hosts":[{` + + `"name":"Example","url":"https://example.com/",` + + `"status":"error","history":[` + + `{"t":1790000000000,"latency":42,"error":null},` + + `{"t":1790000003000,"latency":null,"error":"timeout"}]}],` + + `"timestamp":"2026-10-03T12:00:00.000Z"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newTestHandlers(stubAppender{}, io.Discard) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodPost, "/api/v1/reports", + strings.NewReader(tt.body), + ) + req.Header.Set("Content-Type", "application/json") + + h.HandleReport().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + contentType := rec.Header().Get("Content-Type") + if contentType != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", + contentType, "application/json; charset=utf-8") + } + + if got := rec.Body.String(); got != "{\"status\":\"ok\"}\n" { + t.Errorf("body = %q, want %q", got, "{\"status\":\"ok\"}\n") + } + }) + } +} + +// TestHandleReportStoresOnlyJSON checks that a report is stored only +// when sent as application/json, as the page sends it. One sent as +// text/plain or as a form, which a page on another origin can send +// without a CORS preflight, is refused with 415. +func TestHandleReportStoresOnlyJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contentType string + wantStatus int + wantStored int + }{ + { + name: "text/plain", + contentType: "text/plain;charset=UTF-8", + wantStatus: http.StatusUnsupportedMediaType, + wantStored: 0, + }, + { + name: "a urlencoded form", + contentType: "application/x-www-form-urlencoded", + wantStatus: http.StatusUnsupportedMediaType, + wantStored: 0, + }, + { + name: "a multipart form", + contentType: "multipart/form-data; boundary=x", + wantStatus: http.StatusUnsupportedMediaType, + wantStored: 0, + }, + { + name: "no Content-Type", + contentType: "", + wantStatus: http.StatusUnsupportedMediaType, + wantStored: 0, + }, + { + name: "application/json", + contentType: "application/json", + wantStatus: http.StatusOK, + wantStored: 1, + }, + { + name: "application/json with a charset", + contentType: "application/json;charset=UTF-8", + wantStatus: http.StatusOK, + wantStored: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var stored recordingAppender + + h := handlers.NewForTest(&stored, slog.New(slog.DiscardHandler)) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodPost, "/api/v1/reports", + strings.NewReader(`{"clientId":"c1","hosts":[]}`), + ) + + if tt.contentType != "" { + req.Header.Set("Content-Type", tt.contentType) + } + + h.HandleReport().ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if len(stored.reports) != tt.wantStored { + t.Errorf("reports stored = %d, want %d", + len(stored.reports), tt.wantStored) + } + }) + } +} + func TestHandleReportStorageFailureIsNon2xx(t *testing.T) { t.Parallel() @@ -56,6 +208,7 @@ func TestHandleReportStorageFailureIsNon2xx(t *testing.T) { http.MethodPost, "/api/v1/reports", strings.NewReader(`{"clientId":"c1","hosts":[]}`), ) + req.Header.Set("Content-Type", "application/json") h.HandleReport().ServeHTTP(rec, req) @@ -68,9 +221,9 @@ func TestHandleReportStorageFailureIsNon2xx(t *testing.T) { } } -// TestHandleReportFullIs507 checks the answer when the report files -// are at their size cap: 507 and the usual error body, which tells -// the client nothing more. +// TestHandleReportFullIs507 checks the answer when the reports waiting +// to be written fill the size cap: 507 and the usual error body, which +// tells the client nothing more. func TestHandleReportFullIs507(t *testing.T) { t.Parallel() @@ -81,6 +234,7 @@ func TestHandleReportFullIs507(t *testing.T) { http.MethodPost, "/api/v1/reports", strings.NewReader(`{"clientId":"c1","hosts":[]}`), ) + req.Header.Set("Content-Type", "application/json") h.HandleReport().ServeHTTP(rec, req) @@ -104,6 +258,7 @@ func TestHandleReportMalformedJSONIs400(t *testing.T) { http.MethodPost, "/api/v1/reports", strings.NewReader(`{not json`), ) + req.Header.Set("Content-Type", "application/json") h.HandleReport().ServeHTTP(rec, req) @@ -128,6 +283,8 @@ func TestHandleReportOversizeIs413(t *testing.T) { http.MethodPost, "/api/v1/reports", strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`), ) + req.Header.Set("Content-Type", "application/json") + // No declared length, so only the middleware's read cap can // stop this body. req.ContentLength = -1 @@ -156,6 +313,7 @@ func TestHandleReportDoesNotLogRawGeo(t *testing.T) { `{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`, ), ) + req.Header.Set("Content-Type", "application/json") h.HandleReport().ServeHTTP(rec, req) @@ -188,6 +346,7 @@ func TestHandleReportLogsClientIDCutToBound(t *testing.T) { `{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`, ), ) + req.Header.Set("Content-Type", "application/json") h.HandleReport().ServeHTTP(rec, req) @@ -227,6 +386,7 @@ func TestHandleReportDecodeErrorLogIsBounded(t *testing.T) { http.MethodPost, "/api/v1/reports", strings.NewReader(`{"hosts":[{"history":[{"t":`+huge+`}]}]}`), ) + req.Header.Set("Content-Type", "application/json") h.HandleReport().ServeHTTP(rec, req) diff --git a/backend/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go similarity index 67% rename from backend/internal/healthcheck/healthcheck.go rename to internal/healthcheck/healthcheck.go index 4540abd..8d7bdc3 100644 --- a/backend/internal/healthcheck/healthcheck.go +++ b/internal/healthcheck/healthcheck.go @@ -10,6 +10,7 @@ import ( "sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/logger" + "sneak.berlin/go/netwatch/internal/reportbuf" "go.uber.org/fx" ) @@ -18,6 +19,7 @@ import ( type Params struct { fx.In + Buffer *reportbuf.Buffer Config *config.Config Globals *globals.Globals Logger *logger.Logger @@ -30,14 +32,17 @@ type Healthcheck struct { params *Params } -// Response is the JSON payload returned by the health check -// endpoint. -type Response struct { +// HealthcheckResponse is the JSON payload returned by the health +// check endpoint. Its name and its snake_case keys are the ones +// GO_HTTP_SERVER_CONVENTIONS.md gives. +// +//nolint:revive,tagliatelle // name and keys from the conventions +type HealthcheckResponse struct { Appname string `json:"appname"` Now string `json:"now"` Status string `json:"status"` - UptimeHuman string `json:"uptimeHuman"` - UptimeSeconds int64 `json:"uptimeSeconds"` + UptimeHuman string `json:"uptime_human"` + UptimeSeconds int64 `json:"uptime_seconds"` Version string `json:"version"` } @@ -65,12 +70,18 @@ func New( return s, nil } -// Check returns the current health status of the application. -func (s *Healthcheck) Check() *Response { - return &Response{ +// Healthcheck returns the current health status of the application: +// "ok", or "error" while report files cannot be written. +func (s *Healthcheck) Healthcheck() *HealthcheckResponse { + status := "ok" + if s.params.Buffer.LastWriteFailed() { + status = "error" + } + + return &HealthcheckResponse{ Appname: s.params.Globals.Appname, Now: time.Now().UTC().Format(time.RFC3339Nano), - Status: "ok", + Status: status, UptimeHuman: s.uptime().String(), UptimeSeconds: int64(s.uptime().Seconds()), Version: s.params.Globals.Version, diff --git a/backend/internal/logger/logger.go b/internal/logger/logger.go similarity index 98% rename from backend/internal/logger/logger.go rename to internal/logger/logger.go index de6c884..fb31eeb 100644 --- a/backend/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -5,6 +5,7 @@ package logger import ( "log/slog" "os" + "runtime" "sneak.berlin/go/netwatch/internal/globals" @@ -95,6 +96,6 @@ func (l *Logger) Identify() { l.log.Info("starting", "appname", l.params.Globals.Appname, "version", l.params.Globals.Version, - "buildarch", l.params.Globals.Buildarch, + "arch", runtime.GOARCH, ) } diff --git a/backend/internal/middleware/export_test.go b/internal/middleware/export_test.go similarity index 100% rename from backend/internal/middleware/export_test.go rename to internal/middleware/export_test.go diff --git a/backend/internal/middleware/middleware.go b/internal/middleware/middleware.go similarity index 69% rename from backend/internal/middleware/middleware.go rename to internal/middleware/middleware.go index aff7864..5dd22bf 100644 --- a/backend/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -11,6 +11,7 @@ import ( "net/http" "net/netip" "runtime/debug" + "slices" "strings" "time" @@ -18,9 +19,14 @@ import ( "sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/logger" + basicauth "github.com/99designs/basicauth-go" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/httprate" + "github.com/prometheus/client_golang/prometheus" + metrics "github.com/slok/go-http-metrics/metrics/prometheus" + ghmm "github.com/slok/go-http-metrics/middleware" + "github.com/slok/go-http-metrics/middleware/std" "go.uber.org/fx" ) @@ -128,6 +134,17 @@ func ipFromHostPort(hostPort string) string { // X-Real-IP are honoured only when the direct peer is a // trusted proxy; otherwise the direct peer is returned so a // spoofed header cannot forge the logged address. +// +// Each proxy appends to X-Forwarded-For the address it took the +// request from, so the entries on the right are written by the +// trusted proxies and those further left by the client. The +// client's address is therefore the right-most entry that is not +// a trusted proxy. An entry written as an address with a port is +// read as that address, both for the trust check and as the +// result. When every entry is a trusted proxy, or the walk from +// the right meets an entry that is not an address at all (such as +// "unknown"), the peer is used. X-Real-IP is read only when there +// is no X-Forwarded-For entry at all. func clientIP( remoteAddr string, header http.Header, @@ -139,28 +156,49 @@ func clientIP( return peer } - if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" { - return xff + forwarded := forwardedFor(header) + + if len(forwarded) == 0 { + if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) { + return xr + } + + return peer } - if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) { - return xr + for _, entry := range slices.Backward(forwarded) { + if !validIP(entry) { + return peer + } + + if !addrInAny(entry, trusted) { + return entry + } } return peer } -// firstForwardedFor returns the left-most valid address in an -// X-Forwarded-For list (the original client), or "" if none. -func firstForwardedFor(value string) string { +// forwardedFor returns the entries of X-Forwarded-For in order, +// leaving out blank ones. A proxy may add its entry as a header +// line of its own rather than append it to the line the client +// sent, so every line is read, not only the first. Some proxies +// write an entry as an address with a port, such as +// 198.51.100.7:5000 or [2001:db8::1]:443; the port is removed so +// that the entry is read as its address. +func forwardedFor(header http.Header) []string { + value := strings.Join(header.Values("X-Forwarded-For"), ",") + + var entries []string + for part := range strings.SplitSeq(value, ",") { - candidate := strings.TrimSpace(part) - if validIP(candidate) { - return candidate + entry := strings.TrimSpace(part) + if entry != "" { + entries = append(entries, ipFromHostPort(entry)) } } - return "" + return entries } func validIP(s string) bool { @@ -292,12 +330,16 @@ func (s *Middleware) MaxBodyBytes( // Recoverer returns middleware that recovers from a panic in a // downstream handler, logs the panic and stack trace through -// slog, and responds 500 with no body. http.ErrAbortHandler is -// re-panicked so the server can abort the response as intended. +// slog, and responds 500 with no body, unless a status was already +// written, as Write500OnPanic writes one for a handler's panic. +// http.ErrAbortHandler is re-panicked so the server can abort the +// response as intended. func (s *Middleware) Recoverer() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + defer func() { rec := recover() if rec == nil { @@ -315,10 +357,43 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler { "stack", string(debug.Stack()), ) - w.WriteHeader(http.StatusInternalServerError) + if ww.Status() == 0 { + ww.WriteHeader(http.StatusInternalServerError) + } }() - next.ServeHTTP(w, r) + next.ServeHTTP(ww, r) + }, + ) + } +} + +// Write500OnPanic returns middleware that writes 500 when a downstream +// handler panics before writing a status, and lets the panic go on to +// Sentry and Recoverer. Recoverer, which comes first, would write its +// 500 only after the request log and the metrics had recorded the +// status they saw; placed after each of them, this makes them record +// the 500. +func (s *Middleware) Write500OnPanic() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + defer func() { + rec := recover() + if rec == nil { + return + } + + if ww.Status() == 0 { + ww.WriteHeader(http.StatusInternalServerError) + } + + panic(rec) + }() + + next.ServeHTTP(ww, r) }, ) } @@ -364,3 +439,25 @@ func (s *Middleware) RateLimit( ), ) } + +// Metrics returns middleware that records each request's duration and +// response size, and the requests in progress, in registry. They are +// labelled by the request path. +func (s *Middleware) Metrics( + registry prometheus.Registerer, +) func(http.Handler) http.Handler { + mdlw := ghmm.New(ghmm.Config{ + Recorder: metrics.NewRecorder(metrics.Config{Registry: registry}), + }) + + return std.HandlerProvider("", mdlw) +} + +// MetricsAuth returns middleware that lets a request through only with +// METRICS_USERNAME and METRICS_PASSWORD as its basic auth credentials, +// and answers any other with 401. +func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { + return basicauth.New("metrics", map[string][]string{ + s.params.Config.MetricsUsername: {s.params.Config.MetricsPassword}, + }) +} diff --git a/backend/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go similarity index 69% rename from backend/internal/middleware/middleware_test.go rename to internal/middleware/middleware_test.go index c916cf2..efcd668 100644 --- a/backend/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "log" "log/slog" "net/http" "net/http/httptest" @@ -16,16 +17,24 @@ import ( "sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/middleware" + "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/prometheus/client_golang/prometheus" ) const ( // loopbackPeer is a remote address inside the trusted-proxy allowlist. loopbackPeer = "127.0.0.1:5000" + // loopbackIP is the address of loopbackPeer, which ClientIP returns + // when it uses the peer. + loopbackIP = "127.0.0.1" // forwardedIP is the client address presented via X-Forwarded-For. forwardedIP = "203.0.113.7" // realIP is the client address presented via X-Real-IP. realIP = "203.0.113.9" + // appendedIP is the X-Forwarded-For entry a trusted proxy appended + // after the entries the client wrote. + appendedIP = "198.51.100.7" ) func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix { @@ -82,10 +91,35 @@ func clientIPCases() []clientIPCase { want: forwardedIP, }, { - name: "trusted proxy uses left-most of chain", - remoteAddr: "10.1.2.3:5000", - xff: forwardedIP + ", 10.1.2.3", - want: forwardedIP, + name: "trusted proxy uses right-most of chain", + remoteAddr: loopbackPeer, + xff: "203.0.113.9, " + appendedIP, + want: appendedIP, + }, + { + name: "trusted proxy entries are skipped from the right", + remoteAddr: loopbackPeer, + xff: "203.0.113.9, 10.0.0.5", + want: "203.0.113.9", + }, + { + name: "chain of only trusted proxies uses peer", + remoteAddr: loopbackPeer, + xff: "10.0.0.5, 10.1.2.3", + want: loopbackIP, + }, + { + name: "entry that is not an address uses peer", + remoteAddr: loopbackPeer, + xff: "203.0.113.9, unknown", + want: loopbackIP, + }, + { + name: "x-real-ip ignored when forwarded-for has entries", + remoteAddr: loopbackPeer, + xff: "10.0.0.5", + xRealIP: realIP, + want: loopbackIP, }, { name: "trusted proxy falls back to x-real-ip", @@ -114,7 +148,7 @@ func clientIPCases() []clientIPCase { name: "trusted proxy with garbage header uses peer", remoteAddr: loopbackPeer, xff: "not-an-ip", - want: "127.0.0.1", + want: loopbackIP, }, } } @@ -145,6 +179,47 @@ func TestClientIP(t *testing.T) { } } +// TestClientIPReadsEveryForwardedForLine checks that an entry a proxy +// adds as an X-Forwarded-For line of its own is read, and the line the +// client sent before it is not taken. +func TestClientIPReadsEveryForwardedForLine(t *testing.T) { + t.Parallel() + + header := http.Header{} + header.Add("X-Forwarded-For", "203.0.113.9") + header.Add("X-Forwarded-For", appendedIP) + + got := middleware.ClientIP(loopbackPeer, header, + mustPrefixes(t, "127.0.0.1/32")) + if got != appendedIP { + t.Errorf("ClientIP() = %q, want %q", got, appendedIP) + } +} + +// TestClientIPReadsAddressWithPort checks that an X-Forwarded-For entry +// written as an address with a port is read as that address, both for +// the trust check and as the result. +func TestClientIPReadsAddressWithPort(t *testing.T) { + t.Parallel() + + trusted := mustPrefixes(t, "127.0.0.1/32", "10.0.0.0/8") + + for xff, want := range map[string]string{ + "203.0.113.9, " + appendedIP + ":5000": appendedIP, + "203.0.113.9, [2001:db8::1]:443": "2001:db8::1", + "203.0.113.9, 10.0.0.5:5000": "203.0.113.9", + } { + header := http.Header{} + header.Set("X-Forwarded-For", xff) + + got := middleware.ClientIP(loopbackPeer, header, trusted) + if got != want { + t.Errorf("X-Forwarded-For %q: ClientIP() = %q, want %q", + xff, got, want) + } + } +} + func TestSecurityHeaders(t *testing.T) { t.Parallel() @@ -323,6 +398,172 @@ func TestRecovererRepanicsOnAbortHandler(t *testing.T) { } } +// TestHandlerPanicIsLoggedAndCountedAs500 sends a request to a handler +// that panics through the middleware SetupRoutes puts around a route +// with metrics, in its order, leaving out those that write no status +// for a panic and Sentry, which TestSentry covers. The request log line +// and the metrics must give the 500 the client gets, and the panic must +// still reach Recoverer, which logs it. +func TestHandlerPanicIsLoggedAndCountedAs500(t *testing.T) { + t.Parallel() + + var logbuf bytes.Buffer + + mw := middleware.NewWithLogger( + slog.New(slog.NewJSONHandler(&logbuf, nil)), + ) + registry := prometheus.NewRegistry() + + handler := chi.Chain( + mw.Recoverer(), + chimiddleware.RequestID, + mw.Logging(), + mw.Write500OnPanic(), + mw.Metrics(registry), + mw.Write500OnPanic(), + ).HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("boom") + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", + rec.Code, http.StatusInternalServerError) + } + + request := findLogLine(t, logbuf.Bytes(), "request") + if request.Status != http.StatusInternalServerError { + t.Errorf("request log status = %d, want %d", + request.Status, http.StatusInternalServerError) + } + + recovered := findLogLine(t, logbuf.Bytes(), "panic recovered") + if recovered.Panic != "boom" { + t.Errorf("panic recovered line has panic %q, want %q", + recovered.Panic, "boom") + } + + for _, name := range []string{ + "http_request_duration_seconds", "http_response_size_bytes", + } { + codes := metricCodes(t, registry, name) + if len(codes) != 1 || codes[0] != "500" { + t.Errorf("%s code labels = %v, want [500]", name, codes) + } + } +} + +// loggedLine holds the fields the tests read from a JSON log line. +type loggedLine struct { + Msg string `json:"msg"` + Status int `json:"status"` + Panic string `json:"panic"` +} + +// findLogLine returns the first of the JSON log lines in logged whose +// message is msg. +func findLogLine(t *testing.T, logged []byte, msg string) loggedLine { + t.Helper() + + dec := json.NewDecoder(bytes.NewReader(logged)) + + for { + var line loggedLine + + err := dec.Decode(&line) + if err != nil { + t.Fatalf("no %q line in the log: %v (%q)", msg, err, logged) + } + + if line.Msg == msg { + return line + } + } +} + +// metricCodes returns the code label of each series of the metric +// family name in registry. +func metricCodes( + t *testing.T, + registry *prometheus.Registry, + name string, +) []string { + t.Helper() + + families, err := registry.Gather() + if err != nil { + t.Fatalf("gather metrics: %v", err) + } + + var codes []string + + for _, family := range families { + if family.GetName() != name { + continue + } + + for _, metric := range family.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "code" { + codes = append(codes, label.GetValue()) + } + } + } + } + + return codes +} + +// TestRecovererWritesNoSecondStatus checks, on a real server, that +// Recoverer does not write its 500 again after Write500OnPanic has +// written one, which the server would log as a superfluous +// WriteHeader call. +func TestRecovererWritesNoSecondStatus(t *testing.T) { + t.Parallel() + + var serverLog bytes.Buffer + + mw := middleware.NewWithLogger(slog.New(slog.DiscardHandler)) + + server := httptest.NewUnstartedServer(chi.Chain( + mw.Recoverer(), + mw.Write500OnPanic(), + ).HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("boom") + })) + server.Config.ErrorLog = log.New(&serverLog, "", 0) + server.Start() + + req, err := http.NewRequestWithContext(t.Context(), + http.MethodGet, server.URL, http.NoBody) + if err != nil { + t.Fatalf("new request: %v", err) + } + + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + + _ = resp.Body.Close() + + // Close waits for the handler to return, so whatever the server + // logs for this request is in serverLog after it. + server.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("status = %d, want %d", + resp.StatusCode, http.StatusInternalServerError) + } + + if serverLog.Len() != 0 { + t.Errorf("server logged %q", serverLog.String()) + } +} + // TestLoggingCutsRequestStringsToBound sends an over-long URL and // over-long header values, and checks the request log writes each // one cut to logger.MaxLoggedFieldBytes. @@ -343,7 +584,7 @@ func TestLoggingCutsRequestStringsToBound(t *testing.T) { http.MethodGet, "/"+long, http.NoBody) req.Header.Set("User-Agent", long) req.Header.Set("Referer", long) - req.Header.Set("X-Request-Id", long) + req.Header.Set("X-Request-ID", long) handler.ServeHTTP(httptest.NewRecorder(), req) diff --git a/internal/reportbuf/datadir.go b/internal/reportbuf/datadir.go new file mode 100644 index 0000000..1516e9f --- /dev/null +++ b/internal/reportbuf/datadir.go @@ -0,0 +1,180 @@ +package reportbuf + +import ( + "errors" + "io/fs" + "os" + "os/user" + "path/filepath" + "strconv" + "syscall" +) + +// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR +// that is not the volume or a path below it, written in full. +var ErrDataDirOutsideVolume = errors.New( + "must be /data or a path below it, with no '.', '..' or extra '/'") + +// PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the +// user the server runs as, so that a host directory mounted at volume, +// /data in the image, needs no preparing: it creates dir, gives volume +// and the directories, regular files and symbolic links in it to +// owner, and gives volume and dir the mode the server gives a directory +// it creates. It leaves a file with a second name (hard link) as it +// is, as that name could be outside volume, and logs its path; it +// leaves a device, named pipe or socket as it is too (see chownFile). +// dir must be volume or a path below it, with no '.', '..', empty part +// or '/' at the end. On any system but Linux, the system of the image, it +// fails at the first file in volume that is not a directory (see +// chownFile). +// +// bin/entrypoint.sh runs this as root, which would follow a symbolic +// link anywhere, so every step goes through an os.Root opened on +// volume: it follows a link only when it is written as a relative +// path that stays inside volume, and refuses any other. A process on +// the host can swap a link onto a path in volume at any moment while +// this runs. Even then, the os.Root checks each link as it reaches +// it. MkdirAll creates each directory inside a parent it already has +// open, never following a link at the name it creates, and follows a +// link on the path only as the os.Root allows, so a relative link +// inside volume can lead it to create directories elsewhere inside +// volume. The owners and modes are set on files already opened (see +// chownAll and chmodDir), so the most that process can do is make a +// step fail or wait, or act on something else inside volume. +func PrepareDataDir(volume, dir string, owner *user.User) error { + // rel is dir as a path from volume; IsLocal is false for one that + // leads out of it. + rel, err := filepath.Rel(volume, dir) + if err != nil || dir != filepath.Clean(dir) || !filepath.IsLocal(rel) { + return ErrDataDirOutsideVolume + } + + uid, err := strconv.Atoi(owner.Uid) + if err != nil { + return err + } + + gid, err := strconv.Atoi(owner.Gid) + if err != nil { + return err + } + + root, err := os.OpenRoot(volume) + if err != nil { + return err + } + + defer func() { _ = root.Close() }() + + err = root.MkdirAll(rel, dirPerms) + if err != nil { + return err + } + + err = chownAll(root, ".", uid, gid) + if err != nil { + return err + } + + err = chmodDir(root, ".") + if err != nil { + return err + } + + return chmodDir(root, rel) +} + +// chownAll gives name, a directory inside root, and the directories, +// regular files and symbolic links in it to uid and gid, but not the +// files chownFile leaves. It opens each through root and gives the +// open file to them, as chmodDir sets a mode on the open directory. +// Root.Lchown would not hold: it acts on whatever is at the name by +// then, and a hard link swapped in there shares its owner with a file +// that could be outside root. A symbolic link is given to them itself, +// never what it points to, because in a directory that anyone can +// write to and that has the sticky bit set, as /tmp has, the kernel +// follows a link only for the link's owner or when the link and the +// directory have the same owner. chownAll goes into an entry only when +// the read found a directory there, so it follows no link it finds; +// one swapped in afterwards for a directory is followed only as the +// os.Root allows, and one swapped in for any other file is not +// followed at all (see chownFile). +func chownAll(root *os.Root, name string, uid, gid int) error { + entries, err := chownDir(root, name, uid, gid) + if err != nil { + return err + } + + for _, entry := range entries { + entryName := filepath.Join(name, entry.Name()) + if entry.IsDir() { + err = chownAll(root, entryName, uid, gid) + } else { + err = chownFile(root, entryName, uid, gid) + } + + if err != nil { + return err + } + } + + return nil +} + +// chownDir gives name, a directory inside root, to uid and gid, and +// returns what it holds. Like chmodDir, it opens name through root and +// refuses anything but a directory, which has no second name (hard +// link). It reads the open directory, not root.FS(), which refuses a +// name that is not valid UTF-8. +func chownDir(root *os.Root, name string, uid, gid int) ([]fs.DirEntry, error) { + dir, err := root.Open(name) + if err != nil { + return nil, err + } + + defer func() { _ = dir.Close() }() + + info, err := dir.Stat() + if err != nil { + return nil, err + } + + if !info.IsDir() { + return nil, &fs.PathError{Op: "chown", Path: name, Err: syscall.ENOTDIR} + } + + err = dir.Chown(uid, gid) + if err != nil { + return nil, err + } + + return dir.ReadDir(-1) +} + +// chmodDir gives name, a directory inside root, the mode the server +// gives a directory it creates. Root.Chmod would not hold: on Linux it +// checks that name is not a symbolic link, then sets the mode by name, +// following a link swapped in between. So chmodDir opens name through +// root and sets the mode on the open directory. It refuses anything +// but a directory: a directory has no second name (hard link), so the +// one opened is inside root, where any other file could be a hard link +// to one outside. +func chmodDir(root *os.Root, name string) error { + dir, err := root.Open(name) + if err != nil { + return err + } + + defer func() { _ = dir.Close() }() + + info, err := dir.Stat() + if err != nil { + return err + } + + if !info.IsDir() { + return &fs.PathError{Op: "chmod", Path: name, Err: syscall.ENOTDIR} + } + + return dir.Chmod(dirPerms) +} diff --git a/internal/reportbuf/datadir_linux.go b/internal/reportbuf/datadir_linux.go new file mode 100644 index 0000000..7d9f926 --- /dev/null +++ b/internal/reportbuf/datadir_linux.go @@ -0,0 +1,65 @@ +//go:build linux + +package reportbuf + +import ( + "io/fs" + "log/slog" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +// chownFile gives name, a file inside root that is not a directory, to +// uid and gid if it is a regular file or symbolic link with one name. +// It leaves, and logs, a file of any kind with a second name (hard +// link): all the names of a file share its owner, and the other could +// be outside root. It leaves any other file with one name, such as a +// device, named pipe or socket: the server needs to own none of them. +// It opens name through root with O_PATH, which reads nothing from the +// file, so opening a device or named pipe neither waits nor acts on +// it; os.Root adds O_NOFOLLOW, and the two together open a symbolic +// link itself, never what it points to. It reads the kind of file and +// its number of names from the open file and gives that file to them, +// so a hard link swapped in at name is caught too. +// +// This file is built only on Linux: prepare-data-dir runs only in the +// Linux image, and macOS, where the backend is also built for +// development, has neither O_PATH nor AT_EMPTY_PATH. On any other +// system chownFile returns an error (see datadir_other.go). +func chownFile(root *os.Root, name string, uid, gid int) error { + file, err := root.OpenFile(name, unix.O_PATH, 0) + if err != nil { + return err + } + + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil { + return err + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Nlink > 1 { + slog.Warn("owner not changed: file has a second name (hard link)", + "path", file.Name()) + + return nil + } + + if !info.Mode().IsRegular() && info.Mode().Type() != fs.ModeSymlink { + return nil + } + + // File.Chown would fail: fchown refuses a file opened with O_PATH. + // Fchownat with an empty name and AT_EMPTY_PATH acts on the open + // file instead. + err = unix.Fchownat(int(file.Fd()), "", uid, gid, unix.AT_EMPTY_PATH) + if err != nil { + return &fs.PathError{Op: "chown", Path: file.Name(), Err: err} + } + + return nil +} diff --git a/internal/reportbuf/datadir_other.go b/internal/reportbuf/datadir_other.go new file mode 100644 index 0000000..d563839 --- /dev/null +++ b/internal/reportbuf/datadir_other.go @@ -0,0 +1,21 @@ +//go:build !linux + +package reportbuf + +import ( + "errors" + "io/fs" + "os" +) + +// chownFile returns an error on every system but Linux. Opening a file +// that is not a directory without reading it or following it, and +// giving it to a user on the open file, never by name, is built only +// for Linux (see datadir_linux.go): prepare-data-dir runs only in the +// Linux image, and macOS, where the backend is also built for +// development, has neither O_PATH nor AT_EMPTY_PATH. This file lets +// the package build elsewhere, such as on macOS, where a developer +// runs make build and make run. +func chownFile(_ *os.Root, name string, _, _ int) error { + return &fs.PathError{Op: "chown", Path: name, Err: errors.ErrUnsupported} +} diff --git a/internal/reportbuf/datadir_test.go b/internal/reportbuf/datadir_test.go new file mode 100644 index 0000000..3618dda --- /dev/null +++ b/internal/reportbuf/datadir_test.go @@ -0,0 +1,339 @@ +package reportbuf_test + +import ( + "errors" + "io/fs" + "os" + "os/user" + "path/filepath" + "strconv" + "syscall" + "testing" + + "sneak.berlin/go/netwatch/internal/reportbuf" +) + +// reports is the last part of DATA_DIR in these tests, as in the +// image's /data/reports. +const reports = "reports" + +// currentUser is the user the test runs as, the only owner a test not +// run as root can give files to. +func currentUser() *user.User { + return &user.User{ + Uid: strconv.Itoa(os.Getuid()), + Gid: strconv.Itoa(os.Getgid()), + } +} + +// tempDirMode700 is a new directory in a t.TempDir with mode 0700, so +// a test can tell that PrepareDataDir left its mode alone. +func tempDirMode700(t *testing.T) string { + t.Helper() + + dir := filepath.Join(t.TempDir(), "d") + + err := os.Mkdir(dir, 0o700) + if err != nil { + t.Fatal(err) + } + + return dir +} + +func requireMode(t *testing.T, path string, want fs.FileMode) { + t.Helper() + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + if info.Mode() != want { + t.Errorf("%s: mode %v, want %v", path, info.Mode(), want) + } +} + +func requireMissing(t *testing.T, path string) { + t.Helper() + + _, err := os.Lstat(path) + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("%s: created, or Lstat failed: %v", path, err) + } +} + +func requireOwner(t *testing.T, path string, uid, gid uint32) { + t.Helper() + + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + + stat, _ := info.Sys().(*syscall.Stat_t) + if stat.Uid != uid || stat.Gid != gid { + t.Errorf("%s: owner %d:%d, want %d:%d", path, stat.Uid, stat.Gid, + uid, gid) + } +} + +func TestPrepareDataDirCreatesDataDir(t *testing.T) { + t.Parallel() + + volume := t.TempDir() + dir := filepath.Join(volume, "a", reports) + + err := reportbuf.PrepareDataDir(volume, dir, currentUser()) + if err != nil { + t.Fatal(err) + } + + requireMode(t, volume, fs.ModeDir|0o750) + requireMode(t, dir, fs.ModeDir|0o750) +} + +// TestPrepareDataDirSetsModeOfExistingDataDir: a DATA_DIR already on +// the host with another mode gets the mode too, not only a new one. +func TestPrepareDataDirSetsModeOfExistingDataDir(t *testing.T) { + t.Parallel() + + volume := t.TempDir() + dir := filepath.Join(volume, reports) + + err := os.Mkdir(dir, 0o700) + if err != nil { + t.Fatal(err) + } + + err = reportbuf.PrepareDataDir(volume, dir, currentUser()) + if err != nil { + t.Fatal(err) + } + + requireMode(t, dir, fs.ModeDir|0o750) +} + +func TestPrepareDataDirTakesTheVolumeItself(t *testing.T) { + t.Parallel() + + volume := t.TempDir() + + err := reportbuf.PrepareDataDir(volume, volume, currentUser()) + if err != nil { + t.Fatal(err) + } + + requireMode(t, volume, fs.ModeDir|0o750) +} + +// TestPrepareDataDirRefusesDataDirOutsideVolume covers a DATA_DIR that +// is relative, outside the volume, or not written in full. +func TestPrepareDataDirRefusesDataDirOutsideVolume(t *testing.T) { + t.Parallel() + + volume := tempDirMode700(t) + for _, dir := range []string{ + reports, volume + "/../new", volume + "/", volume + "//" + reports, + volume + "/./" + reports, volume + "/" + reports + "/..", volume + "x", + "/etc", + } { + err := reportbuf.PrepareDataDir(volume, dir, currentUser()) + if !errors.Is(err, reportbuf.ErrDataDirOutsideVolume) { + t.Errorf("%q: error = %v, want ErrDataDirOutsideVolume", dir, err) + } + } + + requireMissing(t, filepath.Join(filepath.Dir(volume), "new")) + requireMode(t, volume, fs.ModeDir|0o700) +} + +// TestPrepareDataDirRefusesLinkOutOfVolume puts a symbolic link to a +// directory outside the volume on the path to DATA_DIR, written as a +// full path and as one that climbs out with '..', and as DATA_DIR +// itself, where the mode would be set through it. +func TestPrepareDataDirRefusesLinkOutOfVolume(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + climbsOut bool + link, dir string + }{ + {"full path", false, "x", "x/reports"}, + {"climbs out", true, "x", "x/reports"}, + {"DATA_DIR itself", false, reports, reports}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + outside := tempDirMode700(t) + volume := t.TempDir() + + climbOut, err := filepath.Rel(volume, outside) + if err != nil { + t.Fatal(err) + } + + target := outside + if tc.climbsOut { + target = climbOut + } + + err = os.Symlink(target, filepath.Join(volume, tc.link)) + if err != nil { + t.Fatal(err) + } + + err = reportbuf.PrepareDataDir(volume, + filepath.Join(volume, tc.dir), currentUser()) + if err == nil { + t.Error("no error") + } + + requireMissing(t, filepath.Join(outside, reports)) + requireMode(t, outside, fs.ModeDir|0o700) + }) + } +} + +// TestPrepareDataDirRefusesDanglingLink: DATA_DIR is a symbolic link +// to a name in the volume that does not exist, which is not created. +func TestPrepareDataDirRefusesDanglingLink(t *testing.T) { + t.Parallel() + + volume := t.TempDir() + + err := os.Symlink("missing", filepath.Join(volume, reports)) + if err != nil { + t.Fatal(err) + } + + err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports), + currentUser()) + if err == nil { + t.Error("no error") + } + + requireMissing(t, filepath.Join(volume, "missing")) +} + +// TestPrepareDataDirTakesDirectoryNamedInLatin1: a host directory can +// hold names that are not valid UTF-8, here "café" written in Latin-1. +// A test not run as root can only check that PrepareDataDir goes into +// such a directory and gives it, and what it holds, to the current +// user. +func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) { + t.Parallel() + + volume := t.TempDir() + latin1 := filepath.Join(volume, "caf\xe9") + + err := os.Mkdir(latin1, 0o700) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(filepath.Join(latin1, "f"), nil, 0o600) + if err != nil { + t.Fatal(err) + } + + owner := currentUser() + + err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports), + owner) + if err != nil { + t.Fatal(err) + } + + uid, _ := strconv.ParseUint(owner.Uid, 10, 32) + gid, _ := strconv.ParseUint(owner.Gid, 10, 32) + + requireOwner(t, latin1, uint32(uid), uint32(gid)) + requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid)) +} + +// TestPrepareDataDirGivesVolumeToOwner gives the directories, regular +// files and symbolic links in the volume to a uid and gid that own +// nothing, which only root can do. A symbolic link in the volume to a +// directory outside it is given to them itself; what it points to is +// left as it was. A hard link to a file outside the volume, one to a +// symbolic link outside it and one to a named pipe outside it are left +// as they were, and so is a named pipe in the volume. +func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) { + t.Parallel() + + if os.Geteuid() != 0 { + t.Skip("only root can give files to another uid") + } + + outside := t.TempDir() + volume := t.TempDir() + old := filepath.Join(volume, "old") + + err := os.WriteFile(filepath.Join(outside, "f"), nil, 0o600) + if err != nil { + t.Fatal(err) + } + + err = os.Symlink("f", filepath.Join(outside, "link")) + if err != nil { + t.Fatal(err) + } + + err = syscall.Mkfifo(filepath.Join(outside, "fifo"), 0o600) + if err != nil { + t.Fatal(err) + } + + err = os.Mkdir(old, 0o700) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(filepath.Join(old, "f"), nil, 0o600) + if err != nil { + t.Fatal(err) + } + + err = os.Symlink(outside, filepath.Join(old, "link")) + if err != nil { + t.Fatal(err) + } + + err = syscall.Mkfifo(filepath.Join(old, "fifo"), 0o600) + if err != nil { + t.Fatal(err) + } + + // os.Link does not follow a symbolic link: it gives the link itself + // a second name. + for _, name := range []string{"f", "link", "fifo"} { + err = os.Link(filepath.Join(outside, name), + filepath.Join(old, "hardlink-to-"+name)) + if err != nil { + t.Fatal(err) + } + } + + err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports), + &user.User{Uid: "4242", Gid: "4343"}) + if err != nil { + t.Fatal(err) + } + + for _, path := range []string{ + volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"), + filepath.Join(old, "link"), + } { + requireOwner(t, path, 4242, 4343) + } + + for _, path := range []string{ + outside, filepath.Join(outside, "f"), filepath.Join(outside, "link"), + filepath.Join(outside, "fifo"), filepath.Join(old, "fifo"), + } { + requireOwner(t, path, 0, 0) + } +} diff --git a/backend/internal/reportbuf/export_test.go b/internal/reportbuf/export_test.go similarity index 50% rename from backend/internal/reportbuf/export_test.go rename to internal/reportbuf/export_test.go index efe8133..4408b9a 100644 --- a/backend/internal/reportbuf/export_test.go +++ b/internal/reportbuf/export_test.go @@ -1,6 +1,13 @@ package reportbuf -import "time" +import ( + "os" + "time" +) + +// FlushSizeThreshold exposes the buffer size at which Append starts +// writing a report file to the external tests. +const FlushSizeThreshold = flushSizeThreshold // Flush writes the buffered reports to a file now, as the periodic // flush does, so tests need not wait a minute for it. @@ -13,3 +20,10 @@ func (b *Buffer) Flush() error { func (b *Buffer) StopClock(at time.Time) { b.now = func() time.Time { return at } } + +// OnFileCreated makes the buffer call fn with each report file it +// writes from now on, once the file is created and before anything is +// written to it. +func (b *Buffer) OnFileCreated(fn func(f *os.File)) { + b.fileCreated = fn +} diff --git a/internal/reportbuf/reportbuf.go b/internal/reportbuf/reportbuf.go new file mode 100644 index 0000000..f6eb88f --- /dev/null +++ b/internal/reportbuf/reportbuf.go @@ -0,0 +1,492 @@ +// Package reportbuf accumulates telemetry reports in memory +// and periodically flushes them to zstd-compressed JSONL files, +// deleting the oldest files to keep them under a size cap. Before the +// server starts, PrepareDataDir gets the directory those files are +// kept in, DATA_DIR, ready for the user the server runs as: it creates +// it and gives it and the volume it is on to that user. +package reportbuf + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "sneak.berlin/go/netwatch/internal/config" + "sneak.berlin/go/netwatch/internal/logger" + + "github.com/klauspost/compress/zstd" + "go.uber.org/fx" +) + +const ( + flushSizeThreshold = 10 << 20 // 10 MiB + flushInterval = 1 * time.Minute + defaultDataDir = "./data/reports" + dirPerms fs.FileMode = 0o750 + filePerms fs.FileMode = 0o640 + + // Report files are named filePrefix + timestamp + "-" + number + + // fileSuffix; see writeFile. + filePrefix = "reports-" + fileSuffix = ".jsonl.zst" +) + +// ErrFull is returned by Append when the reports waiting to be +// written leave no room for the report under the configured maximum +// size, however many report files are deleted. +var ErrFull = errors.New("reports waiting to be written fill the size cap") + +// Params defines the dependencies for Buffer. +type Params struct { + fx.In + + Config *config.Config + Logger *logger.Logger +} + +// reportFile is a report file that may be deleted to make room, with +// the size it counts for in usedBytes. +type reportFile struct { + name string + size int64 +} + +// Buffer accumulates JSON lines in memory and flushes them +// to zstd-compressed files on disk. +type Buffer struct { + buf bytes.Buffer + dataDir string + done chan struct{} + // fileCreated is called with each report file once it is + // created, before anything is written to it: it does nothing, + // except in tests that hold the write open or make it fail. + fileCreated func(f *os.File) + // files are the report files that may be deleted to make room, + // oldest first: those in dataDir at start, in name order, then + // each one this buffer writes, once it is complete. A file still + // being written is not among them. filesBytes is their total size. + files []reportFile + filesBytes int64 + log *slog.Logger + maxBytes int64 + mu sync.Mutex + // now is the clock report files are named by: time.Now, except + // in tests that need two flushes to share a timestamp. + now func() time.Time + // seq numbers the report files, so that two named in the same + // millisecond still get different names. + seq atomic.Int64 + stopOnce sync.Once + // thresholdWriteWaiting is set while a write Append started at + // flushSizeThreshold waits for writeMu; Append starts no other + // while it is set. + thresholdWriteWaiting atomic.Bool + // usedBytes is what Append checks against maxBytes: the size + // of the report files in dataDir, plus the reports waiting to + // be written to one at their uncompressed size. + usedBytes int64 + // writeErr is the error of the last write of a report file to + // finish, nil if it succeeded or none has. While it is set, Append + // refuses reports, and the reports of that write wait in buf for + // the next flush. + writeErr error + // writeMu is held across each write of a report file, so writes + // never overlap: a failed write puts its reports back in buf before + // the next write takes them, which keeps them in the order they + // were appended. + writeMu sync.Mutex + // writers are the goroutines that write report files: flushLoop, + // and each one Append starts at flushSizeThreshold. OnStop waits + // for them before the final flush. + writers sync.WaitGroup +} + +// New creates a Buffer and registers lifecycle hooks to +// manage the data directory and flush goroutine. +func New( + lc fx.Lifecycle, + params Params, +) (*Buffer, error) { + dir := params.Config.DataDir + if dir == "" { + dir = defaultDataDir + } + + b := &Buffer{ + dataDir: dir, + done: make(chan struct{}), + fileCreated: func(*os.File) {}, + log: params.Logger.Get(), + maxBytes: params.Config.DataDirMaxBytes, + now: time.Now, + } + + lc.Append(fx.Hook{ + OnStart: func(_ context.Context) error { + // The errors name DATA_DIR, as config.New's do for the + // settings it checks. + err := os.MkdirAll(b.dataDir, dirPerms) + if err != nil { + return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err) + } + + // Creating and removing a file stops the start when no + // file can be created in DATA_DIR, instead of the first + // write of a report file failing after reports were + // answered 200. A full disk is not caught: the empty + // file is still created. + f, err := os.CreateTemp(b.dataDir, "") + if err != nil { + return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err) + } + + err = errors.Join(f.Close(), os.Remove(f.Name())) + if err != nil { + return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err) + } + + // Report files left by earlier runs count too, and are + // the first to be deleted to make room. + files, err := reportFiles(b.dataDir) + if err != nil { + return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err) + } + + b.mu.Lock() + + b.files = files + for _, f := range files { + b.filesBytes += f.size + } + + b.usedBytes = b.filesBytes + + // The files may be past the cap, if it was lowered since + // the last run. + b.deleteOldestFiles(0) + + b.mu.Unlock() + + b.writers.Go(b.flushLoop) + + return nil + }, + OnStop: func(_ context.Context) error { + // stopOnce makes OnStop idempotent: a second + // invocation must not close an already-closed channel + // (which would panic) or flush again. + var err error + + b.stopOnce.Do(func() { + // Closing done ends flushLoop after any flush it is + // running. Wait for it and for the writes Append + // started, which the process exiting would cut off; + // one that fails puts its reports back in buf, for the + // final flush to write. + close(b.done) + b.writers.Wait() + + err = b.flushLocked() + }) + + // A failed final flush fails the stop, so the process + // exits non-zero. + return err + }, + }) + + return b, nil +} + +// Append marshals v as a single JSON line and appends it to +// the buffer. It stores nothing and returns an error if the last +// write of a report file failed, until a write succeeds again. If the +// line would take usedBytes past maxBytes, the oldest report files are +// deleted to make room; it stores nothing and returns ErrFull if that +// cannot make room. If the buffer reaches the size threshold, it starts +// a write of the reports in the background (see flushAtThreshold), +// unless one it started is still waiting. +func (b *Buffer) Append(v any) error { + line, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal report: %w", err) + } + + lineBytes := int64(len(line)) + 1 // with its newline + + b.mu.Lock() + + if b.writeErr != nil { + err = fmt.Errorf("report files cannot be written: %w", b.writeErr) + b.mu.Unlock() + + return err + } + + b.deleteOldestFiles(lineBytes) + + if b.usedBytes+lineBytes > b.maxBytes { + b.mu.Unlock() + + return ErrFull + } + + b.usedBytes += lineBytes + b.buf.Write(line) + b.buf.WriteByte('\n') + + if b.buf.Len() >= flushSizeThreshold && + b.thresholdWriteWaiting.CompareAndSwap(false, true) { + b.writers.Go(b.flushAtThreshold) + } + + b.mu.Unlock() + + return nil +} + +// LastWriteFailed reports whether the last write of a report file to +// finish failed: false at start until a write fails, and false again +// once a write succeeds. While it is true, Append refuses reports. +func (b *Buffer) LastWriteFailed() bool { + b.mu.Lock() + defer b.mu.Unlock() + + return b.writeErr != nil +} + +// deleteOldestFiles deletes report files, oldest first, until n more +// bytes fit under maxBytes. It deletes none when the reports waiting +// to be written leave no room for n even with every file gone, since +// that would lose the files for nothing. The caller must hold b.mu. +func (b *Buffer) deleteOldestFiles(n int64) { + for b.usedBytes+n > b.maxBytes && len(b.files) > 0 { + if b.usedBytes-b.filesBytes+n > b.maxBytes { + return + } + + f := b.files[0] + b.files = b.files[1:] + b.filesBytes -= f.size + + // A file already gone, deleted by hand, has freed its room too. + err := os.Remove(filepath.Join(b.dataDir, f.name)) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + // The file is still there, so it still counts. It is + // not tried again until the next start. + b.log.Error("delete report file failed", + "file", f.name, "error", err) + + continue + } + + b.usedBytes -= f.size + b.log.Info("deleted report file to make room", + "file", f.name, "bytes", f.size) + } +} + +// flushLoop runs a ticker that periodically flushes buffered +// data to disk until the done channel is closed. +func (b *Buffer) flushLoop() { + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + err := b.flushLocked() + if err != nil { + b.log.Error("flush reports failed", "error", err) + } + case <-b.done: + return + } + } +} + +// flushLocked waits for any write of a report file under way, then +// writes the reports waiting in the buffer to a new one. +func (b *Buffer) flushLocked() error { + b.writeMu.Lock() + defer b.writeMu.Unlock() + + return b.writeFile() +} + +// flushAtThreshold is the write Append starts when the buffer reaches +// flushSizeThreshold. Once any write under way is done, it lets Append +// start another and writes the reports waiting in the buffer. +func (b *Buffer) flushAtThreshold() { + b.writeMu.Lock() + defer b.writeMu.Unlock() + + b.thresholdWriteWaiting.Store(false) + + err := b.writeFile() + if err != nil { + b.log.Error("flush reports failed", "error", err) + } +} + +// drainBuf copies the buffer contents and resets it. +// The caller must hold b.mu. +func (b *Buffer) drainBuf() []byte { + data := make([]byte, b.buf.Len()) + copy(data, b.buf.Bytes()) + b.buf.Reset() + + return data +} + +// writeFile takes the reports waiting in the buffer, if any, and writes +// them to a new timestamped zstd-compressed JSONL file in the data +// directory. If the write fails, it puts the reports back in the +// buffer. The caller must hold b.writeMu. +func (b *Buffer) writeFile() error { + b.mu.Lock() + + if b.buf.Len() == 0 { + b.mu.Unlock() + + return nil + } + + data := b.drainBuf() + b.mu.Unlock() + + // The timestamp comes first, so the names sort by time; the number + // after it tells apart files named in the same millisecond. + ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z") + name := fmt.Sprintf("%s%s-%d%s", filePrefix, ts, b.seq.Add(1), fileSuffix) + + size, err := b.createFile(filepath.Join(b.dataDir, name), data) + + b.mu.Lock() + defer b.mu.Unlock() + + b.writeErr = err + + if err != nil { + // The reports go back in front of any appended since, for the + // next flush to write, and still count at their uncompressed + // size. + appended := bytes.Clone(b.buf.Bytes()) + b.buf.Reset() + b.buf.Write(data) + b.buf.Write(appended) + + return err + } + + // The reports no longer wait to be written, so they stop counting + // at their uncompressed size and count as the file, which from here + // on may be deleted to make room. + b.usedBytes -= int64(len(data)) + b.usedBytes += size + b.filesBytes += size + b.files = append(b.files, reportFile{name: name, size: size}) + + return nil +} + +// createFile creates the file at path holding data compressed with +// zstd, and returns its size. If the write fails once the file is +// created, it removes the file, so that a failed write leaves nothing +// behind to take room. +func (b *Buffer) createFile(path string, data []byte) (int64, error) { + // path is built from the operator-supplied dataDir plus a + // generated timestamp and number, so it carries no external input. + f, err := os.OpenFile( //nolint:gosec // see comment above + path, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + filePerms, + ) + if err != nil { + return 0, fmt.Errorf("create report file: %w", err) + } + + b.fileCreated(f) + + size, err := writeCompressed(f, data) + if err != nil { + _ = f.Close() + + return 0, errors.Join(err, os.Remove(path)) + } + + err = f.Close() + if err != nil { + err = fmt.Errorf("close report file: %w", err) + + return 0, errors.Join(err, os.Remove(path)) + } + + return size, nil +} + +// writeCompressed writes data to f compressed with zstd, and returns +// the size of f. +func writeCompressed(f *os.File, data []byte) (int64, error) { + enc, err := zstd.NewWriter(f) + if err != nil { + return 0, fmt.Errorf("create zstd encoder: %w", err) + } + + _, err = enc.Write(data) + if err != nil { + _ = enc.Close() + + return 0, fmt.Errorf("write compressed data: %w", err) + } + + err = enc.Close() + if err != nil { + return 0, fmt.Errorf("close zstd encoder: %w", err) + } + + info, err := f.Stat() + if err != nil { + return 0, fmt.Errorf("stat report file: %w", err) + } + + return info.Size(), nil +} + +// reportFiles returns the report files in dir, oldest first: +// os.ReadDir sorts them by name, and the names sort by time. +func reportFiles(dir string) ([]reportFile, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read data dir: %w", err) + } + + files := make([]reportFile, 0, len(entries)) + + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, filePrefix) || + !strings.HasSuffix(name, fileSuffix) { + continue + } + + info, err := entry.Info() + if err != nil { + return nil, fmt.Errorf("stat report file: %w", err) + } + + files = append(files, reportFile{name: name, size: info.Size()}) + } + + return files, nil +} diff --git a/internal/reportbuf/reportbuf_test.go b/internal/reportbuf/reportbuf_test.go new file mode 100644 index 0000000..ddb6d52 --- /dev/null +++ b/internal/reportbuf/reportbuf_test.go @@ -0,0 +1,1597 @@ +package reportbuf_test + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "sneak.berlin/go/netwatch/internal/config" + "sneak.berlin/go/netwatch/internal/globals" + "sneak.berlin/go/netwatch/internal/handlers" + "sneak.berlin/go/netwatch/internal/healthcheck" + "sneak.berlin/go/netwatch/internal/logger" + "sneak.berlin/go/netwatch/internal/reportbuf" + + "github.com/klauspost/compress/zstd" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" +) + +// TestFlushOnShutdown proves the flush-on-shutdown path: a +// report appended after start but before the periodic flush +// window must reach disk when the fx lifecycle stops. This is +// the exact case that silent data loss on restart used to +// destroy. +func TestFlushOnShutdown(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + var buf *reportbuf.Buffer + + app := fxtest.New(t, + fx.Provide( + globals.New, + logger.New, + config.New, + reportbuf.New, + ), + fx.Populate(&buf), + ) + + app.RequireStart() + + err := buf.Append(map[string]string{"probe": "shutdown"}) + if err != nil { + t.Fatalf("append report: %v", err) + } + + // RequireStop runs the reportbuf OnStop hook, which is the + // only code path that flushes buffered reports on shutdown. + app.RequireStop() + + if !hasReportFile(t, dir) { + t.Fatal("no report file on disk after shutdown; " + + "the buffered report was lost") + } +} + +// TestFailedFinalFlushFailsStop proves a final flush that cannot +// write its file makes the stop fail, which makes the process +// exit non-zero instead of dropping the buffered reports silently. +func TestFailedFinalFlushFailsStop(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + var buf *reportbuf.Buffer + + app := fxtest.New(t, + fx.Provide( + globals.New, + logger.New, + config.New, + reportbuf.New, + ), + fx.Populate(&buf), + ) + + app.RequireStart() + + err := buf.Append(map[string]string{"probe": "shutdown"}) + if err != nil { + t.Fatalf("append report: %v", err) + } + + // Removing the data directory leaves the final flush nowhere to + // write. A read-only directory would not do: tests run as root + // in the backend image, and root ignores the read-only bit. + err = os.RemoveAll(dir) + if err != nil { + t.Fatalf("remove data dir: %v", err) + } + + err = app.Stop(t.Context()) + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("stop error = %v, want the final flush's error", err) + } +} + +// TestUncreatableDataDirStopsStart sets DATA_DIR below a regular file, +// where no directory can be created, even by root, as the tests run in +// the backend image. The start must fail with an error naming DATA_DIR +// and its value, as the errors for the other settings do. +func TestUncreatableDataDirStopsStart(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + writeBytes(t, file, 1) + + dir := filepath.Join(file, "reports") + t.Setenv("DATA_DIR", dir) + + app := fxtest.New(t, + fx.Provide( + globals.New, + logger.New, + config.New, + reportbuf.New, + ), + fx.Invoke(func(*reportbuf.Buffer) {}), + ) + + err := app.Start(t.Context()) + if err == nil { + t.Fatal("start succeeded with DATA_DIR below a regular file") + } + + want := fmt.Sprintf("DATA_DIR %q: ", dir) + if !strings.Contains(err.Error(), want) { + t.Fatalf("start error = %q, want it to contain %q", err, want) + } +} + +// TestUnwritableDataDirStopsStart sets DATA_DIR to /proc, which exists +// and can be read, but where no file can be created, even by root, as +// the tests run in the backend image: root ignores a directory the test +// makes read-only. The start must fail with an error naming DATA_DIR and +// its value, rather than the first write of a report file. +func TestUnwritableDataDirStopsStart(t *testing.T) { + dir := "/proc" + t.Setenv("DATA_DIR", dir) + + app := fxtest.New(t, + fx.Provide( + globals.New, + logger.New, + config.New, + reportbuf.New, + ), + fx.Invoke(func(*reportbuf.Buffer) {}), + ) + + err := app.Start(t.Context()) + if err == nil { + t.Fatal("start succeeded with DATA_DIR /proc") + } + + want := fmt.Sprintf("DATA_DIR %q: ", dir) + if !strings.Contains(err.Error(), want) { + t.Fatalf("start error = %q, want it to contain %q", err, want) + } +} + +// TestStopWaitsForPeriodicWrite holds open the write the periodic flush +// starts, then stops the app. The stop must wait until the write is +// released, and the report must then be in its file. The test runs in +// a synctest bubble, whose clock moves on at once when every goroutine +// in it is waiting, so the periodic flush does not take a minute. +func TestStopWaitsForPeriodicWrite(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + synctest.Test(t, func(t *testing.T) { + buf, app := startApp(t) + + created := make(chan struct{}) + release := make(chan struct{}) + + buf.OnFileCreated(func(*os.File) { + created <- struct{}{} + + <-release + }) + + err := buf.Append(map[string]string{"id": "periodic"}) + if err != nil { + t.Fatalf("append report: %v", err) + } + + <-created + + err = stopWhileHeld(t, app, release) + if err != nil { + t.Fatalf("stop: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + want := `{"id":"periodic"}` + "\n" + if len(files) != 1 || files[0] != want { + t.Fatalf("report files = %q, want one holding %q", files, want) + } + }) +} + +// TestStopWaitsForWriteAtSizeThreshold holds open the write Append +// starts when the buffer reaches FlushSizeThreshold, then stops the +// app. The stop must wait until the write is released, and the reports +// must then be in its file. +func TestStopWaitsForWriteAtSizeThreshold(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + synctest.Test(t, func(t *testing.T) { + buf, app := startApp(t) + + created := make(chan struct{}) + release := make(chan struct{}) + + buf.OnFileCreated(func(*os.File) { + created <- struct{}{} + + <-release + }) + + appended := appendToThreshold(t, buf) + + <-created + + err := stopWhileHeld(t, app, release) + if err != nil { + t.Fatalf("stop: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + if len(files) != 1 || files[0] != appended { + t.Fatalf("%d report files, want one holding the %d bytes "+ + "appended", len(files), len(appended)) + } + }) +} + +// TestFinalFlushWritesReportsOfFailedWrite holds open the write the +// periodic flush starts, makes it fail, then stops the app. The stop +// waits for the write, which puts its report back in the buffer, and +// the final flush writes it, so the stop succeeds with nothing lost. +func TestFinalFlushWritesReportsOfFailedWrite(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + synctest.Test(t, func(t *testing.T) { + buf, app := startApp(t) + + created := make(chan *os.File) + release := make(chan struct{}) + + buf.OnFileCreated(func(f *os.File) { + created <- f + + <-release + }) + + err := buf.Append(map[string]string{"id": "failed"}) + if err != nil { + t.Fatalf("append report: %v", err) + } + + held := <-created + + // Closing the file under the write makes the write fail. + _ = held.Close() + + // The final flush's write goes through. + buf.OnFileCreated(func(*os.File) {}) + + err = stopWhileHeld(t, app, release) + if err != nil { + t.Fatalf("stop: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + want := `{"id":"failed"}` + "\n" + if len(files) != 1 || files[0] != want { + t.Fatalf("report files = %q, want one holding %q", files, want) + } + }) +} + +// stopWhileHeld stops app while a report file write is held open until +// release is closed, and fails the test if the stop returns before +// that. It then closes release and returns the stop's error. It must +// be called in a synctest bubble. +func stopWhileHeld( + t *testing.T, + app *fxtest.App, + release chan<- struct{}, +) error { + t.Helper() + + stopped := make(chan error, 1) + + go func() { stopped <- app.Stop(t.Context()) }() + + // Wait returns once every other goroutine in the bubble is waiting: + // the stop has got as far as it can while the write is held. + synctest.Wait() + + if len(stopped) != 0 { + t.Error("stop returned while a report file write was held open") + } + + close(release) + + return <-stopped +} + +// startApp starts a Buffer through fx, as cli.RunServer does, with the +// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set, and returns +// it with the app, for tests that stop the app themselves. +func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) { + t.Helper() + + var buf *reportbuf.Buffer + + app := fxtest.New(t, + fx.Provide( + globals.New, + logger.New, + config.New, + reportbuf.New, + ), + fx.Populate(&buf), + ) + + app.RequireStart() + t.Cleanup(app.RequireStop) + + return buf, app +} + +// startBuffer starts a Buffer through fx, as cli.RunServer does, with +// the DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set. +func startBuffer(t *testing.T) *reportbuf.Buffer { + t.Helper() + + buf, _ := startApp(t) + + return buf +} + +// lineBytes is what one report takes in the buffer: its JSON and a +// newline. +func lineBytes(t *testing.T, report any) int { + t.Helper() + + line, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + + return len(line) + 1 +} + +// TestAppendPastCapIsRefused fills the cap with a report not yet +// written. The next report is refused, and the report file already in +// DATA_DIR is kept: it is smaller than a report, so deleting it could +// not make room. +func TestAppendPastCapIsRefused(t *testing.T) { + report := map[string]string{"id": "cap"} + dir := t.TempDir() + earlier := reportFilePath(dir, 1) + + writeBytes(t, earlier, 1) + + t.Setenv("DATA_DIR", dir) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(1+lineBytes(t, report))) + + buf := startBuffer(t) + + err := buf.Append(report) + if err != nil { + t.Fatalf("report that fills the cap exactly: %v", err) + } + + err = buf.Append(report) + if !errors.Is(err, reportbuf.ErrFull) { + t.Fatalf("report past the cap: error = %v, want ErrFull", err) + } + + if !exists(t, earlier) { + t.Fatal("report file deleted, though that could not make room") + } +} + +// TestOldestReportFileDeletedFirst starts on a data directory holding +// report files from an earlier run, and a file that is not a report, +// which neither counts nor is ever deleted. Nothing is deleted while +// there is room; then only the oldest report file is. +func TestOldestReportFileDeletedFirst(t *testing.T) { + const fileBytes = 100 + + report := map[string]string{"id": "oldest"} + dir := t.TempDir() + oldest := reportFilePath(dir, 1) + kept := []string{ + reportFilePath(dir, 2), + reportFilePath(dir, 3), + filepath.Join(dir, "notes.txt"), + } + + writeBytes(t, oldest, fileBytes) + + for _, path := range kept { + writeBytes(t, path, fileBytes) + } + + t.Setenv("DATA_DIR", dir) + // Room for the three report files and one report. + t.Setenv("DATA_DIR_MAX_BYTES", + strconv.Itoa(3*fileBytes+lineBytes(t, report))) + + buf := startBuffer(t) + + err := buf.Append(report) + if err != nil { + t.Fatalf("report that fills the cap exactly: %v", err) + } + + if !exists(t, oldest) { + t.Fatal("oldest report file deleted while there was room") + } + + err = buf.Append(report) + if err != nil { + t.Fatalf("report past the cap: %v", err) + } + + if exists(t, oldest) { + t.Fatal("oldest report file kept when room was needed") + } + + for _, path := range kept { + if !exists(t, path) { + t.Fatalf("%s deleted; only the oldest report file should be", path) + } + } +} + +// TestStartDeletesFilesPastCap starts on report files past the cap, +// as after the cap is lowered: the oldest are deleted until the rest +// fit. +func TestStartDeletesFilesPastCap(t *testing.T) { + const fileBytes = 100 + + dir := t.TempDir() + oldest := reportFilePath(dir, 1) + kept := []string{reportFilePath(dir, 2), reportFilePath(dir, 3)} + + writeBytes(t, oldest, fileBytes) + + for _, path := range kept { + writeBytes(t, path, fileBytes) + } + + t.Setenv("DATA_DIR", dir) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(len(kept)*fileBytes)) + + startBuffer(t) + + if exists(t, oldest) { + t.Fatal("oldest report file kept, though the files were past the cap") + } + + for _, path := range kept { + if !exists(t, path) { + t.Fatalf("%s deleted, though the rest fit without it", path) + } + } +} + +// TestWrittenReportsCountAtFileSize checks that once reports are +// written, they count as their compressed file, not their +// uncompressed size, which frees room under the cap. +func TestWrittenReportsCountAtFileSize(t *testing.T) { + // Repetitive, so its file is far smaller than its JSON. + report := map[string]string{"id": strings.Repeat("a", 1000)} + size := lineBytes(t, report) + dir := t.TempDir() + + t.Setenv("DATA_DIR", dir) + // Room for the report twice over only if the first one counts + // at its file's size by the time the second arrives. + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*size-1)) + + buf := startBuffer(t) + + err := buf.Append(report) + if err != nil { + t.Fatalf("first report: %v", err) + } + + err = buf.Flush() + if err != nil { + t.Fatalf("flush: %v", err) + } + + err = buf.Append(report) + if err != nil { + t.Fatalf("second report, after the first was written: %v", err) + } + + if !hasReportFile(t, dir) { + t.Fatal("first report's file deleted, though the second fit beside it") + } +} + +// TestWrittenFilesDeletedToMakeRoom writes one report file after +// another under a small cap. Every report must be taken; files are +// deleted only when the report would not fit beside them, and the +// files kept leave room for it. +func TestWrittenFilesDeletedToMakeRoom(t *testing.T) { + const ( + maxBytes = 200 + // One file each, which take far more than maxBytes together. + reports = 50 + ) + + report := map[string]string{"id": "written"} + size := int64(lineBytes(t, report)) + dir := t.TempDir() + + t.Setenv("DATA_DIR", dir) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes)) + + buf := startBuffer(t) + + for range reports { + before := reportFilesBytes(t, dir) + + err := buf.Append(report) + if err != nil { + t.Fatalf("with %d bytes of report files: %v", before, err) + } + + after := reportFilesBytes(t, dir) + + if before+size <= maxBytes && after != before { + t.Fatalf("files deleted, though the report fit beside "+ + "their %d bytes", before) + } + + if after+size > maxBytes { + t.Fatalf("%d bytes of report files kept, leaving no room "+ + "for the report", after) + } + + err = buf.Flush() + if err != nil { + t.Fatalf("flush: %v", err) + } + } +} + +// TestWrittenFilesDeletedOldestFirst writes three report files, then +// appends a report that fits only once one of them is deleted. The +// first one written must be the one deleted. +func TestWrittenFilesDeletedOldestFirst(t *testing.T) { + const maxBytes = 1000 + + report := map[string]string{"id": "written"} + + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes)) + + buf := startBuffer(t) + + var written []string + + buf.OnFileCreated(func(f *os.File) { written = append(written, f.Name()) }) + + for range 3 { + err := buf.Append(report) + if err != nil { + t.Fatalf("append report: %v", err) + } + + err = buf.Flush() + if err != nil { + t.Fatalf("flush: %v", err) + } + } + + // The three files hold the same report, so they are the same size. + info, err := os.Stat(written[0]) + if err != nil { + t.Fatalf("stat %s: %v", written[0], err) + } + + // A report that fits beside two of the files, so deleting one makes + // exactly the room it needs. + pad := maxBytes - 2*int(info.Size()) - + lineBytes(t, map[string]string{"id": ""}) + + err = buf.Append(map[string]string{"id": strings.Repeat("a", pad)}) + if err != nil { + t.Fatalf("report that needs room: %v", err) + } + + if exists(t, written[0]) { + t.Fatal("first report file written kept when room was needed") + } + + for _, path := range written[1:] { + if !exists(t, path) { + t.Fatalf("%s deleted before the first report file written", path) + } + } +} + +// TestFailedDeletionStillCounts makes deleting the oldest report file +// fail. It is still there, so it still takes room, and the next oldest +// is deleted in its place. +func TestFailedDeletionStillCounts(t *testing.T) { + const fileBytes = 100 + + report := map[string]string{"id": "stuck"} + dir := t.TempDir() + stuck := reportFilePath(dir, 1) + next := reportFilePath(dir, 2) + newest := reportFilePath(dir, 3) + + for _, path := range []string{stuck, next, newest} { + writeBytes(t, path, fileBytes) + } + + t.Setenv("DATA_DIR", dir) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(3*fileBytes)) + + buf := startBuffer(t) + + // A directory that is not empty cannot be deleted, even by root, + // which the tests run as in the backend image. + err := os.Remove(stuck) + if err != nil { + t.Fatalf("remove %s: %v", stuck, err) + } + + err = os.Mkdir(stuck, 0o750) + if err != nil { + t.Fatalf("make directory %s: %v", stuck, err) + } + + writeBytes(t, filepath.Join(stuck, "file"), 1) + + err = buf.Append(report) + if err != nil { + t.Fatalf("report past the cap: %v", err) + } + + if exists(t, next) { + t.Fatal("next oldest report file kept: the failed deletion " + + "counted as making room") + } + + if !exists(t, newest) { + t.Fatal("newest report file deleted, though deleting one made room") + } +} + +// TestFileDeletedByHandFreesRoom deletes the oldest report file by +// hand after start. When room is needed, its room counts as freed, so +// no other file is deleted. +func TestFileDeletedByHandFreesRoom(t *testing.T) { + const fileBytes = 100 + + report := map[string]string{"id": "by-hand"} + dir := t.TempDir() + gone := reportFilePath(dir, 1) + kept := reportFilePath(dir, 2) + + writeBytes(t, gone, fileBytes) + writeBytes(t, kept, fileBytes) + + t.Setenv("DATA_DIR", dir) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*fileBytes)) + + buf := startBuffer(t) + + err := os.Remove(gone) + if err != nil { + t.Fatalf("remove %s: %v", gone, err) + } + + err = buf.Append(report) + if err != nil { + t.Fatalf("report past the cap: %v", err) + } + + if !exists(t, kept) { + t.Fatal("report file deleted, though the one deleted by hand " + + "had made room") + } +} + +// TestFileBeingWrittenIsNeverDeleted holds the write of a report file +// open under a cap with room for two reports, then appends a second +// report and a third that needs room. The file being written is the +// only report file, and it is not deleted to make room, so the third +// report is refused. Once the write is complete, its file is deleted +// when room is needed. +func TestFileBeingWrittenIsNeverDeleted(t *testing.T) { + report := map[string]string{"id": "writing"} + + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*lineBytes(t, report))) + + buf := startBuffer(t) + + created := make(chan string) + release := make(chan struct{}) + + buf.OnFileCreated(func(f *os.File) { + created <- f.Name() + + <-release + }) + + err := buf.Append(report) + if err != nil { + t.Fatalf("first report: %v", err) + } + + flushed := make(chan error) + + go func() { flushed <- buf.Flush() }() + + writing := <-created + + // Errorf, not Fatalf, until the write is released, so that a + // failure here does not leave it held. + err = buf.Append(report) + if err != nil { + t.Errorf("second report: %v", err) + } + + err = buf.Append(report) + if !errors.Is(err, reportbuf.ErrFull) { + t.Errorf("report that needs room: error = %v, want ErrFull", err) + } + + if !exists(t, writing) { + t.Error("report file deleted while it was being written") + } + + // Only this write is held; the writes after it, the final one at + // stop included, go through. + buf.OnFileCreated(func(*os.File) {}) + + close(release) + + err = <-flushed + if err != nil { + t.Fatalf("flush of the first report: %v", err) + } + + // The second report still waits to be written, so this one fits + // only once the file just written is deleted. + err = buf.Append(report) + if err != nil { + t.Fatalf("report after the write was complete: %v", err) + } + + if exists(t, writing) { + t.Fatal("complete report file kept when room was needed") + } +} + +// TestFailedWriteKeepsItsReports makes a write fail once its file is +// created, under a cap with room for one report. The part of the file +// written is removed, so it takes no room, and the report waits in +// memory for the next flush, which writes it. Once written, it counts +// as its file and no longer as a report waiting, so the cap still holds +// exactly: one more report is taken and the one after it is refused. +func TestFailedWriteKeepsItsReports(t *testing.T) { + report := map[string]string{"id": "kept"} + dir := t.TempDir() + + t.Setenv("DATA_DIR", dir) + t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report))) + + buf := startBuffer(t) + + var failed string + + // Closing the file under the write makes the write fail. + buf.OnFileCreated(func(f *os.File) { + failed = f.Name() + + _ = f.Close() + }) + + err := buf.Append(report) + if err != nil { + t.Fatalf("report that fills the cap exactly: %v", err) + } + + err = buf.Flush() + if err == nil { + t.Fatal("flush succeeded, though its file was closed under it") + } + + if exists(t, failed) { + t.Fatal("file of the failed write kept") + } + + // Writes from here on, the final one at stop included, succeed. + buf.OnFileCreated(func(*os.File) {}) + + err = buf.Flush() + if err != nil { + t.Fatalf("flush after the failed write: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + want := `{"id":"kept"}` + "\n" + if len(files) != 1 || files[0] != want { + t.Fatalf("report files = %q, want one holding %q", files, want) + } + + // The file is deleted to make room if need be. + err = buf.Append(report) + if err != nil { + t.Fatalf("report after the written one: %v", err) + } + + err = buf.Append(report) + if !errors.Is(err, reportbuf.ErrFull) { + t.Fatalf("report past the cap: error = %v, want ErrFull", err) + } +} + +// TestFailedWriteKeepsReportsAppendedDuringIt holds a write open, +// appends a report while it is held, then makes the held write fail. +// Both reports are kept, and the next flush writes them in the order +// they were appended, the held write's first. +func TestFailedWriteKeepsReportsAppendedDuringIt(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + buf := startBuffer(t) + + created := make(chan *os.File) + release := make(chan struct{}) + + buf.OnFileCreated(func(f *os.File) { + created <- f + + <-release + }) + + err := buf.Append(map[string]string{"id": "held"}) + if err != nil { + t.Fatalf("report of the held write: %v", err) + } + + flushed := make(chan error) + + go func() { flushed <- buf.Flush() }() + + held := <-created + + // Errorf, not Fatalf, until the held write is released, so that a + // failure here does not leave it held. + err = buf.Append(map[string]string{"id": "appended"}) + if err != nil { + t.Errorf("report appended while the write was held: %v", err) + } + + // Closing the file under the write makes the write fail. + _ = held.Close() + + close(release) + + err = <-flushed + if err == nil { + t.Fatal("held write succeeded, though its file was closed under it") + } + + // Writes from here on, the final one at stop included, succeed. + buf.OnFileCreated(func(*os.File) {}) + + err = buf.Flush() + if err != nil { + t.Fatalf("flush after the failed write: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + want := `{"id":"held"}` + "\n" + `{"id":"appended"}` + "\n" + if len(files) != 1 || files[0] != want { + t.Fatalf("report files = %q, want one holding %q", files, want) + } +} + +// TestFailedWritesKeepReportsInOrder holds a write open, then appends a +// report that takes the buffer to FlushSizeThreshold, so that Append +// starts a second write. The held write fails first, then the second, +// which waited for it and took both reports. The next flush writes them +// in the order they were appended, the held write's first. +func TestFailedWritesKeepReportsInOrder(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + buf := startBuffer(t) + + created := make(chan struct{}) + release := make(chan struct{}) + + // Each write is held until release is closed, then fails, as its + // file is closed under it. + buf.OnFileCreated(func(f *os.File) { + created <- struct{}{} + + <-release + + _ = f.Close() + }) + + err := buf.Append(map[string]string{"id": "held"}) + if err != nil { + t.Fatalf("report of the held write: %v", err) + } + + flushed := make(chan error) + + go func() { flushed <- buf.Flush() }() + + <-created + + big := strings.Repeat("a", reportbuf.FlushSizeThreshold) + + // Errorf, not Fatalf, until the held write is released, so that a + // failure here does not leave it held. + err = buf.Append(map[string]string{"id": big}) + if err != nil { + t.Errorf("report that starts the second write: %v", err) + } + + close(release) + + err = <-flushed + if err == nil { + t.Error("held write succeeded, though its file was closed under it") + } + + // The second write has created its file, so it has taken the + // reports. + <-created + + // Writes from here on, the final one at stop included, succeed. + buf.OnFileCreated(func(*os.File) {}) + + // Flush waits for the second write to fail before it writes. + err = buf.Flush() + if err != nil { + t.Fatalf("flush after the failed writes: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + want := `{"id":"held"}` + "\n" + `{"id":"` + big + `"}` + "\n" + if len(files) != 1 || files[0] != want { + t.Fatalf("%d report files, want one holding the held write's "+ + "report, then the second one", len(files)) + } +} + +// TestReportsRefusedWhileWritesFail posts reports through the report +// handler, as the page does. Once a write has failed, a report is +// refused with 500 until a write succeeds again; the reports taken +// before and after are stored, and the one refused is not. +func TestReportsRefusedWhileWritesFail(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + buf, h := startBufferWithHandlers(t) + + if code := postReport(t, h, "before"); code != http.StatusOK { + t.Fatalf("report before the failed write: status = %d, want %d", + code, http.StatusOK) + } + + // With the data directory gone, the write has nowhere to go. + err := os.Remove(dir) + if err != nil { + t.Fatalf("remove data dir: %v", err) + } + + err = buf.Flush() + if err == nil { + t.Fatal("flush succeeded with the data directory gone") + } + + if code := postReport(t, h, "refused"); code != http.StatusInternalServerError { + t.Fatalf("report after the failed write: status = %d, want %d", + code, http.StatusInternalServerError) + } + + err = os.Mkdir(dir, 0o750) + if err != nil { + t.Fatalf("make data dir: %v", err) + } + + err = buf.Flush() + if err != nil { + t.Fatalf("flush with the data directory back: %v", err) + } + + if code := postReport(t, h, "after"); code != http.StatusOK { + t.Fatalf("report after a write succeeded: status = %d, want %d", + code, http.StatusOK) + } + + err = buf.Flush() + if err != nil { + t.Fatalf("flush of the report after: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + stored := strings.Join(files, "") + + for _, clientID := range []string{"before", "after"} { + if !strings.Contains(stored, `"clientId":"`+clientID+`"`) { + t.Errorf("report %q not stored", clientID) + } + } + + if strings.Contains(stored, `"clientId":"refused"`) { + t.Error("refused report stored") + } +} + +// TestHealthCheckFailsWhileWritesFail asks the health check handler +// for its answer around a failed write. It answers 200 with +// "status":"ok" at start, 503 with "status":"error" from the failed +// write until a write succeeds, and 200 with "status":"ok" again after. +func TestHealthCheckFailsWhileWritesFail(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + buf, h := startBufferWithHandlers(t) + + checkHealthCheck(t, h, "at start", http.StatusOK, "ok") + + // A flush writes a file only when a report is waiting. + err := buf.Append(map[string]string{"id": "kept"}) + if err != nil { + t.Fatalf("append: %v", err) + } + + // With the data directory gone, the write has nowhere to go. + err = os.Remove(dir) + if err != nil { + t.Fatalf("remove data dir: %v", err) + } + + err = buf.Flush() + if err == nil { + t.Fatal("flush succeeded with the data directory gone") + } + + checkHealthCheck(t, h, "after the failed write", + http.StatusServiceUnavailable, "error") + + err = os.Mkdir(dir, 0o750) + if err != nil { + t.Fatalf("make data dir: %v", err) + } + + err = buf.Flush() + if err != nil { + t.Fatalf("flush with the data directory back: %v", err) + } + + checkHealthCheck(t, h, "after a write succeeded", http.StatusOK, "ok") +} + +// startBufferWithHandlers starts a Buffer and the Handlers that store +// reports in it through fx, as cli.RunServer does, with the DATA_DIR +// the calling test has set. +func startBufferWithHandlers( + t *testing.T, +) (*reportbuf.Buffer, *handlers.Handlers) { + t.Helper() + + var ( + buf *reportbuf.Buffer + h *handlers.Handlers + ) + + app := fxtest.New(t, + fx.Provide( + config.New, + globals.New, + handlers.New, + healthcheck.New, + logger.New, + reportbuf.New, + ), + fx.Populate(&buf, &h), + ) + + app.RequireStart() + t.Cleanup(app.RequireStop) + + return buf, h +} + +// postReport posts a report from clientID to the report handler, as the +// page does, and returns the status of the answer. +func postReport(t *testing.T, h *handlers.Handlers, clientID string) int { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodPost, "/api/v1/reports", + strings.NewReader(`{"clientId":"`+clientID+`","hosts":[]}`), + ) + req.Header.Set("Content-Type", "application/json") + h.HandleReport().ServeHTTP(rec, req) + + return rec.Code +} + +// checkHealthCheck asks the health check handler for its answer, and +// fails the test, saying when it asked, unless the answer has status +// code wantCode and a "status" field of wantStatus. +func checkHealthCheck( + t *testing.T, + h *handlers.Handlers, + when string, + wantCode int, + wantStatus string, +) { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodGet, "/.well-known/healthcheck", http.NoBody) + h.HandleHealthCheck().ServeHTTP(rec, req) + + var body struct { + Status string `json:"status"` + } + + err := json.Unmarshal(rec.Body.Bytes(), &body) + if err != nil { + t.Fatalf("health check %s: body not JSON: %v (%q)", + when, err, rec.Body.String()) + } + + if rec.Code != wantCode || body.Status != wantStatus { + t.Fatalf("health check %s: %d with status %q, want %d with %q", + when, rec.Code, body.Status, wantCode, wantStatus) + } +} + +// TestConcurrentAppendsStopAtCap appends from many goroutines at once +// with room for exactly roomFor reports: exactly that many must be +// taken, which holds only if Append checks and counts each report +// under one lock. +func TestConcurrentAppendsStopAtCap(t *testing.T) { + const ( + roomFor = 5 + senders = 50 + ) + + // Large, so each Append takes long enough for the senders to + // overlap while the cap is reached. + report := map[string]string{"id": strings.Repeat("a", 1_000_000)} + + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("DATA_DIR_MAX_BYTES", + strconv.Itoa(roomFor*lineBytes(t, report))) + + buf := startBuffer(t) + + var ( + taken atomic.Int64 + wg sync.WaitGroup + ) + + start := make(chan struct{}) + + for range senders { + wg.Go(func() { + <-start + + err := buf.Append(report) + if err == nil { + taken.Add(1) + } else if !errors.Is(err, reportbuf.ErrFull) { + t.Errorf("append: %v", err) + } + }) + } + + close(start) + wg.Wait() + + if got := taken.Load(); got != roomFor { + t.Fatalf("%d reports taken, want %d", got, roomFor) + } +} + +// TestTwoFlushesInOneMillisecond flushes twice within one millisecond, +// as a flush for size and the final flush at shutdown can: each flush +// must write a file of its own, and the files must hold every report. +func TestTwoFlushesInOneMillisecond(t *testing.T) { + const flushes = 2 + + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + buf := startBuffer(t) + buf.StopClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + + for id := 1; id <= flushes; id++ { + err := buf.Append(map[string]int{"id": id}) + if err != nil { + t.Fatalf("append report %d: %v", id, err) + } + + err = buf.Flush() + if err != nil { + t.Fatalf("flush %d: %v", id, err) + } + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + if len(files) != flushes { + t.Fatalf("%d report files after %d flushes", len(files), flushes) + } + + for id := 1; id <= flushes; id++ { + want := fmt.Sprintf(`{"id":%d}`+"\n", id) + if !slices.Contains(files, want) { + t.Fatalf("no report file holds report %d alone", id) + } + } +} + +// TestReportFileHoldsTheLinesAppended flushes three reports and reads +// their file back: it must decompress to exactly their JSON lines, in +// the order they were appended. +func TestReportFileHoldsTheLinesAppended(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + buf := startBuffer(t) + + for _, id := range []int{1, 2, 3} { + err := buf.Append(map[string]int{"id": id}) + if err != nil { + t.Fatalf("append report %d: %v", id, err) + } + } + + err := buf.Flush() + if err != nil { + t.Fatalf("flush: %v", err) + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + want := `{"id":1}` + "\n" + `{"id":2}` + "\n" + `{"id":3}` + "\n" + if len(files) != 1 || files[0] != want { + t.Fatalf("report files = %q, want one holding %q", files, want) + } +} + +// TestFlushAtSizeThreshold appends reports until the buffer holds +// FlushSizeThreshold bytes. The append that gets it there must write +// them all to one report file, with no call to Flush and the periodic +// flush a minute away, and no earlier append may write one. +func TestFlushAtSizeThreshold(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + buf := startBuffer(t) + + appended := appendToThreshold(t, buf) + + // Append writes the file in the background, so wait for it. + deadline := time.Now().Add(10 * time.Second) + + for { + files, err := readReportFiles(dir) + if err == nil && len(files) == 1 && files[0] == appended { + return + } + + if time.Now().After(deadline) { + t.Fatalf("%d report files (error: %v), want one holding the "+ + "%d bytes appended", len(files), err, len(appended)) + } + + time.Sleep(10 * time.Millisecond) + } +} + +// TestEachSizeThresholdStartsAWrite reaches FlushSizeThreshold twice, +// with no call to Flush and the periodic flush a minute away. Append +// must start a write each time, so that the reports end up in two +// report files. The test runs in a synctest bubble, so that +// synctest.Wait returns once each write is done. +func TestEachSizeThresholdStartsAWrite(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + synctest.Test(t, func(t *testing.T) { + buf := startBuffer(t) + + var appended string + + for range 2 { + appended = appendToThreshold(t, buf) + + synctest.Wait() + } + + files, err := readReportFiles(dir) + if err != nil { + t.Fatalf("read report files: %v", err) + } + + if !slices.Equal(files, []string{appended, appended}) { + t.Fatalf("%d report files, want two, each holding the %d "+ + "bytes appended to reach the threshold", + len(files), len(appended)) + } + }) +} + +// TestAppendStartsOneWaitingWrite holds a write open, then appends two +// reports of FlushSizeThreshold bytes each. Append starts a write at +// the first, which waits for the held one, and none at the second, +// since that write is still waiting. Every write fails, so a write +// started at the second report would find the reports back in the +// buffer and try them again: once the held write is released, two +// writes must have been started, the held one and one more. +func TestAppendStartsOneWaitingWrite(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + + synctest.Test(t, func(t *testing.T) { + buf := startBuffer(t) + + var writes atomic.Int64 + + release := make(chan struct{}) + + // Each write is held until release is closed, then fails, as its + // file is closed under it. + buf.OnFileCreated(func(f *os.File) { + writes.Add(1) + + <-release + + _ = f.Close() + }) + + err := buf.Append(map[string]string{"id": "one-waiting"}) + if err != nil { + t.Fatalf("report of the held write: %v", err) + } + + go func() { _ = buf.Flush() }() + + // Wait returns once the flush's write is held. + synctest.Wait() + + big := map[string]string{ + "id": strings.Repeat("a", reportbuf.FlushSizeThreshold), + } + + // Errorf, not Fatalf, until the held write is released, so that a + // failure here does not leave it held. + for range 2 { + err = buf.Append(big) + if err != nil { + t.Errorf("report of FlushSizeThreshold bytes: %v", err) + } + } + + close(release) + + // Wait returns once every write started is done. + synctest.Wait() + + started := writes.Load() + + // Writes from here on, the final one at stop included, succeed. + buf.OnFileCreated(func(*os.File) {}) + + if started != 2 { + t.Fatalf("%d writes started, want 2: the held one and one "+ + "at the threshold", started) + } + }) +} + +// appendToThreshold appends reports to buf until it holds +// FlushSizeThreshold bytes, so that the last append starts writing them +// to a report file, and returns the lines appended. +func appendToThreshold(t *testing.T, buf *reportbuf.Buffer) string { + t.Helper() + + // Large reports, so the threshold takes a few hundred appends. + pad := strings.Repeat("a", 64<<10) + + var appended strings.Builder + + for id := 0; appended.Len() < reportbuf.FlushSizeThreshold; id++ { + report := map[string]any{"id": id, "pad": pad} + + err := buf.Append(report) + if err != nil { + t.Fatalf("append report %d: %v", id, err) + } + + line, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report %d: %v", id, err) + } + + appended.Write(line) + appended.WriteByte('\n') + } + + return appended.String() +} + +// reportFilesBytes returns the total size of the report files in dir. +func reportFilesBytes(t *testing.T, dir string) int64 { + t.Helper() + + paths, err := filepath.Glob(filepath.Join(dir, "reports-*.jsonl.zst")) + if err != nil { + t.Fatalf("list report files: %v", err) + } + + var total int64 + + for _, path := range paths { + info, statErr := os.Stat(path) + if statErr != nil { + t.Fatalf("stat %s: %v", path, statErr) + } + + total += info.Size() + } + + return total +} + +// readReportFiles returns the decompressed contents of each report +// file in dir. A file still being written does not decompress, so it +// gives an error. +func readReportFiles(dir string) ([]string, error) { + files := os.DirFS(dir) + + names, err := fs.Glob(files, "reports-*.jsonl.zst") + if err != nil { + return nil, fmt.Errorf("list report files: %w", err) + } + + dec, err := zstd.NewReader(nil) + if err != nil { + return nil, fmt.Errorf("create zstd decoder: %w", err) + } + defer dec.Close() + + contents := make([]string, 0, len(names)) + + for _, name := range names { + compressed, readErr := fs.ReadFile(files, name) + if readErr != nil { + return nil, fmt.Errorf("read %s: %w", name, readErr) + } + + data, decErr := dec.DecodeAll(compressed, nil) + if decErr != nil { + return nil, fmt.Errorf("decompress %s: %w", name, decErr) + } + + contents = append(contents, string(data)) + } + + return contents, nil +} + +// reportFilePath returns the path in dir of a report file named as +// written on the given day of January 2026, so that a lower day sorts +// as older. +func reportFilePath(dir string, day int) string { + return filepath.Join(dir, + fmt.Sprintf("reports-2026-01-%02dT00-00-00.000Z-1.jsonl.zst", day)) +} + +func writeBytes(t *testing.T, path string, n int) { + t.Helper() + + err := os.WriteFile(path, make([]byte, n), 0o600) + if err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func exists(t *testing.T, path string) bool { + t.Helper() + + _, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return false + } + + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + + return true +} + +func hasReportFile(t *testing.T, dir string) bool { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read data dir: %v", err) + } + + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".jsonl.zst") { + info, statErr := e.Info() + if statErr != nil { + t.Fatalf("stat %s: %v", e.Name(), statErr) + } + + if info.Size() > 0 { + return true + } + } + } + + return false +} diff --git a/internal/server/export_test.go b/internal/server/export_test.go new file mode 100644 index 0000000..a113052 --- /dev/null +++ b/internal/server/export_test.go @@ -0,0 +1,23 @@ +package server + +import "github.com/go-chi/chi/v5" + +// Router exposes the router to the external tests, which add routes +// of their own to it after SetupRoutes. +func (s *Server) Router() *chi.Mux { + return s.router +} + +// MaxRequestBodyBytes exposes the router-wide body limit to the +// external tests. +const MaxRequestBodyBytes = maxRequestBodyBytes + +// MetricsRequestsPerMinute exposes the /metrics rate limit to the +// external tests. +const MetricsRequestsPerMinute = metricsRequestsPerMinute + +// ListenAddr exposes the address the server listens on to the +// external tests. +func (s *Server) ListenAddr() string { + return s.newHTTPServer().Addr +} diff --git a/backend/internal/server/http.go b/internal/server/http.go similarity index 97% rename from backend/internal/server/http.go rename to internal/server/http.go index 46f4a67..df66092 100644 --- a/backend/internal/server/http.go +++ b/internal/server/http.go @@ -4,6 +4,7 @@ import ( "errors" "net" "net/http" + "runtime" "strconv" "time" @@ -53,7 +54,7 @@ func (s *Server) listenAndServe() { s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr, "version", s.params.Globals.Version, - "buildarch", s.params.Globals.Buildarch, + "arch", runtime.GOARCH, ) err := s.httpServer.ListenAndServe() diff --git a/backend/internal/server/http_test.go b/internal/server/http_test.go similarity index 100% rename from backend/internal/server/http_test.go rename to internal/server/http_test.go diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..73e715f --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,90 @@ +package server + +import ( + "time" + + sentryhttp "github.com/getsentry/sentry-go/http" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + requestTimeout = 60 * time.Second + + // maxRequestBodyBytes caps every request body. A route group + // can mount s.mw.MaxBodyBytes with a smaller value to lower + // its bound, but cannot raise it: this cap runs first. + maxRequestBodyBytes int64 = 1 << 20 // 1 MiB + + // metricsRequestsPerMinute is how many requests to /metrics each + // client address may make a minute, whatever their credentials. A + // scraper polling every 2 seconds sends half of it, which httprate + // never refuses. + metricsRequestsPerMinute = 60 +) + +// SetupRoutes configures the chi router with middleware and +// all application routes. +func (s *Server) SetupRoutes() { + s.router = chi.NewRouter() + + s.router.Use(s.mw.Recoverer()) + s.router.Use(middleware.RequestID) + s.router.Use(s.mw.Logging()) + // s.mw.Recoverer would write a panic's 500 only after the panic has + // passed the request log, so it is written here, where the log sees it. + s.router.Use(s.mw.Write500OnPanic()) + s.router.Use(s.mw.SecurityHeaders()) + s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins)) + s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes)) + s.router.Use(middleware.Timeout(requestTimeout)) + + // Sentry reports a panic, then panics again, so that it still + // reaches s.mw.Recoverer. + if s.params.Config.SentryDSN != "" { + s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle) + } + + // The metrics go in a registry of this server's own, not in + // Prometheus' default one, which takes them only once per process. + registry := prometheus.NewRegistry() + registry.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) + + // Requests are measured only once chi has matched them to one of + // these routes, by path and method. The metrics are labelled with + // both, which any client can make up, so measuring every request + // would let clients add labels without bound. A Route here would + // be matched by its path prefix alone, so each path is given in + // full. + s.router.Group(func(r chi.Router) { + // config.New refuses one of the two credentials without the + // other. + if s.params.Config.MetricsUsername != "" { + r.Use(s.mw.Metrics(registry)) + // Likewise inside the metrics. + r.Use(s.mw.Write500OnPanic()) + } + + r.Get("/.well-known/healthcheck", s.h.HandleHealthCheck()) + + r.With(s.mw.RateLimit(s.params.Config.ReportsPerMinute)). + Post("/api/v1/reports", s.h.HandleReport()) + }) + + // The rate limit comes before the basic auth, so a client past it + // gets 429 and its password is not checked. + if s.params.Config.MetricsUsername != "" { + s.router.With( + s.mw.RateLimit(metricsRequestsPerMinute), + s.mw.MetricsAuth(), + ).Get("/metrics", promhttp.HandlerFor( + registry, promhttp.HandlerOpts{}, + ).ServeHTTP) + } +} diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go new file mode 100644 index 0000000..a0938f1 --- /dev/null +++ b/internal/server/routes_test.go @@ -0,0 +1,343 @@ +package server_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "sneak.berlin/go/netwatch/internal/config" + "sneak.berlin/go/netwatch/internal/globals" + "sneak.berlin/go/netwatch/internal/handlers" + "sneak.berlin/go/netwatch/internal/healthcheck" + "sneak.berlin/go/netwatch/internal/logger" + "sneak.berlin/go/netwatch/internal/middleware" + "sneak.berlin/go/netwatch/internal/reportbuf" + "sneak.berlin/go/netwatch/internal/server" + + "github.com/getsentry/sentry-go" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" +) + +// newServer builds a Server from the same constructors as +// cli.RunServer, configured from the environment. It is never started, +// so nothing listens. +func newServer(t *testing.T) *server.Server { + t.Helper() + + var srv *server.Server + + app := fxtest.New(t, + fx.Provide( + config.New, + globals.New, + handlers.New, + healthcheck.New, + logger.New, + middleware.New, + reportbuf.New, + server.New, + ), + fx.Populate(&srv), + ) + + err := app.Err() + if err != nil { + t.Fatalf("build server: %v", err) + } + + return srv +} + +// TestReportsAreRateLimited checks that POST /api/v1/reports is +// behind the per-address rate limit, set here to two a minute. +func TestReportsAreRateLimited(t *testing.T) { + t.Setenv("REPORTS_PER_MINUTE", "2") + + srv := newServer(t) + srv.SetupRoutes() + + post := func() int { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodPost, "/api/v1/reports", + strings.NewReader(`{"clientId":"c1","hosts":[]}`), + ) + req.Header.Set("Content-Type", "application/json") + srv.ServeHTTP(rec, req) + + return rec.Code + } + + for i := range 2 { + if code := post(); code != http.StatusOK { + t.Fatalf("report %d: status = %d, want %d", + i+1, code, http.StatusOK) + } + } + + if code := post(); code != http.StatusTooManyRequests { + t.Fatalf("third report in a minute: status = %d, want %d", + code, http.StatusTooManyRequests) + } +} + +// TestCORSAllowedOriginsReachTheRouter checks that an origin listed in +// CORS_ALLOWED_ORIGINS is allowed by the router, not only when handed +// to the CORS middleware directly. +func TestCORSAllowedOriginsReachTheRouter(t *testing.T) { + const origin = "https://netwatch.example:8443" + + t.Setenv("CORS_ALLOWED_ORIGINS", origin) + + srv := newServer(t) + srv.SetupRoutes() + + // The preflight a browser sends before it POSTs JSON from origin. + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodOptions, "/api/v1/reports", http.NoBody) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + req.Header.Set("Access-Control-Request-Headers", "content-type") + srv.ServeHTTP(rec, req) + + got := rec.Header().Get("Access-Control-Allow-Origin") + if got != origin { + t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, origin) + } +} + +// TestNoMetricsWithoutCredentials: with neither metrics setting set, +// there is no /metrics. +func TestNoMetricsWithoutCredentials(t *testing.T) { + t.Setenv("METRICS_USERNAME", "") + t.Setenv("METRICS_PASSWORD", "") + + srv := newServer(t) + srv.SetupRoutes() + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodGet, "/metrics", http.NoBody) + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +// TestMetricsBehindBasicAuth: with both metrics settings set, /metrics +// answers only with them as basic auth credentials, and shows a +// request to a route but not one to a path no route has. +func TestMetricsBehindBasicAuth(t *testing.T) { + t.Setenv("METRICS_USERNAME", "prometheus") + t.Setenv("METRICS_PASSWORD", "right") + + srv := newServer(t) + srv.SetupRoutes() + + get := func(path, username, password string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodGet, path, http.NoBody) + + if username != "" { + req.SetBasicAuth(username, password) + } + + srv.ServeHTTP(rec, req) + + return rec + } + + get("/.well-known/healthcheck", "", "") + get("/api/v1/no-such-route", "", "") + + for _, creds := range [][2]string{ + {"", ""}, + {"prometheus", "wrong"}, + {"someone", "right"}, + } { + rec := get("/metrics", creds[0], creds[1]) + if rec.Code != http.StatusUnauthorized { + t.Errorf("credentials %q: status = %d, want %d", + creds, rec.Code, http.StatusUnauthorized) + } + } + + rec := get("/metrics", "prometheus", "right") + if rec.Code != http.StatusOK { + t.Fatalf("right credentials: status = %d, want %d", + rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, `handler="/.well-known/healthcheck"`) { + t.Errorf("metrics show no health check request:\n%s", body) + } + + if strings.Contains(body, "no-such-route") { + t.Errorf("metrics show a request to a path no route has:\n%s", body) + } + + if !strings.Contains(body, "go_goroutines") { + t.Errorf("metrics show no Go runtime metrics:\n%s", body) + } +} + +// TestMetricsAreRateLimited: a client that has used up its /metrics +// allowance on wrong passwords gets 429 even with the right one, which +// is then not checked, while another client behind the same nginx +// still gets in. +func TestMetricsAreRateLimited(t *testing.T) { + t.Setenv("METRICS_USERNAME", "prometheus") + t.Setenv("METRICS_PASSWORD", "right") + // As in the container: nginx connects from loopback and names the + // client in X-Forwarded-For. + t.Setenv("TRUSTED_PROXIES", "127.0.0.1/32") + + srv := newServer(t) + srv.SetupRoutes() + + get := func(client, password string) int { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodGet, "/metrics", http.NoBody) + req.RemoteAddr = "127.0.0.1:40000" + req.Header.Set("X-Forwarded-For", client) + req.SetBasicAuth("prometheus", password) + srv.ServeHTTP(rec, req) + + return rec.Code + } + + for i := range server.MetricsRequestsPerMinute { + if code := get("203.0.113.7", "wrong"); code != http.StatusUnauthorized { + t.Fatalf("guess %d: status = %d, want %d", + i+1, code, http.StatusUnauthorized) + } + } + + if code := get("203.0.113.7", "right"); code != http.StatusTooManyRequests { + t.Fatalf("right password past the limit: status = %d, want %d", + code, http.StatusTooManyRequests) + } + + if code := get("203.0.113.8", "right"); code != http.StatusOK { + t.Fatalf("another client: status = %d, want %d", + code, http.StatusOK) + } +} + +// TestMetricsInTwoServers: two servers in one process can both have +// metrics on. +func TestMetricsInTwoServers(t *testing.T) { + t.Setenv("METRICS_USERNAME", "prometheus") + t.Setenv("METRICS_PASSWORD", "right") + + for range 2 { + newServer(t).SetupRoutes() + } +} + +// TestSentry: with SENTRY_DSN empty there is no Sentry client. With it +// pointing at a local server standing in for Sentry, a panic in a +// handler reaches that server, and the request still gets the 500 from +// the panic recovery. +func TestSentry(t *testing.T) { + const panicMessage = "handler panic for TestSentry" + + // sentry.Init sets the client for the whole process; take it away + // again so that no other test reports to Sentry. + t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) + + t.Setenv("SENTRY_DSN", "") + newServer(t) + + if sentry.CurrentHub().Client() != nil { + t.Fatal("a Sentry client exists with SENTRY_DSN empty") + } + + // The body of the first request the stand-in for Sentry receives. + received := make(chan string, 1) + + sentryServer := httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + select { + case received <- string(body): + default: + } + }, + )) + defer sentryServer.Close() + + t.Setenv("SENTRY_DSN", + "http://key@"+sentryServer.Listener.Addr().String()+"/1") + + srv := newServer(t) + srv.SetupRoutes() + srv.Router().Get("/panic", func(http.ResponseWriter, *http.Request) { + panic(panicMessage) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodGet, "/panic", http.NoBody) + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", + rec.Code, http.StatusInternalServerError) + } + + // Sentry sends from a goroutine of its own. + select { + case body := <-received: + if !strings.Contains(body, panicMessage) { + t.Fatalf("the Sentry server received no report of the panic:\n%s", + body) + } + case <-time.After(5 * time.Second): + t.Fatal("nothing reached the Sentry server") + } +} + +// TestHealthCheckRejectsOversizeBody sends the health check, which +// never reads its body, a body one byte over the limit. Only the +// router-wide body limit can reject it. +func TestHealthCheckRejectsOversizeBody(t *testing.T) { + t.Parallel() + + srv := newServer(t) + srv.SetupRoutes() + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), + http.MethodGet, "/.well-known/healthcheck", + strings.NewReader( + strings.Repeat("x", int(server.MaxRequestBodyBytes)+1), + ), + ) + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", + rec.Code, http.StatusRequestEntityTooLarge) + } + + if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" { + t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n") + } + + got := rec.Header().Get("Content-Type") + if got != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want a JSON content type", got) + } +} diff --git a/backend/internal/server/server.go b/internal/server/server.go similarity index 69% rename from backend/internal/server/server.go rename to internal/server/server.go index d14dd25..50cac5b 100644 --- a/backend/internal/server/server.go +++ b/internal/server/server.go @@ -7,8 +7,10 @@ package server import ( "context" + "fmt" "log/slog" "net/http" + "time" "sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/globals" @@ -16,10 +18,15 @@ import ( "sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/middleware" + "github.com/getsentry/sentry-go" "github.com/go-chi/chi/v5" "go.uber.org/fx" ) +// sentryFlushTimeout is how long shutdown waits for Sentry to send +// what it still holds. +const sentryFlushTimeout = 2 * time.Second + // Params defines the dependencies for Server. type Params struct { fx.In @@ -56,6 +63,11 @@ func New( s.log = params.Logger.Get() s.shutdowner = params.Shutdowner + err := s.enableSentry() + if err != nil { + return nil, err + } + lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { // Build the router and http.Server synchronously @@ -67,7 +79,7 @@ func New( s.SetupRoutes() s.httpServer = s.newHTTPServer() - go s.listenAndServe() + go func() { s.listenAndServe() }() return nil }, @@ -87,10 +99,37 @@ func (s *Server) ServeHTTP( s.router.ServeHTTP(w, r) } +// enableSentry sets Sentry up when SENTRY_DSN is set, so that +// SetupRoutes can report panics to it. With SENTRY_DSN empty it does +// nothing. A DSN Sentry refuses stops the start. +func (s *Server) enableSentry() error { + if s.params.Config.SentryDSN == "" { + return nil + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: s.params.Config.SentryDSN, + Release: s.params.Globals.Appname + "-" + s.params.Globals.Version, + }) + if err != nil { + return fmt.Errorf("SENTRY_DSN: %w", err) + } + + s.log.Info("sentry error reporting activated") + + return nil +} + // shutdown gracefully stops the HTTP server within the -// deadline of the context fx provides for OnStop. +// deadline of the context fx provides for OnStop, then gives +// Sentry, if set up, time to send what it still holds. func (s *Server) shutdown(ctx context.Context) error { err := s.httpServer.Shutdown(ctx) + + if s.params.Config.SentryDSN != "" { + sentry.Flush(sentryFlushTimeout) + } + if err != nil { s.log.Error("server clean shutdown failed", "error", err) diff --git a/package.json b/package.json index f4cfeaf..be9e3fa 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,21 @@ { "name": "netwatch", - "version": "1.0.0", + "private": true, "description": "Real-time network latency monitor SPA", "type": "module", "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test test/unit/*.test.js" }, "license": "MIT", "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.1.18", "autoprefixer": "^10.4.23", + "eslint": "^10.12.0", + "globals": "^17.13.0", "postcss": "^8.5.6", "prettier": "^3.8.1", "puppeteer-core": "25.5.0", diff --git a/script/add-dependency b/script/add-dependency new file mode 100755 index 0000000..5479b43 --- /dev/null +++ b/script/add-dependency @@ -0,0 +1,30 @@ +#!/bin/sh +# script/add-dependency: add a frontend package, or move one to another +# version, with yarn add, which changes package.json and yarn.lock +# together; then install from yarn.lock with --frozen-lockfile, as +# script/bootstrap does, to show it installs as written. --dev because +# no frontend package is needed when the page runs: it ships as the +# built dist/. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +usage() { + echo "usage: make add-dependency PACKAGE=@" >&2 + exit 2 +} + +main() { + # Exactly one package. A value beginning with - would reach yarn as + # an option; yarn would quietly drop all but the first of several + # packages given in one value. + [ "$#" -eq 1 ] || usage + case "$1" in + "" | -* | *[[:space:]]*) usage ;; + esac + cd "$ROOT" + "$ROOT/script/yarn" add --dev "$1" + "$ROOT/script/yarn" install --frozen-lockfile +} + +main "$@" diff --git a/script/bootstrap b/script/bootstrap index d807ee3..63f4b2c 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,30 +3,34 @@ # 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. Node is -# used directly if it is at least NODE_MIN_VERSION; otherwise it is -# installed at a pinned version via nvm (installing nvm itself first, -# from a hash-verified release archive, never curl | sh). Go, with its -# gofmt, is used directly if it is at least the version backend/go.mod -# asks for; otherwise the pinned Go release is installed from its -# hash-verified archive. +# used directly only if it has the major version of NODE_VERSION and is +# at least NODE_MIN_VERSION; otherwise NODE_VERSION is installed via nvm +# (installing nvm itself first, from a hash-verified release archive, +# never curl | sh). Yarn is used directly only if it is exactly +# YARN_VERSION; otherwise that version is prepared through corepack, +# or installed with npm where there is no corepack. +# Go, with its gofmt, is used directly only if it is exactly GO_VERSION; +# otherwise that release is installed from its hash-verified archive. # # What this script installs outside the system package manager lives # under $HOME and is linked into ~/.local/bin, where make and the git # hook find it once that directory is on PATH. Nothing in ~/.local/bin # that this script did not create is ever replaced. # -# golangci-lint is not installed: make lint runs it in Docker, which -# this script does not install either. +# golangci-lint is not installed: make lint runs it in Docker, as make +# test runs the tests, and this script does not install Docker either. +# +# Unlike the org model: Go for the backend, a newer node for eslint. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-07-07 +# Pinned versions, 2026-07-06 NODE_VERSION="22.17.0" # The oldest node the frontend's dependencies accept: the "engines" -# field of puppeteer-core 25.5.0, the most demanding of them, asks for -# 22.12.0 or newer, 2026-09-29. An older installed node is not used. -NODE_MIN_VERSION="22.12.0" +# field of eslint 10.12.0, the most demanding of them, asks for 22.13.0 +# or newer, 2026-10-03. An older installed node is not used. +NODE_MIN_VERSION="22.13.0" NVM_VERSION="0.40.3" # sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" @@ -64,7 +68,8 @@ detect_pkgmgr() { fi } -# pkg_install +# pkg_install : the apt +# and apk arguments may each list several packages, separated by spaces. pkg_install() { detect_pkgmgr case "$PKGMGR" in @@ -74,10 +79,10 @@ pkg_install() { $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update APT_UPDATED=1 fi - $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y $2 ;; brew) brew install "$3" ;; - apk) apk add --no-cache "$4" ;; + apk) apk add --no-cache $4 ;; esac } @@ -140,32 +145,63 @@ ensure_nvm() { rm -rf "$tmp" } -# node_ok: the node on PATH is at least NODE_MIN_VERSION. node itself -# compares the two: major, then minor, then patch. +# node_ok: the node on PATH has the major version of NODE_VERSION and is +# at least NODE_MIN_VERSION. Only the major is compared with the pin, +# as REPO_POLICIES.md asks. node itself compares the versions: major, +# then minor, then patch. A node that fails does not pass. +# script/bootstrap and script/yarn carry this function unchanged. node_ok() { - if missing node; then return 1; fi + if ! command -v node >/dev/null 2>&1; then return 1; fi node -e ' const have = process.versions.node.split(".").map(Number); const want = process.argv[1].split(".").map(Number); + const pinned = process.argv[2].split(".").map(Number); + if (have[0] !== pinned[0]) process.exit(1); for (let i = 0; i < 3; i++) { if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1); } - ' "$NODE_MIN_VERSION" + ' "$NODE_MIN_VERSION" "$NODE_VERSION" } # ensure_node: unless node_ok, install NODE_VERSION and link its node. +# Afterwards node is looked up through PATH again and must pass node_ok. ensure_node() { - if node_ok; then return 0; fi + if node_ok; then + echo "bootstrap: using node $(node --version)" + return 0 + fi ensure_nvm nvm_sh "nvm install $NODE_VERSION" link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node + hash -r + if ! node_ok; then + echo "bootstrap: after installing node $NODE_VERSION through nvm," >&2 + echo " the node on PATH, $(command -v node), is not it" >&2 + exit 1 + fi + echo "bootstrap: installed node $(node --version)" } -# ensure_yarn: corepack writes its shims (pnpm and yarnpkg as well as -# yarn) into $TOOLCHAIN rather than next to itself, and the npm fallback -# installs there too; only yarn is linked. +# yarn_ok: yarn --version on PATH prints exactly YARN_VERSION, compared +# over the whole version. A yarn that fails or prints anything else does +# not pass. +yarn_ok() { + if missing yarn; then return 1; fi + version="$(yarn --version 2>/dev/null)" || return 1 + [ "$version" = "$YARN_VERSION" ] +} + +# ensure_yarn: unless yarn_ok, prepare YARN_VERSION and link its yarn. +# corepack writes its shims (pnpm and yarnpkg as well as yarn) into +# $TOOLCHAIN rather than next to itself, and the npm fallback installs +# there too; only yarn is linked. Afterwards yarn is looked up through +# PATH again and must pass yarn_ok, so another yarn that hides the link +# stops bootstrap. ensure_yarn() { - if ! missing yarn; then return 0; fi + if yarn_ok; then + echo "bootstrap: using yarn $(yarn --version)" + return 0 + fi shims="$TOOLCHAIN/corepack-shims" mkdir -p "$shims" if ! missing corepack; then @@ -180,23 +216,40 @@ ensure_yarn() { shims="$TOOLCHAIN/npm-global/bin" fi link_bin "$shims/yarn" yarn + hash -r + if ! yarn_ok; then + echo "bootstrap: after installing yarn $YARN_VERSION in $shims," >&2 + echo " the yarn on PATH, $(command -v yarn), does not report it" >&2 + exit 1 + fi + echo "bootstrap: installed yarn $(yarn --version)" } # go_ok: the go on PATH has its gofmt beside it (a Go release ships the -# two together) and is at least the version backend/go.mod asks for. -# GOTOOLCHAIN=local makes an older go fail here instead of fetching a -# newer toolchain for itself. +# two together) and go version reports exactly GO_VERSION, compared over +# the whole version, not a prefix of it. A go that fails or prints +# anything else does not pass. GOTOOLCHAIN=local makes go report itself +# rather than a toolchain it would fetch. go_ok() { if missing go; then return 1; fi [ -x "$(dirname "$(command -v go)")/gofmt" ] || return 1 - (cd "$ROOT/backend" && GOTOOLCHAIN=local go list -m >/dev/null 2>&1) + version="$(GOTOOLCHAIN=local go version 2>/dev/null)" || return 1 + case "$version" in + "go version go$GO_VERSION "*) return 0 ;; + *) return 1 ;; + esac } # ensure_go: unless go_ok, install GO_VERSION and link its go and gofmt. # They are linked on every run that needs them, so a deleted link is put # back, and the archive is unpacked again if either binary is missing. +# Afterwards go is looked up through PATH again and must pass go_ok, so +# another go that hides the link stops bootstrap. ensure_go() { - if go_ok; then return 0; fi + if go_ok; then + echo "bootstrap: using $(GOTOOLCHAIN=local go version)" + return 0 + fi go_dir="$TOOLCHAIN/go-$GO_VERSION" if [ ! -x "$go_dir/bin/go" ] || [ ! -x "$go_dir/bin/gofmt" ]; then # sha256 of each archive, from https://go.dev/dl/?mode=json @@ -237,6 +290,13 @@ ensure_go() { fi link_bin "$go_dir/bin/go" go link_bin "$go_dir/bin/gofmt" gofmt + hash -r + if ! go_ok; then + echo "bootstrap: after installing go $GO_VERSION in $go_dir," >&2 + echo " the go on PATH, $(command -v go), is not it or has no gofmt" >&2 + exit 1 + fi + echo "bootstrap: installed $(GOTOOLCHAIN=local go version)" } main() { @@ -259,11 +319,11 @@ main() { yarn install --frozen-lockfile ensure_go - (cd "$ROOT/backend" && go mod download) + go mod download if missing docker; then - echo "bootstrap: docker not found; make lint, and so make check" >&2 - echo " and the pre-commit hook, need it to run the Go linter" >&2 + echo "bootstrap: docker not found; make test and make lint, and so" >&2 + echo " make check and the pre-commit hook, need it" >&2 fi if [ -n "$path_hint" ] && [ -d "$BIN_DIR" ]; then echo "bootstrap: add $BIN_DIR to the front of your PATH, e.g." >&2 diff --git a/script/build b/script/build new file mode 100755 index 0000000..599369e --- /dev/null +++ b/script/build @@ -0,0 +1,23 @@ +#!/bin/sh +# script/build: build the frontend for production into dist/, then the +# static netwatch-server binary into the repo root, with its version +# stamped in. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + "$ROOT/script/yarn" build + + # VERSION comes from the environment. Unset or empty, it is + # `git describe --tags --always --dirty`, as in script/docker, or + # "dev" where there is no git or no repository history. + version="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}" + + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.Version=$version" \ + -o netwatch-server ./cmd/netwatch-server/ +} + +main "$@" diff --git a/script/check b/script/check index 3e1778c..92875f7 100755 --- a/script/check +++ b/script/check @@ -1,6 +1,8 @@ #!/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. +# extension to scripts-to-rule-them-all. test and lint are Docker +# phases; fmt-check is native, because a formatter writes the working +# tree. Must not modify any files. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/cibuild b/script/cibuild index 688299f..2656771 100755 --- a/script/cibuild +++ b/script/cibuild @@ -2,9 +2,12 @@ # script/cibuild: run the CI build. It bootstraps first: a CI runner # checks out and runs this and nothing else, and script/fmt-check runs # the formatter on the host, which a pristine checkout cannot do. -# --no-cache for the same reason as script/docker: the gate phases the -# final stage depends on are RUN steps, and a cached one is a check that -# did not run. +# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST +# line, since a cached gate phase is a check that did not run; unlike +# --no-cache, it keeps the layers above that line and the cache mounts. +# This relies on every stage of the Dockerfile declaring ARG CACHEBUST +# before every step that lints, tests or compiles; a stage without that +# line is served from cache on an unchanged tree, with no warning. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -14,16 +17,17 @@ main() { cd "$ROOT" "$SCRIPT_DIR/bootstrap" "$SCRIPT_DIR/check" - # 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 yields an empty - # version without failing. + # The version and the tag each get their own line: a failing + # command substitution inside an argument does not trip `set -e`, + # so the inline form degrades silently to an empty constant. The + # VERSION build argument takes precedence over the version a build + # stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" - docker build --no-cache \ + tag="$("$SCRIPT_DIR/projectname")" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ - -t "$("$SCRIPT_DIR/projectname")" . + -t "$tag" . } main "$@" diff --git a/script/clean b/script/clean new file mode 100755 index 0000000..4f00626 --- /dev/null +++ b/script/clean @@ -0,0 +1,13 @@ +#!/bin/sh +# script/clean: remove what script/build writes: dist/ and the +# netwatch-server binary. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + rm -rf dist netwatch-server +} + +main "$@" diff --git a/script/dev b/script/dev new file mode 100755 index 0000000..2a05ba5 --- /dev/null +++ b/script/dev @@ -0,0 +1,13 @@ +#!/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" + "$ROOT/script/yarn" dev +} + +main "$@" diff --git a/script/docker b/script/docker index c4688e8..cc00d9c 100755 --- a/script/docker +++ b/script/docker @@ -1,8 +1,12 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. -# --no-cache because the gate phases the final stage depends on are RUN -# steps, and a cached one is a check that did not run. +# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST +# line, since a cached gate phase is a check that did not run; unlike +# --no-cache, it keeps the layers above that line and the cache mounts. +# This relies on every stage of the Dockerfile declaring ARG CACHEBUST +# before every step that lints, tests or compiles; a stage without that +# line is served from cache on an unchanged tree, with no warning. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -10,16 +14,17 @@ 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 yields an empty - # version without failing. + # The version and the tag each get their own line: a failing + # command substitution inside an argument does not trip `set -e`, + # so the inline form degrades silently to an empty constant. The + # VERSION build argument takes precedence over the version a build + # stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" - docker build --no-cache \ + tag="$("$SCRIPT_DIR/projectname")" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ - -t "$("$SCRIPT_DIR/projectname")" . + -t "$tag" . } main "$@" diff --git a/script/fmt b/script/fmt index 7270800..f998d21 100755 --- a/script/fmt +++ b/script/fmt @@ -1,14 +1,20 @@ #!/bin/sh -# script/fmt: format the whole repo (writes): prettier over everything -# it understands, then gofmt over the Go backend. +# script/fmt: format the whole repo (writes): prettier over the +# JavaScript, CSS, HTML and Markdown, then gofmt over the Go sources in +# cmd/ and internal/; not over the whole root, as node_modules/ holds +# .go files of its own. The org model formats only markdown; this repo +# also has JS and Go. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" + # script/bootstrap links the node, yarn and gofmt it installs into + # ~/.local/bin, which the shell that called it may not have on PATH. + PATH="$HOME/.local/bin:$PATH" "$ROOT/script/frontend-fmt" - "$ROOT/backend/script/fmt" + gofmt -l -w cmd internal } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 28518f7..91dd6b3 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,14 +1,23 @@ #!/bin/sh # script/fmt-check: check formatting across the whole repo (read-only). # 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 ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" + # script/bootstrap links the node, yarn and gofmt it installs into + # ~/.local/bin, which the shell that called it may not have on PATH. + PATH="$HOME/.local/bin:$PATH" "$ROOT/script/frontend-fmt-check" - "$ROOT/backend/script/fmt-check" + unformatted="$(gofmt -l cmd internal)" + if [ -n "$unformatted" ]; then + echo "Go files not formatted:" >&2 + echo "$unformatted" >&2 + exit 1 + fi } main "$@" diff --git a/script/frontend-check b/script/frontend-check deleted file mode 100755 index 0819860..0000000 --- a/script/frontend-check +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -# script/frontend-check: run the frontend half of the checks only (test, -# lint, fmt-check). This exists for the frontend stage of Dockerfile, a -# node image with neither Go nor Docker; the Dockerfile's lint and -# backend build stages gate the backend half. Everywhere else, use -# script/check, which covers the whole repo. Must not modify any files. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -main() { - cd "$ROOT" - "$ROOT/script/frontend-test" - "$ROOT/script/frontend-lint" - "$ROOT/script/frontend-fmt-check" -} - -main "$@" diff --git a/script/frontend-fmt b/script/frontend-fmt index 1af33b9..4ea4264 100755 --- a/script/frontend-fmt +++ b/script/frontend-fmt @@ -1,14 +1,16 @@ #!/bin/sh -# script/frontend-fmt: format the frontend and every other file prettier -# understands, repo-wide (writes). backend/ is in .prettierignore; Go -# sources are formatted by backend/script/fmt. +# script/frontend-fmt: format the JavaScript, CSS, HTML and Markdown, +# repo-wide (writes). Those are the languages REPO_POLICIES.md gives +# prettier; the YAML is left alone, as .golangci.yml must stay the org +# standard byte for byte. Prettier does not read Go; script/fmt runs +# gofmt over the Go sources. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - yarn prettier --write . + "$ROOT/script/yarn" prettier --write '**/*.{js,css,html,md}' } main "$@" diff --git a/script/frontend-fmt-check b/script/frontend-fmt-check index 1501fce..36309db 100755 --- a/script/frontend-fmt-check +++ b/script/frontend-fmt-check @@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - yarn prettier --check . + "$ROOT/script/yarn" prettier --check '**/*.{js,css,html,md}' } main "$@" diff --git a/script/frontend-lint b/script/frontend-lint deleted file mode 100755 index 12b5b29..0000000 --- a/script/frontend-lint +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# script/frontend-lint: run the frontend linter (prettier in check mode). -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -main() { - cd "$ROOT" - yarn prettier --check . -} - -main "$@" diff --git a/script/frontend-test b/script/frontend-test index 4c74c65..da98dd4 100755 --- a/script/frontend-test +++ b/script/frontend-test @@ -1,14 +1,27 @@ #!/bin/sh -# script/frontend-test: run the frontend test suite. The frontend has no -# unit tests; the production build serves as the test (fails on broken -# code). +# script/frontend-test: run the frontend test suite on the host: the +# unit tests in test/unit/, through the test script in package.json, +# then the production build, which fails on broken code. make test runs +# the same tests in the frontend-test stage of Dockerfile, and the image +# build runs the production build in its frontend-build stage. The tests +# print a dot each; if any fails, they run again with every test listed, +# and the script fails even if that run passes. NODE_OPTIONS chooses the +# reporter because yarn adds its arguments after the test files, where +# node would take a reporter option for one more file. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - timeout 30 yarn build + NODE_OPTIONS=--test-reporter=dot \ + timeout 90 "$ROOT/script/yarn" --silent run test || { + echo "--- Rerunning with every test listed for details ---" + NODE_OPTIONS=--test-reporter=spec \ + timeout 90 "$ROOT/script/yarn" --silent run test + exit 1 + } + timeout 30 "$ROOT/script/yarn" build } main "$@" diff --git a/script/frontend-viewport-test b/script/frontend-viewport-test index d26e0fe..47d604b 100755 --- a/script/frontend-viewport-test +++ b/script/frontend-viewport-test @@ -2,11 +2,13 @@ # script/frontend-viewport-test: verify the responsive layout of the built # frontend in a real browser engine. # -# Builds dist/, serves it with the same nginx image and the same nginx.conf -# the shipping container uses, points a containerised headless Chrome at it -# over CDP, and asserts on computed layout at every viewport width derived -# from the app's own CSS. See test/viewport/README.md for what this covers -# and what it cannot. +# Builds dist/, serves it with the same nginx image and the same +# configs/nginx.conf the shipping container uses, points a containerised +# headless Chrome at it over CDP, and asserts on computed layout at every +# viewport width derived from the app's own CSS. See +# test/viewport/README.md for what this covers and what it cannot. +# Before the browser starts, it checks that the page is served with +# Cache-Control: no-cache and Content-Security-Policy. # # Deliberately not part of script/check: it needs Docker and takes far # longer than the 20s budget make test has to stay inside. @@ -61,20 +63,46 @@ main() { # host. docker network create --internal "$NETWORK" > /dev/null - # nginx.conf is a template: the image renders it over its own + # configs/nginx.conf is a template: the image renders it over its own # default.conf, with the same port and limit bin/entrypoint.sh uses. # The empty file it includes trusts no proxy, as bin/entrypoint.sh - # writes it when TRUSTED_PROXIES is unset. nginx.conf also includes + # writes it when TRUSTED_PROXIES is set and empty. It also includes # the security headers, so the page runs under the shipped policy. docker run -d --rm --name "$SERVER" \ --network "$NETWORK" --network-alias netwatch \ -e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \ -v "$ROOT/dist:/usr/share/nginx/html:ro" \ - -v "$ROOT/nginx.conf:/etc/nginx/templates/default.conf.template:ro" \ + -v "$ROOT/configs/nginx.conf:/etc/nginx/templates/default.conf.template:ro" \ -v /dev/null:/etc/nginx/trusted-proxies.conf:ro \ - -v "$ROOT/security-headers.conf:/etc/nginx/security-headers.conf:ro" \ + -v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \ "$SERVER_IMAGE" > /dev/null + # The page, and /no-such-page, which location / answers with the + # page through its try_files fallback, must go out with + # Cache-Control: no-cache. That add_header in its location drops the + # server's own unless the location includes the security headers + # again; only Content-Security-Policy is checked, as it comes from + # the same file as the rest. curl tries up to ten more times, a + # second apart, while nginx starts. It runs without -S, which would + # print an error for every failed attempt, even when a later one + # succeeds. + for path in / /no-such-page; do + if ! headers="$(docker exec "$SERVER" curl -s -o /dev/null -D - \ + --retry 10 --retry-connrefused --retry-delay 1 \ + "http://127.0.0.1:8080$path")"; then + echo "frontend-viewport-test: nginx did not answer $path" >&2 + exit 1 + fi + headers="$(printf '%s\n' "$headers" | tr -d '\r')" + if ! printf '%s\n' "$headers" | grep -qix 'Cache-Control: no-cache' || + ! printf '%s\n' "$headers" | grep -qi '^Content-Security-Policy:'; then + echo "frontend-viewport-test: $path needs" \ + "Cache-Control: no-cache and Content-Security-Policy; got:" >&2 + printf '%s\n' "$headers" >&2 + exit 1 + fi + done + # The image's own entrypoint already exposes CDP on 9222 and passes # --no-sandbox, so only extra flags belong here; re-specifying the # debugging port collides with it and leaves the endpoint bound to diff --git a/script/install-precommit b/script/install-precommit index 723bae1..bef6406 100755 --- a/script/install-precommit +++ b/script/install-precommit @@ -8,8 +8,8 @@ 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" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit echo "pre-commit hook installed: runs script/precommit" } diff --git a/script/lint b/script/lint index 1b3783d..451a067 100755 --- a/script/lint +++ b/script/lint @@ -1,20 +1,27 @@ #!/bin/sh -# script/lint: lint the whole repo: prettier over the frontend, then the -# Go linter over backend/. +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. # -# The Go linter runs only in Docker: this builds the lint stage of -# Dockerfile, the digest-pinned golangci-lint image, which runs the -# backend's fmt-check and lint targets. --no-cache makes the linter -# really run every time rather than reuse an earlier result, and the -# stage is built for its checks alone, so no image is kept. +# The phase is not the last stage in the file, so it is built only when +# --target names it. A new CACHEBUST value reruns every RUN step after +# the ARG CACHEBUST line, since a cached lint layer is a lint that did +# not run; unlike --no-cache, it keeps the layers above that line and +# the cache mounts. This relies on every stage of the Dockerfile +# declaring ARG CACHEBUST before every step that lints, tests or +# compiles; a stage without that line is served from cache on an +# unchanged tree, with no warning. +# --output type=cacheonly writes no image, since nothing uses one. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - "$ROOT/script/frontend-lint" - timeout 300 docker build --no-cache --target lint \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target lint \ --output type=cacheonly . } diff --git a/backend/script/run b/script/run similarity index 53% rename from backend/script/run rename to script/run index 5ea87ba..824af6c 100755 --- a/backend/script/run +++ b/script/run @@ -1,5 +1,6 @@ #!/bin/sh -# script/run: build and run netwatch-server locally. +# script/run: build with script/build, then run netwatch-server locally +# with its defaults: port 8080, reports stored in data/reports. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/test b/script/test index f0ad7e8..1622a11 100755 --- a/script/test +++ b/script/test @@ -1,14 +1,24 @@ #!/bin/sh -# script/test: run the test suite for the whole repo: the frontend at -# the repo root, then the Go backend in backend/. Both halves together -# get 30 seconds; each also keeps its own limit for the Dockerfiles. +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, and a new CACHEBUST value because a cached test layer is a test +# that did not run; unlike --no-cache, it keeps the layers above the ARG +# CACHEBUST line and the cache mounts. This relies on every stage of the +# Dockerfile declaring ARG CACHEBUST before every step that lints, tests +# or compiles; a stage without that line is served from cache on an +# unchanged tree, with no warning. --output type=cacheonly writes no +# image, since nothing uses one. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - timeout 30 sh -c 'script/frontend-test && backend/script/test' + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target test \ + --output type=cacheonly . } main "$@" diff --git a/script/tidy b/script/tidy new file mode 100755 index 0000000..b948505 --- /dev/null +++ b/script/tidy @@ -0,0 +1,13 @@ +#!/bin/sh +# script/tidy: run go mod tidy, which adds the modules the Go sources +# import, drops those they no longer do, and updates go.sum. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy +} + +main "$@" diff --git a/script/yarn b/script/yarn new file mode 100755 index 0000000..51eeb8b --- /dev/null +++ b/script/yarn @@ -0,0 +1,55 @@ +#!/bin/sh +# script/yarn: run yarn with the given arguments under the node +# script/bootstrap would use. Every script that runs yarn on the host +# runs it through this one, except script/bootstrap, which picks its +# own node and yarn. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Must match the pins in script/bootstrap. +NODE_VERSION="22.17.0" +NODE_MIN_VERSION="22.13.0" + +# node_ok: the node on PATH has the major version of NODE_VERSION and is +# at least NODE_MIN_VERSION. Only the major is compared with the pin, +# as REPO_POLICIES.md asks. node itself compares the versions: major, +# then minor, then patch. A node that fails does not pass. +# script/bootstrap and script/yarn carry this function unchanged. +node_ok() { + if ! command -v node >/dev/null 2>&1; then return 1; fi + node -e ' + const have = process.versions.node.split(".").map(Number); + const want = process.argv[1].split(".").map(Number); + const pinned = process.argv[2].split(".").map(Number); + if (have[0] !== pinned[0]) process.exit(1); + for (let i = 0; i < 3; i++) { + if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1); + } + ' "$NODE_MIN_VERSION" "$NODE_VERSION" +} + +# Run the yarn script/bootstrap installed: the yarn on PATH when the +# node on PATH passes node_ok, and otherwise yarn under the pinned node +# in nvm. nvm is a bash script, hence the subshell. +run_yarn() { + if node_ok && command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if node_ok || [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "script/yarn: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + +main() { + cd "$ROOT" + # script/bootstrap links the node and yarn it installs into + # ~/.local/bin, which the shell that called it may not have on PATH. + PATH="$HOME/.local/bin:$PATH" + run_yarn "$@" +} + +main "$@" diff --git a/test/unit/main.test.js b/test/unit/main.test.js new file mode 100644 index 0000000..d265b44 --- /dev/null +++ b/test/unit/main.test.js @@ -0,0 +1,937 @@ +// Unit tests for web/src/main.js, run with Node's built-in test runner by the +// test script in package.json. Importing the module does not start the +// page. + +import { beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + AppState, + buildUI, + CONFIG, + greyOutUI, + hostRowHTML, + HostState, + humanDuration, + latencyClass, + latencyHex, + log, + measureLatency, + Reporter, + tick, + updateClocks, + updateSummary, +} from "../../web/src/main.js"; + +// There is no page here, so the tests stand in for it. The debug log looks +// for its panel by id and finds none, an element the page creates is a +// plain object, and no frame the page asks for ever comes. Each element of +// a host's row that tick or greyOutUI draws into is a plain object, made +// the first time a test looks it up and kept in elements under its +// selector until the next test starts. As on a page, writing its text +// replaces its markup; the status dot greyOutUI looks for in it is not +// there. Drawing a sparkline does nothing; it looks for the pixel ratio on +// window and finds none. The commit the page's footer names, which +// vite.config.js fills in when it builds the page, is blank. In each test, +// console.error and console.info print nothing and keep what they are +// given. +const doNothing = () => {}; +let elements; +beforeEach((t) => { + elements = {}; + t.mock.method(console, "error", doNothing); + t.mock.method(console, "info", doNothing); +}); +const canvasContext = { + clearRect: doNothing, + beginPath: doNothing, + moveTo: doNothing, + lineTo: doNothing, + stroke: doNothing, + fill: doNothing, + fillRect: doNothing, + fillText: doNothing, + arc: doNothing, +}; +globalThis.window = {}; +globalThis.requestAnimationFrame = doNothing; +globalThis.cancelAnimationFrame = doNothing; +globalThis.__COMMIT_HASH__ = ""; +globalThis.__COMMIT_FULL__ = ""; +globalThis.document = { + getElementById: () => null, + createElement: () => ({}), + querySelector: (selector) => + (elements[selector] ??= { + getContext: () => canvasContext, + querySelector: () => null, + set textContent(text) { + this.innerHTML = text; + }, + }), +}; + +// What tick last wrote into the latency figure in host's row, or undefined +// if it has written nothing there. +function latencyFigure(state, host) { + const index = state.allHosts.indexOf(host); + return elements[`.latency-value[data-host="${index}"]`]?.innerHTML; +} + +// What was last written into the status text in host's row. +function statusText(state, host) { + const index = state.allHosts.indexOf(host); + return elements[`.status-text[data-host="${index}"]`]?.innerHTML; +} + +// What this test wrote to the browser console with console[method]. +function consoleLines(method) { + return console[method].mock.calls.map((call) => call.arguments[0]); +} + +// Mocks the clock for test t, so that a check lasting seconds takes no real +// time, and replaces fetch with targets that each answer after +// answerAfter(url) milliseconds of that clock, or never when that is +// Infinity. The target at unreachableUrl, if one is given, does not +// answer: after that time its fetch fails with the error a browser gives +// the page for a network error. Both are restored when the test ends. +function mockTargets(t, answerAfter, unreachableUrl) { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + t.mock.method(performance, "now", () => Date.now()); + t.mock.method( + globalThis, + "fetch", + (url, { signal }) => + new Promise((resolve, reject) => { + const answer = + url === unreachableUrl + ? () => reject(new TypeError("Failed to fetch")) + : resolve; + if (answerAfter(url) !== Infinity) { + setTimeout(answer, answerAfter(url)); + } + signal.addEventListener("abort", () => reject(signal.reason)); + }), + ); +} + +// The result of check if it has ended, otherwise "still waiting". +function settled(check) { + return Promise.race([ + check, + new Promise((resolve) => setImmediate(resolve, "still waiting")), + ]); +} + +for (const interval of [10000, 30000]) { + const timeout = interval * 0.8; + // Over 3 seconds, which the timeout used to be capped at. + const slowAnswer = timeout - 1000; + + test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => { + CONFIG.updateInterval = interval; + mockTargets(t, () => slowAnswer); + const check = measureLatency("https://target.test"); + t.mock.timers.tick(slowAnswer); + assert.deepEqual(await settled(check), { + latency: slowAnswer, + error: null, + reason: null, + }); + }); + + test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => { + CONFIG.updateInterval = interval; + mockTargets(t, () => Infinity); + const check = measureLatency("https://target.test"); + t.mock.timers.tick(timeout - 1); + assert.equal(await settled(check), "still waiting"); + t.mock.timers.tick(1); + assert.deepEqual(await settled(check), { + latency: null, + error: "timeout", + reason: `timed out after ${timeout} ms (limit ${timeout} ms)`, + }); + }); +} + +// A browser can run a timer late, on a busy page or in a background tab. +// Moving the mocked clock on 30000ms at once runs the 24000ms timeout with +// the clock already at 30000ms. +test("at a 30000ms interval, a check whose timeout runs late gives how long the request took and the time limit", async (t) => { + CONFIG.updateInterval = 30000; + mockTargets(t, () => Infinity); + const check = measureLatency("https://target.test"); + t.mock.timers.tick(30000); + assert.deepEqual(await settled(check), { + latency: null, + error: "timeout", + reason: "timed out after 30000 ms (limit 24000 ms)", + }); +}); + +test("a check fetches the target's URL as written, with no query string added", async (t) => { + mockTargets(t, () => 10); + const check = measureLatency("https://fsn1-speed.hetzner.com"); + t.mock.timers.tick(10); + assert.notEqual(await settled(check), "still waiting"); + assert.equal( + fetch.mock.calls[0].arguments[0], + "https://fsn1-speed.hetzner.com", + ); +}); + +test("a check sends no Referer header, wherever the page is served from", async (t) => { + mockTargets(t, () => 10); + const check = measureLatency("https://fsn1-speed.hetzner.com"); + t.mock.timers.tick(10); + assert.notEqual(await settled(check), "still waiting"); + assert.equal( + fetch.mock.calls[0].arguments[1].referrerPolicy, + "no-referrer", + ); +}); + +// These targets' home pages answer with a redirect. A check follows it and +// times the request to the new URL too, often on another host. The Google +// Cloud Console redirects only requests that do not say they come from +// another site, such as curl's. Each target is checked at /robots.txt on +// its own host, which answers without a redirect. An S3 region answers it +// with 403, and any answer counts as reachable. +test("a target whose home page redirects is checked at /robots.txt on its own host", () => { + const checked = new Map( + new AppState([]).wan.map((host) => [host.name, host.url]), + ); + for (const [name, url] of [ + ["AWS Console", "https://console.aws.amazon.com/robots.txt"], + ["Google Cloud Console", "https://console.cloud.google.com/robots.txt"], + ["Akamai", "https://www.akamai.com/robots.txt"], + ["B2", "https://api.backblazeb2.com/robots.txt"], + [ + "S3 af-south-1 (Cape Town)", + "https://s3.af-south-1.amazonaws.com/robots.txt", + ], + [ + "S3 eu-west-2 (London)", + "https://s3.eu-west-2.amazonaws.com/robots.txt", + ], + [ + "S3 me-south-1 (Bahrain)", + "https://s3.me-south-1.amazonaws.com/robots.txt", + ], + [ + "S3 ap-northeast-1 (Tokyo)", + "https://s3.ap-northeast-1.amazonaws.com/robots.txt", + ], + [ + "S3 ap-southeast-1 (Singapore)", + "https://s3.ap-southeast-1.amazonaws.com/robots.txt", + ], + [ + "S3 ap-southeast-2 (Sydney)", + "https://s3.ap-southeast-2.amazonaws.com/robots.txt", + ], + [ + "S3 us-west-2 (Oregon)", + "https://s3.us-west-2.amazonaws.com/robots.txt", + ], + [ + "S3 sa-east-1 (São Paulo)", + "https://s3.sa-east-1.amazonaws.com/robots.txt", + ], + ]) { + assert.equal(checked.get(name), url); + } +}); + +// Chrome keeps the connection to an S3 region open after a HEAD check, but +// closes it after most GET checks. The Hetzner servers do not answer HEAD. +test("a round checks each S3 region with a HEAD request and every other target with a GET request", async (t) => { + const state = new AppState([ + { name: "Local CPE", url: "http://192.168.100.1" }, + ]); + mockTargets(t, () => 10); + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(10); + assert.notEqual(await settled(round), "still waiting"); + const methods = new Map( + fetch.mock.calls.map((call) => [ + call.arguments[0], + call.arguments[1].method, + ]), + ); + for (const host of state.allHosts) { + const method = host.name.startsWith("S3 ") ? "HEAD" : "GET"; + assert.equal(methods.get(host.url), method, host.name); + } +}); + +test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + const waiting = state.wan[0]; + // No target but the answering one ever answers. + mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity)); + // The third tick: the first is discarded as a whole, and the second ends + // by sorting the rows, which rebuilds a page that is not here. + state.tickCount = 2; + + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(1000); + assert.equal(await settled(round), "still waiting"); + assert.match(latencyFigure(state, answering), />1000 { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity)); + state.tickCount = 2; + + const roundChecks = new AbortController(); + const round = tick(state, roundChecks.signal); + t.mock.timers.tick(500); + // As a round started early does to the last round's checks. + roundChecks.abort(); + assert.notEqual(await settled(round), "still waiting"); + assert.equal(latencyFigure(state, answering), undefined); + assert.deepEqual(answering.history, []); + assert.equal(answering.status, "pending"); + assert.deepEqual(consoleLines("error"), []); + assert.deepEqual(consoleLines("info"), []); +}); + +test("at a 30000ms interval, a check still waiting when the user pauses is not recorded and does not show in its row", async (t) => { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity)); + state.tickCount = 2; + + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(500); + state.paused = true; + t.mock.timers.tick(500); + assert.equal(await settled(round), "still waiting"); + assert.equal(latencyFigure(state, answering), undefined); + assert.deepEqual(answering.history, []); + assert.equal(answering.status, "pending"); + assert.deepEqual(consoleLines("error"), []); + assert.deepEqual(consoleLines("info"), []); +}); + +test("at a 30000ms interval, a check in the first round is not recorded and does not show in its row", async (t) => { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity)); + + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(1000); + assert.equal(await settled(round), "still waiting"); + assert.equal(latencyFigure(state, answering), undefined); + assert.deepEqual(answering.history, []); + assert.equal(answering.status, "pending"); + assert.deepEqual(consoleLines("error"), []); + assert.deepEqual(consoleLines("info"), []); +}); + +test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Answering", url: "https://answering.test" }, + ]); + const answering = state.local[0]; + mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity)); + state.tickCount = 2; + + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(1000); + assert.equal(await settled(round), "still waiting"); + // The user pauses, which greys out every row, and resumes, which leaves + // the rows as they are, as togglePause does. The answering target's + // check has already ended, so only the redraw of every row at the end + // of the round can take "paused" out of its row. + state.paused = true; + greyOutUI(state); + state.paused = false; + assert.equal(statusText(state, answering), "paused"); + + // The round ends when the last check times out. + t.mock.timers.tick(CONFIG.requestTimeout - 1000); + assert.notEqual(await settled(round), "still waiting"); + for (const host of state.allHosts) { + assert.notEqual(statusText(state, host), "paused", host.name); + } +}); + +// In the next tests a round checks one target, Target, at a 30000ms +// interval, so a check times out after 24000ms. The mocked clock starts at +// 1970-01-01T00:00:00.000Z. + +// An app state with Target and no WAN targets, so its rounds check only +// Target, and whose next round is recorded: it is the third, as the first +// is discarded and the second ends by sorting the rows, which rebuilds a +// page that is not here. Returns it and Target. +function stateWithOneTarget() { + CONFIG.updateInterval = 30000; + const state = new AppState([ + { name: "Target", url: "https://target.test" }, + ]); + state.wan = []; + state.tickCount = 2; + return { state, target: state.local[0] }; +} + +test("a check that fails with a network error writes one console line with the target, the time, the error and how long the request took", async (t) => { + const { state, target } = stateWithOneTarget(); + mockTargets(t, () => 23, target.url); + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(23); + assert.notEqual(await settled(round), "still waiting"); + assert.deepEqual(consoleLines("error"), [ + "netwatch: check failed: Target https://target.test at 1970-01-01T00:00:00.023Z: network error (TypeError: Failed to fetch) after 23 ms", + ]); + assert.deepEqual(consoleLines("info"), []); +}); + +test("a check that times out writes one console line with the target, the time, how long the request took and the time limit", async (t) => { + const { state } = stateWithOneTarget(); + mockTargets(t, () => Infinity); + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(24000); + assert.notEqual(await settled(round), "still waiting"); + assert.deepEqual(consoleLines("error"), [ + "netwatch: check failed: Target https://target.test at 1970-01-01T00:00:24.000Z: timed out after 24000 ms (limit 24000 ms)", + ]); + assert.deepEqual(consoleLines("info"), []); +}); + +// An answer that took longer than CONFIG.maxLatency is recorded as a +// timeout. The limit is the check's own timeout, so such an answer is one +// that came in just as the check timed out; here it is lowered to 500ms. +test("a check answered over the time limit writes one console line with the target, the time, how long the answer took and the limit", async (t) => { + const { state } = stateWithOneTarget(); + t.mock.getter(CONFIG, "maxLatency", () => 500); + mockTargets(t, () => 1000); + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(1000); + assert.notEqual(await settled(round), "still waiting"); + assert.deepEqual(consoleLines("error"), [ + "netwatch: check failed: Target https://target.test at 1970-01-01T00:00:01.000Z: answered after 1000 ms, over the 500 ms limit", + ]); + assert.deepEqual(consoleLines("info"), []); +}); + +test("a check that answers writes nothing to the console", async (t) => { + const { state } = stateWithOneTarget(); + mockTargets(t, () => 30); + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(30); + assert.notEqual(await settled(round), "still waiting"); + assert.deepEqual(consoleLines("error"), []); + assert.deepEqual(consoleLines("info"), []); +}); + +for (const [failed, checks] of [ + [1, "1 failed check"], + [3, "3 failed checks"], +]) { + test(`a check that answers after ${checks} writes one console line saying the target recovered, and the next writes nothing`, async (t) => { + const { state, target } = stateWithOneTarget(); + for (let i = 0; i < failed; i++) { + target.pushSample(Date.now(), { + latency: null, + error: "unreachable", + }); + } + mockTargets(t, () => 40); + for (let i = 0; i < 2; i++) { + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(40); + assert.notEqual(await settled(round), "still waiting"); + } + assert.deepEqual(consoleLines("info"), [ + `netwatch: target recovered: Target https://target.test at 1970-01-01T00:00:00.040Z: answered after 40 ms, following ${checks} in a row`, + ]); + assert.deepEqual(consoleLines("error"), []); + }); +} + +test("a check that fails in the first round, which is discarded, writes nothing to the console", async (t) => { + const { state, target } = stateWithOneTarget(); + state.tickCount = 0; + mockTargets(t, () => 23, target.url); + const round = tick(state, new AbortController().signal); + t.mock.timers.tick(23); + assert.notEqual(await settled(round), "still waiting"); + assert.deepEqual(consoleLines("error"), []); + assert.deepEqual(consoleLines("info"), []); +}); + +// The reports Reporter sent in this test, each as the hosts it sent. +function reportedHosts() { + return fetch.mock.calls.map( + (call) => JSON.parse(call.arguments[1].body).hosts, + ); +} + +// In the next two tests every report is delivered, and only Target has +// checks to report. target.reset() does to Target what the interval menu +// does to each target. + +// The interval changes after the first report. The second report is sent +// before Target's first check since then, and the third after it. A fourth +// would hold no check the first three did not, so it is not sent. +test("checks recorded before an interval change and not yet reported go in the next report, with the status of the last check, and no check is reported twice", async (t) => { + const state = new AppState([ + { name: "Target", url: "https://target.test" }, + ]); + const target = state.local[0]; + const reporter = new Reporter(state, "client", CONFIG.reportInterval); + t.mock.method(globalThis, "fetch", async () => ({ ok: true })); + + target.pushSample(1000, { latency: 10, error: null }); + await reporter.flush(); + target.pushSample(2000, { latency: 20, error: null }); + target.pushSample(3000, { latency: null, error: "timeout" }); + target.reset(); + await reporter.flush(); + target.pushSample(4000, { latency: 40, error: null }); + await reporter.flush(); + await reporter.flush(); + + const name = "Target"; + const url = "https://target.test"; + assert.deepEqual(reportedHosts(), [ + [ + { + name, + url, + status: "online", + history: [{ t: 1000, latency: 10, error: null }], + }, + ], + [ + { + name, + url, + status: "error", + history: [ + { t: 2000, latency: 20, error: null }, + { t: 3000, latency: null, error: "timeout" }, + ], + }, + ], + [ + { + name, + url, + status: "online", + history: [{ t: 4000, latency: 40, error: null }], + }, + ], + ]); + // The page's history starts over. + assert.deepEqual(target.history, [ + { timestamp: 4000, latency: 40, error: null }, + ]); +}); + +// The interval changes twice, after 60 checks each time, before the first +// report. Of the 120 checks the interval menu cleared, the report has the +// last 100 (CONFIG.maxHistoryPoints). +test("of the checks interval changes cleared before they were reported, the next report has the last 100", async (t) => { + const state = new AppState([ + { name: "Target", url: "https://target.test" }, + ]); + const target = state.local[0]; + const reporter = new Reporter(state, "client", CONFIG.reportInterval); + t.mock.method(globalThis, "fetch", async () => ({ ok: true })); + + for (let n = 1; n <= 120; n++) { + target.pushSample(n * 1000, { latency: 10, error: null }); + if (n % 60 === 0) target.reset(); + } + await reporter.flush(); + + const sent = reportedHosts()[0][0].history; + assert.equal(sent.length, 100); + assert.equal(sent[0].t, 21000); + assert.equal(sent.at(-1).t, 120000); +}); + +// panel stands in for the open debug log panel, and for the element in it +// that holds its lines, whichever the page looks up by id; the lines are +// kept in order in lines. Drawing the whole panel, as opening it does, +// replaces every line with a new one. +test("with the debug log open, writing more lines than it keeps adds each to the panel and leaves the lines already shown as they are", (t) => { + const lines = []; + const panel = { + classList: { contains: () => false }, + children: lines, + get firstElementChild() { + return lines[0]; + }, + append: (line) => lines.push(line), + removeChild: (line) => lines.splice(lines.indexOf(line), 1), + replaceChildren: (...newLines) => + lines.splice(0, lines.length, ...newLines), + }; + t.mock.method(globalThis.document, "getElementById", () => panel); + const kept = CONFIG.maxLogEntries; + + for (let i = 1; i <= kept; i++) log.info(`line ${i}`); + const shown = [...lines]; + for (let i = kept + 1; i <= kept + 5; i++) log.info(`line ${i}`); + + assert.deepEqual( + lines.map((line) => line.textContent.match(/line \d+$/)?.[0]), + Array.from({ length: kept }, (_, i) => `line ${i + 6}`), + ); + // The first five lines shown were dropped, and the rest are the same + // lines, not drawn again. + shown.slice(5).forEach((line, i) => assert.equal(lines[i], line)); +}); + +// The page shows < > " & and ' in a row's markup as +// < > " & and '. +test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => { + const host = new HostState({ + name: `"x" & 'y'`, + url: `https://x.test/?a="x"&b='y'`, + }); + const row = hostRowHTML(host, 0); + assert.doesNotMatch(row, //); + assert.ok( + row.includes( + "><b>"x" & 'y'</b>", + ), + ); + assert.ok( + row.includes( + 'href="https://x.test/<b>?a="x"&b='y'"', + ), + ); + assert.ok( + row.includes( + ">https://x.test/<b>?a="x"&b='y'", + ), + ); +}); + +for (const [seconds, text] of [ + [0, "0s"], + [1, "1s"], + [59, "59s"], + [60, "1m"], + [61, "1m1s"], + [3599, "59m59s"], + [3600, "1h"], + [3601, "1h1s"], + [3660, "1h1m"], + [3661, "1h1m1s"], +]) { + test(`humanDuration writes ${seconds} seconds as ${text}`, () => { + assert.equal(humanDuration(seconds), text); + }); +} + +// The colour of a target's latency figure, as a class, and of its sparkline +// for an answer after latency ms, as the colour coding in README.md gives +// them. The rows sit either side of each boundary. +for (const [latency, figure, sparkline] of [ + [0, "text-green-500", "#22c55e"], + [49, "text-green-500", "#22c55e"], + [50, "text-lime-500", "#84cc16"], + [99, "text-lime-500", "#84cc16"], + [100, "text-yellow-500", "#eab308"], + [199, "text-yellow-500", "#eab308"], + [200, "text-orange-500", "#f97316"], + [499, "text-orange-500", "#f97316"], + [500, "text-red-500", "#ef4444"], +]) { + test(`an answer after ${latency}ms has a ${figure} figure and a ${sparkline} sparkline`, () => { + assert.equal(latencyClass(latency, "online"), figure); + assert.equal(latencyHex(latency), sparkline); + }); +} + +test("a check that timed out or found its target unreachable has a grey figure and sparkline", () => { + assert.equal(latencyClass(null, "error"), "text-gray-500"); + assert.equal(latencyClass(null, "offline"), "text-gray-500"); + assert.equal(latencyHex(null), "#6b7280"); +}); + +// buildUI draws the page into #app, here a plain object. Each entry in the +// footer's color legend is a colored dot and the latencies it stands for, +// with < and > written as < and >. A latency takes the color of the +// first entry in CONFIG.latencyColors whose limit it is below, so the +// legend has, for each of those entries in order, a dot of its color (a +// bg- class where CONFIG.latencyColors has a text- one) and the latencies +// below its limit, or, for the last, which has no limit, the latencies +// from the limit before it up. A gray dot for offline comes after them. +test("the footer's color legend has each entry in CONFIG.latencyColors, with its color and limit, then offline", (t) => { + const app = {}; + t.mock.method(globalThis.document, "getElementById", () => app); + buildUI(new AppState([])); + + const legend = [ + ...app.innerHTML.matchAll(/(bg-\w+-500) [^"]*"><\/span>(\S+)/g), + ].map(([, dot, latencies]) => [dot, latencies]); + const colors = CONFIG.latencyColors; + const expected = colors.map((color, i) => [ + color.className.replace("text-", "bg-"), + color.below === Infinity + ? `>=${colors[i - 1].below}ms` + : `<${color.below}ms`, + ]); + assert.deepEqual(legend, [...expected, ["bg-gray-500", "offline"]]); +}); + +// A target whose checks, in turn, answered after each of latencies ms, or, +// for null, found it unreachable. +function hostAfter(latencies) { + const host = new HostState({ name: "Target", url: "https://target.test" }); + for (const latency of latencies) { + host.pushSample( + Date.now(), + latency === null + ? { latency: null, error: "unreachable" } + : { latency, error: null }, + ); + } + return host; +} + +for (const { history, latencies, statistics } of [ + { + history: "no checks", + latencies: [], + statistics: { min: null, max: null, average: null, median: null }, + }, + { + history: "only unreachable checks", + latencies: [null, null, null], + statistics: { min: null, max: null, average: null, median: null }, + }, + { + history: "three answers and an unreachable check", + latencies: [30, null, 10, 20], + statistics: { min: 10, max: 30, average: 20, median: 20 }, + }, + { + // The median of an even number of answers is the mean of the + // middle two. It and the average, 23.75, are rounded. + history: "four answers", + latencies: [10, 40, 20, 25], + statistics: { min: 10, max: 40, average: 24, median: 23 }, + }, + { + // Sorted as text rather than as numbers, these answers would put + // 100 in the middle. The average, 39.67, is rounded. + history: "answers with different numbers of digits", + latencies: [100, 9, 10], + statistics: { min: 9, max: 100, average: 40, median: 10 }, + }, +]) { + test(`a target's min, max, average and median latency over ${history}`, () => { + const { min, max, avg, med } = hostAfter(latencies).historyStats(); + assert.deepEqual({ min, max, average: avg, median: med }, statistics); + }); +} + +// The summary's figures come from each WAN target's last check, by the same +// rules as a target's own: here four answered, one was found unreachable +// and the rest have not been checked yet. The median, 22.5, and the +// average, 21.25, are rounded. +test("the summary's min, max, median and average latency over the WAN targets' last checks", () => { + const state = new AppState([]); + [30, 10, null, 25, 20].forEach((latency, i) => + state.wan[i].pushSample( + Date.now(), + latency === null + ? { latency: null, error: "unreachable" } + : { latency, error: null }, + ), + ); + assert.deepEqual(state.wanStats(), { + reachable: 4, + total: state.wan.length, + min: 10, + max: 30, + med: 23, + avg: 21, + }); +}); + +// In the next two tests each element of the summary that updateSummary +// writes into is a plain object, made the first time it is looked up and +// kept in elements under "#" and its id. + +// The interval menu resets each target and counts the rounds from 0 again, +// then updates the summary, before any check since the change is recorded. +test("after an interval change, the summary's Reachable slot reads --/-- in white, as at page load", (t) => { + t.mock.method( + globalThis.document, + "getElementById", + (id) => (elements[`#${id}`] ??= {}), + ); + const state = new AppState([]); + for (const host of state.wan) { + host.pushSample(Date.now(), { latency: 30, error: null }); + } + state.tickCount = 5; + + for (const host of state.allHosts) host.reset(); + state.tickCount = 0; + updateSummary(state); + + assert.equal(elements["#summary-reachable"].textContent, "--/--"); + assert.equal(elements["#summary-reachable"].className, "text-white"); +}); + +test("once every WAN target's check has failed, the summary's Reachable slot reads 0 of them, in red", (t) => { + t.mock.method( + globalThis.document, + "getElementById", + (id) => (elements[`#${id}`] ??= {}), + ); + const state = new AppState([]); + for (const host of state.wan) { + host.pushSample(Date.now(), { latency: null, error: "timeout" }); + } + + updateSummary(state); + + assert.equal( + elements["#summary-reachable"].textContent, + `0/${state.wan.length}`, + ); + assert.equal(elements["#summary-reachable"].className, "text-red-400"); +}); + +// In the next two tests each of the header's clocks is a plain object, made +// the first time updateClocks looks it up and kept in elements under "#" and +// its id. + +// The mocked clock reads 1970-01-01T00:00:00.000Z. +test("the header's clocks show the local time with its time zone's name, and the UTC time", (t) => { + t.mock.method( + globalThis.document, + "getElementById", + (id) => (elements[`#${id}`] ??= {}), + ); + t.mock.timers.enable({ apis: ["Date"] }); + + updateClocks(); + + assert.match( + elements["#clock-local"].textContent, + /^Local: \d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S/, + ); + assert.equal( + elements["#clock-utc"].textContent, + "UTC: 1970-01-01 00:00:00 UTC", + ); +}); + +// Here Intl.DateTimeFormat is as in Chrome before 95, Firefox before 91 or +// Safari before 15.4: it throws a RangeError when asked for the time zone's +// "shortGeneric" name. +test("in a browser that cannot name the time zone, updating the clocks throws nothing and leaves them blank", (t) => { + t.mock.method( + globalThis.document, + "getElementById", + (id) => (elements[`#${id}`] ??= {}), + ); + const DateTimeFormat = Intl.DateTimeFormat; + t.mock.method(Intl, "DateTimeFormat", function (locales, options) { + if (options?.timeZoneName === "shortGeneric") { + throw new RangeError( + "Value shortGeneric out of range for Intl.DateTimeFormat options property timeZoneName", + ); + } + return new DateTimeFormat(locales, options); + }); + + updateClocks(); + + assert.equal(elements["#clock-local"].textContent, ""); + assert.equal(elements["#clock-utc"].textContent, ""); +}); + +// An app state in which, of the WAN targets, the first timedOut timed out, +// the next unreachable were found unreachable, the next answered answered +// after latency ms, and the rest have not been checked yet. +function stateAfter({ timedOut, unreachable, answered, latency }) { + const state = new AppState([]); + const results = [ + ...Array(timedOut).fill({ latency: null, error: "timeout" }), + ...Array(unreachable).fill({ latency: null, error: "unreachable" }), + ...Array(answered).fill({ latency, error: null }), + ]; + results.forEach((result, i) => state.wan[i].pushSample(Date.now(), result)); + return state; +} + +// For each number the health is decided by, the rows put it one under, at +// and one over its threshold. +for (const { timedOut, unreachable = 0, answered, latency, health } of [ + // Offline: more than 10 timed out and at most 4 answered. + { timedOut: 9, answered: 4, latency: 30, health: "degraded" }, + { timedOut: 10, answered: 4, latency: 30, health: "degraded" }, + { timedOut: 11, answered: 4, latency: 30, health: "offline" }, + { timedOut: 11, answered: 3, latency: 30, health: "offline" }, + { timedOut: 11, answered: 5, latency: 30, health: "degraded" }, + // Otherwise degraded: more than 4 timed out. + { timedOut: 3, answered: 10, latency: 30, health: "healthy" }, + { timedOut: 4, answered: 10, latency: 30, health: "healthy" }, + { timedOut: 5, answered: 10, latency: 30, health: "degraded" }, + // Otherwise slow: more than 3 answered after more than 1000ms. + { timedOut: 0, answered: 4, latency: 999, health: "healthy" }, + { timedOut: 0, answered: 4, latency: 1000, health: "healthy" }, + { timedOut: 0, answered: 4, latency: 1001, health: "slow" }, + { timedOut: 0, answered: 2, latency: 1001, health: "healthy" }, + { timedOut: 0, answered: 3, latency: 1001, health: "healthy" }, + // A target found unreachable counts as one that timed out. + { + timedOut: 5, + unreachable: 6, + answered: 4, + latency: 30, + health: "offline", + }, + { + timedOut: 0, + unreachable: 5, + answered: 10, + latency: 30, + health: "degraded", + }, +]) { + test(`with ${timedOut} WAN targets timed out, ${unreachable} found unreachable and ${answered} answering after ${latency}ms, the health is ${health}`, () => { + const state = stateAfter({ timedOut, unreachable, answered, latency }); + assert.equal(state.healthStatus(), health); + }); +} diff --git a/test/viewport/README.md b/test/viewport/README.md index d37a688..a0fa863 100644 --- a/test/viewport/README.md +++ b/test/viewport/README.md @@ -1,112 +1,146 @@ -# Responsive-layout harness +# The viewport test -Automated verification of the responsive layout that landed in #5. Run it with: +`make frontend-viewport-test` checks the page's layout at several window widths +in a real browser: ```bash make frontend-viewport-test ``` -It builds `dist/`, serves it from the same digest-pinned `nginx` image and the -same `nginx.conf` the shipping container uses, drives a digest-pinned headless -Chrome against it over CDP, and asserts on computed layout at every viewport -width derived from the app's own CSS. Screenshots land in `tmp/viewport/` -alongside a `results.json`; they are artifacts for a human to look at when -something fails, not the evidence. The assertions are the evidence. +First it runs the unit tests. Then it builds the page into `dist/`. It serves +`dist/` with the nginx image and the `configs/nginx.conf` that the Docker image +ships. Before the browser starts, it fetches `/` and `/no-such-page`, which +nginx answers with the page. Both must carry `Cache-Control: no-cache` and a +`Content-Security-Policy` header. Then it starts a headless Chrome in a +container and opens the page in it at each width. For each width it measures the +layout inside the page and checks the measurements in Node. The checks decide +pass or fail. Screenshots and a `results.json` land in `tmp/viewport/`, for a +person to look at when a check fails. -The target is deliberately outside `make check`: it needs Docker and takes -minutes, and `make test` has to stay under 20 seconds. +The test is not part of `make check`: it takes minutes, and `make test` has to +finish in under 60 seconds. -## How the widths are chosen +## The widths -Not from a list of phone models. `viewports.js` parses the `@media` conditions -out of `src/styles.css` and scans `src/main.js` and `index.html` for Tailwind -responsive prefixes, then tests every breakpoint it finds at one pixel below it, -exactly on it, and one pixel above it. A generic 375px "phone" test sails -straight past an off-by-one at a media query boundary; `max-width: 768px` -matches _at_ 768, and the sweep pins down which side of that line each layout is -on. +The breakpoints are read from the page's own files. `viewports.js` reads the +`min-width` and `max-width` conditions out of the `@media` blocks in +`web/src/styles.css`. It also looks in `web/src/main.js` and `web/index.html` +for Tailwind's responsive class prefixes, such as `md:`, and takes their +breakpoints. Each breakpoint is tested at one pixel below it, exactly on it, and +one pixel above it. Today the CSS has one breakpoint, `max-width: 768px`, and no +responsive prefix is used. -Nothing hardcodes 768. Add a second media block or start using `md:` classes and -the new breakpoint is covered without this directory being touched. The app is -desktop-first today (all narrow rules live in `max-width` blocks); a -`min-width`-only, mobile-first set is handled as its inverse, and a set that -mixes the two makes the run fail loudly rather than test the right widths with -the wrong expectation. Four further viewports are fixed anchors, each with a -stated reason: a 320px floor, a 1280px desktop baseline, and two phone-landscape -sizes straddling the breakpoint for the rotation case. +A `max-width: 768px` block matches at 768 pixels. Testing both sides of each +breakpoint catches a layout that switches one pixel too early or too late. A +second media block, or a first `md:` class, is covered without a change to this +directory. -## What it asserts +The page is desktop-first: the wide layout is the default, and every narrow rule +is in a `max-width` block. So a width is expected to show the narrow layout when +one of those blocks matches it. A page with only `min-width` blocks would be +read the other way round. A page that mixes the two kinds fails the run with a +message, because the right expectation for each width can no longer be read from +the breakpoints alone. -- **app-rendered** — enough host rows exist and enough of them show a numeric - latency. This one exists so the rest cannot pass vacuously against a blank - page. -- **no-horizontal-overflow** — `documentElement.scrollWidth` fits the layout - viewport, with the widest offending element named. -- **nothing-past-viewport-edge** — no visible element's box extends past the - viewport edge. -- **no-clipped-text** — nothing hides text behind `overflow: hidden`. Deliberate - ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is - excluded: it is a design choice, not breakage. -- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on - touch viewports, _and_ each selector in the control list matched at least the - number of visible elements it declares. The second half is what stops the - check passing vacuously: with size alone, a renamed class would take its - controls out of the measured set and the check would report "all 0 controls - are at least 44x44" and pass. See below. -- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow. - Computed `flex-direction` _and_ the actual geometry are checked, and in the - narrow layout the info block and the sparkline must each occupy essentially - the full row width. A row that merely shrank its 420px column would fail. -- **probing-still-runs / gateway-detection-still-runs** — narrow viewports keep - probing and keep detecting the gateway. The mobile early-return path proposed - in #8 was rejected; this is what would catch it coming back. +Four more widths are fixed, each for a stated reason: -### The tap-target threshold +- 320 by 568 pixels: the narrowest width still in common use +- 667 by 375 pixels: a phone in landscape, inside the narrow layout +- 844 by 390 pixels: a large phone in landscape, in the wide layout but still a + touch device +- 1280 by 800 pixels: a desktop window -44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in -WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". WCAG 2.2 SC 2.5.8 (level AA) sets a -lower 24x24 floor, but that floor comes with a spacing exception these controls -do not qualify for — the pin buttons sit directly against the host name they -belong to. +Every width but 1280 by 800 is tested as a touch device. -## Determinism +## The checks -The browser container runs on an `--internal` docker network and has no route to -the internet, so the app's latency probes cannot reach anything real. The -harness answers them itself from a fixed delay table, with a deterministic -fraction failed outright, so the rows render a realistic spread of one-, two- -and three-digit latencies plus some unreachable rows. That spread is what the -layout has to survive; 24 identical `---` placeholders would not exercise it. +A failed check does not stop the others. -## What this cannot verify +- `app-rendered`: at least 10 WAN host rows exist, and at least 5 rows show a + number as their latency. Without this, the other checks could pass against a + blank page. +- `no-horizontal-overflow`: the document is no wider than the window. On a + failure, the widest content is named. +- `nothing-past-viewport-edge`: no visible element extends past the window's + edge. +- `no-clipped-text`: no element hides its text behind `overflow: hidden` or + `overflow: clip`. Text cut with an ellipsis is allowed: that cut is a design + choice, not a defect. Tailwind's `truncate` cuts host names and URLs that way. +- `tap-targets-44px`, on touch widths: each measured control is at least 44 by + 44 CSS pixels. The controls are `#pause-btn`, `#interval-select`, + `#debug-toggle` and the `.pin-btn` in each WAN host row. The check also + demands that each selector matches enough visible elements: one of each single + control, and one pin button per WAN host row. A renamed class would otherwise + take its controls out of the measured set, and the check would pass on + nothing. +- `host-rows-stacked` or `host-rows-side-by-side`: each host row shows the + layout expected at that width. In the narrow layout, the row's + `flex-direction` is `column`, the graph sits below the host's details, and + each takes at least 90% of the row's width. In the wide layout, the direction + is `row` and the graph sits to the right of the details. A row that only + shrank its fixed 420-pixel column fails. +- `probing-still-runs`: the page sent at least one check to a target at this + width. The narrow layout must not skip the checks. +- `gateway-detection-still-runs`: the `Local Gateway` row is present at this + width, so the gateway was looked for and found. The narrow layout must not + skip that search. -Real limits, so nobody re-parks this issue as needing hardware: +### The tap-target size -- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be - emulated by it; Safari-specific bugs (viewport units under a collapsing URL - bar, `-webkit-fill-available`, form control metrics) will not show up here. -- **Real touch input.** `hasTouch` emulation changes what the page is told, not - how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and - hover-state fallbacks on touch are out of scope. -- **Physical pixel density and rendering.** `deviceScaleFactor` is set, but - subpixel antialiasing, OLED colour rendering and actual legibility at a given - physical size are not measurable here. -- **Fonts.** The container has DejaVu, not the platform's own UI monospace. Text - metrics are therefore close to, but not identical to, a real device — a layout - that fits here by a few pixels might not there. -- **On-device performance.** Canvas sparkline redraw cost, battery, and - behaviour on a slow radio are not measured. -- **Browser chrome.** The address bar, safe-area insets and notch cutouts are - not simulated. +44 by 44 CSS pixels is the figure in Apple's Human Interface Guidelines and in +WCAG 2.2 success criterion 2.5.5, "Target Size (Enhanced)". WCAG 2.2 criterion +2.5.8 sets a lower minimum of 24 by 24 pixels, with an exception that depends on +the space around a control. The pin buttons sit against the host name they +belong to, so the test keeps 44. -Everything else this issue was actually about — does the layout reflow, does -anything overflow, is content clipped, are the controls big enough — is a -function of viewport width and CSS, and is covered above. +## The browser's network -## Relation to the unit test framework (#21) +The browser container runs on an internal Docker network with no route to the +internet, so the page's checks reach no real host. The test answers them itself +from a fixed table of delays: 2, 45, 123, 456 and 780 milliseconds. Which delay +a target gets depends on its URL, and about one target in seven fails outright. +So the rows show one-, two- and three-digit latencies and some unreachable rows, +and the layout is tested against that spread. The request to +`http://192.168.1.1` is answered after 5 milliseconds, so the `Local Gateway` +row appears. -Complementary layers, not two stacks. `vitest` (#21) will exercise module-level -logic in-process with no browser. This harness exercises rendered layout in a -real engine and is the only thing here that can see a media query. Neither -replaces the other; assertions about computed styles and element geometry belong -here, assertions about functions belong in `vitest`. +At each width, the test sets the interval to 1 second and waits until at least 5 +rows show a latency before it measures. The page discards its first round, so +this reaches a filled page without waiting out two rounds at 3 seconds. + +## What the test cannot check + +- Other browser engines. The test runs Chrome. Safari uses WebKit, and bugs that + only WebKit has do not show up here. +- Real touch input. Touch emulation changes what the page is told, not how a + finger behaves. Gestures, scroll momentum, double-tap zoom and hover states on + touch are not tested. +- Physical pixels. The device pixel ratio is set, but antialiasing, colour + rendering and legibility at a given physical size are not measured. +- Fonts. The container has DejaVu, not a phone's own monospace font. Text is + about as wide as on a real device, but not exactly. A layout that fits here by + a few pixels might not fit there. +- Performance. The cost of redrawing the graphs, battery use and behaviour on a + slow connection are not measured. +- The browser's own frame. The address bar, safe-area insets and notch cutouts + are not simulated. + +## The files + +- `viewports.js` derives the widths from the sources. +- `facts.js` is run inside the page and measures the layout. It only measures; + it decides nothing. +- `checks.js` turns the measurements into the pass or fail of each check, with + the numbers that decided it. +- `harness.js` connects to the browser, runs each width, writes the screenshots + and `results.json`, and prints the results. `script/frontend-viewport-test` + starts it; every input it needs arrives in the environment. + +## Relation to the unit tests + +The unit tests in `test/unit/` call the functions that `web/src/main.js` +exports, with no browser. `make test` runs them with Node's built-in test +runner. This test runs the built page in a real browser engine and is the only +test here that can see a media query. Neither replaces the other. Checks on +computed styles and element geometry belong here; checks on functions belong in +`test/unit/`. diff --git a/test/viewport/checks.js b/test/viewport/checks.js index 14c7ebf..4f5648a 100644 --- a/test/viewport/checks.js +++ b/test/viewport/checks.js @@ -15,7 +15,8 @@ export const MIN_TAP_TARGET_PX = 44; // The controls named in the definition of done, plus the pause button. // Each carries the smallest number of *visible* instances the page has to -// contain for the tap-target oracle to be measuring anything at all. +// contain, worked out from the facts gathered from that page, for the +// tap-target oracle to be measuring every control it should. // // Without those floors the check is inert: `undersized` is empty both when // every control is large enough and when the selectors have gone stale and @@ -24,13 +25,12 @@ export const MIN_TAP_TARGET_PX = 44; // for all three singleton controls vanishing at once — so the floor is per // selector, and one stale selector out of four fails the check. export const INTERACTIVE_CONTROLS = [ - { selector: "#pause-btn", minCount: 1 }, - { selector: "#interval-select", minCount: 1 }, - // One per pinnable host row. `app-rendered` already requires at least - // 10 host rows, so a count below that means the pin buttons stopped - // being rendered per row rather than that there were fewer hosts. - { selector: ".pin-btn", minCount: 10 }, - { selector: "#debug-toggle", minCount: 1 }, + { selector: "#pause-btn", minCount: () => 1 }, + { selector: "#interval-select", minCount: () => 1 }, + // One per WAN host row, so pin buttons missing from even one row fail + // the check rather than only a drop below some fixed number. + { selector: ".pin-btn", minCount: (facts) => facts.wanRowCount }, + { selector: "#debug-toggle", minCount: () => 1 }, ]; export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map( @@ -105,8 +105,8 @@ export function evaluateChecks(facts, viewport, probes) { // never rendered. Everything below is only meaningful if this holds. check( "app-rendered", - facts.rowCount >= 10 && facts.numericLatencies >= 5, - `${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`, + facts.wanRowCount >= 10 && facts.numericLatencies >= 5, + `${facts.wanRowCount} WAN host rows, ${facts.numericLatencies} showing a numeric latency`, ); const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth); @@ -162,7 +162,8 @@ export function evaluateChecks(facts, viewport, probes) { seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1); } const missing = INTERACTIVE_CONTROLS.filter( - (control) => (seen.get(control.selector) ?? 0) < control.minCount, + (control) => + (seen.get(control.selector) ?? 0) < control.minCount(facts), ); const undersized = facts.tapTargets.filter( @@ -184,11 +185,11 @@ export function evaluateChecks(facts, viewport, probes) { const detail = []; if (missing.length > 0) { detail.push( - "oracle is not measuring the page: " + + "oracle is not measuring every control: " + summarise( missing, (c) => - `${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`, + `${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount(facts)}`, 4, ), ); diff --git a/test/viewport/facts.js b/test/viewport/facts.js index f266e6c..5c124f8 100644 --- a/test/viewport/facts.js +++ b/test/viewport/facts.js @@ -173,7 +173,9 @@ export function collectLayoutFacts(options) { clipped, tapTargets, rows, - rowCount: document.querySelectorAll(".host-row").length, + // WAN host rows only: each has a pin button, and the tap-target + // check expects one per row. The local host rows have none. + wanRowCount: document.querySelectorAll("#wan-hosts .host-row").length, numericLatencies: Array.from( document.querySelectorAll(".latency-value"), ).filter((el) => /\d/.test(el.textContent)).length, diff --git a/test/viewport/harness.js b/test/viewport/harness.js index 52a4483..d5a90c8 100644 --- a/test/viewport/harness.js +++ b/test/viewport/harness.js @@ -16,6 +16,10 @@ import { collectLayoutFacts } from "./facts.js"; import { evaluateChecks, INTERACTIVE_SELECTORS } from "./checks.js"; import { deriveViewports } from "./viewports.js"; +// page.waitForFunction and page.evaluate run the functions given them in +// the page, where these are defined. +/* global document, requestAnimationFrame */ + function required(name) { const value = process.env[name]; if (!value) { @@ -47,7 +51,8 @@ const PROBE_DELAYS_MS = [2, 45, 123, 456, 780]; // offline row rendering is exercised too. const UNREACHABLE_MODULUS = 7; // The gateway candidate that "answers", so gateway detection succeeds and -// the Local Gateway row renders. Matches GATEWAY_CANDIDATES in src/main.js. +// the Local Gateway row renders. Matches GATEWAY_CANDIDATES in +// web/src/main.js. const RESPONSIVE_GATEWAY = "http://192.168.1.1"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -62,7 +67,6 @@ function stableHash(text) { async function connectBrowser() { const deadline = Date.now() + BROWSER_TIMEOUT_MS; - let lastError; for (;;) { try { const response = await fetch(`${CDP_URL}/json/version`); @@ -77,9 +81,10 @@ async function connectBrowser() { }); return { browser, version: info.Browser }; } catch (error) { - lastError = error; if (Date.now() > deadline) { - throw new Error(`browser never came up: ${lastError}`); + throw new Error(`browser never came up: ${error}`, { + cause: error, + }); } await sleep(250); } diff --git a/test/viewport/viewports.js b/test/viewport/viewports.js index 23a20b2..31579ca 100644 --- a/test/viewport/viewports.js +++ b/test/viewport/viewports.js @@ -161,26 +161,27 @@ const ANCHOR_VIEWPORTS = [ export function deriveViewports(root) { const conditions = [ ...mediaConditionsFromCss( - readFileSync(join(root, "src/styles.css"), "utf8"), - "src/styles.css", + readFileSync(join(root, "web/src/styles.css"), "utf8"), + "web/src/styles.css", ), ...mediaConditionsFromMarkup([ { - path: "src/main.js", - text: readFileSync(join(root, "src/main.js"), "utf8"), + path: "web/src/main.js", + text: readFileSync(join(root, "web/src/main.js"), "utf8"), }, { - path: "index.html", - text: readFileSync(join(root, "index.html"), "utf8"), + path: "web/index.html", + text: readFileSync(join(root, "web/index.html"), "utf8"), }, ]), ]; if (conditions.length === 0) { throw new Error( - "no responsive breakpoints found in src/styles.css, src/main.js or " + - "index.html — either the responsive layout was deleted or this " + - "derivation has stopped matching the sources", + "no responsive breakpoints found in web/src/styles.css, " + + "web/src/main.js or web/index.html — either the responsive " + + "layout was deleted or this derivation has stopped matching " + + "the sources", ); } diff --git a/vite.config.js b/vite.config.js index 0f362bd..317bd86 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,10 @@ const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); const commitFull = execSync("git rev-parse HEAD").toString().trim(); export default defineConfig({ + // The page's source is in web/; the build still goes to dist/ at the + // repo root, which Dockerfile and the viewport test serve. Vite + // empties only an outDir inside root unless told to. + root: "web", plugins: [tailwindcss()], server: { // Proxy /api to a locally running netwatch-server so `yarn dev` @@ -19,6 +23,8 @@ export default defineConfig({ __COMMIT_FULL__: JSON.stringify(commitFull), }, build: { + outDir: "../dist", + emptyOutDir: true, target: "esnext", minify: "esbuild", cssMinify: true, diff --git a/index.html b/web/index.html similarity index 74% rename from index.html rename to web/index.html index 3af620d..a1e31c4 100644 --- a/index.html +++ b/web/index.html @@ -9,6 +9,9 @@ type="image/svg+xml" href="data:image/svg+xml,📡" /> + +
diff --git a/src/main.js b/web/src/main.js similarity index 71% rename from src/main.js rename to web/src/main.js index 526a544..85d16f1 100644 --- a/src/main.js +++ b/web/src/main.js @@ -1,14 +1,16 @@ -import "./styles.css"; - // --- Configuration ----------------------------------------------------------- -// Timing, axis labels, and display constants. Latency above maxLatency is -// clamped to "unreachable". The sparkline Y-axis is capped at +// Timing, axis labels, and display constants. A target check times out +// after requestTimeout, 80% of updateInterval, so a round's checks have +// all finished before the next round is due; latency above maxLatency is +// recorded as a timeout. The sparkline Y-axis is capped at // graphMaxLatency — values above it pin to the top of the chart but still // display their real value in the latency figure. The history buffer holds // maxHistoryPoints samples (historyDuration / updateInterval). // reportInterval is how often collected samples are POSTed to the backend. -const CONFIG = { +// The interval menu changes updateInterval while the page runs; the +// getters compute their values from it each time they are read. +export const CONFIG = { updateInterval: 3000, maxHistoryPoints: 100, reportInterval: 60000, @@ -16,7 +18,7 @@ const CONFIG = { return (this.maxHistoryPoints * this.updateInterval) / 1000; }, get requestTimeout() { - return Math.min(this.updateInterval - 100, 3000); + return this.updateInterval * 0.8; }, get maxLatency() { return this.requestTimeout; @@ -28,55 +30,103 @@ const CONFIG = { return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5)); }, canvasHeight: 96, + // A latency figure and its sparkline take the color of the first entry + // whose limit, in ms, the latency is below. + latencyColors: [ + { below: 50, hex: "#22c55e", className: "text-green-500" }, + { below: 100, hex: "#84cc16", className: "text-lime-500" }, + { below: 200, hex: "#eab308", className: "text-yellow-500" }, + { below: 500, hex: "#f97316", className: "text-orange-500" }, + { below: Infinity, hex: "#ef4444", className: "text-red-500" }, + ], + // The health is offline when more than offlineTimeouts WAN hosts timed + // out or were unreachable and at most offlineReachable answered; + // otherwise degraded when more than degradedTimeouts timed out or were + // unreachable; otherwise slow when more than slowHosts answered after + // more than slowLatency ms. + offlineTimeouts: 10, + offlineReachable: 4, + degradedTimeouts: 4, + slowHosts: 3, + slowLatency: 1000, + // The debug log keeps its last maxLogEntries lines. + maxLogEntries: 1000, + // A gateway candidate that has not answered after gatewayTimeout ms is + // passed over. + gatewayTimeout: 1500, + // When no WAN host answers, the recovery probe checks recoveryProbeHosts + // random ones every recoveryProbeInterval ms. + recoveryProbeHosts: 4, + recoveryProbeInterval: 500, + // The rows are sorted after the first round that is not discarded, then + // every roundsPerSort rounds. + roundsPerSort: 10, + // The sparklines are sized and drawn again resizeDelay ms after start. + resizeDelay: 100, }; // WAN endpoints to monitor. These are used for the aggregate health/stats // display (min/max/avg). Ordered: personal, LLM APIs, big-3 cloud, then -// CDN/hosting/other. +// CDN/hosting/other. A check follows a redirect and times the request to +// the new URL too. So a target whose home page redirects is checked at +// /robots.txt on its own host. That URL answers without a redirect. const WAN_HOSTS = [ { name: "datavi.be", url: "https://datavi.be" }, { name: "Anthropic API", url: "https://api.anthropic.com" }, { name: "OpenAI API", url: "https://api.openai.com" }, - { name: "AWS Console", url: "https://console.aws.amazon.com" }, - { name: "Google Cloud Console", url: "https://console.cloud.google.com" }, + { name: "AWS Console", url: "https://console.aws.amazon.com/robots.txt" }, + { + name: "Google Cloud Console", + url: "https://console.cloud.google.com/robots.txt", + }, { name: "Microsoft Azure", url: "https://portal.azure.com" }, { name: "Cloudflare", url: "https://www.cloudflare.com" }, { name: "Fastly CDN", url: "https://www.fastly.com" }, - { name: "Akamai", url: "https://www.akamai.com" }, + { name: "Akamai", url: "https://www.akamai.com/robots.txt" }, { name: "Google", url: "https://www.google.com" }, { name: "GitHub", url: "https://github.com" }, - { name: "B2", url: "https://api.backblazeb2.com" }, + { name: "B2", url: "https://api.backblazeb2.com/robots.txt" }, + // Chrome keeps the connection to an S3 region open after a HEAD check, + // but closes it after most GET checks. { name: "S3 af-south-1 (Cape Town)", - url: "https://s3.af-south-1.amazonaws.com", + url: "https://s3.af-south-1.amazonaws.com/robots.txt", + method: "HEAD", }, { name: "S3 eu-west-2 (London)", - url: "https://s3.eu-west-2.amazonaws.com", + url: "https://s3.eu-west-2.amazonaws.com/robots.txt", + method: "HEAD", }, { name: "S3 me-south-1 (Bahrain)", - url: "https://s3.me-south-1.amazonaws.com", + url: "https://s3.me-south-1.amazonaws.com/robots.txt", + method: "HEAD", }, { name: "S3 ap-northeast-1 (Tokyo)", - url: "https://s3.ap-northeast-1.amazonaws.com", + url: "https://s3.ap-northeast-1.amazonaws.com/robots.txt", + method: "HEAD", }, { name: "S3 ap-southeast-1 (Singapore)", - url: "https://s3.ap-southeast-1.amazonaws.com", + url: "https://s3.ap-southeast-1.amazonaws.com/robots.txt", + method: "HEAD", }, { name: "S3 ap-southeast-2 (Sydney)", - url: "https://s3.ap-southeast-2.amazonaws.com", + url: "https://s3.ap-southeast-2.amazonaws.com/robots.txt", + method: "HEAD", }, { name: "S3 us-west-2 (Oregon)", - url: "https://s3.us-west-2.amazonaws.com", + url: "https://s3.us-west-2.amazonaws.com/robots.txt", + method: "HEAD", }, { name: "S3 sa-east-1 (São Paulo)", - url: "https://s3.sa-east-1.amazonaws.com", + url: "https://s3.sa-east-1.amazonaws.com/robots.txt", + method: "HEAD", }, // Hetzner regional speed test servers — genuine per-DC endpoints { @@ -109,12 +159,15 @@ const WAN_HOSTS = [ const debugLog = []; -const log = (() => { +export const log = (() => { function append(level, message) { - debugLog.push({ timestamp: new Date(), level, message }); - if (debugLog.length > 1000) debugLog.splice(0, debugLog.length - 1000); + const entry = { timestamp: new Date(), level, message }; + debugLog.push(entry); + if (debugLog.length > CONFIG.maxLogEntries) + debugLog.splice(0, debugLog.length - CONFIG.maxLogEntries); const panel = document.getElementById("debug-panel"); - if (panel && !panel.classList.contains("hidden")) renderDebugLog(); + if (panel && !panel.classList.contains("hidden")) + appendDebugLogLine(entry); } return { error: (msg) => append("error", msg), @@ -151,7 +204,7 @@ function formatUTCTimestamp(date) { // --- Duration Formatting ----------------------------------------------------- -function humanDuration(seconds) { +export function humanDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; @@ -172,7 +225,10 @@ async function detectGateway() { const result = await Promise.any( GATEWAY_CANDIDATES.map(async (url) => { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); + const timeoutId = setTimeout( + () => controller.abort(), + CONFIG.gatewayTimeout, + ); try { await fetch(url, { method: "GET", @@ -197,14 +253,53 @@ async function detectGateway() { // --- App State --------------------------------------------------------------- -class HostState { +// The min, max, median and average of latencies, a list of numbers, or all +// null when it is empty. The median of an even count is the mean of the +// middle two; it and the average are rounded. +function latencyStats(latencies) { + if (latencies.length === 0) + return { min: null, max: null, med: null, avg: null }; + const sorted = [...latencies].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return { + min: sorted[0], + max: sorted[sorted.length - 1], + med: + sorted.length % 2 + ? sorted[mid] + : Math.round((sorted[mid - 1] + sorted[mid]) / 2), + avg: Math.round( + latencies.reduce((a, b) => a + b, 0) / latencies.length, + ), + }; +} + +// The status a check's error gives its host: "error" for a timeout, +// "offline" for any other error, and "online" for none. +function statusFromError(error) { + if (error === "timeout") return "error"; + if (error) return "offline"; + return "online"; +} + +export class HostState { constructor(host, pinned = false) { this.name = host.name; this.url = host.url; - this.history = []; // { timestamp, latency, paused } + this.method = host.method; + // Each entry is either a check's result, { timestamp, latency, + // error }, or a round skipped while paused, { timestamp, + // latency: null, paused: true }. + this.history = []; + // The last 100 (CONFIG.maxHistoryPoints) entries the interval menu + // has cleared from the history, which the reporter may not have + // sent yet. + this.clearedHistory = []; this.lastLatency = null; this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending' this.pinned = pinned; + // How many recorded checks in a row have failed, up to the last one. + this.consecutiveFailures = 0; } pushSample(timestamp, result) { @@ -215,9 +310,10 @@ class HostState { }); this._trim(); this.lastLatency = result.latency; - if (result.error === "timeout") this.status = "error"; - else if (result.error) this.status = "offline"; - else this.status = "online"; + this.status = statusFromError(result.error); + this.consecutiveFailures = result.error + ? this.consecutiveFailures + 1 + : 0; } pushPaused(timestamp) { @@ -225,36 +321,26 @@ class HostState { this._trim(); } - averageLatency() { - const valid = this.history.filter((p) => p.latency !== null); - if (valid.length === 0) return null; - return Math.round( - valid.reduce((s, p) => s + p.latency, 0) / valid.length, + // What the interval menu does to each host: the history starts over and + // the host waits for its next check. The history's entries move to + // clearedHistory, so the reporter still sends those it has not. + reset() { + this.clearedHistory = [...this.clearedHistory, ...this.history].slice( + -CONFIG.maxHistoryPoints, ); + this.history = []; + this.lastLatency = null; + this.status = "pending"; } - minLatency() { - const valid = this.history.filter((p) => p.latency !== null); - if (valid.length === 0) return null; - return Math.min(...valid.map((p) => p.latency)); - } - - maxLatency() { - const valid = this.history.filter((p) => p.latency !== null); - if (valid.length === 0) return null; - return Math.max(...valid.map((p) => p.latency)); - } - - medianLatency() { - const sorted = this.history - .filter((p) => p.latency !== null) - .map((p) => p.latency) - .sort((a, b) => a - b); - if (sorted.length === 0) return null; - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 - ? sorted[mid] - : Math.round((sorted[mid - 1] + sorted[mid]) / 2); + // The min, max, median and average latency of the checks in the history + // that got an answer. + historyStats() { + return latencyStats( + this.history + .filter((p) => p.latency !== null) + .map((p) => p.latency), + ); } _trim() { @@ -263,7 +349,7 @@ class HostState { } } -class AppState { +export class AppState { constructor(localHosts) { this.wan = WAN_HOSTS.map( (h) => new HostState(h, h.name === "datavi.be"), @@ -271,6 +357,10 @@ class AppState { this.local = localHosts.map((h) => new HostState(h)); this.paused = false; this.tickCount = 0; + // The recovery probe's timer, null while it is not running, and the + // checks it started last. + this._recoveryProbeId = null; + this._recoveryProbeChecks = null; } get allHosts() { @@ -279,33 +369,13 @@ class AppState { /** WAN-only stats from latest sample (excludes local) */ wanStats() { - const reachable = this.wan.filter((h) => h.lastLatency !== null); - const latencies = reachable.map((h) => h.lastLatency); - const total = this.wan.length; - if (latencies.length === 0) - return { - reachable: 0, - total, - min: null, - max: null, - med: null, - avg: null, - }; - const sorted = [...latencies].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - const med = - sorted.length % 2 - ? sorted[mid] - : Math.round((sorted[mid - 1] + sorted[mid]) / 2); + const latencies = this.wan + .filter((h) => h.lastLatency !== null) + .map((h) => h.lastLatency); return { reachable: latencies.length, - total, - min: Math.min(...latencies), - max: Math.max(...latencies), - med, - avg: Math.round( - latencies.reduce((a, b) => a + b, 0) / latencies.length, - ), + total: this.wan.length, + ...latencyStats(latencies), }; } @@ -331,12 +401,16 @@ class AppState { const timeouts = this.wan.filter( (h) => h.status === "error" || h.status === "offline", ).length; - if (timeouts > 10 && reachable <= 4) return "offline"; - if (timeouts > 4) return "degraded"; + if ( + timeouts > CONFIG.offlineTimeouts && + reachable <= CONFIG.offlineReachable + ) + return "offline"; + if (timeouts > CONFIG.degradedTimeouts) return "degraded"; const slow = this.wan.filter( - (h) => h.lastLatency !== null && h.lastLatency > 1000, + (h) => h.lastLatency !== null && h.lastLatency > CONFIG.slowLatency, ).length; - if (slow > 3) return "slow"; + if (slow > CONFIG.slowHosts) return "slow"; return "healthy"; } @@ -395,9 +469,12 @@ function getClientId() { // Build the delta report body the backend decodes, plus the new per-host // high-water marks. Pure function of the passed state: `hosts` is an array -// of { name, url, status, history }, `since` maps a host url to the Unix-ms -// timestamp of the last sample already reported for it, and `now` is a Date. -// Only non-paused samples newer than the mark are included. Returns null +// of { name, url, status, clearedHistory, history }, `since` maps a host url +// to the Unix-ms timestamp of the last sample already reported for it, and +// `now` is a Date. Only non-paused samples newer than the mark are included, +// from clearedHistory as well as history. A host whose status is pending, +// as the interval menu leaves it until its next check, is sent with the +// status of its last check, which is the last sample sent. Returns null // when no host has an unreported sample. export function buildReport(hosts, clientId, now, since) { const reportHosts = []; @@ -406,7 +483,7 @@ export function buildReport(hosts, clientId, now, since) { const mark = since.get(host.url) ?? 0; const samples = []; let high = mark; - for (const p of host.history) { + for (const p of [...host.clearedHistory, ...host.history]) { if (p.paused) continue; if (p.timestamp <= mark) continue; samples.push({ @@ -420,7 +497,10 @@ export function buildReport(hosts, clientId, now, since) { reportHosts.push({ name: host.name, url: host.url, - status: host.status, + status: + host.status === "pending" + ? statusFromError(samples.at(-1).error) + : host.status, history: samples, }); marks.set(host.url, high); @@ -447,7 +527,7 @@ export function buildReport(hosts, clientId, now, since) { // Only one report is ever in flight, and it is abandoned after half the // interval, so a slow POST can neither overlap the next report (which would // carry the same samples) nor stall reporting for good. -class Reporter { +export class Reporter { constructor(state, clientId, intervalMs) { this.state = state; this.clientId = clientId; @@ -503,62 +583,74 @@ class Reporter { // --- Latency Measurement ----------------------------------------------------- -async function measureLatency(url) { +// Checks one target with the given HTTP method, GET when none is given. The +// check times out after CONFIG.requestTimeout; the caller can give it up +// sooner through the optional signal, which also ends it as a timeout. A +// failed check's reason says what went wrong and after how long; a check +// that answered has none. +// +// The Hetzner speed-test servers close the connection without an answer +// when the URL has a query string or the request has a Referer header. So +// the URL is fetched as written, and referrerPolicy: "no-referrer" sends +// no Referer header, wherever the page is served from. cache: "no-store" +// keeps the browser's cache out of the measurement. +export async function measureLatency(url, signal, method = "GET") { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), CONFIG.requestTimeout, ); - - const targetUrl = new URL(url); - targetUrl.searchParams.set("_cb", Date.now().toString()); + signal?.addEventListener("abort", () => controller.abort()); const start = performance.now(); try { - await fetch(targetUrl.toString(), { - method: "GET", + await fetch(url, { + method, mode: "no-cors", cache: "no-store", + referrerPolicy: "no-referrer", signal: controller.signal, }); const latency = Math.round(performance.now() - start); clearTimeout(timeoutId); if (latency > CONFIG.maxLatency) { - log.error(`${url} timeout (${latency}ms > ${CONFIG.maxLatency}ms)`); - return { latency: null, error: "timeout" }; + return { + latency: null, + error: "timeout", + reason: `answered after ${latency} ms, over the ${CONFIG.maxLatency} ms limit`, + }; } - return { latency, error: null }; + return { latency, error: null, reason: null }; } catch (err) { + const took = Math.round(performance.now() - start); clearTimeout(timeoutId); if (err.name === "AbortError") { - log.error(`${url} timeout (aborted)`); - return { latency: null, error: "timeout" }; + return { + latency: null, + error: "timeout", + reason: `timed out after ${took} ms (limit ${CONFIG.requestTimeout} ms)`, + }; } - log.error(`${url} unreachable`); - return { latency: null, error: "unreachable" }; + return { + latency: null, + error: "unreachable", + reason: `network error (${err.name}: ${err.message}) after ${took} ms`, + }; } } // --- Color Helpers ----------------------------------------------------------- -function latencyHex(latency) { +export function latencyHex(latency) { if (latency === null) return "#6b7280"; - if (latency < 50) return "#22c55e"; - if (latency < 100) return "#84cc16"; - if (latency < 200) return "#eab308"; - if (latency < 500) return "#f97316"; - return "#ef4444"; + return CONFIG.latencyColors.find((c) => latency < c.below).hex; } -function latencyClass(latency, status) { +export function latencyClass(latency, status) { if (status === "offline" || status === "error" || latency === null) return "text-gray-500"; - if (latency < 50) return "text-green-500"; - if (latency < 100) return "text-lime-500"; - if (latency < 200) return "text-yellow-500"; - if (latency < 500) return "text-orange-500"; - return "text-red-500"; + return CONFIG.latencyColors.find((c) => latency < c.below).className; } // --- Sparkline Renderer ------------------------------------------------------ @@ -576,8 +668,8 @@ class SparklineRenderer { const ch = h - m.top - m.bottom; ctx.clearRect(0, 0, w, h); - SparklineRenderer._drawYAxis(ctx, w, h, m, ch); - SparklineRenderer._drawXAxis(ctx, w, h, m, cw); + SparklineRenderer._drawYAxis(ctx, w, m, ch); + SparklineRenderer._drawXAxis(ctx, h, m, cw); const len = history.length; const pw = cw / (CONFIG.maxHistoryPoints - 1); @@ -593,7 +685,7 @@ class SparklineRenderer { SparklineRenderer._drawTip(ctx, history, getX, getY); } - static _drawYAxis(ctx, w, h, m, ch) { + static _drawYAxis(ctx, w, m, ch) { ctx.font = "300 12px monospace"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; @@ -610,7 +702,7 @@ class SparklineRenderer { } } - static _drawXAxis(ctx, w, h, m, cw) { + static _drawXAxis(ctx, h, m, cw) { ctx.textAlign = "center"; ctx.textBaseline = "top"; for (const tick of CONFIG.xAxisTicks) { @@ -698,7 +790,18 @@ class SparklineRenderer { // horizontally. const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5"; -function hostRowHTML(host, index, showPin = true) { +// Escapes text for HTML, so it shows as written inside an element or a +// quoted attribute and is never read as markup. +function escapeHTML(text) { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function hostRowHTML(host, index, showPin = true) { const pinColor = host.pinned ? "text-blue-500" : "text-gray-600 hover:text-gray-400"; @@ -717,12 +820,12 @@ function hostRowHTML(host, index, showPin = true) {
- ${host.name} + ${escapeHTML(host.name)}
---
- ${host.url} + ${escapeHTML(host.url)}
waiting...
@@ -745,7 +848,7 @@ function wanRowsHTML(wanHosts) { return rows.join(""); } -function buildUI(state) { +export function buildUI(state) { const app = document.getElementById("app"); app.innerHTML = `
@@ -821,13 +924,13 @@ function buildUI(state) {