1 Commits
Author SHA1 Message Date
sneak 1cdf2013c8 Bootstrap keeps yarn and node only at their pinned versions (closes #154)
check / check (push) Canceled after 0s
script/bootstrap kept any yarn found on PATH, so YARN_VERSION applied
only where none was installed. It now keeps the yarn on PATH only when
yarn --version is exactly YARN_VERSION, and otherwise installs that
version as before. The node on PATH is used only when it has the
pinned major version, as REPO_POLICIES.md asks, and is at least
NODE_MIN_VERSION. After installing either, bootstrap looks the tool up
through PATH again, stops, naming the path, if that one fails the same
check, and prints the version it uses, as it does for Go. Every host
script except bootstrap that runs yarn now runs it through the new
script/yarn, which uses the first node on PATH, with ~/.local/bin
first, when it passes the same test, and otherwise the pinned node in
nvm.

Model: opus-5-5
2026-10-19 04:54:44 +00:00
32 changed files with 1535 additions and 1689 deletions

No files matched your search

+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.
-12
View File
@@ -1,21 +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
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
# Free the shared runner from a hung build. The limit is on the
# step because the runner ignores a job's timeout-minutes.
- run: script/cibuild
timeout-minutes: 20
+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
+2 -2
View File
@@ -62,9 +62,9 @@ WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN NODE_OPTIONS=--test-reporter=dot yarn --silent run test || \
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 yarn --silent run test; \
NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test; \
exit 1; }
# Test phase: the backend's tests with the race detector and coverage,
+553 -436
View File
File diff suppressed because it is too large. Load diff
+642 -6
View File
@@ -2,24 +2,660 @@
- branch from `next`
- do the work in Next Step
- delete Next Step from `TODO.md`
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- push the branch and open a PR against `next`
# Status
pre-1.0. Release candidates are tagged on `next`; `git tag --merged origin/next`
lists them. `main` is the stable branch and `next` the development branch, which
every PR targets. The frontend and the Go backend ship as one Docker image. The
Gitea workflow `.gitea/workflows/check.yml` runs `script/cibuild` on every push,
and cancels the older run on the same branch. Working toward 1.0.0.
pre-1.0. No git tags. `main` is the stable branch and `next` the development
branch, which every PR targets. The frontend and the Go backend ship as one
Docker image, and the Gitea workflow `.gitea/workflows/check.yml` runs
`script/cibuild` on every push. Working toward 1.0.0.
# Next Step
Run `make frontend-viewport-test` in CI as its own step; it is not part of
`make check`, as it needs Docker and takes minutes.
# Completed Steps
- 2026-10-19: `script/bootstrap` keeps the `yarn` on `PATH` only when
`yarn --version` is exactly `YARN_VERSION`, and otherwise installs that
version as it did when no yarn was installed
([#154](https://git.eeqj.de/sneak/netwatch/issues/154)); any yarn used to be
kept. It uses the `node` on `PATH` only when that node has the major version
of `NODE_VERSION`, as `REPO_POLICIES.md` asks, and is at least
`NODE_MIN_VERSION`; a node 24 was kept before. After installing node or yarn
it looks the tool up through `PATH` again and stops if that one fails the same
check, as it does for Go, and it prints the node and yarn versions it uses.
Every script that runs yarn on the host, except `script/bootstrap`, which
picks its own node and yarn, runs it through the new `script/yarn`. That
script puts `~/.local/bin` first on `PATH`, uses the first `node` on that
`PATH` when it passes the same test, and otherwise runs yarn under the pinned
node in nvm; `make build` ran yarn under whatever node was first on `PATH`
- 2026-10-19: report file writes no longer overlap
([#150](https://git.eeqj.de/sneak/netwatch/issues/150)), so the reports of
failed writes are written in the order they arrived. Two writes that failed at
the same time put their reports back in the wrong order when the older failed
first. One write runs at a time, and each takes the waiting reports only when
it starts, after a failed write has put its own back. The write `Append`
starts when 10 MiB are waiting waits for any write under way, and `Append`
starts no other while it waits. Files written since the start are deleted to
make room in the order they were written, after those found at start. A unit
test holds a write open, starts a second at 10 MiB, fails both and checks the
order in the next file. Others check that `Append` starts no second write at
10 MiB while one waits, that it starts one again the next time 10 MiB are
waiting, and that the first file written since the start is the first deleted
- 2026-10-19: `DEBUG` unset or empty leaves debug logging off, and any other
value, `false` and `0` included, turns it on, as the code style guide says
([#159](https://git.eeqj.de/sneak/netwatch/issues/159)). No value of it stops
the start any more; before, only `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`,
`f`, `F`, `FALSE`, `false` and `False` were accepted. `README.md` no longer
gives `false` as its default
- 2026-10-19: check builds keep Go's modules and compiled packages between runs
([#118](https://git.eeqj.de/sneak/netwatch/issues/118)), on sneak's rulings in
[prompts#113](https://git.eeqj.de/sneak/prompts/issues/113) and
[prompts#130](https://git.eeqj.de/sneak/prompts/issues/130).
`REPO_POLICIES.md`, `script/test`, `script/lint`, `script/docker` and
`script/cibuild` are the sneak/prompts copies at `0653579`: the scripts pass a
new `CACHEBUST` value where they passed `--no-cache`, and the lint and test
builds write no image. In each stage of `Dockerfile`, `ARG CACHEBUST` comes
after the system packages and the Go module download, and before every step
that lints, tests or compiles and the node stages' `yarn install`. Go's build
cache and golangci-lint's are cache mounts, and both `go test` runs carry
`-count=1`. The test phase's Go caches used to be in memory, empty on every
build
- 2026-10-19: the three unit tests of a check whose result is discarded (its
round given up, the user paused, or the first round) also check that the
host's history is still empty, its status still `pending`, and nothing was
written to the console
([#151](https://git.eeqj.de/sneak/netwatch/issues/151)). They checked only the
row, so a change that recorded the result and skipped only the row would have
kept them passing
- 2026-10-18: when the backend runs on its own, a `DATA_DIR` that cannot be
created or read, or where no file can be created, stops the start with an
error naming `DATA_DIR` and its value, as the other settings' errors do
([#148](https://git.eeqj.de/sneak/netwatch/issues/148)); the first two named
only the path, and the third was found only at the first write of a report
file. At start the server creates a file in `DATA_DIR` and removes it. Unit
tests start the buffer with `DATA_DIR` below a regular file and with
`DATA_DIR` set to `/proc`, and check the error. The report file number is an
`atomic.Int64`, as the Go style guide asks for int types
([#161](https://git.eeqj.de/sneak/netwatch/issues/161)); file names are
unchanged
- 2026-10-18: the image's nginx sends the page with `Cache-Control: no-cache`
([#141](https://git.eeqj.de/sneak/netwatch/issues/141)), so a browser that
keeps a copy checks with the server that it is current before using it again.
The page went out with no `Cache-Control`, so a browser could keep it after a
deploy and ask for files under `/assets/` that the new build no longer has,
and show an empty page. A path that `location /` answers with the page through
its `try_files` fallback gets the same header. `location /` now includes the
security headers again, as `/assets/` does, since its own `add_header` drops
the server's. `/assets/` keeps its immutable caching.
`make frontend-viewport-test` fails unless `/` and a path that falls back to
the page carry `Cache-Control: no-cache` and `Content-Security-Policy`, which
comes from the same file as the other security headers
- 2026-10-18: `README.md` says what the page loses while reports are refused or
the page is paused ([#145](https://git.eeqj.de/sneak/netwatch/issues/145)),
how fast one address can replace the stored reports
([#146](https://git.eeqj.de/sneak/netwatch/issues/146)), and that in the image
nginx answers a body over 1 MiB itself
([#147](https://git.eeqj.de/sneak/netwatch/issues/147)). A sample in the
history that no report has delivered is sent only as long as the history holds
it: at the 1-second interval one refused or failed report loses the oldest 20
seconds of each host's samples, and a pause longer than the history loses
every sample between the last delivered report and the pause. Samples an
interval change cleared are kept apart, the last 100 per host, until they are
delivered. At the defaults, one address sending 1 MiB reports made up to
compress badly can fill the 1 GiB cap in about 17 minutes. nginx's
`client_max_body_size` is also 1 MiB by default, so in the image it answers a
larger body with 413 and its own HTML page, and the backend never sees or
records it; the backend's own 413 sentences now say they apply to it run on
its own. The reporter, the body limit and nginx are unchanged
- 2026-10-18: a browser that cannot format the header's clocks still probes
([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the
local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which
Chrome before 95, Firefox before 91 and Safari before 15.4 reject with an
error; it came before the first round started, so such a browser never checked
a target. The clocks now start last, after the rounds, and an error formatting
them leaves both blank. The footer's color legend reads `>=500ms` for red,
where it read `>500ms`, so 500 ms, which is red, is in one of its colors
([#152](https://git.eeqj.de/sneak/netwatch/issues/152)). Unit tests check the
clocks with a working `Intl.DateTimeFormat` and with one that rejects
`shortGeneric`, and draw the page and check the legend against every entry of
`CONFIG.latencyColors`
- 2026-10-18: `netwatch-server prepare-data-dir` no longer changes the owner of
a file outside `/data` through a hard link in the volume
([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A file with a second
name (hard link) keeps its owner, and the start logs its path. Owners are now
set on the opened directory, file or link, never by name, so a hard link
swapped in after the check is caught too; a symbolic link is opened without
following it and given to the netwatch user itself, so the server can still
follow one in a sticky directory that anyone can write to. Devices, named
pipes and sockets keep their owner. Setting the owner on an open file that is
not a directory is built only for Linux: `prepare-data-dir` runs only in the
Linux image, and macOS, where the backend is also built for development, has
neither `O_PATH` nor `AT_EMPTY_PATH`. On any other system the package still
builds, and `prepare-data-dir` fails at the first file that is not a
directory. The test that runs as root hard-links a file, a symbolic link and a
named pipe from outside the volume into it and finds their owners unchanged
- 2026-10-18: from a trusted proxy, the backend takes the client address from
the right-most `X-Forwarded-For` entry that is not in `TRUSTED_PROXIES`
([#144](https://git.eeqj.de/sneak/netwatch/issues/144)). It took the left-most
entry, which the client writes itself behind a proxy that appends to the
header, so a client could choose the address the rate limit counts and the
request log records. An entry written as an address with a port is read as
that address. With only trusted addresses in the header, or an entry that is
not an address at all (such as `unknown`) met on the way, the direct peer is
used. `X-Real-IP` is read only without `X-Forwarded-For`, and every
`X-Forwarded-For` header line is read, not only the first. The image's nginx,
and the `TRUSTED_PROXIES=127.0.0.1/32` it starts the backend with, are
unchanged; the comment in `configs/nginx.conf` that said the backend takes the
first entry now says it takes the one address nginx passes on. Unit tests
resolve the address from chains ending in an untrusted address, in trusted
ones, of only trusted ones, with ports, with an entry that is not an address,
and split over two header lines
- 2026-10-18: after an interval change, the summary's Reachable slot reads
`--/--` in white, as at page load, until the second round since the change
ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read
0/26 in red from the change on, as no WAN host had a check recorded, for up to
two intervals with the network fine. The summary update now writes `--/--`
while every WAN host still waits for its first check. A unit test resets the
hosts as the interval menu does and checks the slot; another checks that it
reads 0/26 in red once every WAN host's check has failed
- 2026-10-14: stopping the server waits for a report file write already under
way, the periodic flush's or the one `Append` starts when 10 MiB are waiting,
before the final flush
([#122](https://git.eeqj.de/sneak/netwatch/issues/122)). It used to return at
once, and the process exiting cut the write off, losing its reports. A write
under way that fails puts its reports back in memory, so the final flush
writes them; the stop fails, and the process exits non-zero, only if that
flush fails too. Two tests hold the periodic write and the one at 10 MiB open
and find the stop waiting until each is released; a third makes the held write
fail and finds its report written by the final flush. They run in a
`testing/synctest` bubble, so the periodic flush comes without waiting a
minute
- 2026-10-14: `make test` no longer runs the frontend's production build, which
the tests do not need ([#118](https://git.eeqj.de/sneak/netwatch/issues/118)).
`yarn build` moved out of the stage that runs the frontend's unit tests, now
named `frontend-test`, into a new `frontend-build` stage, which starts from it
and, like the `builder` stage, waits on the lint and test phases, so the image
still cannot be built unless they pass. The runtime stage copies `dist/` from
it, and the `git` that `vite.config.js` reads the commit with is installed
there. No build cache was added
- 2026-10-14: opening the debug log no longer raises the measured latencies
([#131](https://git.eeqj.de/sneak/netwatch/issues/131)). With the panel open,
each line written drew the panel again with every line before it, up to 1000,
and a check that ends while the page draws is timed only when the drawing is
done. Each line is now added at the end of the open panel, its first line is
dropped once it holds more than 1000, and it scrolls to its newest line once a
frame rather than once a line; the whole panel is drawn only when it is
opened. A unit test writes more lines than the panel keeps and checks that the
lines already shown are left as they are
- 2026-10-14: unset, the backend's own `TRUSTED_PROXIES` trusts `10.0.0.0/8`,
`172.16.0.0/12` and `192.168.0.0/16`, and no longer loopback; a list replaces
that; set and empty, it trusts no proxy, where it used to get the default, as
viper reads an empty environment variable as unset
([#129](https://git.eeqj.de/sneak/netwatch/issues/129)). A value it cannot
parse still stops the start, and the image still starts the backend with
`TRUSTED_PROXIES=127.0.0.1/32`. Tests load the config with it unset, empty and
set to a list
- 2026-10-14: while report files cannot be written, `POST /api/v1/reports`
answers 500, and storing recovers once writing works again, with no restart
([#123](https://git.eeqj.de/sneak/netwatch/issues/123)). A failed write puts
its reports back in the report buffer, still counted against
`DATA_DIR_MAX_BYTES`, where they used to be lost; the next flush, within a
minute, tries them again. Until a write succeeds, every report is refused and
nothing of it is kept, so the buffer does not grow while writes fail. A test
posts reports through the report handler before, during and after a failure;
others check that a report appended while a write runs is kept, behind that
write's own, when the write fails, and that the cap still holds exactly once a
failed write's reports are written
- 2026-10-14: a request whose handler panics is logged with status 500 and
counted with `code` 500 in the metrics
([#125](https://git.eeqj.de/sneak/netwatch/issues/125)), not 200. `Recoverer`
is still the first middleware; `Write500OnPanic`, after the request log and
after the metrics, writes the 500 as the panic passes and lets it go on to
Sentry and `Recoverer`, which now writes a 500 only when no status was written
- 2026-10-14: with `TRUSTED_PROXIES` unset, the container's nginx trusts the
three private ranges of RFC 1918, `10.0.0.0/8`, `172.16.0.0/12` and
`192.168.0.0/16` ([#119](https://git.eeqj.de/sneak/netwatch/issues/119)), as
sneak ruled on 2026-09-22 for every app; it trusted no proxy before. A list
replaces them, and set and empty it trusts none. The backend inside the
container still trusts only `127.0.0.1/32`
- 2026-10-14: `POST /api/v1/reports` takes only a report sent with
`Content-Type: application/json`, parameters such as `charset` allowed, and
refuses any other with 415 and the usual error body, storing nothing
([#126](https://git.eeqj.de/sneak/netwatch/issues/126)). A page on another
origin can send a `text/plain` POST without a CORS preflight, so any site
could make its visitors' browsers store reports; a JSON POST waits for the
preflight, which only an origin in `CORS_ALLOWED_ORIGINS` passes. The page
already sent `application/json`. A test posts a report as `text/plain`, as
`application/x-www-form-urlencoded`, as `multipart/form-data`, with no
`Content-Type`, and as `application/json` with and without a charset, and
checks which are stored
- 2026-10-14: changing the interval no longer drops the samples not yet reported
to the backend ([#124](https://git.eeqj.de/sneak/netwatch/issues/124)). The
interval menu resets each host with the new `HostState.reset`, which moves the
history's entries to `clearedHistory`, keeping the last 100 of all it has
cleared, and `buildReport` reads both, so the next report has the samples
recorded before the change that no report has delivered. Until its next check,
such a host is sent with the status of its last check, not `pending`. The
page's history still starts over, and the per-host marks still leave out of a
report the samples a delivered one had. Unit tests reset a host as the
interval menu does and find its unreported samples in the next report, and
only the last 100 after more were cleared; `web/src/main.js` now exports
`Reporter` for them
- 2026-10-14: the repo has the layout `REPO_POLICIES.md` gives
([#30](https://git.eeqj.de/sneak/netwatch/issues/30)). The Go module is at the
root (`go.mod`, `go.sum`, `.golangci.yml`, `cmd/netwatch-server/`,
`internal/`), so its path `sneak.berlin/go/netwatch` names the repository.
`go.mod` ignores `node_modules/`, where npm packages can ship `.go` files that
`make tidy` would otherwise read as this module's packages. The page's source
is in `web/` (`index.html`, `src/`), which `vite.config.js` makes Vite's root;
the build still goes to `dist/`. `nginx.conf` and `security-headers.conf` are
in `configs/`. `backend/` is gone: its `LICENSE` and `.dockerignore` are
deleted, its `.gitignore` entries are in the root one, its `README.md` is
folded into the root `README.md`, and its scripts into the root `script/`:
`script/build` builds the server binary after the frontend, `script/run` and
`script/clean` came from `backend/script/`, and `make test`, `make lint`,
`make fmt` and `make fmt-check` do what its `test`, `lint`, `fmt` and
`fmt-check` did. `script/bootstrap` no longer installs gcc, which only the
host Go tests needed. The image and the page are unchanged
- 2026-10-07: the files shared from `sneak/prompts` are its copies at commit
`dd4027b` ([#113](https://git.eeqj.de/sneak/netwatch/issues/113)), with this
repository's own entries after the shared content in `.gitignore`,
`.dockerignore` and `.editorconfig`. `make lint` and `make test` each build
one phase of `Dockerfile` without the cache: `lint` runs golangci-lint v2.14.0
over `backend/` and, through the `frontend-lint` stage, eslint; `test` runs
the Go tests on the Debian Go image and, through the `frontend` stage, the
frontend's unit tests and build. The builder stage waits on both, and without
a `VERSION` build argument takes the version from `git describe` on the `.git`
in the build context. Neither linter nor the tests run on the host for
`make check`; `backend/script/lint` runs the root one, and
`backend/.golangci.yml` is no longer checked against a sha256. prettier
formats only the JavaScript, CSS, HTML and Markdown, so the shared
`.golangci.yml` stays as fetched. `script/fmt` and `script/fmt-check` put
`~/.local/bin` on `PATH`, which the shared workflow no longer does.
`script/frontend-lint` and `script/frontend-check` are gone
- 2026-10-07: the six Hetzner targets answer again, and failed checks are
written to the browser console
([#114](https://git.eeqj.de/sneak/netwatch/issues/114)). The Hetzner
speed-test servers close the connection without an answer when the URL has a
query string, and every check added `?_cb=` and the time; checks now fetch
each target's URL as written, with `cache: 'no-store'` as before. Each
recorded check that fails writes one `console.error` line, and the same line
to the debug log, with the target's name and URL, the time, what failed and
how long the request took; a target that answers after failed checks writes
one `console.info` line. Checks the page does not record write none, so the
debug log no longer lists failures in the first round or in the recovery probe
- 2026-10-04: the page's footer no longer says "IPv4 only"
([#111](https://git.eeqj.de/sneak/netwatch/issues/111)): each check is a
`fetch`, the browser picks IPv4 or IPv6 for each WAN host, and the local
targets are IPv4 addresses. The rest of the footer is unchanged
- 2026-10-04: `README.md`, `TODO.md` and `test/viewport/README.md` say what the
tree does (issue #24). The README's Getting Started leads with `make` targets;
a new Backend section says what `netwatch-server` stores, its routes and how
the image builds and runs it, and points to `backend/README.md` for its
settings; the checks are GET requests; the 26 WAN hosts, the four health
states, the summary's figures and the features the list lacked are described
as the page has them; and its TODO section points here, as does the one in
`backend/README.md`, whose open items moved to Future Steps. This file's
Workflow branches from `next` and opens the PR against `next`, Status says
where the repo stands, and Next Step and Future Steps hold only open work,
linked to its issue where one exists. The viewport harness README names Node's
test runner, not `vitest`
- 2026-10-04: in `src/main.js` (issue #102), a target's min, max, median and
average latency come from one list of its answers, through the same function
the summary's figures use, so the median is written once. The latency color
limits are one table in `CONFIG`, read by both the figure's and the
sparkline's color. The health thresholds, the debug log's length, 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 too. A unit test checks the summary's figures. Nothing the
page does or shows changed; the footer's color legend still writes the limits
out as text
- 2026-10-04: password guesses at `/metrics` are rate limited (issue #104): each
client address, resolved through `TRUSTED_PROXIES` as for reports, may make 60
requests to `/metrics` a minute, counted by `go-chi/httprate` apart from its
reports; past that it gets 429 and its basic auth credentials are not checked.
The limit is a constant in `backend/internal/server/routes.go`. 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
- 2026-10-04: the backend reports errors to Sentry (issue #95). With
`SENTRY_DSN` set, it sets up `sentry-go` with the release `netwatch-server-`
and its version, reports each panic in a handler through `sentryhttp`, the
last of the middleware every request goes through, which panics again so the
request still gets the 500 from the panic recovery, and waits up to 2 seconds
on shutdown for Sentry to finish sending. A DSN Sentry refuses stops the start
with an error naming `SENTRY_DSN`. With it empty, Sentry is not set up and
nothing is sent to it
- 2026-10-04: a target's name and URL and a debug log message show as the
characters they are and are never read as HTML (issue #29): a host row escapes
the name and URL it writes into its markup, and the debug log sets each line
as text. A unit test checks a target whose name and URL hold `<`, `>`, `"`,
`&` and `'`. `README.md` no longer calls `CONFIG` frozen: the interval menu
sets its `updateInterval`, and the values computed from it follow. `AppState`
declares the recovery probe's two properties, the sparkline axis functions
lose the parameters they did not use, and the comment on a target's history
names both kinds of entry it holds. Nothing the page does changed
- 2026-10-04: a dependency can be added without running yarn or go by hand
(issue #45): `make add-dependency PACKAGE=<name>@<version>` shims to the new
`script/add-dependency`, which runs `yarn add --dev`, so `package.json` and
`yarn.lock` change together, then `yarn install --frozen-lockfile`; the same
command moves a package to another version. `make tidy` shims to the new
`script/tidy`, which runs `go mod tidy` in `backend/`: a Go module is added by
importing it, or moved by editing its `require` line, then `make tidy`.
`script/bootstrap` still installs with `--frozen-lockfile`
- 2026-10-04: the backend serves Prometheus metrics (issue #94). With
`METRICS_USERNAME` and `METRICS_PASSWORD` both set, it records request
duration and response size through `go-http-metrics` and serves them, with
Go's runtime and process metrics, at `GET /metrics` behind basic auth with
those credentials; nginx passes `/metrics` to it as it does `/api/`. With
neither set there are no metrics and `/metrics` is 404; one without the other
stops the start with an error naming both, and so does a `METRICS_USERNAME`
containing `:`, with an error naming it. Only requests that reach the health
check or `POST /api/v1/reports` are recorded, not `/metrics` itself and not
every request as `GO_HTTP_SERVER_CONVENTIONS.md` shows, because the labels are
the request's path and method, which clients can make up without end. For
that, `POST /api/v1/reports` is now registered by its full path instead of
inside a `/api/v1` route group; it answers as before
- 2026-10-04: `script/` and `Makefile` follow the org models (issue #28):
`make dev` shims to the new `script/dev`, the Vite dev server, and the new
`make build` to `script/build`, the frontend production build.
`.prettierignore` no longer leaves out `backend/`, so `make fmt` and
`make fmt-check` cover `backend/README.md`; it leaves out
`backend/.golangci.yml` by name, the org standard file whose sha256
`backend/script/lint` checks. `script/install-precommit` and the date on
`script/bootstrap`'s pins are the org model again; `script/bootstrap`,
`script/fmt` and `script/fmt-check` each say in a comment why they differ from
it
- 2026-10-04: a frontend build on Node 26 or newer, such as `make test` on a
host with Node 26, no longer prints Node's warning that `module.register()` is
deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never
printed it. The call was in `@tailwindcss/node`, which `@tailwindcss/vite`
brings in at its own exact version; tailwind 4.3.1 calls
`module.registerHooks()` instead where Node has it. `yarn.lock` now has
`@tailwindcss/vite` and `tailwindcss` at 4.3.3, inside the ranges
`package.json` already allowed, and tailwind's own dependencies moved with
them. The built CSS changes only in how it is written out, in tailwind's
Firefox focus-ring rule, which no longer applies to iframes (the page has
none, so nothing on it looks different), and in tailwind's default sans-serif
font list, which the page does not use: `body` sets a monospace font
- 2026-10-03: the frontend's unit tests cover what the page computes (issue
#21): `humanDuration`, the latency colours of a figure and of a sparkline
either side of each boundary, a target's min, max, average and median latency
over an empty history, an all-unreachable one and a mixed one, and each of the
four health states either side of its thresholds. `package.json` has a `test`
script, so `yarn run test` and `npm run test` run them, and
`script/frontend-test` runs it: quietly, and if a test fails, again with every
test listed, and then fails. `src/main.js` now exports `humanDuration`,
`HostState`, `latencyClass` and `latencyHex` for the tests; nothing it does
changed
- 2026-10-03: the backend's logs are one stream (issue #27): fx logs its own
steps of starting and stopping through the backend's logger, so off a terminal
every line the backend's own logger and fx write is JSON, where fx used to
write plain text to stderr. A config file that is found but cannot be read now
stops the start with its error, logged as JSON like a bad setting, where it
used to end in a Go panic. A test runs the server as a child process and
checks both. 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 path, content type, `"status":"ok"` and 200 are unchanged.
`SENTRY_DSN`, `METRICS_USERNAME` and `METRICS_PASSWORD` are still read and
still unused, and left out of `backend/README.md`, until issues #94 and #95
wire them up
- 2026-10-03: the frontend has a real linter (issue #47, and item 2 of issue
#28): `eslint` with its recommended rules, set in `eslint.config.js`, runs in
a new `frontend-lint` stage of `Dockerfile`, which the frontend stage waits
on, as the builder stage waits on the Go `lint` stage. `script/lint` builds
both stages without the cache and runs no linter on the host.
`script/frontend-lint` runs eslint where it used to repeat the prettier check
that `script/fmt-check` runs on the host, and `script/frontend-check`, run by
the frontend stage, is now the tests and the format check. `script/bootstrap`
wants node 22.13.0 or newer, as eslint 10 does
- 2026-10-03: the tap-target check in `make frontend-viewport-test` expects one
visible pin button per WAN host row (issue #46), where it expected at least 10
of the 26, so pin buttons missing from only some rows now fail it. The host
row count the harness gathers, which the `app-rendered` check also reads, now
counts only the WAN host rows: the local host rows have no pin button
- 2026-10-03: each target's row shows its result as soon as its check ends
(issue #91), where every row waited for the round's slowest check, up to 24
seconds at a 30-second interval. Every row is still redrawn, and sorting, the
summary, the health box and offline detection still run, once, when the
round's last check ends, so no row reads "paused" after a pause and resume
during the round. A check that ends after the user pauses or after its round
is given up shows nothing, and the first round is still discarded as a whole
- 2026-10-03: root no longer acts outside `/data` when it prepares `DATA_DIR`
(issue #80): `bin/entrypoint.sh` runs `netwatch-server prepare-data-dir`,
which refuses a `DATA_DIR` that is not `/data` or a path below it written in
full, then creates `DATA_DIR`, gives `/data` and everything in it to
`netwatch` and sets the modes, all through a Go `os.Root` opened on `/data`.
That refuses any path leading out of `/data`, so neither a symbolic link
already there nor one a host process swaps in during the start can make root
create or change anything elsewhere, and `DATA_DIR=/etc` no longer gives
`/etc` to `netwatch`. The `README.md` section "Running under upaas" says which
values are accepted
- 2026-10-03: `DATA_DIR_MAX_BYTES` is now how much of the report files is kept
(issue #54): when a report would take them past it, the oldest report files
are deleted to make room, each deletion logged, and at start files already
past it are deleted the same way. A file still being written is never deleted.
A report is refused with 507 only when the reports waiting to be written fill
the cap on their own, and then no file is deleted. The reports of a failed
write stop counting, and the part of its file written is removed. A file that
cannot be deleted still counts until the next start; one already deleted by
hand counts as freed
- 2026-10-03: `backend/script/lint` says what went wrong with its
`.golangci.yml` check (issue #34). On a hash mismatch it says to compare the
file with the org standard: if they differ, restore the org standard; if they
are the same, the org standard changed, so update `GOLANGCI_CONFIG_SHA256` in
that script. It used to say only to restore the file, which loops once the org
standard itself has moved. A missing `.golangci.yml`, and a `sha256sum` that
is missing or prints no hash, each get their own message instead of being
reported as a mismatch; every one still fails the lint
- 2026-10-03: the Go tests run with the race detector and coverage (issue #88):
`backend/script/test` runs `go test -timeout 30s -race -cover ./...` and, if
that fails, runs it again with `-v` and fails. Go's `-timeout` bounds the
tests, not their compile; the root `script/test` no longer puts one 30-second
timeout around both halves, which a cold Go build cache could use up on
compiling alone. The race detector needs a C compiler: the builder stage of
`Dockerfile` has gcc and musl-dev, and `script/bootstrap` installs gcc, with
the C library headers on apt and apk, when gcc is missing; the binary is still
built with `CGO_ENABLED=0`. New tests cover the health check's answer, a valid
report's answer, a report file's exact contents, and the flush when the buffer
reaches 10 MiB; the handlers' `TestImport` stub is gone
- 2026-10-03: each target check times out after 80% of the refresh interval
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
round started early, after an interval change or when the recovery probe finds
a target answering, gives up the last round's checks if they are still
waiting, so rounds never overlap; the recovery probe gives up its own checks
after half a second. The frontend has its first unit tests, run by
`script/frontend-test` with Node's built-in test runner; for them,
`index.html` now links `src/styles.css`, which `src/main.js` used to import
- 2026-09-29: the container sets up its own data directory (issue #75):
`bin/entrypoint.sh`, still as root, creates `DATA_DIR` if missing and gives it
and `/data` to the `netwatch` user with mode 750 before starting the backend
as that user, so an empty host directory owned by root, or one holding files
from another uid, works with no step on the host. It stops the start instead
when a symbolic link is on the path to `DATA_DIR`, since root would change
whatever the link points to. The `README.md` first-run step that created and
chowned the host directory is gone, and the image no longer sets that
ownership at build time
- 2026-09-29: CI can no longer pass on checks that did not run (issue #37):
`script/cibuild` is now the org model, byte for byte. It runs
`script/bootstrap` and `script/check`, then builds the image with `--no-cache`
and the version from `git describe` as the `VERSION` build argument, where it
used to be a plain `docker build .` whose check steps could come from the
build cache. The workflow puts `~/.local/bin`, where bootstrap links what it
installs, on the step's `PATH`, and bootstrap now installs its pinned node
when the installed one is older than the frontend's dependencies need
- 2026-09-29: `backend/.golangci.yml` re-vendored from `sneak/prompts` (issue
#41): `gomodguard`, deprecated in golangci-lint v2.12.0, is disabled and its
successor `gomodguard_v2` enabled with the org block list, so lint runs print
no deprecation warning. The new file also turns `depguard` on with its
`test-support` rule, which keeps `net/http/httptest` out of non-test code;
netwatch adds no entries of its own to that rule. `backend/script/lint` checks
the new sha256
- 2026-09-29: nginx sends the security headers `REPO_POLICIES.md` requires on
every response (issue #18), including errors, `/assets/` and what it passes on
from the backend, whose own copies it drops so each header goes out once. They
live in `security-headers.conf`, which `nginx.conf` includes. The content
security policy allows no inline script or style, so the status dot's grey in
`src/main.js` is now a class; `connect-src` is `*` because probed hosts
redirect to others, and the browser checks each redirect against it
- 2026-09-29: the request log is bounded (issue #60): the method, URL, protocol,
`User-Agent`, `Referer`, request ID (which chi takes from the client's
`X-Request-Id` header) and client address it writes are each cut to 128 bytes,
the bound the report handler already used, so one request can no longer put
about 1 MiB per field into a log line. That bound and its helper now live in
the `logger` package, shared by both
- 2026-09-29: nginx takes the client address from `X-Forwarded-For` only on
requests from the reverse proxies named in the container's `TRUSTED_PROXIES`
(issue #64), and by default from none, where it trusted every RFC1918 address
before, so a client could write a new address on each request and escape the
rate limit. `bin/entrypoint.sh` writes one `set_real_ip_from` line per entry
into `/etc/nginx/trusted-proxies.conf`, which `nginx.conf` includes, refusing
an entry that is not an IP address or CIDR, as `netwatch-server check-cidr`
finds; it starts the backend with `TRUSTED_PROXIES=127.0.0.1/32`, since nginx
is its only client
- 2026-09-29: report file names can no longer collide (issue #61): each is
`reports-<timestamp>-<number>.jsonl.zst`, where the number goes up by one for
each file the server starts to write, so two flushes in the same millisecond,
such as a flush for size and the final flush at shutdown, each get a file of
their own instead of the second one failing. A failed write uses up its
number, leaving a gap if the file could not be created and otherwise a file
under that number that may be incomplete.
- 2026-09-29: ready to run under upaas (issue #59): the image has a
`HEALTHCHECK` that requests `/.well-known/healthcheck` through nginx on the
port from `PORT`. The backend no longer reads a bad `PORT` as 0 or a bad
`DEBUG` as false: those, and a `BIND_ADDRESS` that is not an IP address, stop
it from starting with an error naming the variable, as the limits,
`CORS_ALLOWED_ORIGINS` and, now by name, `TRUSTED_PROXIES` already did.
`bin/entrypoint.sh` also refuses a `PORT` outside 1 to 65535, and `8081`,
where the backend listens inside the container, naming `PORT`. `README.md` has
a "Running under upaas" section, whose first-run steps create the host
directory for `/data` owned by uid 1000; the image does not change its owner
- 2026-09-29: nginx listens on `PORT` (issue #26), 8080 when unset or empty: the
nginx image renders `nginx.conf` as a template at container start, filling in
`PORT` and no other variable. `bin/entrypoint.sh` refuses to start when `PORT`
is not digits only. `server_tokens off` keeps the nginx version out of
responses. `script/frontend-viewport-test` renders the template the same way.
Gzip and a `50x.html` error page are not added
- 2026-09-29: bounded the report endpoint (issue #20): `POST /api/v1/reports`
still needs no credentials, but each client address, as resolved through
`TRUSTED_PROXIES`, may send `REPORTS_PER_MINUTE` (default 60) reports a
minute, counted by `go-chi/httprate`, and past that gets 429 with
`Retry-After`; the report files in `DATA_DIR`, counted from start with those
already there, may total at most `DATA_DIR_MAX_BYTES` (default 1 GiB), past
which reports get 507; and the wildcard CORS is gone: no CORS headers unless
`CORS_ALLOWED_ORIGINS` lists origins, and an entry that is not a plain
`scheme://host[:port]` origin, `*` included, stops the server from starting.
Deleting report files frees room only at the next start; pruning is issue #54
- 2026-09-28: one container image (issue #52): the root `Dockerfile` builds the
only image, and `Dockerfile.backend` is gone. nginx serves the frontend on
port 8080 and proxies `/api/` and `/.well-known/healthcheck` to the backend,
which listens on `127.0.0.1:8081` in the same container; the new
`BIND_ADDRESS` setting sets its listen address. `bin/entrypoint.sh` starts
both, passes TERM and INT on to both, and exits non-zero when either exits on
its own. The backend runs as user `netwatch` and stores reports on the `/data`
volume. `script/docker` is the org model again
- 2026-09-28: unified the gate (issue #16): the root `make check` covers the Go
backend as well as the frontend, and the pre-commit hook with it; the backend
moved onto scripts-to-rule-them-all (`backend/script/*`, `backend/Makefile` as
shims, its duplicate hook installer removed); `script/cibuild` builds both
images and is the workflow's only build step. The root `make lint` runs
golangci-lint only in Docker, by building the lint stage of
`Dockerfile.backend` without the cache. `script/bootstrap` installs the pinned
Go unless the installed one is at least what `backend/go.mod` asks for, links
what it installs into `~/.local/bin` without replacing anything it did not
create, and installs no linter. Root `make test` runs both halves within one
30-second timeout. When `VERSION` is unset or empty, the backend binary's
version falls back to `git describe` inside a git checkout, then to `dev`
- 2026-09-28: frontend reporting client (issue #53): a `Reporter` class posts
collected samples to `/api/v1/reports` every `reportInterval` (default 60s) as
a per-host delta, with the report-building step a pure exported function of
host state; a per-host mark advances only on a delivered POST; at most one
report POST is pending at a time and it is abandoned after half the interval,
so a slow POST never overlaps the next report and a mark never moves
backwards; the samples of an abandoned POST are sent again at the next
interval, so a backend that stored them but answered late receives them twice;
the per-browser client id works in insecure (plain-HTTP) contexts;
`vite.config.js` proxies `/api` to the local backend for `yarn dev`
- 2026-09-28: report ingest correctness (issue #23): a storage failure now
returns 500 instead of a false `ok`; oversize bodies return 413 (distinguished
from malformed JSON, which stays 400); a `MaxBodyBytes` middleware caps every
route, not just the report route; the raw attacker-controlled `geo` blob is no
longer logged (only its length), and `client_id`, `timestamp` and decode error
text are length-bounded before logging; a `decodeJSON` handler helper was
added; panic recovery now routes the stack through slog instead of chi's
plain-text stderr; and writing a report file now returns its error, so a
failed final flush on shutdown makes the process exit non-zero instead of
losing the buffered reports silently
- 2026-09-21: shutdown lifecycle correctness. The process now shuts down through
fx instead of `os.Exit`, so every component's `OnStop` runs and buffered
reports are flushed to disk on `SIGTERM` — previously a full flush window of
telemetry was silently lost on every restart. The `http.Server` is now built
before its serving goroutine starts, so shutdown can no longer race or
nil-deref it; a listen failure exits non-zero via `fx.Shutdowner`; `reportbuf`
`OnStop` is idempotent; and `writeTimeout` now exceeds the chi per-request
budget so that budget is actually reachable. Dead `startupTime`, `exitCode`,
and `cancelFunc` fields were removed
- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and
`IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP,
frame/sniff/referrer/permissions headers) registered before CORS, and
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of
`Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by
digest); the previous config declared `version: "2"` but used v1 schema keys,
so every threshold in it was inert and its green result was meaningless.
`backend/Makefile`'s `lint` target now asserts the config's sha256 against the
canonical file first, so drift from the org standard fails the build instead
of silently degrading to defaults
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`). The pin button's hit area grows via matching
negative margins, so its layout footprint and row density are unchanged
- 2026-08-10: per-host status line wraps below the 768px breakpoint instead of
forcing horizontal page scroll at 320px
- 2026-08-09: `Dockerfile.backend` reworked to the mandated Go multistage
lint-stage pattern: separate `lint` stage on the hash-pinned
`golangci/golangci-lint` image, `COPY --from=lint` stage dependency,
`CGO_ENABLED=0` static build driven by `ARG VERSION`, and no more `COPY .git`
- 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo
root so `root = true` covers the frontend too, and replaced `.gitignore` with
the org model (OS, editor, node, and environment/secrets sections) plus this
repo's `dist/` and `*.log`. `.env`, `.env.*`, `*.pem`, and `*.key` are now
ignored repo-wide, not just under `backend/`. Excluding `.git` from
`.dockerignore` stays deferred: both images read git metadata at build time
(`COPY .git` in `Dockerfile.backend`, `git rev-parse` in `vite.config.js`)
- 2026-08-09: automated responsive-layout harness
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
against the built `dist/`, viewport widths derived from the breakpoints in
`src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). The
tap-target and host-row checks each fail when they measured nothing; the
overflow, viewport-edge and clipped-text checks have no such guard of their
own and rely on the `app-rendered` check, which fails the run when the app did
not render. Found two real layout defects, filed as
[#42](https://git.eeqj.de/sneak/netwatch/issues/42) and
[#43](https://git.eeqj.de/sneak/netwatch/issues/43)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
and backend repo standard files; backend Dockerfile fixed (Go 1.25,
golangci-lint) and moved to repo root (feat/reportbuf-storage)
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
fixes; nginx config extracted; port hardcoded to 8080
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
S3 Singapore endpoint added
- 2026-02-23: summary box redesign, host pinning, local and UTC clocks, checks
counter
- 2026-02-23: hosts sorted by latency; GET instead of HEAD for latency; timeout
derived from interval; Hetzner regional endpoints; 3s interval
- 2026-01-29: initial NetWatch network latency monitor
# Future Steps
- A backend test that posts a report to `POST /api/v1/reports` and checks the
+63 -91
View File
@@ -13,27 +13,24 @@ 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
@@ -50,23 +47,21 @@ 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-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
set -f
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
case "$proxy" in
*/*) cidr="$proxy" ;;
*:*) cidr="$proxy/128" ;;
*) cidr="$proxy/32" ;;
esac
if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then
echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \
"separated by commas; '$proxy' is neither" >&2
exit 1
fi
echo "set_real_ip_from $cidr;"
done > /etc/nginx/trusted-proxies.conf
# netwatch-server keeps its report files in DATA_DIR, on the /data
# volume, which may be a host directory owned by root or by another
@@ -78,10 +73,12 @@ write_trusted_proxies() {
# 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
}
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 +87,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 +98,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 +109,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()
}
+10 -126
View File
@@ -36,103 +36,16 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
// TestOutputIsJSONWithoutFxLines: off a terminal, every line the server
// writes from start to stop is JSON, and without DEBUG none of them is
// one of fx's.
func TestOutputIsJSONWithoutFxLines(t *testing.T) {
// TestOutputIsJSON: off a terminal, every line the server writes from
// start to stop is JSON, fx's own lines included.
func TestOutputIsJSON(t *testing.T) {
t.Parallel()
stdout, stderr := runServerUntilStopped(t)
requireJSONLines(t, stdout, stderr)
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
t.Fatalf("no startup line in stdout:\n%s", stdout)
}
if strings.Contains(stdout.String(), `"msg":"started"`) {
t.Fatalf("fx line in stdout without DEBUG:\n%s", stdout)
}
}
// TestDebugLogsFxLines: with DEBUG set, fx's lines are logged, as
// JSON, from the start to the signal that stops the server.
func TestDebugLogsFxLines(t *testing.T) {
t.Parallel()
stdout, stderr := runServerUntilStopped(t, "DEBUG=1")
requireJSONLines(t, stdout, stderr)
for _, msg := range []string{"started", "received signal"} {
if !strings.Contains(stdout.String(), `"msg":"`+msg+`"`) {
t.Fatalf("no fx line %q in stdout with DEBUG set:\n%s",
msg, stdout)
}
}
}
// TestRefusedSettingIsLastLine: a setting the server refuses stops the
// start, and the one error line logged is the last, naming the setting
// and why, without fx's text around it.
func TestRefusedSettingIsLastLine(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel()
child, stdout, stderr := startServer(ctx, t, t.TempDir(),
freePort(ctx, t), "REPORTS_PER_MINUTE=0")
err := child.Wait()
if child.ProcessState.ExitCode() != 1 {
t.Fatalf("server exit = %v, want exit status 1", err)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want nothing", stderr)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
for _, line := range lines[:len(lines)-1] {
if strings.Contains(line, `"level":"ERROR"`) {
t.Fatalf("an error line before the last line:\n%s", stdout)
}
}
var last struct {
Level string `json:"level"`
Error string `json:"error"`
}
err = json.Unmarshal([]byte(lines[len(lines)-1]), &last)
if err != nil {
t.Fatalf("last line is not JSON: %v\n%s", err, stdout)
}
want := `REPORTS_PER_MINUTE "0": must be a positive whole number`
if last.Level != "ERROR" || last.Error != want {
t.Fatalf("last line has level %q, error %q; want ERROR, %q\n%s",
last.Level, last.Error, want, stdout)
}
}
// runServerUntilStopped runs main() in a child process with env added
// to its environment, waits until it answers its health check, stops it
// with SIGTERM and returns its stdout and stderr. It fails the test if
// the child does not exit cleanly.
func runServerUntilStopped(
t *testing.T,
env ...string,
) (*bytes.Buffer, *bytes.Buffer) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel()
port := freePort(ctx, t)
child, stdout, stderr := startServer(ctx, t, t.TempDir(), port, env...)
child, stdout, stderr := startServer(ctx, t, t.TempDir(), port)
waitForHealthcheck(ctx, t, port)
@@ -159,7 +72,11 @@ func runServerUntilStopped(
t.Fatalf("server exit = %v, want success", err)
}
return stdout, stderr
requireJSONLines(t, stdout, stderr)
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
t.Fatalf("no startup line in stdout:\n%s", stdout)
}
}
// TestMalformedConfigFileStopsTheStart: a config file the server finds
@@ -220,40 +137,9 @@ func TestRefusedSentryDSNStopsTheStart(t *testing.T) {
}
}
// TestPortInUseExitsWithStatus1: a server whose port is already taken
// cannot listen, and stops with exit status 1.
func TestPortInUseExitsWithStatus1(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel()
var lc net.ListenConfig
taken, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = taken.Close() }()
_, port, err := net.SplitHostPort(taken.Addr().String())
if err != nil {
t.Fatal(err)
}
child, stdout, _ := startServer(ctx, t, t.TempDir(), port)
err = child.Wait()
if child.ProcessState.ExitCode() != 1 {
t.Fatalf("server exit = %v, want exit status 1\n%s", err, stdout)
}
}
// startServer runs main() in a child process listening on
// 127.0.0.1:port, with home as its HOME and working directory and its
// data directory in home, so it touches nothing outside home. env is
// added to its environment, as NAME=value strings. Its
// data directory in home, so it touches nothing outside home. Its
// stdout and stderr go to the two buffers returned, which hold all of
// it once child.Wait returns. The child is killed when ctx ends, and
// killed and reaped when the test ends if nothing waited for it.
@@ -261,7 +147,6 @@ func startServer(
ctx context.Context,
t *testing.T,
home, port string,
env ...string,
) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -281,7 +166,6 @@ func startServer(
"BIND_ADDRESS=127.0.0.1",
"PORT="+port,
)
child.Env = append(child.Env, env...)
child.Stdout = &stdout
child.Stderr = &stderr
+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
@@ -16,7 +16,6 @@ require (
github.com/prometheus/client_golang v1.24.1
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0
go.uber.org/dig v1.19.0
go.uber.org/fx v1.24.0
golang.org/x/sys v0.47.0
)
@@ -39,6 +38,7 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/dig v1.19.0 // indirect
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
-194
View File
@@ -1,194 +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 (
"context"
"fmt"
"io"
"log/slog"
"os"
"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/dig"
"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, and returns serve's exit code.
// 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.
func Main(
stderr io.Writer,
appname, version string,
args []string,
serve func(appname, version string) int,
) 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
}
return serve(appname, version)
}
// 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, and returns the
// exit code. If the server fails to start, to listen or to stop, it
// returns 1. A failed start logs one line with the error's cause, such
// as the setting refused and why, as the server's last line.
func RunServer(appname, version string) int {
globals.Appname = appname
globals.Version = version
var (
log *logger.Logger
fxLog *fxevent.SlogLogger
)
app := fx.New(
// fx logs each step of starting and stopping, errors included,
// at debug level through the server's own logger: they show
// only with DEBUG set, and off a terminal they are JSON like
// every other line.
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
fxLog = &fxevent.SlogLogger{Logger: log.Get()}
fxLog.UseLogLevel(slog.LevelDebug)
fxLog.UseErrorLevel(slog.LevelDebug)
return fxLog
}),
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
// First, so that log is set for the error below even when a
// setting stops the start.
fx.Populate(&log),
fx.Invoke(
// Before the server, so that the name and version are
// logged even when a setting stops the start.
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
)
startCtx, cancelStart := context.WithTimeout(
context.Background(), app.StartTimeout())
defer cancelStart()
err := app.Start(startCtx)
if err != nil {
// fx wraps a constructor's error in its own text; the cause
// alone names the setting. log is nil only if fx failed
// before it built the logger.
cause := dig.RootCause(err)
if log == nil {
_, _ = fmt.Fprintln(os.Stderr, cause)
} else {
log.Get().Error("cannot start", "error", cause)
}
return 1
}
// A signal, or the server asking to stop after a listen error.
// fx's Run logs the signal here, so this does too.
shutdown := <-app.Wait()
fxLog.LogEvent(&fxevent.Stopping{Signal: shutdown.Signal})
stopCtx, cancelStop := context.WithTimeout(
context.Background(), app.StopTimeout())
defer cancelStop()
err = app.Stop(stopCtx)
if err != nil {
log.Get().Error("cannot stop cleanly", "error", err)
return 1
}
return shutdown.ExitCode
}
-223
View File
@@ -1,223 +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 the server's exit code.
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) int {
gotAppname, gotVersion = appname, version
return 3
})
if code != 3 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 3 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) int {
t.Helper()
return func(string, string) int {
t.Error("Main started the server")
return 0
}
}
-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)
}
+4 -4
View File
@@ -13,8 +13,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,8 +31,8 @@ func requireConfigError(t *testing.T, setting string) {
}
}
// requireTrustedProxies builds the config as cli.RunServer does and
// fails the test unless its TrustedProxies are want.
// requireTrustedProxies builds the config as main does and fails the
// test unless its TrustedProxies are want.
func requireTrustedProxies(t *testing.T, want []string) {
t.Helper()
+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
+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()
+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,
-10
View File
@@ -255,16 +255,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
+7 -85
View File
@@ -334,9 +334,9 @@ func stopWhileHeld(
return <-stopped
}
// startApp starts a Buffer through fx, as cli.RunServer does, with the
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set, and returns
// it with the app, for tests that stop the app themselves.
// startApp starts a Buffer through fx, as main does, with the DATA_DIR
// and DATA_DIR_MAX_BYTES the calling test has set, and returns it with
// the app, for tests that stop the app themselves.
func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
t.Helper()
@@ -358,8 +358,8 @@ func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
return buf, app
}
// startBuffer starts a Buffer through fx, as cli.RunServer does, with
// the DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
// startBuffer starts a Buffer through fx, as main does, with the
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
func startBuffer(t *testing.T) *reportbuf.Buffer {
t.Helper()
@@ -1074,54 +1074,9 @@ func TestReportsRefusedWhileWritesFail(t *testing.T) {
}
}
// TestHealthCheckFailsWhileWritesFail asks the health check handler
// for its answer around a failed write. It answers 200 with
// "status":"ok" at start, 503 with "status":"error" from the failed
// write until a write succeeds, and 200 with "status":"ok" again after.
func TestHealthCheckFailsWhileWritesFail(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
buf, h := startBufferWithHandlers(t)
checkHealthCheck(t, h, "at start", http.StatusOK, "ok")
// A flush writes a file only when a report is waiting.
err := buf.Append(map[string]string{"id": "kept"})
if err != nil {
t.Fatalf("append: %v", err)
}
// With the data directory gone, the write has nowhere to go.
err = os.Remove(dir)
if err != nil {
t.Fatalf("remove data dir: %v", err)
}
err = buf.Flush()
if err == nil {
t.Fatal("flush succeeded with the data directory gone")
}
checkHealthCheck(t, h, "after the failed write",
http.StatusServiceUnavailable, "error")
err = os.Mkdir(dir, 0o750)
if err != nil {
t.Fatalf("make data dir: %v", err)
}
err = buf.Flush()
if err != nil {
t.Fatalf("flush with the data directory back: %v", err)
}
checkHealthCheck(t, h, "after a write succeeded", http.StatusOK, "ok")
}
// startBufferWithHandlers starts a Buffer and the Handlers that store
// reports in it through fx, as cli.RunServer does, with the DATA_DIR
// the calling test has set.
// reports in it through fx, as main does, with the DATA_DIR the calling
// test has set.
func startBufferWithHandlers(
t *testing.T,
) (*reportbuf.Buffer, *handlers.Handlers) {
@@ -1166,39 +1121,6 @@ func postReport(t *testing.T, h *handlers.Handlers, clientID string) int {
return rec.Code
}
// checkHealthCheck asks the health check handler for its answer, and
// fails the test, saying when it asked, unless the answer has status
// code wantCode and a "status" field of wantStatus.
func checkHealthCheck(
t *testing.T,
h *handlers.Handlers,
when string,
wantCode int,
wantStatus string,
) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
h.HandleHealthCheck().ServeHTTP(rec, req)
var body struct {
Status string `json:"status"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("health check %s: body not JSON: %v (%q)",
when, err, rec.Body.String())
}
if rec.Code != wantCode || body.Status != wantStatus {
t.Fatalf("health check %s: %d with status %q, want %d with %q",
when, rec.Code, body.Status, wantCode, wantStatus)
}
}
// TestConcurrentAppendsStopAtCap appends from many goroutines at once
// with room for exactly roomFor reports: exactly that many must be
// taken, which holds only if Append checks and counts each report
+3 -3
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()
+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": {
+3 -4
View File
@@ -10,10 +10,9 @@ main() {
cd "$ROOT"
"$ROOT/script/yarn" build
# VERSION comes from the environment. Unset or empty, it is
# `git describe --tags --always --dirty`, as in script/docker, or
# "dev" where there is no git or no repository history.
version="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
# 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" \
+3 -3
View File
@@ -15,13 +15,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
NODE_OPTIONS=--test-reporter=dot \
"$ROOT/script/yarn" --silent run test || {
timeout 90 "$ROOT/script/yarn" --silent run test || {
echo "--- Rerunning with every test listed for details ---"
NODE_OPTIONS=--test-reporter=spec \
"$ROOT/script/yarn" --silent run test
timeout 90 "$ROOT/script/yarn" --silent run test
exit 1
}
"$ROOT/script/yarn" build
timeout 30 "$ROOT/script/yarn" build
}
main "$@"
+3 -3
View File
@@ -31,8 +31,8 @@ HARNESS="netwatch-viewport-harness-$RUN_ID"
ARTIFACT_DIR="$ROOT/tmp/viewport"
# Every container is named and removed here, including the harness itself:
# a `docker run` client that is killed outright leaves its container
# running, and an unnamed survivor keeps the --internal network in use so
# `timeout` below kills the `docker run` client, not the container it
# started, and an unnamed survivor keeps the --internal network in use so
# `docker network rm` fails too. This host runs many sessions at once and
# neither may be left behind.
cleanup() {
@@ -122,7 +122,7 @@ main() {
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
"$BROWSER")"
docker run --rm --init --name "$HARNESS" \
timeout 900 docker run --rm --init --name "$HARNESS" \
--network "$NETWORK" \
--user "$(id -u):$(id -g)" \
-v "$ROOT:/app" \
+3 -207
View File
@@ -28,8 +28,8 @@ import {
// 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; setting a style keeps it in the element's style
// object. Drawing a sparkline does nothing; it looks for the pixel ratio on
// 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
@@ -63,7 +63,7 @@ globalThis.document = {
querySelector: (selector) =>
(elements[selector] ??= {
getContext: () => canvasContext,
style: {},
querySelector: () => null,
set textContent(text) {
this.innerHTML = text;
},
@@ -83,12 +83,6 @@ function statusText(state, host) {
return elements[`.status-text[data-host="${index}"]`]?.innerHTML;
}
// The color last given to the dot beside host's name.
function statusDot(state, host) {
const index = state.allHosts.indexOf(host);
return elements[`.status-dot[data-host="${index}"]`]?.style.backgroundColor;
}
// What this test wrote to the browser console with console[method].
function consoleLines(method) {
return console[method].mock.calls.map((call) => call.arguments[0]);
@@ -186,91 +180,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 ap-south-1 (Mumbai)",
"https://s3.ap-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([
@@ -689,37 +598,6 @@ test("a check that timed out or found its target unreachable has a grey figure a
assert.equal(latencyHex(null), "#6b7280");
});
test("a check that answers after 488ms turns the dot beside its target's name orange", async (t) => {
const { state, target } = stateWithOneTarget();
mockTargets(t, () => 488);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(488);
assert.notEqual(await settled(round), "still waiting");
assert.equal(statusDot(state, target), "#f97316");
});
test("a check that fails after one that answered turns the dot beside its target's name gray", async (t) => {
const { state, target } = stateWithOneTarget();
target.pushSample(Date.now(), { latency: 8, error: null });
mockTargets(t, () => 23, target.url);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(23);
assert.notEqual(await settled(round), "still waiting");
assert.equal(statusDot(state, target), "#6b7280");
});
test("pausing turns the dot beside a target's name gray", async (t) => {
const { state, target } = stateWithOneTarget();
mockTargets(t, () => 8);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(8);
assert.notEqual(await settled(round), "still waiting");
assert.equal(statusDot(state, target), "#22c55e");
state.paused = true;
greyOutUI(state);
assert.equal(statusDot(state, target), "#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
@@ -868,88 +746,6 @@ test("once every WAN target's check has failed, the summary's Reachable slot rea
assert.equal(elements["#summary-reachable"].className, "text-red-400");
});
// The network has just come back: every WAN target's last check failed, so
// the health box reads OFFLINE. In this round every WAN target but one
// answers after 10ms, and the one never answers. Each element of the summary
// and the health box is a plain object, as in the tests above; the debug
// log's panel is not there.
test("at a 30000ms interval, the summary and the health box take in each WAN target's answer while another WAN target's check is still waiting", async (t) => {
CONFIG.updateInterval = 30000;
t.mock.method(globalThis.document, "getElementById", (id) =>
id.startsWith("summary-") || id.startsWith("health-")
? (elements[`#${id}`] ??= {})
: null,
);
const state = new AppState([]);
for (const host of state.wan) {
host.pushSample(Date.now(), { latency: null, error: "timeout" });
}
const silent = state.wan[0];
mockTargets(t, (url) => (url === silent.url ? Infinity : 10));
// The third tick, as in the tests above.
state.tickCount = 2;
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(10);
assert.equal(await settled(round), "still waiting");
assert.equal(elements["#health-text"].textContent, "HEALTHY");
assert.equal(
elements["#summary-reachable"].textContent,
`${state.wan.length - 1}/${state.wan.length}`,
);
assert.equal(elements["#summary-max"].textContent, "10");
// Checks counts finished rounds only.
assert.equal(elements["#summary-checks"].textContent, 2);
t.mock.timers.tick(CONFIG.requestTimeout - 10);
assert.notEqual(await settled(round), "still waiting");
assert.equal(elements["#summary-checks"].textContent, 3);
});
// The user pauses during the first recorded round after the page loads.
// Every WAN target's check ends during the pause. Silent never answers, and
// the user resumes before its check times out. Every element the page looks
// up by id is a plain object, except the debug log's panel, which is not
// there.
test(`at a 30000ms interval, after the user pauses and resumes during a round that records no WAN target's check, the debug log has no "Health:" line`, async (t) => {
CONFIG.updateInterval = 30000;
t.mock.method(globalThis.document, "getElementById", (id) =>
id === "debug-panel" ? null : (elements[`#${id}`] ??= {}),
);
t.mock.method(log, "notice");
t.mock.method(log, "warning");
const state = new AppState([
{ name: "Silent", url: "https://silent.test" },
]);
const silent = state.local[0];
mockTargets(t, (url) => (url === silent.url ? Infinity : 10));
// The second tick: the first that is recorded.
state.tickCount = 1;
const round = tick(state, new AbortController().signal);
// The user pauses, which greys out the summary and the health box, as
// togglePause does.
state.paused = true;
greyOutUI(state);
t.mock.timers.tick(10);
assert.equal(await settled(round), "still waiting");
// The user resumes, which leaves the page as it is.
state.paused = false;
t.mock.timers.tick(CONFIG.requestTimeout - 10);
assert.notEqual(await settled(round), "still waiting");
assert.equal(state.tickCount, 2);
const lines = [...log.notice.mock.calls, ...log.warning.mock.calls].map(
(call) => call.arguments[0],
);
assert.deepEqual(
lines.filter((line) => line.startsWith("Health:")),
[],
);
assert.equal(elements["#health-text"].textContent, "---");
assert.equal(elements["#summary-reachable"].textContent, "--/--");
});
// 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.
+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/`.
+48 -75
View File
@@ -30,8 +30,8 @@ export const CONFIG = {
return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5));
},
canvasHeight: 96,
// A latency figure, its row's dot and its sparkline take the color of
// the first entry whose limit, in ms, the latency is below.
// A latency figure and its sparkline take the color of the first entry
// whose limit, in ms, the latency is below.
latencyColors: [
{ below: 50, hex: "#22c55e", className: "text-green-500" },
{ below: 100, hex: "#84cc16", className: "text-lime-500" },
@@ -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 ap-south-1 (Mumbai)",
url: "https://s3.ap-south-1.amazonaws.com/robots.txt",
method: "HEAD",
name: "S3 me-south-1 (Bahrain)",
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
{
@@ -286,7 +271,6 @@ 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 }.
@@ -583,18 +567,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 +588,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);
@@ -819,7 +800,7 @@ export function hostRowHTML(host, index, showPin = true) {
${pinBtn}
<div class="w-[420px] flex-shrink-0 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div class="flex items-center gap-2 min-w-[200px]">
<div class="status-dot w-3 h-3 rounded-full flex-shrink-0 bg-[#6b7280]" data-host="${index}"></div>
<div class="w-3 h-3 rounded-full flex-shrink-0 bg-[#6b7280]"></div>
<span class="font-medium text-white truncate">${escapeHTML(host.name)}</span>
</div>
<div class="latency-value text-4xl font-bold tabular-nums text-right mt-3" data-host="${index}">
@@ -924,7 +905,7 @@ export function buildUI(state) {
</div>
<footer class="mt-8 text-center text-gray-600 text-xs">
<p>Latency: the time until a target's response headers arrive</p>
<p>Latency measured via GET requests | CORS restrictions may affect some measurements</p>
<p class="mt-2">
<span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span>&lt;50ms
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span>&lt;100ms
@@ -982,10 +963,8 @@ function updateHostRow(host, index) {
const canvas = document.querySelector(
`.sparkline-canvas[data-host="${index}"]`,
);
const dot = document.querySelector(`.status-dot[data-host="${index}"]`);
if (!latencyEl || !statusEl || !canvas || !dot) return;
if (!latencyEl || !statusEl || !canvas) return;
dot.style.backgroundColor = latencyHex(host.lastLatency);
if (host.lastLatency !== null) {
const cls = latencyClass(host.lastLatency, host.status);
latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
@@ -1128,6 +1107,11 @@ function updateHealthBox(state) {
el.textContent = labels[health];
el.className = textColors[health];
box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${boxColors[health]}`;
if (health === "offline" || health === "degraded")
log.warning(`Health: ${labels[health]}`);
else if (health === "slow") log.warning(`Health: ${labels[health]}`);
else if (health === "healthy") log.notice(`Health: ${labels[health]}`);
}
// Writes the local time, with its time zone's name, and the UTC time into
@@ -1298,26 +1282,21 @@ export async function tick(state, signal, onOffline) {
log.debug(`Tick #${state.tickCount + 1} started`);
// Each host's row shows its result as soon as its check ends, and a WAN
// host's result goes into the summary and the health box at the same
// time. The result is discarded if by then the user has paused or the
// next round has given up this one's checks, and in the first tick
// (tickCount is still 0), which is discarded as a whole below. The row
// is looked up when the check ends, as a pin click may have re-sorted
// the rows since the round started.
// Each host's row shows its result as soon as its check ends. The
// result is discarded if by then the user has paused or the next round
// has given up this one's checks, and in the first tick (tickCount is
// still 0), which is discarded as a whole below. The row is looked up
// when the check ends, as a pin click may have re-sorted the rows since
// 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;
}
logCheck(host, r);
host.pushSample(ts, r);
updateHostRow(host, state.allHosts.indexOf(host));
if (state.wan.includes(host)) {
updateSummary(state);
updateHealthBox(state);
}
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
}),
);
@@ -1344,19 +1323,9 @@ export async function tick(state, signal, onOffline) {
sortAndRebuildWAN(state);
}
// Redraw the summary and the health box: a pause greys them out too, and
// Checks now counts this round.
updateSummary(state);
updateHealthBox(state);
// Like the health box, the log gives no health until a WAN host's check
// is recorded: a pause may have discarded every WAN check so far.
if (state.wan.some((h) => h.status !== "pending")) {
const health = state.healthStatus();
if (health === "healthy") log.notice("Health: HEALTHY");
else log.warning(`Health: ${health.toUpperCase()}`);
}
const reachable = state.allHosts.filter(
(h) => h.status === "online",
).length;
@@ -1394,7 +1363,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);
@@ -1432,8 +1401,12 @@ export function greyOutUI(state) {
statusEl.textContent = "paused";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
}
const dot = document.querySelector(`.status-dot[data-host="${i}"]`);
if (dot) dot.style.backgroundColor = latencyHex(null);
// Grey out the status dot
const row = document.querySelector(`.host-row[data-index="${i}"]`);
if (row) {
const dot = row.querySelector(".rounded-full");
if (dot) dot.style.backgroundColor = "#6b7280";
}
});
// Grey out summary current stats