Files
netwatch/TODO.md
T
clawbot 5788ea43e3 README.md: rework against the review findings
The check sentence names HEAD for the S3 regions up front. The rate-limit
sentence states the safe rate plainly: an address that sends at most half
the limit in any 60 seconds is never refused. The upaas `TRUSTED_PROXIES`
entry again warns that a client connecting from a trusted address can
write its own `X-Forwarded-For`, that a connection through a published
Docker port can arrive from the Docker network's gateway, which the
default includes, and that set and empty, every client behind the proxy
shares one allowance. `TODO.md` gets the Completed Steps entry for
#189, now that
#196 has merged. For
#195.

Model: fable-5-1
2026-10-26 05:41:11 +00:00

710 lines
48 KiB
Markdown

# Workflow
- branch from `next`
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- push the branch and open a PR against `next`
# Status
pre-1.0. Release candidates are tagged on `next`, the first being `v1.0.0-rc1`;
`git tag --merged origin/next` lists them. `main` is the stable branch and
`next` the development branch, which every PR targets. The frontend and the Go
backend ship as one Docker image. The Gitea workflow
`.gitea/workflows/check.yml` runs `script/cibuild` on every push, and cancels
the older run on the same branch. Working toward 1.0.0.
# Next Step
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-26: `README.md`, `TODO.md` and `test/viewport/README.md` are rewritten
sentence by sentence ([#189](https://git.eeqj.de/sneak/netwatch/issues/189)).
Each sentence reads on the first pass: one idea, under about 30 words, the
verb early. Every statement matches the code on `next`.
- 2026-10-26: a target whose home page answers with a redirect is checked at
`/robots.txt` on the same host, which answers without one
([#194](https://git.eeqj.de/sneak/netwatch/issues/194)). These are the AWS
Console, Akamai, B2 and the 8 S3 regions. The browser followed each redirect,
so their figures also timed the page it led to. The Google Cloud Console is
checked at `/robots.txt` too, as its home page redirects `curl`'s requests.
The S3 regions are checked with a HEAD request, as Chrome closes their
connection after most GET requests. `README.md` says what a figure includes.
- 2026-10-26: the six Hetzner targets answer wherever the page is served from
([#191](https://git.eeqj.de/sneak/netwatch/issues/191)). They close the
connection without an answer when the request has a `Referer` header. The
browser sent one unless the page came with the image's
`Referrer-Policy: no-referrer` header, which a static host and `make dev` do
not send. Each check now sets `referrerPolicy: 'no-referrer'`.
- 2026-10-19: the server's `OnStart` hook in `internal/server/server.go` starts
`listenAndServe` in an anonymous function, as the Go style guide asks
([#162](https://git.eeqj.de/sneak/netwatch/issues/162)). It was
`go s.listenAndServe()`. Nothing the server does changed.
- 2026-10-19: `.gitignore`, `.editorconfig` and `.golangci.yml` are the
sneak/prompts copies at `0653579`
([#174](https://git.eeqj.de/sneak/netwatch/issues/174)). `.gitignore` is the
standard file followed by this repository's own entries. `.editorconfig` is
the standard file alone, which carries the `[*.go]` section. `.golangci.yml`
disables `canonicalheader`, as the canonical copy does: golangci-lint v2.14.0
misses its findings at random.
- 2026-10-19: `package.json` has no `version` field and is marked
`"private": true` ([#158](https://git.eeqj.de/sneak/netwatch/issues/158)). It
said `1.0.0`, a release that has not been made, and nothing read it. The git
tag is the one version; the image and `make build` take it from
`git describe --tags`. The package is never published.
- 2026-10-19: Status names `v1.0.0-rc1`, the first release candidate, and says
release candidates are tagged on `next`
([#157](https://git.eeqj.de/sneak/netwatch/issues/157)). It said there were no
git tags.
- 2026-10-19: `netwatch-server` starts the server only when given no arguments
([#179](https://git.eeqj.de/sneak/netwatch/issues/179)). `check-cidr CIDR` and
`prepare-data-dir DATA_DIR` behave as before. Any other arguments print a
one-line usage message naming both forms to stderr and exit 2; they used to
start the server. Unit tests cover each case.
- 2026-10-19: `main()` in `cmd/netwatch-server/main.go` is one call into the new
package `internal/cli`, as the Go style guide asks
([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs
the `check-cidr` and `prepare-data-dir` subcommands, with the same arguments,
messages and exit codes, and otherwise the server with the `fx` wiring.
`Appname` and `Version` stay in `main`, where the build sets `Version`. `main`
gives `cli.Main` stderr and the function that runs the server; the tests
replace both. Unit tests give each subcommand input it takes and input it
refuses.
- 2026-10-19: when `VERSION` is unset or empty, `script/build` stamps the
version from `git describe --tags --always --dirty`, as `script/docker` does
([#156](https://git.eeqj.de/sneak/netwatch/issues/156)). Without `--tags` it
skipped lightweight tags, so `make run` could report a commit where the image
built from the same tree reported the tag.
- 2026-10-19: `.dockerignore` keeps Go test binaries and coverage output at the
root out of the build context, with `/*.test` and `/*.out` in the repository's
own section ([#155](https://git.eeqj.de/sneak/netwatch/issues/155)). Such
files were copied into the lint, test and build stages. The entries match only
at the root, so the same files in a subdirectory still reach the image.
- 2026-10-19: `.gitea/workflows/check.yml` is the sneak/prompts copy at
`0653579` ([#153](https://git.eeqj.de/sneak/netwatch/issues/153)). Its
checkout step sets `fetch-depth: 0`, so CI clones all history and tags. The
image built from a tagged commit then gets the tag as its version; it got the
short commit. The step also sets `persist-credentials: false`, so the job's
token is not left in `.git/config`. A new push cancels the older run on the
same branch, and a run stops after 20 minutes.
- 2026-10-19: `script/bootstrap` keeps the `yarn` on `PATH` only when
`yarn --version` is exactly `YARN_VERSION`; otherwise it installs that version
([#154](https://git.eeqj.de/sneak/netwatch/issues/154)). Any yarn used to be
kept. It uses the `node` on `PATH` only when that node has the major version
of `NODE_VERSION` and is at least `NODE_MIN_VERSION`; a node 24 was kept
before. After installing node or yarn it looks the tool up through `PATH`
again and stops if that one fails the same check, as it does for Go. It prints
the node and yarn versions it uses. Every script that runs yarn on the host,
except `script/bootstrap`, runs it through the new `script/yarn`. That script
puts `~/.local/bin` first on `PATH`. It uses the first `node` there when it
passes the same test, and otherwise runs yarn under the pinned node in nvm.
`make build` ran yarn under whatever node was first on `PATH`.
- 2026-10-19: `bin/entrypoint.sh` has each step in a function, and a `main` at
the end calls them in order, as the code style guide's shell section asks
([#163](https://git.eeqj.de/sneak/netwatch/issues/163)). It is still POSIX sh
with no `set -e`, and does what it did, with the same messages and exit codes.
- 2026-10-19: while report files cannot be written, the health check answers 503
with `"status":"error"` and the same fields, so the image's `HEALTHCHECK`
reports the container `unhealthy`
([#142](https://git.eeqj.de/sneak/netwatch/issues/142)). It answered 200
whatever happened, so the platform saw a healthy container while every report
was refused with 500. It answers 200 at start, before any write has failed,
and again from the first write that succeeds.
- 2026-10-19: report file writes no longer overlap
([#150](https://git.eeqj.de/sneak/netwatch/issues/150)), so the reports of
failed writes are written in the order they arrived. Two writes that failed at
the same time put their reports back in the wrong order when the older failed
first. One write runs at a time. Each takes the waiting reports only when it
starts, after a failed write has put its own back. The write that `Append`
starts at 10 MiB waits for any write under way, and `Append` starts no other
while it waits. Files written since the start are deleted to make room in the
order they were written, after those found at start.
- 2026-10-19: `DEBUG` unset or empty leaves debug logging off, and any other
value, `false` and `0` included, turns it on, as the code style guide says
([#159](https://git.eeqj.de/sneak/netwatch/issues/159)). No value of it stops
the start any more. Before, only `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`,
`f`, `F`, `FALSE`, `false` and `False` were accepted. `README.md` no longer
gives `false` as its default.
- 2026-10-19: check builds keep Go's modules and compiled packages between runs
([#118](https://git.eeqj.de/sneak/netwatch/issues/118)), on sneak's rulings in
[prompts#113](https://git.eeqj.de/sneak/prompts/issues/113) and
[prompts#130](https://git.eeqj.de/sneak/prompts/issues/130).
`REPO_POLICIES.md`, `script/test`, `script/lint`, `script/docker` and
`script/cibuild` are the sneak/prompts copies at `0653579`. The scripts pass a
new `CACHEBUST` value where they passed `--no-cache`, and the lint and test
builds write no image. In each stage of `Dockerfile`, `ARG CACHEBUST` comes
after the system packages and the Go module download. Every step that lints,
tests or compiles comes after it, as does the node stages' `yarn install`.
Go's build cache and golangci-lint's are cache mounts, and both `go test` runs
carry `-count=1`. The test phase's Go caches used to be in memory, empty on
every build.
- 2026-10-19: the three unit tests of a discarded check also check that the
host's history is still empty, its status still `pending`, and the console
untouched ([#151](https://git.eeqj.de/sneak/netwatch/issues/151)). The
discarded cases are a round given up, a pause, and the first round. The tests
checked only the row. So a change that recorded the result and skipped only
the row would have kept them passing.
- 2026-10-18: run on its own, the backend stops at start when `DATA_DIR` cannot
be created or read, or when no file can be created in it
([#148](https://git.eeqj.de/sneak/netwatch/issues/148)). The error names
`DATA_DIR` and its value. The first two cases named only the path, and the
third was found only at the first write of a report file. At start the server
creates a file in `DATA_DIR` and removes it. The report file number is an
`atomic.Int64`, as the Go style guide asks for int types
([#161](https://git.eeqj.de/sneak/netwatch/issues/161)); file names are
unchanged.
- 2026-10-18: the image's nginx sends the page with `Cache-Control: no-cache`
([#141](https://git.eeqj.de/sneak/netwatch/issues/141)), so a browser that
keeps a copy checks with the server that it is current before using it again.
The page went out with no `Cache-Control`. So a browser could keep it after a
deploy, ask for files under `/assets/` that the new build no longer has, and
show an empty page. A path that `location /` answers with the page through its
`try_files` fallback gets the same header. `location /` includes the security
headers again, as `/assets/` does, since its own `add_header` drops the
server's. `/assets/` keeps its immutable caching.
`make frontend-viewport-test` fails unless `/` and a path that falls back to
the page carry `Cache-Control: no-cache` and `Content-Security-Policy`.
- 2026-10-18: `README.md` says what the page loses while reports are refused or
the page is paused ([#145](https://git.eeqj.de/sneak/netwatch/issues/145)). It
says how fast one address can replace the stored reports
([#146](https://git.eeqj.de/sneak/netwatch/issues/146)). It says that in the
image nginx answers a body over 1 MiB itself
([#147](https://git.eeqj.de/sneak/netwatch/issues/147)). nginx's
`client_max_body_size` is 1 MiB by default, so in the image it answers a
larger body with 413 and its own HTML page, and the backend never sees it. The
reporter, the body limit and nginx are unchanged.
- 2026-10-18: a browser that cannot format the header's clocks still probes
([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the
local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which
Chrome before 95, Firefox before 91 and Safari before 15.4 reject with an
error. It came before the first round started, so such a browser never checked
a target. The clocks now start last, after the rounds, and an error formatting
them leaves both blank. The footer's color legend reads `>=500ms` for red,
where it read `>500ms`
([#152](https://git.eeqj.de/sneak/netwatch/issues/152)).
- 2026-10-18: `netwatch-server prepare-data-dir` no longer changes the owner of
a file outside `/data` through a hard link in the volume
([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A file with a second
name (hard link) keeps its owner, and the start logs its path. Owners are set
on the opened directory, file or link, never by name, so a hard link swapped
in after the check is caught too. A symbolic link is opened without following
it, and the link itself is given to the netwatch user. So the server can still
follow one in a sticky directory that anyone can write to. Devices, named
pipes and sockets keep their owner. Setting the owner on an open file that is
not a directory is built only for Linux. `prepare-data-dir` runs only in the
Linux image, and macOS has neither `O_PATH` nor `AT_EMPTY_PATH`. On any other
system the package still builds, and `prepare-data-dir` fails at the first
file that is not a directory.
- 2026-10-18: from a trusted proxy, the backend takes the client address from
the right-most `X-Forwarded-For` entry that is not in `TRUSTED_PROXIES`
([#144](https://git.eeqj.de/sneak/netwatch/issues/144)). It took the left-most
entry, which the client writes itself behind a proxy that appends to the
header. So a client could choose the address the rate limit counts and the
request log records. An entry written as an address with a port is read as
that address. The direct peer is used when the header holds only trusted
addresses, or when the search meets an entry that is not an address at all,
such as `unknown`. `X-Real-IP` is read only without `X-Forwarded-For`, and
every `X-Forwarded-For` header line is read, not only the first. The image's
nginx, and the `TRUSTED_PROXIES=127.0.0.1/32` it starts the backend with, are
unchanged.
- 2026-10-18: after an interval change, the summary's Reachable slot reads
`--/--` in white, as at page load, until the second round since the change
ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It read 0/26 in
red from the change on, as no WAN host had a check recorded, for up to two
intervals with the network fine.
- 2026-10-14: stopping the server waits for a report file write already under
way, then runs the final flush
([#122](https://git.eeqj.de/sneak/netwatch/issues/122)). Such a write is the
periodic flush's, or the one `Append` starts when 10 MiB are waiting. It used
to return at once, and the process exiting cut the write off, losing its
reports. A write under way that fails puts its reports back in memory, so the
final flush writes them. The stop fails, and the process exits non-zero, only
if that flush fails too.
- 2026-10-14: `make test` no longer runs the frontend's production build, which
the tests do not need ([#118](https://git.eeqj.de/sneak/netwatch/issues/118)).
`yarn build` moved out of the stage that runs the frontend's unit tests, now
named `frontend-test`, into a new `frontend-build` stage. That stage starts
from `frontend-test` and, like the `builder` stage, waits on the lint and test
phases, so the image still cannot be built unless they pass. The runtime stage
copies `dist/` from it, and the `git` that `vite.config.js` reads the commit
with is installed there. No build cache was added.
- 2026-10-14: opening the debug log no longer raises the measured latencies
([#131](https://git.eeqj.de/sneak/netwatch/issues/131)). With the panel open,
each line written drew the panel again with every line before it, up to 1000.
A check that ends while the page draws is timed only when the drawing is done.
Each line is now added at the end of the open panel, and its first line is
dropped once it holds more than 1000. It scrolls to its newest line once a
frame rather than once a line. The whole panel is drawn only when it is
opened.
- 2026-10-14: unset, the backend's own `TRUSTED_PROXIES` trusts `10.0.0.0/8`,
`172.16.0.0/12` and `192.168.0.0/16`, and no longer loopback
([#129](https://git.eeqj.de/sneak/netwatch/issues/129)). A list replaces that.
Set and empty, it trusts no proxy; it used to get the default, as viper reads
an empty environment variable as unset. A value it cannot parse still stops
the start, and the image still starts the backend with
`TRUSTED_PROXIES=127.0.0.1/32`.
- 2026-10-14: while report files cannot be written, `POST /api/v1/reports`
answers 500, and storing recovers once writing works again, with no restart
([#123](https://git.eeqj.de/sneak/netwatch/issues/123)). A failed write puts
its reports back in the report buffer, still counted against
`DATA_DIR_MAX_BYTES`, where they used to be lost. The next flush, within a
minute, tries them again. Until a write succeeds, every report is refused and
nothing of it is kept, so the buffer does not grow while writes fail.
- 2026-10-14: a request whose handler panics is logged with status 500 and
counted with `code` 500 in the metrics, not 200
([#125](https://git.eeqj.de/sneak/netwatch/issues/125)). `Recoverer` is still
the first middleware. `Write500OnPanic` sits after the request log and after
the metrics. It writes the 500 as the panic passes and lets the panic go on to
Sentry and `Recoverer`. `Recoverer` now writes a 500 only when no status was
written.
- 2026-10-14: with `TRUSTED_PROXIES` unset, the container's nginx trusts the
three private ranges of RFC 1918, `10.0.0.0/8`, `172.16.0.0/12` and
`192.168.0.0/16` ([#119](https://git.eeqj.de/sneak/netwatch/issues/119)), as
sneak ruled on 2026-09-22 for every app. It trusted no proxy before. A list
replaces them, and set and empty it trusts none. The backend inside the
container still trusts only `127.0.0.1/32`.
- 2026-10-14: `POST /api/v1/reports` takes only a report sent with
`Content-Type: application/json`, parameters such as `charset` allowed
([#126](https://git.eeqj.de/sneak/netwatch/issues/126)). It refuses any other
with 415 and the usual error body, storing nothing. A page on another origin
can send a `text/plain` POST without a CORS preflight, so any site could make
its visitors' browsers store reports. A JSON POST waits for the preflight,
which only an origin in `CORS_ALLOWED_ORIGINS` passes. The page already sent
`application/json`.
- 2026-10-14: changing the interval no longer drops the samples not yet reported
to the backend ([#124](https://git.eeqj.de/sneak/netwatch/issues/124)). The
interval menu resets each host with the new `HostState.reset`, which moves the
history's entries to `clearedHistory`, keeping the last 100 of all it has
cleared. `buildReport` reads both, so the next report has the samples recorded
before the change that no report has delivered. Until its next check, such a
host is sent with the status of its last check, not `pending`. The page's
history still starts over. `web/src/main.js` exports `Reporter` for the tests.
- 2026-10-14: the repo has the layout `REPO_POLICIES.md` gives
([#30](https://git.eeqj.de/sneak/netwatch/issues/30)). The Go module is at the
root (`go.mod`, `go.sum`, `.golangci.yml`, `cmd/netwatch-server/`,
`internal/`), so its path `sneak.berlin/go/netwatch` names the repository.
`go.mod` ignores `node_modules/`, where npm packages can ship `.go` files that
`make tidy` would otherwise read as this module's packages. The page's source
is in `web/` (`index.html`, `src/`), which `vite.config.js` makes Vite's root;
the build still goes to `dist/`. `nginx.conf` and `security-headers.conf` are
in `configs/`. `backend/` is gone. Its `LICENSE` and `.dockerignore` are
deleted, its `.gitignore` entries are in the root one, and its `README.md` is
folded into the root `README.md`. Its scripts are in the root `script/`:
`script/build` builds the server binary after the frontend, and `script/run`
and `script/clean` came from `backend/script/`. `make test`, `make lint`,
`make fmt` and `make fmt-check` do what its `test`, `lint`, `fmt` and
`fmt-check` did. `script/bootstrap` no longer installs gcc, which only the
host Go tests needed. The image and the page are unchanged.
- 2026-10-07: the files shared from `sneak/prompts` were its copies at commit
`dd4027b` ([#113](https://git.eeqj.de/sneak/netwatch/issues/113)), with this
repository's own entries after the shared content in `.gitignore`,
`.dockerignore` and `.editorconfig`. `make lint` and `make test` each built
one phase of `Dockerfile` without the cache. `lint` ran golangci-lint v2.14.0
over `backend/` and, through the `frontend-lint` stage, eslint. `test` ran the
Go tests on the Debian Go image and, through the `frontend` stage, the
frontend's unit tests and build. The builder stage waits on both. Without a
`VERSION` build argument it takes the version from `git describe` on the
`.git` in the build context. Neither linter nor the tests run on the host for
`make check`. `backend/.golangci.yml` was no longer checked against a sha256.
prettier formats only the JavaScript, CSS, HTML and Markdown, so the shared
`.golangci.yml` stays as fetched. `script/fmt` and `script/fmt-check` put
`~/.local/bin` on `PATH`. `script/frontend-lint` and `script/frontend-check`
are gone.
- 2026-10-07: the six Hetzner targets answer again, and failed checks are
written to the browser console
([#114](https://git.eeqj.de/sneak/netwatch/issues/114)). The Hetzner
speed-test servers close the connection without an answer when the URL has a
query string, and every check added `?_cb=` and the time. Checks now fetch
each target's URL as written, with `cache: 'no-store'` as before. Each
recorded check that fails writes one `console.error` line, and the same line
to the debug log. The line gives the target's name and URL, the time, what
failed and how long the request took. A target that answers after failed
checks writes one `console.info` line. Checks the page does not record write
none, so the debug log no longer lists failures in the first round or in the
recovery probe.
- 2026-10-04: the page's footer no longer says "IPv4 only"
([#111](https://git.eeqj.de/sneak/netwatch/issues/111)). Each check is a
`fetch`, the browser picks IPv4 or IPv6 for each WAN host, and the local
targets are IPv4 addresses.
- 2026-10-04: `README.md`, `TODO.md` and `test/viewport/README.md` say what the
tree does ([#24](https://git.eeqj.de/sneak/netwatch/issues/24)). The README's
Getting Started leads with `make` targets. A new Backend section says what
`netwatch-server` stores, its routes and how the image builds and runs it; it
pointed to `backend/README.md` for its settings. The checks are GET requests.
The 26 WAN hosts, the four health states, the summary's figures and the
features the list lacked are described as the page has them. The README's TODO
section points here. This file's Workflow branches from `next` and opens the
PR against `next`. Status says where the repo stands, and Next Step and Future
Steps hold only open work, linked to its issue where one exists. The viewport
test's README names Node's test runner, not `vitest`.
- 2026-10-04: in `src/main.js`, a target's min, max, median and average latency
come from one list of its answers
([#102](https://git.eeqj.de/sneak/netwatch/issues/102)). The summary's figures
use the same function, so the median is written once. The latency color limits
are one table in `CONFIG`, read by both the figure's and the sparkline's
color. The health thresholds, the debug log's length and the gateway check's
timeout are `CONFIG` entries too. So are the recovery probe's host count and
interval, how often the rows are sorted, and the delay before the first
sparkline resize. Nothing the page does or shows changed; the footer's color
legend still writes the limits out as text.
- 2026-10-04: password guesses at `/metrics` are rate limited
([#104](https://git.eeqj.de/sneak/netwatch/issues/104)). Each client address,
resolved through `TRUSTED_PROXIES` as for reports, may make 60 requests to
`/metrics` a minute, counted by `go-chi/httprate` apart from its reports. Past
that it gets 429 and its basic auth credentials are not checked. The limit is
a constant in `internal/server/routes.go`.
- 2026-10-04: the backend reports errors to Sentry
([#95](https://git.eeqj.de/sneak/netwatch/issues/95)). With `SENTRY_DSN` set,
it sets up `sentry-go` with the release `netwatch-server-` and its version. It
reports each panic in a handler through `sentryhttp`, the last middleware
every request goes through. `sentryhttp` panics again, so the request still
gets the 500 from the panic recovery. On shutdown it waits up to 2 seconds for
Sentry to finish sending. A DSN Sentry refuses stops the start with an error
naming `SENTRY_DSN`. With it empty, Sentry is not set up and nothing is sent
to it.
- 2026-10-04: a target's name and URL and a debug log message show as the
characters they are and are never read as HTML
([#29](https://git.eeqj.de/sneak/netwatch/issues/29)). A host row escapes the
name and URL it writes into its markup, and the debug log sets each line as
text. `README.md` no longer calls `CONFIG` frozen: the interval menu sets its
`updateInterval`, and the values computed from it follow. `AppState` declares
the recovery probe's two properties. The sparkline axis functions lose the
parameters they did not use, and the comment on a target's history names both
kinds of entry it holds.
- 2026-10-04: a dependency can be added without running yarn or go by hand
([#45](https://git.eeqj.de/sneak/netwatch/issues/45)).
`make add-dependency PACKAGE=<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`. A Go module is added by importing it,
or moved by editing its `require` line, then `make tidy`. `script/bootstrap`
still installs with `--frozen-lockfile`.
- 2026-10-04: the backend serves Prometheus metrics
([#94](https://git.eeqj.de/sneak/netwatch/issues/94)). With `METRICS_USERNAME`
and `METRICS_PASSWORD` both set, it records request duration and response size
through `go-http-metrics`. It serves them, with Go's runtime and process
metrics, at `GET /metrics` behind basic auth with those credentials. nginx
passes `/metrics` to it as it does `/api/`. With neither set there are no
metrics and `/metrics` is 404. One without the other stops the start with an
error naming both, and so does a `METRICS_USERNAME` containing `:`. Only
requests that reach the health check or `POST /api/v1/reports` are recorded,
not `/metrics` itself and not every request as `GO_HTTP_SERVER_CONVENTIONS.md`
shows. The labels are the request's path and method, which clients can make up
without end. For that, `POST /api/v1/reports` is registered by its full path
instead of inside a `/api/v1` route group.
- 2026-10-04: `script/` and `Makefile` follow the org models
([#28](https://git.eeqj.de/sneak/netwatch/issues/28)). `make dev` shims to the
new `script/dev`, the Vite dev server, and the new `make build` to
`script/build`, the frontend production build. `.prettierignore` no longer
left out `backend/`, so `make fmt` and `make fmt-check` covered
`backend/README.md`; it left out `backend/.golangci.yml` by name.
`script/install-precommit` and the date on `script/bootstrap`'s pins are the
org model again. `script/bootstrap`, `script/fmt` and `script/fmt-check` each
say in a comment why they differ from it.
- 2026-10-04: a frontend build on Node 26 or newer no longer prints Node's
warning that `module.register()` is deprecated
([#32](https://git.eeqj.de/sneak/netwatch/issues/32)). The build in
`Dockerfile` runs on Node 22, which never printed it. The call was in
`@tailwindcss/node`, which `@tailwindcss/vite` brings in at its own exact
version. tailwind 4.3.1 calls `module.registerHooks()` instead where Node has
it. `yarn.lock` has `@tailwindcss/vite` and `tailwindcss` at 4.3.3, inside the
ranges `package.json` already allowed, and tailwind's own dependencies moved
with them. The built CSS changed only in how it is written out, in tailwind's
Firefox focus-ring rule, and in tailwind's default sans-serif font list, which
the page does not use.
- 2026-10-03: the frontend's unit tests cover what the page computes
([#21](https://git.eeqj.de/sneak/netwatch/issues/21)). They cover
`humanDuration`, and the latency colours of a figure and of a sparkline either
side of each boundary. They cover a target's min, max, average and median
latency, and each of the four health states either side of its thresholds.
`package.json` has a `test` script, so `yarn run test` and `npm run test` run
them, and `script/frontend-test` runs it. `src/main.js` exports
`humanDuration`, `HostState`, `latencyClass` and `latencyHex` for the tests;
nothing it does changed.
- 2026-10-03: the backend's logs are one stream
([#27](https://git.eeqj.de/sneak/netwatch/issues/27)). fx logs its own steps
of starting and stopping through the backend's logger. So off a terminal,
every line the backend and fx write is JSON; fx used to write plain text to
stderr. A config file that is found but cannot be read stops the start with
its error, logged as JSON like a bad setting. It used to end in a Go panic.
The backend logs its name, version and architecture once at start. The health
check's uptime keys are `uptime_seconds` and `uptime_human`; its path, content
type, `"status":"ok"` and 200 are unchanged. `SENTRY_DSN`, `METRICS_USERNAME`
and `METRICS_PASSWORD` were still read and unused until
[#94](https://git.eeqj.de/sneak/netwatch/issues/94) and
[#95](https://git.eeqj.de/sneak/netwatch/issues/95) wired them up.
- 2026-10-03: the frontend has a real linter
([#47](https://git.eeqj.de/sneak/netwatch/issues/47), and item 2 of
[#28](https://git.eeqj.de/sneak/netwatch/issues/28)): `eslint` with its
recommended rules, set in `eslint.config.js`, runs in a new `frontend-lint`
stage of `Dockerfile`. `script/lint` builds the lint stages and runs no linter
on the host. `script/bootstrap` wants node 22.13.0 or newer, as eslint 10
does.
- 2026-10-03: the tap-target check in `make frontend-viewport-test` expects one
visible pin button per WAN host row
([#46](https://git.eeqj.de/sneak/netwatch/issues/46)), where it expected at
least 10 of the 26. Pin buttons missing from only some rows now fail it. The
host row count the test gathers, which the `app-rendered` check also reads,
counts only the WAN host rows: the local host rows have no pin button.
- 2026-10-03: each target's row shows its result as soon as its check ends
([#91](https://git.eeqj.de/sneak/netwatch/issues/91)). Every row waited for
the round's slowest check, up to 24 seconds at a 30-second interval. Every row
is still redrawn, and sorting, the summary, the health box and offline
detection still run, once, when the round's last check ends. So no row reads
"paused" after a pause and resume during the round. A check that ends after
the user pauses or after its round is given up shows nothing. The first round
is still discarded as a whole.
- 2026-10-03: root no longer acts outside `/data` when it prepares `DATA_DIR`
([#80](https://git.eeqj.de/sneak/netwatch/issues/80)). `bin/entrypoint.sh`
runs `netwatch-server prepare-data-dir`, which refuses a `DATA_DIR` that is
not `/data` or a path below it written in full. Then it creates `DATA_DIR`,
gives `/data` and everything in it to `netwatch` and sets the modes, all
through a Go `os.Root` opened on `/data`. That refuses any path leading out of
`/data`. So no symbolic link, already there or swapped in by a host process
during the start, can make root create or change anything elsewhere.
`DATA_DIR=/etc` no longer gives `/etc` to `netwatch`. The `README.md` section
"Running under upaas" says which values are accepted.
- 2026-10-03: `DATA_DIR_MAX_BYTES` is how much of the report files is kept
([#54](https://git.eeqj.de/sneak/netwatch/issues/54)). When a report would
take them past it, the oldest report files are deleted to make room, each
deletion logged. At start, files already past it are deleted the same way. A
file still being written is never deleted. A report is refused with 507 only
when the reports waiting to be written fill the cap on their own, and then no
file is deleted. A file that cannot be deleted still counts until the next
start; one already deleted by hand counts as freed.
- 2026-10-03: `backend/script/lint` said what went wrong with its
`.golangci.yml` check ([#34](https://git.eeqj.de/sneak/netwatch/issues/34)).
On a hash mismatch it said to compare the file with the org standard. If they
differed, restore the org standard. If they were the same, the org standard
had changed, so update `GOLANGCI_CONFIG_SHA256` in that script. It used to say
only to restore the file, which loops once the org standard itself has moved.
A missing `.golangci.yml`, and a `sha256sum` that is missing or prints no
hash, each got their own message instead of being reported as a mismatch.
- 2026-10-03: the Go tests run with the race detector and coverage
([#88](https://git.eeqj.de/sneak/netwatch/issues/88)):
`go test -timeout 30s -race -cover ./...` and, if that fails, the same again
with `-v`, then a failing exit. Go's `-timeout` bounds the tests, not their
compile. The race detector needs a C compiler; the binary is still built with
`CGO_ENABLED=0`. New tests cover the health check's answer, a valid report's
answer, a report file's exact contents, and the flush when the buffer reaches
10 MiB.
- 2026-10-03: each target check times out after 80% of the refresh interval
([#78](https://git.eeqj.de/sneak/netwatch/issues/78)), 24 seconds at 30
seconds, where it was capped at 3 seconds. A round started early gives up the
last round's checks if they are still waiting, so rounds never overlap. A
round starts early after an interval change, or when the recovery probe finds
a target answering. The recovery probe gives up its own checks after half a
second. The frontend has its first unit tests, run by `script/frontend-test`
with Node's built-in test runner. For them, `index.html` links
`src/styles.css`, which `src/main.js` used to import.
- 2026-09-29: the container sets up its own data directory
([#75](https://git.eeqj.de/sneak/netwatch/issues/75)). `bin/entrypoint.sh`,
still as root, created `DATA_DIR` if missing and gave it and `/data` to the
`netwatch` user with mode 750 before starting the backend as that user. So an
empty host directory owned by root, or one holding files from another uid,
works with no step on the host. It stopped the start when a symbolic link was
on the path to `DATA_DIR`, since root would change whatever the link points
to. The `README.md` first-run step that created and chowned the host directory
is gone, and the image no longer sets that ownership at build time.
- 2026-09-29: CI can no longer pass on checks that did not run
([#37](https://git.eeqj.de/sneak/netwatch/issues/37)). `script/cibuild` became
the org model, byte for byte. It runs `script/bootstrap` and `script/check`,
then builds the image with the version from `git describe` as the `VERSION`
build argument. It used to be a plain `docker build .` whose check steps could
come from the build cache. The workflow put `~/.local/bin`, where bootstrap
links what it installs, on the step's `PATH`. bootstrap installs its pinned
node when the installed one is older than the frontend's dependencies need.
- 2026-09-29: `backend/.golangci.yml` was re-vendored from `sneak/prompts`
([#41](https://git.eeqj.de/sneak/netwatch/issues/41)). `gomodguard`,
deprecated in golangci-lint v2.12.0, is disabled and its successor
`gomodguard_v2` enabled with the org block list, so lint runs print no
deprecation warning. The file also turns `depguard` on with its `test-support`
rule, which keeps `net/http/httptest` out of non-test code; netwatch adds no
entries of its own to that rule.
- 2026-09-29: nginx sends the security headers `REPO_POLICIES.md` requires on
every response ([#18](https://git.eeqj.de/sneak/netwatch/issues/18)), errors
and `/assets/` included. On what it passes on from the backend, it drops the
backend's own copies, so each header goes out once. They live in
`security-headers.conf`, which `nginx.conf` includes. The content security
policy allows no inline script or style, so the status dot's grey in
`src/main.js` is a class. `connect-src` is `*` because probed hosts redirect
to others, and the browser checks each redirect against it.
- 2026-09-29: the request log is bounded
([#60](https://git.eeqj.de/sneak/netwatch/issues/60)). Each field it writes
from the request is cut to 128 bytes, the bound the report handler already
used. The fields are the method, URL, protocol, `User-Agent`, `Referer`,
request ID and client address. chi takes the request ID from the client's
`X-Request-Id` header. One request could put about 1 MiB per field into a log
line. That bound and its helper live in the `logger` package, shared by both.
- 2026-09-29: nginx takes the client address from `X-Forwarded-For` only on
requests from the reverse proxies named in the container's `TRUSTED_PROXIES`
([#64](https://git.eeqj.de/sneak/netwatch/issues/64)), and at the time by
default from none. It trusted every RFC1918 address before, so a client could
write a new address on each request and escape the rate limit.
`bin/entrypoint.sh` writes one `set_real_ip_from` line per entry into
`/etc/nginx/trusted-proxies.conf`, which `nginx.conf` includes. It refuses an
entry that is not an IP address or CIDR, as `netwatch-server check-cidr`
finds. It starts the backend with `TRUSTED_PROXIES=127.0.0.1/32`, since nginx
is its only client.
- 2026-09-29: report file names can no longer collide
([#61](https://git.eeqj.de/sneak/netwatch/issues/61)). Each is
`reports-<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
each get a file of their own; the second one used to fail. A flush for size
and the final flush at shutdown can fall in the same millisecond. A failed
write uses up its number.
- 2026-09-29: ready to run under upaas
([#59](https://git.eeqj.de/sneak/netwatch/issues/59)). The image has a
`HEALTHCHECK` that requests `/.well-known/healthcheck` through nginx on the
port from `PORT`. The backend no longer reads a bad `PORT` as 0 or a bad
`DEBUG` as false. Those, and a `BIND_ADDRESS` that is not an IP address, stop
it from starting with an error naming the variable. 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` got
a "Running under upaas" section.
- 2026-09-29: nginx listens on `PORT`
([#26](https://git.eeqj.de/sneak/netwatch/issues/26)), 8080 when unset or
empty. The nginx image renders `nginx.conf` as a template at container start,
filling in `PORT` and no other variable. `bin/entrypoint.sh` refuses to start
when `PORT` is not digits only. `server_tokens off` keeps the nginx version
out of responses. `script/frontend-viewport-test` renders the template the
same way. Gzip and a `50x.html` error page are not added.
- 2026-09-29: the report endpoint is bounded
([#20](https://git.eeqj.de/sneak/netwatch/issues/20)). `POST /api/v1/reports`
still needs no credentials. Each client address, as resolved through
`TRUSTED_PROXIES`, may send `REPORTS_PER_MINUTE` (default 60) reports a
minute, counted by `go-chi/httprate`, and past that gets 429 with
`Retry-After`. The report files in `DATA_DIR`, counted from start with those
already there, may total at most `DATA_DIR_MAX_BYTES` (default 1 GiB); past
that, reports got 507. The wildcard CORS is gone: no CORS headers unless
`CORS_ALLOWED_ORIGINS` lists origins. An entry that is not a plain
`scheme://host[:port]` origin, `*` included, stops the server from starting.
Deleting report files freed room only at the next start, until
[#54](https://git.eeqj.de/sneak/netwatch/issues/54).
- 2026-09-28: one container image
([#52](https://git.eeqj.de/sneak/netwatch/issues/52)). The root `Dockerfile`
builds the only image, and `Dockerfile.backend` is gone. nginx serves the
frontend on port 8080 and proxies `/api/` and `/.well-known/healthcheck` to
the backend, which listens on `127.0.0.1:8081` in the same container. The new
`BIND_ADDRESS` setting sets its listen address. `bin/entrypoint.sh` starts
both, passes TERM and INT on to both, and exits non-zero when either exits on
its own. The backend runs as user `netwatch` and stores reports on the `/data`
volume. `script/docker` is the org model again.
- 2026-09-28: one gate ([#16](https://git.eeqj.de/sneak/netwatch/issues/16)).
The root `make check` covers the Go backend as well as the frontend, and the
pre-commit hook with it. The backend moved onto scripts-to-rule-them-all:
`backend/script/*`, `backend/Makefile` as shims, its duplicate hook installer
removed. `script/cibuild` built both images and was the workflow's only build
step. The root `make lint` ran golangci-lint only in Docker, by building the
lint stage of `Dockerfile.backend` without the cache. `script/bootstrap`
installed the pinned Go unless the installed one was at least what
`backend/go.mod` asked for. It links what it installs into `~/.local/bin`
without replacing anything it did not create, and installs no linter. When
`VERSION` is unset or empty, the backend binary's version falls back to
`git describe` inside a git checkout, then to `dev`.
- 2026-09-28: frontend reporting client
([#53](https://git.eeqj.de/sneak/netwatch/issues/53)). A `Reporter` class
posts collected samples to `/api/v1/reports` every `reportInterval` (default
60s) as a per-host delta. The report-building step is a pure exported function
of host state. A per-host mark advances only on a delivered POST. At most one
report POST is pending at a time, and it is abandoned after half the interval.
So a slow POST never overlaps the next report, and a mark never moves
backwards. The samples of an abandoned POST are sent again at the next
interval, so a backend that stored them but answered late receives them twice.
The per-browser client id works in insecure (plain-HTTP) contexts.
`vite.config.js` proxies `/api` to the local backend for `yarn dev`.
- 2026-09-28: report ingest correctness
([#23](https://git.eeqj.de/sneak/netwatch/issues/23)). A storage failure
returns 500 instead of a false `ok`. An oversize body returns 413, apart from
malformed JSON, which stays 400. A `MaxBodyBytes` middleware caps every route,
not just the report route. The raw attacker-controlled `geo` blob is no longer
logged, only its length, and `client_id`, `timestamp` and decode error text
are length-bounded before logging. A `decodeJSON` handler helper was added.
Panic recovery routes the stack through slog instead of chi's plain-text
stderr. Writing a report file returns its error, so a failed final flush on
shutdown makes the process exit non-zero instead of losing the buffered
reports silently.
- 2026-09-21: shutdown lifecycle correctness. The process shuts down through fx
instead of `os.Exit`, so every component's `OnStop` runs and buffered reports
are flushed to disk on `SIGTERM`. Before, a full flush window of telemetry was
lost on every restart. The `http.Server` is built before its serving goroutine
starts, so shutdown can no longer race or nil-deref it. A listen failure exits
non-zero via `fx.Shutdowner`. `reportbuf`'s `OnStop` is idempotent.
`writeTimeout` exceeds the chi per-request budget, so that budget is
reachable. Dead `startupTime`, `exitCode` and `cancelFunc` fields were
removed.
- 2026-09-21: backend HTTP hardening
([#19](https://git.eeqj.de/sneak/netwatch/issues/19)): `ReadHeaderTimeout` and
`IdleTimeout` on the server, and a `SecurityHeaders` middleware (HSTS, tight
CSP, frame, sniff, referrer and permissions headers) registered before CORS.
`X-Forwarded-For` and `X-Real-IP` are honoured only from a `TRUSTED_PROXIES`
allowlist, at the time loopback plus RFC1918 by default.
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
moved the pinned golangci-lint from v2.7.2 to v2.12.2; the `lint` stage of
`Dockerfile.backend` pinned the `golangci/golangci-lint:v2.12.2` image by
digest. The previous config declared `version: "2"` but used v1 schema keys,
so every threshold in it was inert and its green result meant nothing.
`backend/Makefile`'s `lint` target asserted the config's sha256 against the
canonical file first, so drift from the org standard failed the build instead
of silently degrading to defaults.
- 2026-08-10: every interactive control meets the 44x44 CSS px minimum tap
target: `.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`. The pin button's hit area grows via matching negative
margins, so its layout footprint and row density are unchanged.
- 2026-08-10: the per-host status line wraps below the 768px breakpoint instead
of forcing horizontal page scroll at 320px.
- 2026-08-09: `Dockerfile.backend` was reworked to the Go multistage lint-stage
pattern. It got a separate `lint` stage on the hash-pinned
`golangci/golangci-lint` image, and a `COPY --from=lint` stage dependency. The
build became a `CGO_ENABLED=0` static build driven by `ARG VERSION`, with no
more `COPY .git`.
- 2026-08-09: dotfile compliance. `backend/.editorconfig` moved to the repo root
so `root = true` covers the frontend too. `.gitignore` became the org model
(OS, editor, node, and environment/secrets sections) plus this repo's `dist/`
and `*.log`. `.env`, `.env.*`, `*.pem` and `*.key` are ignored repo-wide, not
just under `backend/`. Excluding `.git` from `.dockerignore` stayed deferred:
both images read git metadata at build time.
- 2026-08-09: automated responsive-layout test (`make frontend-viewport-test`):
a digest-pinned headless Chrome driven over CDP against the built `dist/`, at
viewport widths derived from the breakpoints in `src/styles.css`
([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). The tap-target and
host-row checks each fail when they measured nothing. The overflow,
viewport-edge and clipped-text checks have no such guard of their own and rely
on the `app-rendered` check, which fails the run when the app did not render.
It found two real layout defects, filed as
[#42](https://git.eeqj.de/sneak/netwatch/issues/42) and
[#43](https://git.eeqj.de/sneak/netwatch/issues/43).
- 2026-07-07: adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section.
- 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
compressed file it is written to
- A backend route that decompresses the stored reports and answers queries on
them
- Prometheus metrics for the backend's in-memory buffer: its size, the number of
flushes and the number of reports
- A configurable host list (an environment variable or a config file)
- Export of the latency history (CSV or JSON)
- A notification when the health status changes to DEGRADED