66 Commits
Author SHA1 Message Date
clawbot 8c4c823bb5 Footer says what a latency figure is (closes #208)
check / check (push) Canceled after 0s
The old line said every check is a GET and that CORS may affect
figures; the S3 targets use HEAD and a no-cors check counts any answer.

Model: opus-5-5
2026-11-11 20:26:20 +00:00
clawbot 93bbd80d57 TODO.md holds only open work (closes #202)
check / check (push) Canceled after 0s
Completed Steps, the dated log of finished work, is removed. The
Workflow says to delete Next Step once done instead of moving it there,
and Status no longer names the first release candidate. The README's
TODO section no longer says the file records what has been done.

Model: opus-5-5
2026-11-11 19:25:43 +01:00
clawbot e5db494187 CI time limit on the step; no timeout in Dockerfile or scripts (closes #199)
check / check (push) Successful in 6m51s
The runner ignores a job's `timeout-minutes`, so the 20-minute limit moves to the step that runs `script/cibuild`. The `Dockerfile`, `script/frontend-test` and `script/frontend-viewport-test` drop their `timeout` wrappers, which also stopped `make check` outside CI. `go test -timeout 90s` stays, as `REPO_POLICIES.md` requires it.

Deviation: `REPO_POLICIES.md`, vendored from `sneak/prompts`, still says the job sets the limit.

Model: opus-5-5
2026-11-10 21:22:26 +01:00
sneak 437e0ea80c Merge branch 'main' into next
check / check (push) Canceled after 0s
2026-10-26 13:09:53 +01:00
clawbot 0d08a75793 README.md rewritten sentence by sentence (closes #189)
check / check (push) Canceled after 0s
README.md is rewritten from scratch so that every sentence reads on the first pass, and is cut from about 5,600 words to about 3,500. It leads with what netwatch is, what it is for and how to run it, then the page, reporting and deployment, then every setting in a reference section, with design and development last. Mechanism detail the code and its comments already hold is left out; the PR lists each topic dropped and why. TODO.md records the rewrite of the three documents.

Model: fable-5-1 (rewrite, review); opus-5-5 (merge)
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-10-26 08:12:59 +01:00
clawbot 8ce69b7b40 test/viewport/README.md rewritten sentence by sentence
check / check (push) Canceled after 0s
test/viewport/README.md is rewritten from scratch so that every sentence reads on the first pass. It says what the layout test checks, at which widths, how to run it and what it cannot cover, and drops the history of how the test came about. Part of #189.

Model: fable-5-1 (rewrite, review); opus-5-5 (merge)
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-10-26 06:40:20 +01:00
clawbot 83a095b572 TODO.md rewritten sentence by sentence
check / check (push) Canceled after 0s
TODO.md is rewritten from scratch so that every sentence reads on the first pass: one idea each, under about 30 words, the verb early. Workflow, Next Step and Future Steps keep their substance. Each Completed Steps entry keeps its date, its issue link and what changed; entries describing a state since changed are in the past tense. Part of #189.

Model: fable-5-1 (rewrite, review); opus-5-5 (merge)
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-10-26 04:59:21 +01:00
clawbot 8a62f12802 Targets whose home page redirects are checked at /robots.txt (closes #194)
check / check (push) Canceled after 0s
A check follows a redirect and times the request to the new URL too. So the AWS Console, the Google Cloud Console, Akamai, B2 and the eight S3 regions are now checked at /robots.txt on their own host, which answers without a redirect. Before, the S3 and B2 figures also timed a page on another host. The S3 regions are checked with HEAD, because Chrome closes the connection after a GET of their /robots.txt and the figure would then include connection setup every time. README.md says what a figure includes.

Reports name a target by its URL, so these targets' samples are stored under the new URLs.

Model: opus-5-5
2026-10-26 04:11:48 +01:00
clawbot a72797a403 Checks send no Referer header, so Hetzner answers (closes #191)
check / check (push) Canceled after 0s
Each check now sets referrerPolicy: "no-referrer". The Hetzner speed-test servers close the connection without an answer when the request has a Referer header, and the browser sent one unless the page came with the image's Referrer-Policy: no-referrer header, as from a static host or make dev. The checks now send none wherever the page is served from. s3.me-south-1.amazonaws.com still times out from this machine; curl gets no answer from it either.

Model: opus-5-5
2026-10-26 02:10:43 +01:00
clawbot db5eb507c1 server.go starts listenAndServe in an anonymous function (closes #162)
check / check (push) Canceled after 0s
The server's OnStart hook started the HTTP listener with
`go s.listenAndServe()`; the Go style guide asks for a function called
with `go` to be wrapped in an anonymous function, so it is now
`go func() { s.listenAndServe() }()`. No other go statement in the Go
code called a function directly. Nothing the server does changed.

Model: opus-5-5
2026-10-19 08:24:49 +02:00
clawbot 6e4c44b3d6 Match .gitignore, .editorconfig and .golangci.yml to sneak/prompts (closes #174)
check / check (push) Canceled after 0s
REPO_POLICIES.md asks for the standard .gitignore and .editorconfig
followed by the repository's own entries, and for the canonical
.golangci.yml. The shared parts and .golangci.yml now match the
sneak/prompts copies at 065357920a0a09bd736bf33d4cb5ce935fdc2328 byte
for byte. .editorconfig has no entries of its own left, since the
standard file now carries the [*.go] section. .golangci.yml disables
canonicalheader, as the canonical copy does.

Model: opus-5-5
2026-10-19 08:22:05 +02:00
clawbot 91eb3d6771 package.json drops its version field and is private (closes #158)
check / check (push) Canceled after 0s
package.json said version 1.0.0, a release that has not been made.
Nothing reads the field, and the package is never published; the git
tag is the one version, which the image and make build take from
git describe --tags. The field is gone, so there is nothing to keep in
step with tags, and the package is marked "private": true.

Model: opus-5-5
2026-10-19 08:16:29 +02:00
clawbot edcf7f8c3c TODO.md Status says where release candidates are tagged (closes #157)
check / check (push) Canceled after 0s
Status said there were no git tags, but one is tagged on `next`. It now
says release candidates are tagged on `next`, names the first one, and
gives the command that lists them, so it stays true as more are tagged.
The other Status sentences match the tree and are unchanged.

Model: opus-5-5
2026-10-19 08:13:00 +02:00
clawbot 5b01b50bc7 netwatch-server refuses arguments it does not understand (closes #179)
check / check (push) Canceled after 0s
cli.Main starts the server only when given no arguments. check-cidr
CIDR and prepare-data-dir DATA_DIR run as before. Any other command
line, such as a subcommand with no value or two values, or a misspelt
subcommand, gets a one-line usage message naming both forms on stderr
and exit code 2. It used to start the server, which then held the
port.

Model: opus-5-5
2026-10-19 08:09:13 +02:00
clawbot 7a3c706fe6 main() is one call into internal/cli (closes #160)
check / check (push) Canceled after 0s
cmd/netwatch-server/main.go parsed the command line, ran the check-cidr
and prepare-data-dir subcommands and built the fx application itself;
the Go style guide puts all of that in internal/ or pkg/. The new
package internal/cli does it, with the same arguments, messages and
exit codes. main() is now os.Exit(cli.Main(os.Stderr, Appname, Version,
os.Args[1:], cli.RunServer)): Main takes the writer for errors and the
function that runs the server, so its tests give it a buffer and a
function that fails them. Appname and Version stay in main, where the
build sets Version. The function behind prepare-data-dir also takes the
volume and the user's name, so its own tests give it a temporary
directory and the user they run as.

Model: opus-5-5
2026-10-19 07:53:09 +02:00
clawbot 7a2b6b0494 script/build stamps lightweight tags too (closes #156)
check / check (push) Canceled after 0s
When VERSION is unset or empty, script/build now takes the version from
`git describe --tags --always --dirty`, the form script/docker and
script/cibuild use. Without --tags it ignored lightweight tags, so a
binary from `make build` could report a commit where the image built
from the same tree reported the tag. README.md Entrypoints names the
same form.

Model: opus-5-5
2026-10-19 07:45:17 +02:00
clawbot a42a220bb7 .dockerignore keeps root test binaries and coverage out (closes #155)
check / check (push) Canceled after 0s
A Go test binary or coverage file left at the repository root, which
.gitignore already ignores, went into the build context of every docker
build in script/ and was copied into the lint, test and build stages.
The repository's own section of .dockerignore now lists /*.test and
/*.out, root-anchored like its other entries.

Checked with a probe image that copies the whole context: x.test and
x.out planted at the root are no longer in it, and nothing else changed.

Model: opus-5-5
2026-10-19 07:42:43 +02:00
clawbot 65c0b6bcbe CI checkout fetches all history and tags (closes #153)
check / check (push) Canceled after 0s
The checkout step cloned one commit and fetched no tags, so git
describe in script/cibuild gave the image built from a tagged commit
the short commit as its version. .gitea/workflows/check.yml is now the
sneak/prompts copy at 0653579, whose checkout step sets fetch-depth: 0.
That copy also sets persist-credentials: false on the checkout, cancels
the older run on the same branch when a new push arrives, and stops a
run after 20 minutes. The TODO.md status line says the workflow cancels
the older run.

Model: opus-5-5
2026-10-19 07:32:35 +02:00
clawbot 8879aeb68e Bootstrap keeps yarn at its pinned version and node at its pinned major (closes #154)
check / check (push) Canceled after 0s
script/bootstrap kept any yarn 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. 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 for Go. Every other host script that
runs yarn now goes through the new script/yarn, which uses the first
node on PATH, ~/.local/bin first, when it passes the same test, and
otherwise the pinned node in nvm.

Model: opus-5-5
2026-10-19 07:20:42 +02:00
clawbot 0ea169ec46 Entrypoint steps are functions called from main (closes #163)
check / check (push) Canceled after 0s
bin/entrypoint.sh was top-level code with one helper; the shell section
of the code style guide asks for every step in a function and a main
called at the end. The PORT and TRUSTED_PROXIES checks, the data
directory, starting each process, the wait loop, stopping both and the
exit are now functions, which main calls in order, setting the stop
trap before either process starts. It stays POSIX sh with no set -e.
Messages and exit codes are unchanged; the 8081 message is passed to
echo as two arguments so its line fits in 80 columns.

Model: opus-5-5
2026-10-19 07:17:32 +02:00
clawbot 9285591563 Health check answers 503 while report files cannot be written (closes #142)
check / check (push) Canceled after 0s
The health check answered 200 "status":"ok" whatever happened, so a
container whose report files could not be written stayed healthy while
every report was refused with 500. The report buffer now says whether
its last write of a report file failed. While it has, the health
check's status is "error" and the handler answers 503 with the same
fields, which fails the image's HEALTHCHECK. At start no write has
failed, and the first write that succeeds brings back 200. A test
removes DATA_DIR to make a write fail and checks the 503, then the 200
once a write succeeds again.

Model: opus-5-5
2026-10-19 06:55:51 +02:00
clawbot 9677c73e0a Report file writes run one at a time (closes #150)
check / check (push) Canceled after 0s
Two writes that failed at the same time put their reports back in the
wrong order when the older one failed first. A mutex is now held
across each write, from taking the waiting reports out of the buffer
to putting them back if the write fails, so the next write takes them
in order. The write Append starts at the 10 MiB threshold waits for
one under way, and Append starts no other while it waits. Files
written since the start are now deleted to make room in the order they
were written. A test that needed two writes at once is removed; new
tests cover the one waiting write and the order files are deleted in.

Model: opus-5-5
2026-10-19 06:02:11 +02:00
clawbot f5aa81f024 DEBUG turns debug logging on for any non-empty value (closes #159)
check / check (push) Canceled after 0s
DEBUG was read with strconv.ParseBool, so a value such as "yes" stopped
the start. It now follows the code style guide: unset or empty is off,
and any other value, "false" and "0" included, turns debug logging on.
No value of DEBUG stops the start. The test that "yes" fails is replaced
by one covering unset, empty, "false", "0" and "yes". README.md no
longer gives false as the default, and says which values turn it on.

Model: opus-5-5
2026-10-19 05:48:20 +02:00
clawbot 4e3943f02f Check builds keep Go's modules and caches between runs (refs #118)
check / check (push) Canceled after 0s
On sneak's rulings in sneak/prompts#113 and
sneak/prompts#130. REPO_POLICIES.md and
script/test, script/lint, script/docker and script/cibuild are the
sneak/prompts copies at 0653579: the scripts pass a new CACHEBUST value
instead of --no-cache, and the lint and test builds write no image. In
each Dockerfile stage the system packages and the Go module download
come above ARG CACHEBUST; every step that lints, tests or compiles, and
the node stages' yarn install, comes after it. Go's build cache and
golangci-lint's are cache mounts, and both go test runs carry -count=1.
README.md and TODO.md say the same.

Model: opus-5-5
2026-10-19 04:26:25 +02:00
clawbot 648c225919 Discarded-check unit tests check nothing was recorded (closes #151)
check / check (push) Canceled after 0s
The three unit tests of a check whose result is discarded (its round
given up, the user paused, or the first round) checked only that the
row showed nothing. Each now also checks that the host's history is
still empty, its status still pending, and nothing was written to the
console, so a change that recorded the result and skipped only the row
fails them. The console helper moves above its first use.

Model: opus-5-5
2026-10-19 03:44:13 +02:00
clawbot be6dc93eaa Unusable DATA_DIR error names DATA_DIR; report file number is atomic.Int64 (closes #148, closes #161)
check / check (push) Canceled after 0s
When the backend runs on its own, a DATA_DIR that could not be created
or read stopped the start with an error that named the path but not
DATA_DIR, and one where no file could be created let it start, to fail
at the first write of a report file. The start now creates a file in
DATA_DIR and removes it, and all three cases fail with
DATA_DIR "<value>": <reason>, the form config.New uses for the other
settings. Tests start the buffer with DATA_DIR below a regular file and
with DATA_DIR set to /proc.

The report file sequence counter is atomic.Int64 instead of
atomic.Uint64, as the Go style guide asks for int types. File names are
unchanged.

Model: opus-5-5
2026-10-19 03:15:35 +02:00
clawbot 6df743f7fa Page is sent with Cache-Control: no-cache (closes #141)
check / check (push) Canceled after 0s
nginx sent index.html with no Cache-Control, so a browser could keep
it after a deploy and ask for hashed assets the new build no longer
has, and show an empty page. location / now adds Cache-Control:
no-cache, which covers its try_files fallback to /index.html, and
includes security-headers.conf again, since its own add_header drops
the server's. /assets/ and the paths passed to the backend are
unchanged.

script/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.

Model: opus-5-5
2026-10-19 03:10:39 +02:00
clawbot 984047e6ca README states what refused reports, one sender and nginx's 413 do (closes #145, closes #146, closes #147)
check / check (push) Canceled after 0s
The README said the page keeps refused samples and sends them later.
Those in the 100-round history are sent only while it holds them: at
the 1-second interval one refused 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
delivered. Report limits now says one address at the defaults can fill
the 1 GiB cap in about 17 minutes. In the image, nginx answers a body
over 1 MiB itself, with 413 and an HTML page; the backend's own 413
sentences now apply to it run on its own.

Model: opus-5-5
2026-10-19 02:55:29 +02:00
clawbot 007190b541 Clock errors no longer stop probing; legend reads >=500ms (closes #149, closes #152)
check / check (push) Canceled after 0s
The header's clocks start last in init, after the rounds, and an error
formatting them, as in a browser that rejects the "shortGeneric" time
zone name, leaves both clocks blank instead of stopping the page before
its first round. updateClocks moved out of init so a unit test can call
it.

The footer legend's red entry reads >=500ms, as README.md and
CONFIG.latencyColors have it. buildUI is exported so a unit test can draw
the page and check the legend against every entry of
CONFIG.latencyColors.

Model: opus-5-5
2026-10-19 01:34:45 +02:00
clawbot fd32f348b7 prepare-data-dir leaves files with a second name to their owner (closes #143)
check / check (push) Canceled after 0s
A hard link in /data shares its owner with a file that can be anywhere
on the same filesystem, so chowning it gave that file to the netwatch
user. A file with more than one name now keeps its owner and its path
is logged.

Owners are now set on the opened directory, file or link after
checking it, as chmodDir sets modes, so a hard link swapped in
afterwards is caught. Every other file is opened without following it;
a symbolic link is given to the netwatch user itself, and devices,
named pipes and sockets keep their owner. This is built only for
Linux: prepare-data-dir runs only in the Linux image, and macOS has
neither O_PATH nor AT_EMPTY_PATH; elsewhere it returns an error. A
failed owner change names the file.

Model: opus-5-5
2026-10-19 01:11:41 +02:00
clawbot 2532da55e9 Client address is the right-most untrusted X-Forwarded-For entry (closes #144)
check / check (push) Canceled after 0s
From a peer in TRUSTED_PROXIES, clientIP took the left-most
X-Forwarded-For 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. It now walks the entries from the right past trusted
proxies and takes the first that is not one. An entry with a port is
read as its address; with only trusted entries, or one that is not an
address at all, it uses the peer. Every X-Forwarded-For header line is
read, and X-Real-IP only when there is no X-Forwarded-For entry.

Model: opus-5-5
2026-10-19 00:28:18 +02:00
clawbot e43a710114 Summary reads --/-- after an interval change, not 0/26 in red (closes #140)
check / check (push) Canceled after 0s
The interval menu resets every host to pending and updates the summary,
which counted the WAN hosts with a latency recorded, none, and showed
0/26 in red until the second round after the change ended. While every
WAN host still waits for its first check, the Reachable slot now reads
--/-- in white, as at page load. updateSummary is exported for a unit
test that resets the hosts as the interval menu does; a second test
checks that the slot still reads 0/26 in red once every check failed.

Model: opus-5-5
2026-10-18 23:00:35 +02:00
clawbot 2861be1c4d Stopping the server waits for report file writes under way (closes #122)
check / check (push) Canceled after 0s
The report buffer's stop now waits, through a sync.WaitGroup, for
flushLoop and for the write Append starts at 10 MiB before its final
flush. It used to return at once, so the process exiting cut such a
write off and its reports were lost.

A write under way that fails puts its reports back in the buffer, so
the final flush writes them; the stop fails only if that flush fails
too.

Tests hold the periodic write and the one at 10 MiB open in a synctest
bubble 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.

Model: opus-5-5
2026-10-14 14:47:01 +02:00
clawbot 580d12a934 make test no longer runs the frontend's production build (refs #118)
check / check (push) Canceled after 0s
yarn build moves out of the stage that runs the frontend's unit tests,
now named frontend-test, into a frontend-build stage of its own. It
starts from frontend-test for its yarn packages and source, installs
the git that vite.config.js reads the commit with, and copies from the
lint and test phases as builder does, so it cannot run unless they
passed. The runtime stage copies dist/ from it. The Go compile, most of
make test's time, is unchanged and waits on the ruling asked for in
sneak/prompts#113; no build cache is added.

Model: opus-5-5
2026-10-14 14:30:03 +02:00
clawbot 12f3dd5b07 Debug log adds each line instead of drawing the whole panel (closes #131)
check / check (push) Canceled after 0s
With the debug log open, every line written drew the panel again with
up to 1000 lines, and a check that ends while the page draws is timed
only when the drawing is done, so latencies measured with the panel open
read too high. Each line is now appended to the open panel and its first
line dropped once it holds more than maxLogEntries; the whole panel is
drawn only when it is opened. Scrolling to the newest line reads the
panel's height, which lays out all its lines, so it runs once a frame,
with at most one frame request waiting.

Model: opus-5-5
2026-10-14 14:13:03 +02:00
clawbot 2a47fb9a8e Unset TRUSTED_PROXIES trusts RFC 1918, empty trusts none (closes #129)
check / check (push) Canceled after 0s
The backend's own TRUSTED_PROXIES now follows the ruling for every app:
unset, it 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.

viper reads an empty environment variable as unset, so an empty value
got the default. config.New now reads this one setting with
os.LookupEnv first. viper's AllowEmptyEnv would apply to every setting,
so an empty PORT or DEBUG would stop the start instead of counting as
unset, as README.md says it does.

bin/entrypoint.sh still starts the backend with
TRUSTED_PROXIES=127.0.0.1/32.

Model: opus-5-5
2026-10-14 14:01:37 +02:00
clawbot cff7b07bbc Reports refused while report files cannot be written (closes #123)
check / check (push) Canceled after 0s
A failed write of a report file used to drop its reports, and
POST /api/v1/reports kept answering 200 for as long as writes failed.
Now a failed write puts its reports back in the buffer, still counted
against DATA_DIR_MAX_BYTES, and Append refuses every report, which the
handler answers 500, until a write succeeds. The minute flush tries
the held reports again, so storing resumes with no restart once
writing works, and the buffer takes nothing new while writes fail.
Tests also cover a report appended while a write that fails runs, and
the cap once a failed write's reports are written.

Model: opus-5-5
2026-10-14 13:47:14 +02:00
clawbot e628dfe6cf A handler's panic is logged and counted as 500 (closes #125)
check / check (push) Canceled after 0s
Recoverer comes first, so it wrote the 500 only after the panic had
passed the request log and the metrics, which both recorded 200.
Write500OnPanic, placed after the request log and after the metrics,
writes the 500 as the panic passes and panics again, so Sentry and
Recoverer still get it. Recoverer now writes its 500 only when no
status was written, so the server logs no superfluous WriteHeader call.
The test also requires Recoverer's log line for the handler's panic, so
it fails if Write500OnPanic stops passing the panic on.

Model: opus-5-5
2026-10-14 13:30:11 +02:00
clawbot cbcde098dc Unset TRUSTED_PROXIES trusts the RFC 1918 ranges (closes #119)
check / check (push) Canceled after 0s
With TRUSTED_PROXIES unset, bin/entrypoint.sh gives nginx one
set_real_ip_from line for each of 10.0.0.0/8, 172.16.0.0/12 and
192.168.0.0/16, as sneak ruled on 2026-09-22 for every app. A list
replaces them, and set and empty trusts no proxy; each entry is checked
as before. The backend inside the container still trusts only
127.0.0.1/32. README.md, configs/nginx.conf and the comment in
script/frontend-viewport-test say the new default.

Model: opus-5-5
2026-10-14 13:01:34 +02:00
clawbot 73b39e485b Reports are taken only as application/json (closes #126)
check / check (push) Canceled after 0s
A page on another origin can POST text/plain or a form without a CORS
preflight, so its visitors' browsers could store reports although the
server sends no CORS headers. decodeJSON now refuses a request whose
Content-Type is not application/json (parameters such as charset
allowed), and the report handler answers it 415 with the usual error
body, storing nothing. A JSON POST waits for the preflight, which only
an origin in CORS_ALLOWED_ORIGINS passes; the page already sends
application/json.

Model: opus-5-5
2026-10-14 12:43:25 +02:00
clawbot 03bc282871 Interval change keeps unreported samples for the reporter (closes #124)
check / check (push) Canceled after 0s
The interval menu cleared every host's history, and the reporter builds
each report from that history, so the samples recorded since the last
delivered report were never sent. The menu now resets each host with
HostState.reset, which moves the history's entries to clearedHistory,
keeping the last 100 it has cleared, and buildReport reads both. A host
still pending after the reset is sent with the status of its last check.
The per-host marks are unchanged, so a sample a delivered report had is
not sent again, and the page's history still starts over. Reporter is
exported for the new unit tests.

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

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

Model: opus-5-5
2026-10-07 13:41:50 +02:00
clawbot dcdee6bfab Hetzner checks answer again; failed checks go to the console (closes #114)
check / check (push) Canceled after 0s
The Hetzner speed-test servers close the connection without an answer
when the URL has a query string, and every check added ?_cb= and the
time, so all six showed unreachable. Checks now fetch each target's URL
as written; cache: "no-store" still keeps the browser's cache out of the
measurement.

Each recorded check that fails writes one console.error line, and the
same line to the debug log: the target's name and URL, the time, what
failed and how long the request took. A target that answers after
failed checks writes one console.info line. Checks the page does not
record write nothing, so the debug log no longer lists failures in the
first round or the recovery probe.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-02 01:05:09 +02:00
7 changed files with 20 additions and 695 deletions

No files matched your search

+3 -2
View File
@@ -7,8 +7,6 @@ concurrency:
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -17,4 +15,7 @@ jobs:
persist-credentials: false persist-credentials: false
# All history and tags, so git describe finds the version tag. # All history and tags, so git describe finds the version tag.
fetch-depth: 0 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 - run: script/cibuild
timeout-minutes: 20
+2 -2
View File
@@ -62,9 +62,9 @@ WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY . . COPY . .
RUN NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || \ RUN NODE_OPTIONS=--test-reporter=dot yarn --silent run test || \
{ echo "--- Rerunning with every test listed for details ---"; \ { echo "--- Rerunning with every test listed for details ---"; \
NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test; \ NODE_OPTIONS=--test-reporter=spec yarn --silent run test; \
exit 1; } exit 1; }
# Test phase: the backend's tests with the race detector and coverage, # Test phase: the backend's tests with the race detector and coverage,
+2 -2
View File
@@ -445,8 +445,8 @@ says what it checks and what it cannot.
## TODO ## TODO
The to-do list is [TODO.md](TODO.md): where the work stands, the next step, the The to-do list is [TODO.md](TODO.md): where the work stands, the next step and
open work, and what has been done. the open work.
## License ## License
+6 -682
View File
@@ -2,700 +2,24 @@
- branch from `next` - branch from `next`
- do the work in Next Step - do the work in Next Step
- move Next Step to the top of Completed Steps - delete Next Step from `TODO.md`
- move the top item of Future Steps into Next Step - move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work) - commit (`TODO.md` changes in the same commit as the work)
- push the branch and open a PR against `next` - push the branch and open a PR against `next`
# Status # Status
pre-1.0. Release candidates are tagged on `next`, the first being `v1.0.0-rc1`; pre-1.0. Release candidates are tagged on `next`; `git tag --merged origin/next`
`git tag --merged origin/next` lists them. `main` is the stable branch and lists them. `main` is the stable branch and `next` the development branch, which
`next` the development branch, which every PR targets. The frontend and the Go every PR targets. The frontend and the Go backend ship as one Docker image. The
backend ship as one Docker image. The Gitea workflow Gitea workflow `.gitea/workflows/check.yml` runs `script/cibuild` on every push,
`.gitea/workflows/check.yml` runs `script/cibuild` on every push, and cancels and cancels the older run on the same branch. Working toward 1.0.0.
the older run on the same branch. Working toward 1.0.0.
# Next Step # Next Step
Run `make frontend-viewport-test` in CI as its own step; it is not part of 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. `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 # Future Steps
- A backend test that posts a report to `POST /api/v1/reports` and checks the - A backend test that posts a report to `POST /api/v1/reports` and checks the
+3 -3
View File
@@ -15,13 +15,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
NODE_OPTIONS=--test-reporter=dot \ NODE_OPTIONS=--test-reporter=dot \
timeout 90 "$ROOT/script/yarn" --silent run test || { "$ROOT/script/yarn" --silent run test || {
echo "--- Rerunning with every test listed for details ---" echo "--- Rerunning with every test listed for details ---"
NODE_OPTIONS=--test-reporter=spec \ NODE_OPTIONS=--test-reporter=spec \
timeout 90 "$ROOT/script/yarn" --silent run test "$ROOT/script/yarn" --silent run test
exit 1 exit 1
} }
timeout 30 "$ROOT/script/yarn" build "$ROOT/script/yarn" build
} }
main "$@" main "$@"
+3 -3
View File
@@ -31,8 +31,8 @@ HARNESS="netwatch-viewport-harness-$RUN_ID"
ARTIFACT_DIR="$ROOT/tmp/viewport" ARTIFACT_DIR="$ROOT/tmp/viewport"
# Every container is named and removed here, including the harness itself: # Every container is named and removed here, including the harness itself:
# `timeout` below kills the `docker run` client, not the container it # a `docker run` client that is killed outright leaves its container
# started, and an unnamed survivor keeps the --internal network in use so # running, and an unnamed survivor keeps the --internal network in use so
# `docker network rm` fails too. This host runs many sessions at once and # `docker network rm` fails too. This host runs many sessions at once and
# neither may be left behind. # neither may be left behind.
cleanup() { cleanup() {
@@ -122,7 +122,7 @@ main() {
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
"$BROWSER")" "$BROWSER")"
timeout 900 docker run --rm --init --name "$HARNESS" \ docker run --rm --init --name "$HARNESS" \
--network "$NETWORK" \ --network "$NETWORK" \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
-v "$ROOT:/app" \ -v "$ROOT:/app" \
+1 -1
View File
@@ -924,7 +924,7 @@ export function buildUI(state) {
</div> </div>
<footer class="mt-8 text-center text-gray-600 text-xs"> <footer class="mt-8 text-center text-gray-600 text-xs">
<p>Latency measured via GET requests | CORS restrictions may affect some measurements</p> <p>Latency: the time until a target's response headers arrive</p>
<p class="mt-2"> <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-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 <span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span>&lt;100ms