25 Commits
Author SHA1 Message Date
clawbot d44c9b60bd Move to the layout REPO_POLICIES.md gives (closes #30)
check / check (push) Canceled after 0s
The Go module moves from backend/ to the root (go.mod, go.sum,
.golangci.yml, cmd/, internal/), the page's source to web/, and nginx's
two files to configs/. backend/ is gone: its LICENSE and .dockerignore
are deleted, and its .gitignore entries, README and scripts are folded
into the root ones. script/build builds the server binary after the
frontend; script/run and script/clean move up from backend/script/;
make test, lint, fmt and fmt-check do what the backend's did. Vite's
root is web/ and it still builds into dist/. go.mod ignores
node_modules/, whose npm packages can ship .go files. script/bootstrap
no longer installs gcc, which only the host Go tests needed.

Model: opus-5-5
2026-10-14 09:33:01 +00:00
clawbot b1eefe0c29 Re-vendor the shared files from sneak/prompts at dd4027b (closes #113)
check / check (push) Canceled after 0s
The shared files are the sneak/prompts copies at dd4027b, plus this
repository's own entries. make lint and make test each build one
Dockerfile phase without the cache, both covering the frontend; the
builder stage waits on both and takes its version from git describe
unless VERSION is given. The test phase keeps Go's module and build
caches in memory, out of the image make test tags. golangci-lint moves
to v2.14.0 with the new .golangci.yml; one test spells X-Request-ID as
canonicalheader asks. prettier formats only JavaScript, CSS, HTML and
Markdown, so .golangci.yml stays as fetched. script/fmt and
script/fmt-check put ~/.local/bin on PATH. script/bootstrap keeps a Go
only if it is exactly GO_VERSION, and re-checks the go on PATH after
installing.

Model: opus-5-5
2026-10-07 13:41:50 +02:00
clawbot dcdee6bfab Hetzner checks answer again; failed checks go to the console (closes #114)
check / check (push) Canceled after 0s
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, so all six showed unreachable. Checks now fetch each target's URL
as written; cache: "no-store" still keeps the browser's cache out of the
measurement.

Each recorded check that fails writes one console.error line, and the
same line to the debug log: 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 nothing, so the debug log no longer lists failures in the
first round or the recovery probe.

Model: opus-5-5
2026-10-07 12:24:51 +02:00
clawbot 186f932eb8 Footer no longer says IPv4 only (closes #111)
check / check (push) Successful in 2m48s
Each check is a fetch: the browser picks IPv4 or IPv6 for each WAN
host, and the local targets are IPv4 addresses, so "IPv4 only" was
wrong for the page as a whole. The footer's first line drops it and
its separator; the rest of the footer is unchanged. TODO.md moves this
to Completed Steps and the layout decision from Future Steps into Next
Step.

Model: opus-5-5
2026-10-04 07:38:06 +02:00
clawbot 64e142c17f README, TODO and the viewport README say what the tree does (closes #24)
check / check (push) Successful in 2m57s
README.md: Getting Started leads with make targets; a Backend section
gives netwatch-server's routes and how the image builds and runs it;
the checks are GET requests; the WAN host list, health states, summary
figures, sorting and missing features match src/main.js; the TODO
section points to TODO.md, which holds the one to-do list.

backend/README.md: its TODO section points to TODO.md too, whose
Future Steps take its three open items.

TODO.md: Workflow branches from next and opens the PR against next;
Status, Next Step and Future Steps describe the open work, linked to
its issue where one exists.

test/viewport/README.md: the unit tests run on Node's test runner,
not vitest.

Model: opus-5-5
2026-10-04 07:03:05 +02:00
clawbot 161f955ae2 Latency statistics written once, thresholds read from CONFIG (closes #102)
check / check (push) Successful in 2m50s
A target's min, max, median and average latency now come from one list
of its answers, through latencyStats(), which the summary's figures use
too, so the median is written once. latencyHex() and latencyClass() read
one table of color limits in CONFIG. The health thresholds, the debug
log's length, the gateway check's timeout, the recovery probe's number
of hosts and interval, how often the rows are sorted and the delay
before the first sparkline resize are CONFIG entries, read where the
numbers were. HostState's minLatency(), maxLatency(), averageLatency()
and medianLatency() are gone; the statistics test reads historyStats().
Nothing the page does or shows changes. A unit test now covers the
summary's figures.

Model: opus-5-5
2026-10-04 06:51:24 +02:00
clawbot d40e67d4ab Rate limit password attempts on /metrics (closes #104)
check / check (push) Successful in 3m32s
Each client address may make 60 requests to /metrics a minute,
through the same httprate middleware and TRUSTED_PROXIES
resolution the report route uses, with an allowance of its own.
The limit runs before the basic auth, so past it the answer is 429
and the password is not checked. backend/README.md says so; a test
uses up one client's allowance on wrong passwords, gets 429 with
the right one, and checks that another client behind the same
nginx still gets in.

Model: opus-5-5
2026-10-04 06:19:06 +02:00
clawbot dc2d240725 Report handler panics to Sentry when SENTRY_DSN is set (closes #95)
check / check (push) Successful in 3m6s
With SENTRY_DSN set, the server initialises sentry-go with the release
netwatch-server-<version>, adds the sentryhttp middleware with Repanic
as the last router-wide middleware, after the timeout, and flushes
Sentry for 2 seconds on shutdown. A DSN Sentry refuses stops the start
with an error naming SENTRY_DSN. With it empty, nothing is set up.

The metrics middleware stays on the matched routes only, so it runs
inside the Sentry middleware rather than before it.

Model: opus-5-5
2026-10-04 05:57:02 +02:00
clawbot 00c9f8d7d9 Target names, URLs and log lines reach the page as text (closes #29)
check / check (push) Successful in 3m8s
A host row escapes the name and URL it writes into its markup with a new
escapeHTML function, and the debug log builds each line as an element
whose text is set, so neither is read as HTML once targets can be
configured. A unit test builds the row of a target whose name and URL
hold < > " & and ' and checks each comes out escaped; hostRowHTML is
exported for it.

README.md stops calling CONFIG frozen: the interval menu sets
updateInterval, and the timeouts, history span and axis ticks are
computed from it. AppState declares _recoveryProbeId and
_recoveryProbeChecks, the sparkline axis functions drop the parameters
they never used, and HostState's history comment names both entry
shapes.

Model: opus-5-5
2026-10-04 05:35:36 +02:00
clawbot dc11beb6fe Add make add-dependency and make tidy (closes #45)
check / check (push) Successful in 3m29s
No entrypoint could change yarn.lock or go.mod: script/bootstrap
installs with --frozen-lockfile, so adding a package meant running
yarn by hand. make add-dependency PACKAGE=<name>@<version> shims to
the new script/add-dependency: yarn add --dev, then yarn install
--frozen-lockfile. make tidy shims to the new script/tidy, go mod
tidy in backend/; a Go module is added by importing it, or moved by
editing its require line, then make tidy. script/bootstrap is
unchanged.

Model: opus-5-5
2026-10-04 05:17:33 +02:00
clawbot 81d4153e78 Serve Prometheus metrics at /metrics behind basic auth (closes #94)
check / check (push) Successful in 2m53s
With METRICS_USERNAME and METRICS_PASSWORD both set, the backend
records request metrics through go-http-metrics in a registry of its
own, with Go's runtime and process metrics, and serves them at
GET /metrics behind basic auth; nginx passes /metrics to it. With
neither set there is no such route; one alone, or a METRICS_USERNAME
containing ":", stops the start with an error naming the setting.

Only requests that reach the health check or POST /api/v1/reports are
recorded, as the labels are path and method, which clients could
otherwise make up without end; POST /api/v1/reports is registered by
its full path for that.

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

Model: opus-5-5
2026-10-04 04:58:47 +02:00
clawbot d412815953 Shim make dev and make build, format backend/ markdown (closes #28)
check / check (push) Successful in 4m11s
make dev ran yarn dev inline and there was no make build. Both are now
shims, over script/dev and the new script/build. .prettierignore stops
leaving out backend/, so backend/README.md is formatted and checked;
backend/.golangci.yml is left out by name instead: it is the org
standard file whose sha256 backend/script/lint checks, and prettier
would reindent it. .claude/ stays in .prettierignore, as git does not
ignore it. script/install-precommit and the date on bootstrap's pins go
back to the org model; bootstrap, fmt and fmt-check keep what the Go
backend and eslint need, each with a comment saying so.

Model: opus-5-5
2026-10-04 04:14:59 +02:00
clawbot 9b548da90d Move tailwind off the deprecated module.register() (closes #32)
check / check (push) Successful in 2m40s
Frontend builds on Node 26 or newer, such as make test on a host with
Node 26, printed Node's DEP0205 warning; the build in Dockerfile runs on
Node 22, which never printed it. The trace names @tailwindcss/node,
which @tailwindcss/vite brings in at its own exact version; tailwind
4.3.1 calls module.registerHooks() where Node has it. @tailwindcss/vite
and tailwindcss move to 4.3.3, inside the ranges package.json already
allows, so only yarn.lock changes, every entry still with an integrity
hash. tailwindcss moves too so that one tailwind version is installed.
Deviation: yarn.lock was changed by a raw yarn upgrade; no entrypoint adds a dependency yet (issue #45).

Model: opus-5-5
2026-10-04 03:51:55 +02:00
clawbot 3b1262718d Frontend unit tests cover durations, colours, statistics and health (closes #21)
check / check (push) Successful in 3m38s
New table-driven tests in test/unit/main.test.js: humanDuration, the
figure and sparkline colours either side of each latency boundary, a
target's min, max, average and median over an empty history, an
all-unreachable one, a mixed one, one of only answers and one whose
answers have different numbers of digits, and the four health states
either side of their thresholds, with targets found unreachable counted
as timed out. src/main.js exports the four names they need.

package.json gains a test script, which script/frontend-test runs with
the dot reporter and, if a test fails, again with the spec reporter
before failing. NODE_OPTIONS picks the reporter, since yarn appends its
arguments after the test files.

Model: opus-5-5
2026-10-04 03:25:06 +02:00
clawbot 82bd142429 Log fx through slog, snake_case health check keys (closes #27)
check / check (push) Successful in 4m40s
fx wrote its own steps of starting and stopping as plain text to
stderr. It now logs them with its slog event logger through the
backend's logger, so off a terminal every line the backend's own
logger and fx write is JSON. A malformed config file now makes
config.New return the error instead of panicking. A test runs the
server as a child process and checks that all it writes is JSON, on
a normal start and stop and with such a config file. The backend
logs its name, version and architecture once at start.

The health check's uptime keys are now uptime_seconds and
uptime_human; its type and method take the names
GO_HTTP_SERVER_CONVENTIONS.md gives.
Rules suppressed: revive and tagliatelle on HealthcheckResponse, whose name and keys come from the conventions; gosec where the test starts its own binary.

Model: opus-5-5
2026-10-04 03:11:43 +02:00
clawbot 924527b744 Lint the frontend with eslint in its own Docker stage (closes #47)
check / check (push) Successful in 3m48s
script/lint ran prettier --check, the same check script/fmt-check
runs, so the JavaScript had no linter. eslint now runs with its
recommended rules, set in eslint.config.js, in a new frontend-lint
stage of Dockerfile built from the pinned node image and the lockfile.
The frontend stage copies a file from it, as the builder stage does
from the Go lint stage, so the image cannot build unless eslint passed.
script/lint builds both lint stages with --no-cache and runs no linter
on the host; script/fmt-check keeps prettier on the host, and
script/frontend-check drops its lint step. The viewport harness fixes
the two kinds of finding eslint made. bootstrap wants node 22.13.0, as
eslint 10 does. Also covers item 2 of
#28.

Model: opus-5-5
2026-10-04 01:58:07 +02:00
clawbot 2f0489e3a4 Tap-target check expects a pin button per WAN host row (closes #46)
check / check (push) Successful in 3m50s
The viewport harness's tap-target check required at least 10 visible
pin buttons while 26 render, so pin buttons missing from up to 16 rows
went unnoticed. It now expects one per WAN host row. The host row count
the harness gathers, which the app-rendered check also reads, counts
only the WAN host rows, since the local host rows have no pin button.
Each control's minimum is now worked out from the gathered facts.

TODO.md's harness entry no longer says every check guards itself: the
overflow, viewport-edge and clipped-text checks rely on app-rendered.

Model: opus-5-5
2026-10-04 01:53:02 +02:00
clawbot 91856fa170 Each target's row shows its result as soon as its check ends (closes #91)
check / check (push) Successful in 3m43s
tick drew no row until the round's slowest check ended, up to 24
seconds at a 30-second interval since checks time out at 80% of it.
Each check now pushes its sample and redraws its row as it ends. When
the last check ends, every row is redrawn, so none still reads "paused"
after a pause and resume, and sorting, the summary, the health box and
offline detection run once. A check that ends while paused or after its
round is given up draws nothing, and the first round is still discarded
as a whole. The row is looked up when the check ends, as a pin click can
re-sort the rows mid-round.

Unit tests run tick on the mocked clock against a stand-in page.

Model: opus-5-5
2026-10-04 01:41:04 +02:00
clawbot 39ee6ca839 Entrypoint acts as root on nothing outside /data (closes #80)
check / check (push) Successful in 1m58s
`bin/entrypoint.sh` now runs `netwatch-server prepare-data-dir`, which
refuses a `DATA_DIR` that is not `/data` or a path below it written in
full, then creates `DATA_DIR`, gives `/data` and everything in it to
`netwatch`, and sets mode 750 on `/data` and `DATA_DIR`. Every step goes
through a Go `os.Root` opened on `/data`, and the modes are set on the
opened directories rather than by name, so neither a symbolic link
already there nor one a host process swaps in while the container
starts can make root create or change anything outside `/data`. The
README says which `DATA_DIR` values are accepted.

Model: opus-5-5
2026-10-03 18:24:38 +02:00
clawbot e4df415676 Delete the oldest report files to stay under the size cap (closes #54)
check / check (push) Successful in 1m57s
When a report would take the report files past DATA_DIR_MAX_BYTES,
reportbuf now deletes the oldest report files until it fits, and does
the same at start when files left by an earlier run are already past
it. A file joins the files that may be deleted, at its place by name,
only once it is completely written, so 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. The reports of a failed write stop counting, and the part of
its file written is removed. A file whose deletion fails keeps
counting; one already deleted by hand counts as freed.

Model: opus-5-5
2026-10-03 17:51:08 +02:00
clawbot 9e4d3fdb54 Lint's .golangci.yml check says which fix applies (closes #34)
check / check (push) Successful in 2m0s
backend/script/lint compared .golangci.yml with its pinned sha256 and,
on any failure, said to restore the file from sneak/prompts. Once the
org standard has legitimately changed, that advice loops: the copied
file is right and GOLANGCI_CONFIG_SHA256 is stale. On a mismatch the
script now says to compare the file with the org standard, restore it
if they differ, and update GOLANGCI_CONFIG_SHA256 if they are the same;
it still prints both hashes. A missing .golangci.yml, and a sha256sum
that is missing or prints no hash, get their own messages instead of
being reported as a mismatch. Each failure still exits 1. .golangci.yml
is unchanged.

Model: opus-5-5
2026-10-03 17:14:25 +02:00
clawbot aa35625d47 Go tests run with -race and -cover under Go's own timeout (closes #88)
check / check (push) Successful in 2m30s
backend/script/test runs go test -timeout 30s -race -cover and, if
that fails, runs it again with -v and fails. The root script/test drops
its one 30-second timeout around both halves: from a cold Go build
cache, compiling the tests with -race used it all up. Each half keeps
its own limit. The race detector needs a C compiler: the Dockerfile
builder stage gains gcc and musl-dev, and script/bootstrap installs gcc,
with the C library headers on apt and apk, when gcc is missing; make
build still sets CGO_ENABLED=0. New tests: the health check's answer, a
valid report's answer, a report file's exact lines, and the flush at the
10 MiB threshold. The handlers TestImport stub is gone.

Model: opus-5-5
2026-10-03 16:26:17 +02:00
clawbot 0ab6418e3d Target check timeout is 80% of the refresh interval (closes #78)
check / check (push) Successful in 1m55s
Each target check now times out after 80% of the refresh interval, 24
seconds at 30 seconds, where it was capped at 3 seconds, so slow, far
targets are recorded with their real time. Rounds never overlap: a
round gives up the last round's checks if they are still waiting, which
happens only when a round starts early, after an interval change or
when the recovery probe finds a target answering. The probe still
checks every half second, giving up its previous checks, so they do not
pile up.

The frontend has its first unit tests, run by script/frontend-test with
Node's built-in test runner on a mocked clock. index.html now links
src/styles.css, which src/main.js imported, since Node cannot import
CSS.

Model: opus-5-5
2026-10-03 15:36:47 +02:00
clawbot 4ce0814b14 Stamp the git tag or short commit in a plain docker build (closes #86)
check / check (push) Successful in 1m36s
The builder stage now has git and takes the version from the VERSION
build argument when one is given, otherwise from `git describe --tags
--always` of the repo's .git, copied to /git so go build does not see
it. A version that still comes out empty, dev or unknown fails the
build. ARG VERSION has no default. .dockerignore keeps .git/config out
of the build context, and the comments in script/docker and
script/cibuild no longer say .git is excluded.

Model: opus-5-5
2026-10-02 04:26:40 +02:00
clawbot e6d6815ecb Remove Buildarch: read the architecture at run time (closes #83)
check / check (push) Successful in 1m37s
The architecture is no longer passed in at build time. The Buildarch
variable and field are gone from main and globals, script/build no
longer stamps it in with -X, and the startup and listen log lines
report runtime.GOARCH under the key "arch". The Dockerfile comment and
backend/README.md no longer describe an architecture being stamped in.

Model: opus-5-5
2026-10-02 01:05:09 +02:00
54 changed files with 1642 additions and 4479 deletions
+1 -4
View File
@@ -73,12 +73,9 @@
# 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.
# writes and the reports `make run` stores, and log files at the root.
/dist
/tmp
/netwatch-server
/data
/*.test
/*.out
/*.log
+2 -3
View File
@@ -11,8 +11,7 @@ insert_final_newline = true
[Makefile]
indent_style = tab
# This repository's own entries, after the shared content above.
[*.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.
-11
View File
@@ -1,20 +1,9 @@
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 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
+2 -4
View File
@@ -27,7 +27,7 @@ node_modules/
# 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`.
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
@@ -46,9 +46,7 @@ node_modules/
[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/`.
# This repository's own entries, after the shared content above.
# Build output
dist/
-2
View File
@@ -25,8 +25,6 @@ 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
+37 -67
View File
@@ -5,27 +5,16 @@
#
# 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.
# stage alone, and the builder stage depends on both, so the image
# cannot be built unless they pass. Each 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.
# 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-lint
ARG CACHEBUST
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
@@ -40,86 +29,69 @@ FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a924
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 ./...
RUN 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.
# Frontend stage: the unit tests in test/unit/, then the production
# build into dist/, which the runtime stage serves. The test phase below
# runs 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
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# vite.config.js reads the commit for the page's footer with git.
RUN apk add --no-cache git
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; }
RUN yarn build
# 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
# and the frontend's through the copy from the frontend 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.
# Go's module and build caches are in memory (tmpfs) for the go test
# step alone, so the image make test tags does not carry them and the
# build spends no time writing them into it. They start empty on every
# build, so no stored test result can stand in for a run.
# 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 ./... || \
RUN --mount=type=tmpfs,target=/go/pkg/mod \
--mount=type=tmpfs,target=/root/.cache/go-build \
go test -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
go test -timeout 90s -race -v ./...; exit 1; }
# Nothing is wanted from the frontend 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
COPY --from=frontend /app/yarn.lock /dev/null
# 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.
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.25-alpine (2026-02-27)
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
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
@@ -132,8 +104,7 @@ COPY . .
# 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)}"; \
RUN version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$version" in ""|dev|unknown) \
echo "version is '$version' although .git is present" >&2; \
@@ -148,7 +119,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# 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
@@ -161,7 +131,7 @@ RUN addgroup -g 1000 -S netwatch && \
RUN rm /etc/nginx/conf.d/default.conf
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=frontend /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
+456 -416
View File
@@ -1,17 +1,8 @@
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.
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 from its Docker image, where a
small Go backend stores the measurements the page reports.
## Getting Started
@@ -19,10 +10,10 @@ the same and nothing is stored.
# Install the dependencies and the git pre-commit hook
make setup
# Serve the page from the Vite dev server
# Run the page on the Vite dev server
make dev
# Build the page and netwatch-server, then run the server on port 8080
# Build netwatch-server and run it on port 8080
make run
# Run the tests, both linters and the format check
@@ -31,417 +22,466 @@ make check
# Build the page into dist/ and netwatch-server into the repo root
make build
# Build the Docker image and run it
# Build the image and run it
make docker
docker run -p 8080:8080 netwatch
```
`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.
`make check` and `make docker` need Docker. `make dev` passes `/api` to
`http://127.0.0.1:8080`, where `make run` starts `netwatch-server` with its
defaults, so the reports the page posts are stored in `data/reports`.
## The page
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.
### Targets
The page checks 26 hosts on the internet, the WAN hosts:
- `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
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.
The host list is written in `web/src/main.js`; there is no setting for it.
### What a figure measures
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.
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.
### Health and summary
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.
- `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
Below it, a summary line covers the WAN hosts only:
- `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
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.
### Rows, colors and controls
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.
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.
`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.
## Reporting
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:
- `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
`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.
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`.
The image also:
- 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.
### Running under upaas
The [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
- 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.
The container stops at start when a variable holds a value it cannot use. The
reason is in its log.
## Settings
`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-<timestamp>-<number>.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
## 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.
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them.
The scripts cover both the frontend and the Go backend, so `make check` fails if
either one is broken. We provide:
- `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=<name>@<version>`.
- `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.
- `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`, Go `GO_VERSION` unless the `go` on
`PATH` is exactly that version, 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 test` and `make lint` run in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
- `script/dev` — run the Vite dev server, which proxies `/api` to a locally
running `netwatch-server`
- `script/build` — build the frontend for production into `dist/`, then the
static `netwatch-server` binary into the repo root with its version stamped
in: `VERSION` from the environment, or when that is unset or empty,
`git describe` inside a git checkout, then `dev`
- `script/run` — build with `script/build`, then run `netwatch-server` with its
defaults (see [Settings](#settings))
- `script/clean` — remove what `script/build` writes
- `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — build the `test` phase of `Dockerfile` without the cache: the
frontend's unit tests and production build in its `frontend` stage, and the
backend's Go tests with the race detector and coverage
- `script/lint` — build the `lint` phase of `Dockerfile` without the cache:
eslint in its `frontend-lint` stage, and golangci-lint over the Go packages
- `script/fmt` — format all files (writes): prettier over the JavaScript, CSS,
HTML and Markdown, then gofmt over `cmd/` and `internal/`. It runs on the
host, as `script/fmt-check` does, with `~/.local/bin` put on `PATH`
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
- `script/check` — run test, lint, and fmt-check
- `script/add-dependency` — add a frontend package, or move one to another
version: `make add-dependency PACKAGE=<name>@<version>` runs `yarn add --dev`,
which changes `package.json` and `yarn.lock` together, then
`yarn install --frozen-lockfile`
- `script/tidy` — run `go mod tidy`: to add a Go module, import it and run
`make tidy`; to move one to another version, edit its `require` line in
`go.mod`, then run `make tidy`
- `script/frontend-test` — run the unit tests in `test/unit/` on the host with
Node's built-in test runner, through the `test` script in `package.json`, and
if any fails, run them again listing every test, and fail; then the production
build. Each test run has a 90-second timeout. `make test` runs the same in
Docker
- `script/frontend-fmt` — format the JavaScript, CSS, HTML and Markdown with
prettier (writes)
- `script/frontend-fmt-check` — check prettier formatting (read-only)
- `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 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
### Tests
## Responsive layout
`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.
The narrow-viewport layout lives in the `max-width: 768px` media block in
`web/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 — on every breakpoint it declares and one pixel either side of it, 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.
## Rationale
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 single page that does all its measuring in the browser, so it
can be served from anywhere static files are served. The backend in its Docker
image only stores the measurements the page reports; without it, the page works
the same and nothing is stored.
## Design
The page's source is in `web/`, the Go backend's in `cmd/` and `internal/`, and
nginx's configuration in `configs/`.
The page is built with Vite and Tailwind CSS v4. Its code is all in
`web/src/main.js`, with a class-based architecture:
- **`CONFIG`**: Configuration object (update interval, timeouts, axis ticks,
etc.). The interval menu sets `updateInterval`, the one value the page writes
into `CONFIG`; the timeouts, the time the history spans and the x-axis ticks
are computed from it
- **`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, pushing each host's
sample and redrawing its row as soon as its check ends, then redraws every
row, the summary and the health box once the last check ends. The first round,
after loading or an interval change, is discarded. The rows are sorted when
the last check ends in round 2, the first one kept, and in rounds 11, 21, 31
and so on. When paused, pushes blank markers (no probes, no false outage)
- **`Reporter`**: Posts collected samples to the backend
### Reporting
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.
### Backend
`netwatch-server` is a small Go HTTP server that stores the reports the page
posts. It keeps them in memory and writes them to `DATA_DIR` as zstd-compressed
files of JSON lines: every minute, whenever 10 MiB are waiting, and when it
stops. Its routes:
- `POST /api/v1/reports` — takes a report, without credentials; each client
address may send a limited number a minute, and the report files are capped in
size, the oldest deleted first
- `GET /.well-known/healthcheck` — answers 200 with `"status":"ok"`, the
server's version and its uptime
- `GET /metrics` — Prometheus metrics behind basic auth, only when
`METRICS_USERNAME` and `METRICS_PASSWORD` are set; each client address may
make a limited number of requests to it a minute
It is an `fx`-wired Go application: `cmd/netwatch-server/` starts it, and its
packages are in `internal/`:
- **`config`**: Loads configuration from environment variables and config files
via Viper.
- **`handlers`**: HTTP request handlers for the API (health check, report
ingestion).
- **`reportbuf`**: The in-memory buffer of reports and the files it writes them
to.
- **`server`**: Chi-based HTTP server with middleware wiring and route
registration.
- **`healthcheck`**, **`middleware`**, **`logger`**, **`globals`**: Supporting
infrastructure.
In the image, the `test` phase of `Dockerfile` tests it, the `builder` stage
builds it, and `bin/entrypoint.sh` runs it as user `netwatch` on
`127.0.0.1:8081`, behind nginx (see [Deployment](#deployment)). Outside the
image, `make run` builds it and runs it on port 8080.
#### Settings
| 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) | Most bytes of report files kept in `DATA_DIR`, oldest deleted first; 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) |
| `METRICS_USERNAME` | empty | Basic auth user name for `/metrics`; see [Metrics](#metrics) |
| `METRICS_PASSWORD` | empty | Basic auth password for `/metrics`; see [Metrics](#metrics) |
| `SENTRY_DSN` | empty | DSN of the Sentry project to send errors to; see [Sentry](#sentry) |
`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 [Deployment](#deployment).
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.
#### Report storage
Reports are written as `reports-<timestamp>-<number>.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 and leaves a gap in
the numbers: its file, if it was created, is removed. The file stays, counted
toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
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;
those lost to a failed write stop counting, and the part of its file written
is removed. When a report would take the total past the cap, the oldest report
files are deleted to make room, and each deletion is logged with the file's
name and size; a file still being written is never deleted. A report is
refused with 507, and nothing of it is stored, only when the reports waiting
to be written fill the cap on their own, and then no file is deleted. At
start, report files past the cap, as after lowering it, are deleted the same
way. So the cap is how much of the newest reports is kept: the default of 1
GiB is small enough for any host; set it to the space you can give `DATA_DIR`.
#### 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`.
#### Metrics
With both `METRICS_USERNAME` and `METRICS_PASSWORD` set, the server serves
Prometheus metrics at `GET /metrics` to requests with those as their basic auth
credentials, and answers any other with 401. For each request that reaches the
health check or `POST /api/v1/reports`, those the rate limit refuses included,
the metrics record its duration and response size, labelled with its path,
method and status; they also count those requests in progress, and include Go's
runtime and process metrics. No other request is recorded: not those to
`/metrics` itself, and not those answered before they reach either route, such
as a CORS preflight, or a request refused with 404 for a path no route has, 405
for a method its route does not take, or 413 for declaring a body length over
the 1 MiB limit. A report whose body goes over the limit without declaring its
length reaches the route, is answered 413 there, and is recorded with that
status. Clients can make up any number of paths and methods, and each would add
labels to the metrics for as long as the server runs.
`/metrics` is rate limited, so that its password cannot be guessed quickly: each
client address, resolved through `TRUSTED_PROXIES`, may make 60 requests to it a
minute, whatever their credentials. Past that it gets 429 with
`Retry-After: 60`, and its credentials are not checked. The minute slides as it
does for reports (see [Report limits](#report-limits)), so a scraper polling
every 2 seconds or less often is never refused. This allowance is apart from the
one for reports.
#### Sentry
With `SENTRY_DSN` set, each panic in a handler is reported to that Sentry
project under the release `netwatch-server-` followed by the server's version.
On shutdown the server waits up to 2 seconds for Sentry to finish sending.
### Monitoring targets
- **26 WAN hosts**: datavi.be (pinned at start), Anthropic API, OpenAI API, AWS
Console, Google Cloud Console, Microsoft Azure, Cloudflare, Fastly CDN,
Akamai, Google, GitHub, B2, 8 S3 regional endpoints (Cape Town, London,
Bahrain, Tokyo, Singapore, Sydney, Oregon, São Paulo) and 6 Hetzner speed test
servers (Nuremberg, Falkenstein, Helsinki, Ashburn, Hillsboro, Singapore)
- **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.
Local hosts are tracked separately from WAN stats.
### Latency measurement
GET requests to each target's URL as written, with `mode: 'no-cors'` and
`cache: 'no-store'`, timed with `performance.now()`. No query string is added:
the Hetzner speed-test servers close the connection without an answer when the
URL has one, and `no-store` keeps the browser's cache out of the measurement.
Each check times out after 80% of the refresh interval (24 seconds at 30
seconds) and is then recorded as a timeout, so a round's checks have all
finished before the next round is due. When no WAN host answers, a recovery
probe checks 4 WAN hosts, picked at random when it starts, every half second,
giving up the checks it started half a second before. As soon as one answers, a
new round starts at once, as it does after an interval change. A round started
early gives up the last round's checks if they are still waiting, and that round
records nothing more, so rounds never overlap. The browser chooses between IPv4
and IPv6 for each target, as for any request; the local targets are IPv4
addresses.
Each recorded check that fails writes one line to the browser console with
`console.error`, and the same line to the debug log: the target's name and URL,
the time, whether it timed out, answered over the time limit or hit a network
error (with the error the browser gives the page, such as
`TypeError: Failed to fetch`, which does not say why), and how long the request
took. A target that answers after failed checks writes one `console.info` line
with its latency and how many checks in a row had failed.
### Color coding
| Latency | Color |
| ----------- | ------ |
| < 50ms | Green |
| < 100ms | Lime |
| < 200ms | Yellow |
| < 500ms | Orange |
| >= 500ms | Red |
| Unreachable | Gray |
### Output structure
```
dist/
├── index.html
└── assets/
├── index-*.css
└── index-*.js
```
## Features
- A round of checks every 3 seconds by default; the interval menu sets 1, 2, 3,
5, 10, 15, 30 or 60 seconds and clears the history
- Sparklines of each target's last 100 rounds: 300 seconds at 3 seconds
- The first round after loading or an interval change is discarded, as DNS and
TLS setup inflate its latencies
- Health indicator from the WAN hosts' latest results: OFFLINE (red) when more
than 10 fail and at most 4 answer, otherwise DEGRADED (orange) when more than
4 fail, otherwise SLOW (yellow) when more than 3 take over 1000ms, otherwise
HEALTHY (green)
- Summary stats across WAN hosts only: how many answered, the min, median,
average and max of their latest latencies, the min and max over the whole
history, and the number of rounds run (`Checks`)
- Fixed chart axes: Y-axis 0–1000ms, higher latencies drawn at the top; X-axis
the time the history spans
- Color-coded latency figures and sparkline line segments
- WAN host rows sorted by latest latency, unreachable last; pinned rows stay on
top, in name order
- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no
false outage)
- Debug log panel, behind a checkbox in the footer, with five levels (error,
warning, notice, info, debug) and the last 1000 lines
- Local and UTC clocks
- Clickable service URLs
- A footer link to the commit the page was built from
- Canvas-based sparkline rendering with devicePixelRatio scaling
- Zero runtime dependencies: all resources bundled into build artifacts
## Deployment
`make build` writes the page to `dist/`, which any static file host (S3, GCS,
Cloudflare Pages, Vercel, Netlify, GitHub Pages) can serve; with no backend
there, its reports fail quietly and nothing is stored. Or run the Docker image
behind a reverse proxy.
The Docker image, built from `Dockerfile` by `make docker`, is the whole service
in one container: nginx serves the built frontend and passes `/api/`,
`/.well-known/healthcheck` and `/metrics` to the Go backend, `netwatch-server`.
`bin/entrypoint.sh` starts it listening only inside the container, on
`127.0.0.1:8081`, with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the client
address nginx passes on and no other. The image:
- 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.
`bin/entrypoint.sh` checks each entry with `netwatch-server check-cidr`, which
parses it as the backend parses its own `TRUSTED_PROXIES`
- Sends access logs to stdout
- Caches static assets with immutable headers
- Sends the security headers `REPO_POLICIES.md` requires on every response, as
`configs/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, with
`netwatch-server prepare-data-dir`, which acts on nothing outside `/data`, 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
## Running under upaas
What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
- **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; the oldest are deleted to stay under it
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
the API
- `DEBUG`, default `false`: debug logging
- `DATA_DIR`, default `/data/reports`: the directory the reports are kept
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
The container also stops if the path goes through a symbolic link that
leads out of `/data` or is written as a full path
- `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`.
- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: with both set,
the backend records Prometheus metrics of its requests and serves them at
`/metrics` on the container port, to requests with this user name and
password as their basic auth credentials. With neither set, there are no
metrics and `/metrics` is not found. One set without the other, or a user
name containing `:`, stops the container
- `SENTRY_DSN`, default empty: set to a Sentry project's DSN, the backend
sends its errors to that Sentry project: each request whose handling
crashes, which still gets a 500 response. A value Sentry does not accept
stops the container. Empty, the backend sends nothing to Sentry
- **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.
## Browser Compatibility
Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom
properties.
## Limitations
- **CORS**: The checks are cross-origin requests in `no-cors` mode, so the page
cannot read the answer, only time it: any answer counts as reachable, an error
page included.
- **Local targets**: The cable modem at 192.168.100.1 and the detected gateway
answer only on a network that has them, and only when NetWatch is served from
localhost or a private address (see Monitoring targets).
- **Network conditions**: Measurements reflect browser-to-endpoint latency,
which includes your local network, ISP, and internet routing.
## TODO
+86 -196
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-18
last_modified: 2026-10-04
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -54,39 +54,34 @@ 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 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@<version> --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
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, 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
containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
source nvm for the pinned node version before invoking it, exactly as
`script/bootstrap`'s own install step does. 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/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
@@ -123,10 +118,8 @@ style conventions are in separate documents:
and nothing else:
```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 .
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
```
**A stage that is not the last one in the file is built only when the final
@@ -136,15 +129,10 @@ style conventions are in separate documents:
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.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
@@ -159,42 +147,22 @@ style conventions are in separate documents:
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.
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
- **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.
- **Any build that runs checks is built with `--no-cache`.** 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 `--no-cache`:
`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. 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
@@ -203,56 +171,40 @@ style conventions are in separate documents:
`Dockerfile`:
```dockerfile
# 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 --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
RUN golangci-lint run --config .golangci.yml ./...
# 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.
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... 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 ./... || \
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
go test -timeout 90s -race -v ./...; exit 1; }
# 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.
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
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 VERSION build arg when one is given, otherwise
@@ -260,8 +212,7 @@ style conventions are in separate documents:
# .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)}"; \
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
@@ -274,7 +225,6 @@ style conventions are in separate documents:
# Runtime stage, and the last one
FROM alpine@sha256:...
ARG CACHEBUST
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
@@ -289,26 +239,6 @@ style conventions are in separate documents:
- 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 phase must
create placeholder files so the embed directives resolve. Example:
@@ -317,20 +247,12 @@ style conventions are in separate documents:
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:
lists in the same `RUN`, so the layer does not keep them:
```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
@@ -356,39 +278,20 @@ style conventions are in separate documents:
`.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` 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
runs `script/cibuild` on push, and checks out the repo as its only other step.
That script 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.
image build is uncached 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. 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
@@ -448,11 +351,12 @@ style conventions are in separate documents:
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.
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example:
@@ -480,12 +384,9 @@ style conventions are in separate documents:
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
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 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
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
setting up a new repo. 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.
@@ -533,15 +434,13 @@ style conventions are in separate documents:
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.
# 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)-$$" \
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$tag" .
-t "$(script/projectname)" .
```
`--always` makes an untagged repo yield an abbreviated commit hash rather
@@ -553,8 +452,8 @@ style conventions are in separate documents:
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.
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `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
@@ -621,11 +520,6 @@ style conventions are in separate documents:
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
@@ -742,11 +636,7 @@ style conventions are in separate documents:
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
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.
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
+359 -626
View File
File diff suppressed because it is too large Load Diff
+70 -103
View File
@@ -13,35 +13,30 @@ 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.
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
export PORT="${PORT:-8080}"
case "$PORT" in
*[!0-9]*)
echo "entrypoint: PORT must be a port number, 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
}
;;
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
# 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.
# 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.
# address from X-Forwarded-For only on a request from one of them, so
# unset or empty, it trusts no one. 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
@@ -50,38 +45,35 @@ check_port() {
# 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.
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
}
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
# 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. 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
}
# and gives /data and everything in it to the netwatch user, so the
# host directory needs no preparing. 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 links it meets there.
export DATA_DIR="${DATA_DIR:-/data/reports}"
netwatch-server prepare-data-dir "$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
# netwatch-server runs as the netwatch user and listens on loopback
# only, on a port other than the public one; configs/nginx.conf proxies
@@ -90,11 +82,9 @@ prepare_data_dir() {
# 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.
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=$!
}
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
@@ -103,11 +93,9 @@ start_backend() {
# that rendering to PORT: a variable nginx itself uses, such as $uri,
# would otherwise be replaced by an environment variable of the same
# name.
start_nginx() {
NGINX_ENVSUBST_FILTER='^PORT$' \
/docker-entrypoint.sh nginx -g 'daemon off;' &
nginx=$!
}
NGINX_ENVSUBST_FILTER='^PORT$' \
/docker-entrypoint.sh nginx -g 'daemon off;' &
nginx=$!
running() {
kill -0 "$1" 2>/dev/null
@@ -116,47 +104,26 @@ 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.
wait_for_stop_or_exit() {
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do
sleep 1
done
}
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do
sleep 1
done
# Stop both, then wait until neither is left.
stop_both() {
kill -TERM "$backend" "$nginx" 2>/dev/null
while running "$backend" || running "$nginx"; do
sleep 1
done
}
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"
# Success is a requested stop that both processes exited cleanly from.
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 "$@"
if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] &&
[ "$nginx_status" -eq 0 ]; then
exit 0
fi
exit 1
+74 -4
View File
@@ -1,11 +1,22 @@
// Package main is the entry point for netwatch-server; package cli
// does the work.
// Package main is the entry point for netwatch-server.
package main
import (
"fmt"
"os"
"os/user"
"sneak.berlin/go/netwatch/internal/cli"
"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"
)
//nolint:gochecknoglobals // set via ldflags at build time
@@ -15,5 +26,64 @@ var (
)
func main() {
os.Exit(cli.Main(os.Stderr, Appname, Version, os.Args[1:], cli.RunServer))
// "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
}
// "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(os.Args) == 3 && os.Args[1] == "prepare-data-dir" {
netwatch, err := user.Lookup("netwatch")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
err = reportbuf.PrepareDataDir("/data", os.Args[2], netwatch)
if err != nil {
fmt.Fprintf(os.Stderr, "DATA_DIR '%s': %v\n", os.Args[2], err)
os.Exit(1)
}
return
}
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()
}
+4 -12
View File
@@ -18,10 +18,9 @@ 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. 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.
# 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.
include /etc/nginx/trusted-proxies.conf;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
@@ -30,13 +29,8 @@ 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
@@ -51,9 +45,7 @@ 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. 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.
# with: the backend takes the first entry, which a client can write.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
+4 -3
View File
@@ -8,9 +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 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
# in web/index.html. connect-src is * because the browser checks each
# probe in web/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.
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;
+1 -1
View File
@@ -17,7 +17,6 @@ require (
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 (
@@ -42,6 +41,7 @@ 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.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
-143
View File
@@ -1,143 +0,0 @@
// 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()
}
-219
View File
@@ -1,219 +0,0 @@
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")
}
}
-16
View File
@@ -1,16 +0,0 @@
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)
}
+16 -20
View File
@@ -9,7 +9,6 @@ import (
"math"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
@@ -22,11 +21,13 @@ import (
)
// defaultTrustedProxies lists the networks whose forwarded
// headers are honoured when TRUSTED_PROXIES is unset: the
// RFC 1918 ranges. The container image does not use it:
// 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:
// bin/entrypoint.sh gives the server 127.0.0.1/32, since
// nginx is its only client there.
const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
const defaultTrustedProxies = "127.0.0.1/32,::1/128," +
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// Default limits on stored reports; README.md gives the reasons for
// these values.
@@ -41,6 +42,7 @@ 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(
@@ -100,7 +102,7 @@ func New(
viper.SetDefault("CORS_ALLOWED_ORIGINS", "")
viper.SetDefault("DATA_DIR", "./data/reports")
viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes)
viper.SetDefault("DEBUG", "")
viper.SetDefault("DEBUG", "false")
// An empty BIND_ADDRESS listens on every interface.
viper.SetDefault("BIND_ADDRESS", "")
viper.SetDefault("PORT", "8080")
@@ -119,24 +121,18 @@ func New(
}
}
// Read with strconv: viper's GetInt would read a value it cannot
// parse as 0 instead of failing.
// Read with strconv: viper's GetInt and GetBool would read a value
// they cannot parse as 0 or false 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)
}
// 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")
debug, err := strconv.ParseBool(viper.GetString("DEBUG"))
if err != nil {
return nil, fmt.Errorf("DEBUG %q: %w",
viper.GetString("DEBUG"), errNotBool)
}
s := &Config{
@@ -150,7 +146,7 @@ func New(
Port: port,
ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"),
SentryDSN: viper.GetString("SENTRY_DSN"),
TrustedProxies: splitList(trustedProxies),
TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
log: log,
params: &params,
}
@@ -169,8 +165,8 @@ func New(
}
// check fails with an error naming the first setting here whose value
// the server cannot use. New checks PORT as it reads it, and the
// middleware checks TRUSTED_PROXIES as it parses it.
// the server cannot use. New checks PORT and DEBUG as it reads them,
// 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.
+7 -105
View File
@@ -1,8 +1,6 @@
package config_test
import (
"os"
"slices"
"strings"
"testing"
@@ -13,8 +11,8 @@ import (
"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
// 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()
@@ -31,62 +29,6 @@ func requireConfigError(t *testing.T, setting string) {
}
}
// 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.
@@ -127,52 +69,12 @@ func TestPortMustBeAPortNumber(t *testing.T) {
}
}
// 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)
// TestDebugMustBeTrueOrFalse: viper reads any other value, such as
// "yes", as false.
func TestDebugMustBeTrueOrFalse(t *testing.T) {
t.Setenv("DEBUG", "yes")
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)
}
})
}
requireConfigError(t, "DEBUG")
}
// TestBindAddressMustBeAnIPAddress: a host name would be looked up
+1 -1
View File
@@ -4,7 +4,7 @@ package globals
import "go.uber.org/fx"
//nolint:gochecknoglobals // set by cli.RunServer before fx starts
//nolint:gochecknoglobals // set from main before fx starts
var (
// Appname is the application name.
Appname string
+4 -21
View File
@@ -5,9 +5,7 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"mime"
"net/http"
"sneak.berlin/go/netwatch/internal/globals"
@@ -20,10 +18,6 @@ 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.
@@ -86,25 +80,14 @@ func (s *Handlers) respondJSON(
}
}
// 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.
// 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.
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)
}
+2 -10
View File
@@ -3,17 +3,9 @@ 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.
// endpoint.
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)
s.respondJSON(w, r, s.hc.Healthcheck(), http.StatusOK)
}
}
+9 -22
View File
@@ -9,19 +9,17 @@ import (
"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.
// server named in g, and starts them, which records the time the
// uptime counts from.
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Helper()
@@ -32,23 +30,14 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
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})
healthcheck.Params{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,
})
h, err := handlers.New(lc,
handlers.Params{Globals: g, Healthcheck: hc, Logger: log})
if err != nil {
t.Fatalf("handlers: %v", err)
}
@@ -59,12 +48,10 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
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.
// TestHandleHealthCheck checks the health check's answer: 200, a JSON
// content type, and a JSON object with exactly the fields of
// healthcheck.HealthcheckResponse, carrying this server's name and
// version and an uptime counted from its start.
func TestHandleHealthCheck(t *testing.T) {
t.Parallel()
+4 -10
View File
@@ -30,7 +30,7 @@ type report struct {
}
// HandleReport returns a handler that accepts telemetry
// reports from NetWatch clients, sent as application/json.
// reports from NetWatch clients.
func (s *Handlers) HandleReport() http.HandlerFunc {
type response struct {
Status string `json:"status"`
@@ -66,15 +66,9 @@ func (s *Handlers) HandleReport() http.HandlerFunc {
}
// decodeErrorStatus logs a report decode failure and returns the
// 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.
// status to send: 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)
@@ -93,7 +87,7 @@ func (s *Handlers) decodeErrorStatus(err error) int {
// appendErrorStatus logs a failure to store a report and returns
// 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.
// the size cap, otherwise 500.
func (s *Handlers) appendErrorStatus(err error) int {
if errors.Is(err, reportbuf.ErrFull) {
s.log.Warn("report refused: " +
-103
View File
@@ -27,17 +27,6 @@ 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)))
}
@@ -94,7 +83,6 @@ func TestHandleReportAcceptsValidReports(t *testing.T) {
http.MethodPost, "/api/v1/reports",
strings.NewReader(tt.body),
)
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req)
@@ -115,89 +103,6 @@ func TestHandleReportAcceptsValidReports(t *testing.T) {
}
}
// 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()
@@ -208,7 +113,6 @@ 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)
@@ -234,7 +138,6 @@ 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)
@@ -258,7 +161,6 @@ 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)
@@ -283,8 +185,6 @@ 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
@@ -313,7 +213,6 @@ func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
`{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`,
),
)
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req)
@@ -346,7 +245,6 @@ func TestHandleReportLogsClientIDCutToBound(t *testing.T) {
`{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`,
),
)
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req)
@@ -386,7 +284,6 @@ 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)
+2 -10
View File
@@ -10,7 +10,6 @@ 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"
)
@@ -19,7 +18,6 @@ import (
type Params struct {
fx.In
Buffer *reportbuf.Buffer
Config *config.Config
Globals *globals.Globals
Logger *logger.Logger
@@ -70,18 +68,12 @@ func New(
return s, nil
}
// Healthcheck returns the current health status of the application:
// "ok", or "error" while report files cannot be written.
// Healthcheck returns the current health status of the application.
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: status,
Status: "ok",
UptimeHuman: s.uptime().String(),
UptimeSeconds: int64(s.uptime().Seconds()),
Version: s.params.Globals.Version,
+15 -85
View File
@@ -11,7 +11,6 @@ import (
"net/http"
"net/netip"
"runtime/debug"
"slices"
"strings"
"time"
@@ -134,17 +133,6 @@ 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,
@@ -156,49 +144,28 @@ func clientIP(
return peer
}
forwarded := forwardedFor(header)
if len(forwarded) == 0 {
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
return xr
}
return peer
if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" {
return xff
}
for _, entry := range slices.Backward(forwarded) {
if !validIP(entry) {
return peer
}
if !addrInAny(entry, trusted) {
return entry
}
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
return xr
}
return peer
}
// 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
// firstForwardedFor returns the left-most valid address in an
// X-Forwarded-For list (the original client), or "" if none.
func firstForwardedFor(value string) string {
for part := range strings.SplitSeq(value, ",") {
entry := strings.TrimSpace(part)
if entry != "" {
entries = append(entries, ipFromHostPort(entry))
candidate := strings.TrimSpace(part)
if validIP(candidate) {
return candidate
}
}
return entries
return ""
}
func validIP(s string) bool {
@@ -330,16 +297,12 @@ 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, 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.
// slog, and responds 500 with no body. 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 {
@@ -357,43 +320,10 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
"stack", string(debug.Stack()),
)
if ww.Status() == 0 {
ww.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusInternalServerError)
}()
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)
next.ServeHTTP(w, r)
},
)
}
+5 -246
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"errors"
"log"
"log/slog"
"net/http"
"net/http/httptest"
@@ -17,24 +16,16 @@ 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 {
@@ -91,35 +82,10 @@ func clientIPCases() []clientIPCase {
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 uses left-most of chain",
remoteAddr: "10.1.2.3:5000",
xff: forwardedIP + ", 10.1.2.3",
want: forwardedIP,
},
{
name: "trusted proxy falls back to x-real-ip",
@@ -148,7 +114,7 @@ func clientIPCases() []clientIPCase {
name: "trusted proxy with garbage header uses peer",
remoteAddr: loopbackPeer,
xff: "not-an-ip",
want: loopbackIP,
want: "127.0.0.1",
},
}
}
@@ -179,47 +145,6 @@ 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()
@@ -398,172 +323,6 @@ 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.
+32 -62
View File
@@ -18,15 +18,9 @@ var ErrDataDirOutsideVolume = errors.New(
// 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).
// and everything in it to owner, and gives volume and dir the mode the
// server gives a directory it creates. dir must be volume or a path
// below it, with no '.', '..', empty part or '/' at the end.
//
// bin/entrypoint.sh runs this as root, which would follow a symbolic
// link anywhere, so every step goes through an os.Root opened on
@@ -38,9 +32,10 @@ var ErrDataDirOutsideVolume = errors.New(
// 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.
// volume. Lchown never changes what a link points to, and the modes
// are set on directories already opened (see 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.
@@ -71,7 +66,7 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
return err
}
err = chownAll(root, ".", uid, gid)
err = lchownAll(root, ".", uid, gid)
if err != nil {
return err
}
@@ -84,23 +79,28 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
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)
// lchownAll gives name, a directory inside root, and everything in it
// to uid and gid. It reads each directory opened through root, not
// through root.FS(), which refuses a name that is not valid UTF-8, and
// calls Lchown on every entry, which gives a symbolic link itself to
// them, not what it points to. It goes into an entry only when the
// read found a directory there, so it follows no link it finds; one
// swapped in for that directory afterwards is followed only as the
// os.Root allows.
func lchownAll(root *os.Root, name string, uid, gid int) error {
err := root.Lchown(name, uid, gid)
if err != nil {
return err
}
dir, err := root.Open(name)
if err != nil {
return err
}
entries, err := dir.ReadDir(-1)
_ = dir.Close()
if err != nil {
return err
}
@@ -108,9 +108,9 @@ func chownAll(root *os.Root, name string, uid, gid int) error {
for _, entry := range entries {
entryName := filepath.Join(name, entry.Name())
if entry.IsDir() {
err = chownAll(root, entryName, uid, gid)
err = lchownAll(root, entryName, uid, gid)
} else {
err = chownFile(root, entryName, uid, gid)
err = root.Lchown(entryName, uid, gid)
}
if err != nil {
@@ -121,36 +121,6 @@ func chownAll(root *os.Root, name string, uid, gid int) error {
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,
-65
View File
@@ -1,65 +0,0 @@
//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
}
-21
View File
@@ -1,21 +0,0 @@
//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}
}
+8 -40
View File
@@ -254,13 +254,10 @@ func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
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.
// TestPrepareDataDirGivesVolumeToOwner gives everything 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.
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
t.Parallel()
@@ -277,16 +274,6 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
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)
@@ -302,21 +289,6 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
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 {
@@ -324,16 +296,12 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
}
for _, path := range []string{
volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"),
filepath.Join(old, "link"),
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)
}
requireOwner(t, outside, 0, 0)
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
}
+55 -128
View File
@@ -1,9 +1,6 @@
// 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.
// deleting the oldest files to keep them under a size cap.
package reportbuf
import (
@@ -16,6 +13,7 @@ import (
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -72,9 +70,11 @@ type Buffer struct {
// 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.
// in name order, which is oldest first: those in dataDir at
// start, and each one this buffer writes, put in at its place by
// name once it is complete, even when an older file's write
// completes after a newer one's. A file still being written is
// not among them. filesBytes is their total size.
files []reportFile
filesBytes int64
log *slog.Logger
@@ -85,30 +85,12 @@ type Buffer struct {
now func() time.Time
// seq numbers the report files, so that two named in the same
// millisecond still get different names.
seq atomic.Int64
seq atomic.Uint64
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
@@ -133,33 +115,16 @@ func New(
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)
return fmt.Errorf("create data dir: %w", 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)
return err
}
b.mu.Lock()
@@ -177,7 +142,7 @@ func New(
b.mu.Unlock()
b.writers.Go(b.flushLoop)
go b.flushLoop()
return nil
},
@@ -188,14 +153,7 @@ func New(
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()
})
@@ -209,13 +167,11 @@ func New(
}
// 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.
// the buffer. 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 is drained and written to disk
// asynchronously.
func (b *Buffer) Append(v any) error {
line, err := json.Marshal(v)
if err != nil {
@@ -226,13 +182,6 @@ func (b *Buffer) Append(v any) error {
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 {
@@ -245,9 +194,18 @@ func (b *Buffer) Append(v any) error {
b.buf.Write(line)
b.buf.WriteByte('\n')
if b.buf.Len() >= flushSizeThreshold &&
b.thresholdWriteWaiting.CompareAndSwap(false, true) {
b.writers.Go(b.flushAtThreshold)
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()
@@ -255,16 +213,6 @@ func (b *Buffer) Append(v any) error {
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
@@ -315,28 +263,21 @@ func (b *Buffer) flushLoop() {
}
}
// flushLocked waits for any write of a report file under way, then
// writes the reports waiting in the buffer to a new one.
// flushLocked acquires the lock, drains the buffer, and
// writes the data to a compressed file.
func (b *Buffer) flushLocked() error {
b.writeMu.Lock()
defer b.writeMu.Unlock()
b.mu.Lock()
return b.writeFile()
}
if b.buf.Len() == 0 {
b.mu.Unlock()
// 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)
return nil
}
data := b.drainBuf()
b.mu.Unlock()
return b.writeFile(data)
}
// drainBuf copies the buffer contents and resets it.
@@ -349,22 +290,9 @@ func (b *Buffer) drainBuf() []byte {
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()
// writeFile writes data, reports drained from the buffer, to a new
// 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")
@@ -372,30 +300,29 @@ func (b *Buffer) writeFile() error {
size, err := b.createFile(filepath.Join(b.dataDir, name), data)
// The reports no longer wait to be written, so they stop counting
// at their uncompressed size. If the write failed they are lost;
// otherwise they count as the file, which from here on may be
// deleted to make room.
b.mu.Lock()
defer b.mu.Unlock()
b.writeErr = err
b.usedBytes -= int64(len(data))
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})
// At its place by name, not at the end: another write, of a newer
// file, may have completed while this one was being written.
i, _ := slices.BinarySearchFunc(b.files, name,
func(f reportFile, target string) int {
return strings.Compare(f.name, target)
})
b.files = slices.Insert(b.files, i, reportFile{name: name, size: size})
return nil
}
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -34,16 +34,13 @@ func (s *Server) SetupRoutes() {
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.
// Sentry reports a panic, then panics again, so that s.mw.Recoverer
// still answers 500.
if s.params.Config.SentryDSN != "" {
s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
}
@@ -67,8 +64,6 @@ func (s *Server) SetupRoutes() {
// 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())
+3 -4
View File
@@ -22,9 +22,9 @@ import (
"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.
// 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()
@@ -66,7 +66,6 @@ func TestReportsAreRateLimited(t *testing.T) {
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
)
req.Header.Set("Content-Type", "application/json")
srv.ServeHTTP(rec, req)
return rec.Code
+1 -1
View File
@@ -79,7 +79,7 @@ func New(
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
go func() { s.listenAndServe() }()
go s.listenAndServe()
return nil
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "netwatch",
"private": true,
"version": "1.0.0",
"description": "Real-time network latency monitor SPA",
"type": "module",
"scripts": {
+2 -2
View File
@@ -23,8 +23,8 @@ main() {
"" | -* | *[[:space:]]*) usage ;;
esac
cd "$ROOT"
"$ROOT/script/yarn" add --dev "$1"
"$ROOT/script/yarn" install --frozen-lockfile
yarn add --dev "$1"
yarn install --frozen-lockfile
}
main "$@"
+14 -55
View File
@@ -3,14 +3,11 @@
# 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 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.
# 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 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
@@ -145,63 +142,32 @@ ensure_nvm() {
rm -rf "$tmp"
}
# 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: the node on PATH is at least NODE_MIN_VERSION. node itself
# compares the two: major, then minor, then patch.
node_ok() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
if missing node; 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"
' "$NODE_MIN_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
echo "bootstrap: using node $(node --version)"
return 0
fi
if node_ok; then 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)"
}
# 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: 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.
ensure_yarn() {
if yarn_ok; then
echo "bootstrap: using yarn $(yarn --version)"
return 0
fi
if ! missing yarn; then return 0; fi
shims="$TOOLCHAIN/corepack-shims"
mkdir -p "$shims"
if ! missing corepack; then
@@ -216,13 +182,6 @@ 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
+4 -5
View File
@@ -8,12 +8,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/yarn" build
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)}"
# VERSION comes from the environment. 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" \
+9 -14
View File
@@ -2,12 +2,9 @@
# 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.
# 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.
# --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.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -17,17 +14,15 @@ main() {
cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# 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.
# 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"
tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$tag" .
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/yarn" dev
yarn dev
}
main "$@"
+8 -14
View File
@@ -1,12 +1,8 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# 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.
# --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.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -14,17 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# 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.
# 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"
tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$tag" .
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
+1 -1
View File
@@ -10,7 +10,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/yarn" prettier --write '**/*.{js,css,html,md}'
yarn prettier --write '**/*.{js,css,html,md}'
}
main "$@"
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/yarn" prettier --check '**/*.{js,css,html,md}'
yarn prettier --check '**/*.{js,css,html,md}'
}
main "$@"
+6 -9
View File
@@ -2,10 +2,9 @@
# 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
# the same in the frontend stage of Dockerfile. 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
@@ -14,14 +13,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
NODE_OPTIONS=--test-reporter=dot \
timeout 90 "$ROOT/script/yarn" --silent run test || {
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 "$ROOT/script/yarn" --silent run test
NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test
exit 1
}
timeout 30 "$ROOT/script/yarn" build
timeout 30 yarn build
}
main "$@"
+2 -30
View File
@@ -7,8 +7,6 @@
# 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.
@@ -66,8 +64,8 @@ main() {
# 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 set and empty. It also includes
# the security headers, so the page runs under the shipped policy.
# writes it when TRUSTED_PROXIES is unset. 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$' \
@@ -77,32 +75,6 @@ main() {
-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
+5 -10
View File
@@ -5,14 +5,9 @@
# make its answer untrustworthy.
#
# 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.
# --target names it. --no-cache because a cached lint layer is a lint
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -20,9 +15,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
docker build --no-cache \
--target lint \
--output type=cacheonly .
-t "$("$SCRIPT_DIR/projectname")-lint" .
}
main "$@"
+4 -9
View File
@@ -2,13 +2,8 @@
# 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.
# named, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -16,9 +11,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
docker build --no-cache \
--target test \
--output type=cacheonly .
-t "$("$SCRIPT_DIR/projectname")-test" .
}
main "$@"
-55
View File
@@ -1,55 +0,0 @@
#!/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 "$@"
+17 -375
View File
@@ -6,7 +6,6 @@ import { beforeEach, test } from "node:test";
import assert from "node:assert/strict";
import {
AppState,
buildUI,
CONFIG,
greyOutUI,
hostRowHTML,
@@ -14,26 +13,19 @@ import {
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.
// for its panel by id and finds none. 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. In
// each test, console.error and console.info print nothing and keep what
// they are given.
const doNothing = () => {};
let elements;
beforeEach((t) => {
@@ -53,13 +45,8 @@ const canvasContext = {
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,
@@ -83,11 +70,6 @@ function statusText(state, 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
@@ -180,91 +162,6 @@ test("a check fetches the target's URL as written, with no query string added",
);
});
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([
@@ -292,10 +189,9 @@ test("at a 30000ms interval, a target answering after 1000ms shows in its row wh
});
// In the next three tests, the answering target's check is still waiting
// when something happens after which its result must not be recorded or
// show in its row, and the check must write nothing to the console.
// when something happens after which its result must not show.
test("at a 30000ms interval, a check still waiting when its round is given up is not recorded and does not show in its row", async (t) => {
test("at a 30000ms interval, a check still waiting when its round is given up does not show in its row", async (t) => {
CONFIG.updateInterval = 30000;
const state = new AppState([
{ name: "Answering", url: "https://answering.test" },
@@ -311,13 +207,9 @@ test("at a 30000ms interval, a check still waiting when its round is given up is
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) => {
test("at a 30000ms interval, a check still waiting when the user pauses does not show in its row", async (t) => {
CONFIG.updateInterval = 30000;
const state = new AppState([
{ name: "Answering", url: "https://answering.test" },
@@ -332,13 +224,9 @@ test("at a 30000ms interval, a check still waiting when the user pauses is not r
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) => {
test("at a 30000ms interval, a check in the first round does not show in its row", async (t) => {
CONFIG.updateInterval = 30000;
const state = new AppState([
{ name: "Answering", url: "https://answering.test" },
@@ -350,10 +238,6 @@ test("at a 30000ms interval, a check in the first round is not recorded and does
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) => {
@@ -403,6 +287,11 @@ function stateWithOneTarget() {
return { state, target: state.local[0] };
}
// What this test wrote to the browser console with console[method].
function consoleLines(method) {
return console[method].mock.calls.map((call) => call.arguments[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);
@@ -489,131 +378,6 @@ test("a check that fails in the first round, which is discarded, writes nothing
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 &lt; &gt; &quot; &amp; and &#39; in a row's markup as
// < > " & and '.
test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {
@@ -683,32 +447,6 @@ test("a check that timed out or found its target unreachable has a grey figure a
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 &lt; and &gt;. 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
? `&gt;=${colors[i - 1].below}ms`
: `&lt;${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) {
@@ -785,102 +523,6 @@ test("the summary's min, max, median and average latency over the WAN targets' l
});
});
// 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.
+91 -123
View File
@@ -1,146 +1,114 @@
# The viewport test
# Responsive-layout harness
`make frontend-viewport-test` checks the page's layout at several window widths
in a real browser:
Automated verification of the responsive layout that landed in #5. Run it with:
```bash
make frontend-viewport-test
```
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.
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the
same `configs/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.
The test is not part of `make check`: it takes minutes, and `make test` has to
finish in under 60 seconds.
The target is deliberately outside `make check`: it takes minutes, and
`make test` has to stay under 60 seconds.
## The widths
## How the widths are chosen
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.
Not from a list of phone models. `viewports.js` parses the `@media` conditions
out of `web/src/styles.css` and scans `web/src/main.js` and `web/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.
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.
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.
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.
## What it asserts
Four more widths are fixed, each for a stated reason:
- **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: one of each single control, and one
pin button per WAN host row. 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.
- 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
### The tap-target threshold
Every width but 1280 by 800 is tested as a touch device.
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.
## The checks
## Determinism
A failed check does not stop the others.
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; a `---` placeholder in every row would not exercise it.
- `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.
## What this cannot verify
### The tap-target size
Real limits, so nobody re-parks this issue as needing hardware:
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.
- **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.
## The browser's network
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.
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.
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.
## 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/`.
Complementary layers, not two stacks. The unit tests in `test/unit/`, which
`make test` runs with Node's built-in test runner, exercise the functions
`web/src/main.js` exports 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 `test/unit/`.
+81 -172
View File
@@ -67,66 +67,51 @@ export const CONFIG = {
// 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. 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.
// CDN/hosting/other.
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/robots.txt" },
{
name: "Google Cloud Console",
url: "https://console.cloud.google.com/robots.txt",
},
{ name: "AWS Console", url: "https://console.aws.amazon.com" },
{ name: "Google Cloud Console", url: "https://console.cloud.google.com" },
{ 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/robots.txt" },
{ name: "Akamai", url: "https://www.akamai.com" },
{ name: "Google", url: "https://www.google.com" },
{ name: "GitHub", url: "https://github.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: "B2", url: "https://api.backblazeb2.com" },
{
name: "S3 af-south-1 (Cape Town)",
url: "https://s3.af-south-1.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.af-south-1.amazonaws.com",
},
{
name: "S3 eu-west-2 (London)",
url: "https://s3.eu-west-2.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.eu-west-2.amazonaws.com",
},
{
name: "S3 me-south-1 (Bahrain)",
url: "https://s3.me-south-1.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.me-south-1.amazonaws.com",
},
{
name: "S3 ap-northeast-1 (Tokyo)",
url: "https://s3.ap-northeast-1.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.ap-northeast-1.amazonaws.com",
},
{
name: "S3 ap-southeast-1 (Singapore)",
url: "https://s3.ap-southeast-1.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.ap-southeast-1.amazonaws.com",
},
{
name: "S3 ap-southeast-2 (Sydney)",
url: "https://s3.ap-southeast-2.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.ap-southeast-2.amazonaws.com",
},
{
name: "S3 us-west-2 (Oregon)",
url: "https://s3.us-west-2.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.us-west-2.amazonaws.com",
},
{
name: "S3 sa-east-1 (São Paulo)",
url: "https://s3.sa-east-1.amazonaws.com/robots.txt",
method: "HEAD",
url: "https://s3.sa-east-1.amazonaws.com",
},
// Hetzner regional speed test servers — genuine per-DC endpoints
{
@@ -159,15 +144,13 @@ const WAN_HOSTS = [
const debugLog = [];
export const log = (() => {
const log = (() => {
function append(level, message) {
const entry = { timestamp: new Date(), level, message };
debugLog.push(entry);
debugLog.push({ timestamp: new Date(), level, message });
if (debugLog.length > CONFIG.maxLogEntries)
debugLog.splice(0, debugLog.length - CONFIG.maxLogEntries);
const panel = document.getElementById("debug-panel");
if (panel && !panel.classList.contains("hidden"))
appendDebugLogLine(entry);
if (panel && !panel.classList.contains("hidden")) renderDebugLog();
}
return {
error: (msg) => append("error", msg),
@@ -274,27 +257,14 @@ function latencyStats(latencies) {
};
}
// 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.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;
@@ -310,7 +280,9 @@ export class HostState {
});
this._trim();
this.lastLatency = result.latency;
this.status = statusFromError(result.error);
if (result.error === "timeout") this.status = "error";
else if (result.error) this.status = "offline";
else this.status = "online";
this.consecutiveFailures = result.error
? this.consecutiveFailures + 1
: 0;
@@ -321,18 +293,6 @@ export class HostState {
this._trim();
}
// 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";
}
// The min, max, median and average latency of the checks in the history
// that got an answer.
historyStats() {
@@ -469,12 +429,9 @@ 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, 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
// 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
// when no host has an unreported sample.
export function buildReport(hosts, clientId, now, since) {
const reportHosts = [];
@@ -483,7 +440,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.clearedHistory, ...host.history]) {
for (const p of host.history) {
if (p.paused) continue;
if (p.timestamp <= mark) continue;
samples.push({
@@ -497,10 +454,7 @@ export function buildReport(hosts, clientId, now, since) {
reportHosts.push({
name: host.name,
url: host.url,
status:
host.status === "pending"
? statusFromError(samples.at(-1).error)
: host.status,
status: host.status,
history: samples,
});
marks.set(host.url, high);
@@ -527,7 +481,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.
export class Reporter {
class Reporter {
constructor(state, clientId, intervalMs) {
this.state = state;
this.clientId = clientId;
@@ -583,18 +537,16 @@ export class Reporter {
// --- Latency Measurement -----------------------------------------------------
// 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.
// Checks one target. 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") {
// The URL is fetched as written, with nothing added to it: the Hetzner
// speed-test servers close the connection without an answer when the URL
// has a query string, and cache: "no-store" keeps the browser's cache out
// of the measurement.
export async function measureLatency(url, signal) {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
@@ -606,10 +558,9 @@ export async function measureLatency(url, signal, method = "GET") {
try {
await fetch(url, {
method,
method: "GET",
mode: "no-cors",
cache: "no-store",
referrerPolicy: "no-referrer",
signal: controller.signal,
});
const latency = Math.round(performance.now() - start);
@@ -848,7 +799,7 @@ function wanRowsHTML(wanHosts) {
return rows.join("");
}
export function buildUI(state) {
function buildUI(state) {
const app = document.getElementById("app");
app.innerHTML = `
<div class="mx-auto px-[5%] py-8">
@@ -930,7 +881,7 @@ export function buildUI(state) {
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span>&lt;100ms
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500 mr-1 ml-3 align-middle"></span>&lt;200ms
<span class="inline-block w-3 h-3 rounded-full bg-orange-500 mr-1 ml-3 align-middle"></span>&lt;500ms
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>&gt;=500ms
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>&gt;500ms
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p>
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
@@ -1014,7 +965,7 @@ function updateHostRow(host, index) {
SparklineRenderer.draw(canvas, host.history);
}
export function updateSummary(state) {
function updateSummary(state) {
const stats = state.wanStats();
const hstats = state.wanHistoryStats();
@@ -1033,20 +984,13 @@ export function updateSummary(state) {
const hmaxUnitEl = document.getElementById("summary-hmax-unit");
if (!reachableEl) return;
// Before any WAN host's check is recorded, as after an interval change,
// no host has answered or failed, so the slot reads as at page load.
if (state.wan.every((h) => h.status === "pending")) {
reachableEl.textContent = "--/--";
reachableEl.className = "text-white";
} else {
reachableEl.textContent = `${stats.reachable}/${stats.total}`;
reachableEl.className =
stats.reachable === stats.total
? "text-green-400"
: stats.reachable === 0
? "text-red-400"
: "text-yellow-400";
}
reachableEl.textContent = `${stats.reachable}/${stats.total}`;
reachableEl.className =
stats.reachable === stats.total
? "text-green-400"
: stats.reachable === 0
? "text-red-400"
: "text-yellow-400";
if (stats.min !== null) {
minEl.textContent = `${stats.min}`;
@@ -1133,37 +1077,11 @@ function updateHealthBox(state) {
else if (health === "healthy") log.notice(`Health: ${labels[health]}`);
}
// Writes the local time, with its time zone's name, and the UTC time into
// the header's clocks. The clocks only decorate the page: in a browser
// whose Intl.DateTimeFormat rejects the "shortGeneric" time zone name
// (Chrome before 95, Firefox before 91, Safari before 15.4) they are left
// blank.
export function updateClocks() {
let local = "";
let utc = "";
try {
const now = new Date();
const zone = new Intl.DateTimeFormat("en-US", {
timeZoneName: "shortGeneric",
})
.formatToParts(now)
.find((p) => p.type === "timeZoneName").value;
const pad = (n) => String(n).padStart(2, "0");
local = `Local: ${now.toLocaleString("sv-SE", { hour12: false })} ${zone}`;
utc =
`UTC: ${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` +
`${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`;
} catch {
// Both clocks are left blank.
}
document.getElementById("clock-local").textContent = local;
document.getElementById("clock-utc").textContent = utc;
}
// --- Debug Log Renderer ------------------------------------------------------
// One line of the debug log panel, its message set as text.
function debugLogLine(entry) {
function renderDebugLog() {
const el = document.getElementById("debug-log");
if (!el) return;
const levelColors = {
error: "text-red-400",
warning: "text-orange-400",
@@ -1171,43 +1089,19 @@ function debugLogLine(entry) {
info: "text-gray-300",
debug: "text-gray-500",
};
const ts = formatUTCTimestamp(entry.timestamp);
const lvl = entry.level.toUpperCase().padEnd(7);
const line = document.createElement("div");
line.className = levelColors[entry.level] || "text-gray-400";
line.textContent = `${ts} ${lvl} ${entry.message}`;
return line;
}
// Draws every line of the debug log into the panel, when it is opened.
function renderDebugLog() {
const el = document.getElementById("debug-log");
if (!el) return;
el.replaceChildren(...debugLog.map((entry) => debugLogLine(entry)));
el.replaceChildren(
...debugLog.map((entry) => {
const ts = formatUTCTimestamp(entry.timestamp);
const lvl = entry.level.toUpperCase().padEnd(7);
const line = document.createElement("div");
line.className = levelColors[entry.level] || "text-gray-400";
line.textContent = `${ts} ${lvl} ${entry.message}`;
return line;
}),
);
el.scrollTop = el.scrollHeight;
}
// The request that scrolls the open panel to its newest line in the next
// frame. One waits at most, also in a hidden tab, where frames do not run.
let debugLogScrollFrame = 0;
// Adds entry to the open panel as its last line, and drops the first line
// once the panel holds more than maxLogEntries. A check that ends while the
// page is busy is timed only when the page is done, so the lines already
// shown are left as they are, and the panel is scrolled once a frame
// rather than once a line: scrolling it lays out all of its lines again.
function appendDebugLogLine(entry) {
const el = document.getElementById("debug-log");
if (!el) return;
el.append(debugLogLine(entry));
if (el.children.length > CONFIG.maxLogEntries)
el.removeChild(el.firstElementChild);
cancelAnimationFrame(debugLogScrollFrame);
debugLogScrollFrame = requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
// --- Sorting -----------------------------------------------------------------
// Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts
@@ -1309,7 +1203,7 @@ export async function tick(state, signal, onOffline) {
// the round started.
await Promise.all(
state.allHosts.map(async (host) => {
const r = await measureLatency(host.url, signal, host.method);
const r = await measureLatency(host.url, signal);
if (state.paused || signal.aborted || state.tickCount === 0) {
return;
}
@@ -1382,7 +1276,7 @@ function startRecoveryProbe(state, startRounds) {
const checks = new AbortController();
state._recoveryProbeChecks = checks;
for (const host of canaries) {
measureLatency(host.url, checks.signal, host.method).then((r) => {
measureLatency(host.url, checks.signal).then((r) => {
if (r.error !== null || checks.signal.aborted) return;
log.notice("Recovery probe: connectivity detected");
stopRecoveryProbe(state);
@@ -1555,6 +1449,24 @@ async function init() {
sortAndRebuildWAN(state);
});
function updateClocks() {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const utc =
`${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` +
`${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`;
const local =
now.toLocaleString("sv-SE", { hour12: false }) +
" " +
new Intl.DateTimeFormat("en-US", { timeZoneName: "shortGeneric" })
.formatToParts(now)
.find((p) => p.type === "timeZoneName").value;
document.getElementById("clock-local").textContent = "Local: " + local;
document.getElementById("clock-utc").textContent = "UTC: " + utc;
}
updateClocks();
setInterval(updateClocks, 1000);
// Rounds never overlap: a round first gives up the last round's checks
// if they are still waiting, and the last round then records nothing
// more. At a steady interval they never are, as they time out at 80% of
@@ -1590,7 +1502,9 @@ async function init() {
// Clear all host history and reset state
for (const host of state.allHosts) {
host.reset();
host.history = [];
host.lastLatency = null;
host.status = "pending";
}
state.tickCount = 0;
@@ -1634,11 +1548,6 @@ async function init() {
window.addEventListener("resize", () => handleResize(state));
setTimeout(() => handleResize(state), CONFIG.resizeDelay);
// The clocks start last, after the rounds: they only decorate the page,
// so nothing in them may stop the monitor from probing.
updateClocks();
setInterval(updateClocks, 1000);
}
// Bootstrap only when loaded as the page: a real DOM containing the #app