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
51 KiB
51 KiB
Workflow
- branch from
next - do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (
TODO.mdchanges in the same commit as the work) - push the branch and open a PR against
next
Status
pre-1.0. No git tags. main is the stable branch and next the development
branch, which every PR targets. The frontend and the Go backend ship as one
Docker image, and the Gitea workflow .gitea/workflows/check.yml runs
script/cibuild on every push, and cancels the older run on the same branch.
Working toward 1.0.0.
Next Step
Run make frontend-viewport-test in CI as its own step; it is not part of
make check, as it needs Docker and takes minutes.
Completed Steps
- 2026-10-19:
netwatch-serverstarts the server only when given no arguments (#179).check-cidr CIDRandprepare-data-dir DATA_DIRbehave as before; any other arguments, such as a subcommand with no value or two, or a misspelt one, print a one-line usage message naming both forms to stderr and exit 2. They used to start the server. Unit tests callcli.Mainwith no arguments and check that it starts the server, and with each subcommand given no value and two values and with a misspelt subcommand, and check the exit code, the message and that the server does not start - 2026-10-19:
main()incmd/netwatch-server/main.gois one call into a new package,internal/cli, as the Go style guide asks (#160).internal/cliruns thecheck-cidrandprepare-data-dirsubcommands, with the same arguments, messages and exit codes, and otherwise the server, with thefxwiring.AppnameandVersionstay inmain, where the build setsVersion, andmaingivescli.Mainstderr and the function that runs the server, which the tests replace with a buffer and a function that fails them. Unit tests callcli.Mainwithcheck-cidrand a CIDR the server takes and one it refuses, and check the exit code and what it writes to stderr, and withprepare-data-dirand a path outside/data, and check that it exits 1 with the error from looking up thenetwatchuser, which the test image does not have. Others give the functions behind the subcommands input they take and input they refuse, and check the exit code and what they write to stderr - 2026-10-19: when
VERSIONis unset or empty,script/buildstamps the version fromgit describe --tags --always --dirty, asscript/dockerdoes (#156). Without--tagsit skipped lightweight tags, somake runcould report a commit where the image built from the same tree reported the tag. Checked once with a lightweight tag on a scratch clone:make buildstamped it - 2026-10-19:
.dockerignorekeeps Go test binaries and coverage output at the root out of the build context, with/*.testand/*.outin the repository's own section (#155). Such files went into the build context of everydocker buildinscript/and were copied into the lint, test and build stages. Checked with a probe image that copies the whole context:x.testandx.outplanted at the root no longer land in it, and nothing else changed. The entries match only at the root, so the same files in a subdirectory still reach the image - 2026-10-19:
.gitea/workflows/check.ymlis the sneak/prompts copy at0653579(#153). Its checkout step setsfetch-depth: 0, so CI clones all history and tags, andgit describeinscript/cibuildgives the image built from a tagged commit the tag as its version; it gave the short commit. The checkout step also setspersist-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/bootstrapkeeps theyarnonPATHonly whenyarn --versionis exactlyYARN_VERSION, and otherwise installs that version as it did when no yarn was installed (#154); any yarn used to be kept. It uses thenodeonPATHonly when that node has the major version ofNODE_VERSION, asREPO_POLICIES.mdasks, and is at leastNODE_MIN_VERSION; a node 24 was kept before. After installing node or yarn it looks the tool up throughPATHagain and stops if that one fails the same check, as it does for Go, and it prints the node and yarn versions it uses. Every script that runs yarn on the host, exceptscript/bootstrap, which picks its own node and yarn, runs it through the newscript/yarn. That script puts~/.local/binfirst onPATH, uses the firstnodeon thatPATHwhen it passes the same test, and otherwise runs yarn under the pinned node in nvm;make buildran yarn under whatever node was first onPATH - 2026-10-19:
bin/entrypoint.shhas each of its steps in a function, and amainat the end calls them in order, as the shell section of the code style guide asks (#163). It is still POSIX sh with noset -e, and does what it did, with the same messages and exit codes. Checked by running the image with each script: a normal start, badPORT,TRUSTED_PROXIESandDATA_DIRvalues,docker stop, INT, and nginx exiting on its own - 2026-10-19: while report files cannot be written, the health check answers 503
with
"status":"error"and the same fields, so the image'sHEALTHCHECKreports the containerunhealthy(#142). It answered 200"status":"ok"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. A unit test removesDATA_DIRto make a write fail and checks the 503, then puts it back and checks the 200 after the next write - 2026-10-19: report file writes no longer overlap
(#150), so the reports of
failed writes are written in the order they arrived. Two writes that failed at
the same time put their reports back in the wrong order when the older failed
first. One write runs at a time, and each takes the waiting reports only when
it starts, after a failed write has put its own back. The write
Appendstarts when 10 MiB are waiting waits for any write under way, andAppendstarts no other while it waits. Files written since the start are deleted to make room in the order they were written, after those found at start. A unit test holds a write open, starts a second at 10 MiB, fails both and checks the order in the next file. Others check thatAppendstarts no second write at 10 MiB while one waits, that it starts one again the next time 10 MiB are waiting, and that the first file written since the start is the first deleted - 2026-10-19:
DEBUGunset or empty leaves debug logging off, and any other value,falseand0included, turns it on, as the code style guide says (#159). No value of it stops the start any more; before, only1,t,T,TRUE,true,True,0,f,F,FALSE,falseandFalsewere accepted.README.mdno longer givesfalseas its default - 2026-10-19: check builds keep Go's modules and compiled packages between runs
(#118), on sneak's rulings in
prompts#113 and
prompts#130.
REPO_POLICIES.md,script/test,script/lint,script/dockerandscript/cibuildare the sneak/prompts copies at0653579: the scripts pass a newCACHEBUSTvalue where they passed--no-cache, and the lint and test builds write no image. In each stage ofDockerfile,ARG CACHEBUSTcomes after the system packages and the Go module download, and before every step that lints, tests or compiles and the node stages'yarn install. Go's build cache and golangci-lint's are cache mounts, and bothgo testruns carry-count=1. The test phase's Go caches used to be in memory, empty on every build - 2026-10-19: the three unit tests of a check whose result is discarded (its
round given up, the user paused, or the first round) also check that the
host's history is still empty, its status still
pending, and nothing was written to the console (#151). They checked only the row, so a change that recorded the result and skipped only the row would have kept them passing - 2026-10-18: when the backend runs on its own, a
DATA_DIRthat cannot be created or read, or where no file can be created, stops the start with an error namingDATA_DIRand its value, as the other settings' errors do (#148); the first two named only the path, and the third was found only at the first write of a report file. At start the server creates a file inDATA_DIRand removes it. Unit tests start the buffer withDATA_DIRbelow a regular file and withDATA_DIRset to/proc, and check the error. The report file number is anatomic.Int64, as the Go style guide asks for int types (#161); file names are unchanged - 2026-10-18: the image's nginx sends the page with
Cache-Control: no-cache(#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 noCache-Control, so a browser could keep it after a deploy and ask for files under/assets/that the new build no longer has, and show an empty page. A path thatlocation /answers with the page through itstry_filesfallback gets the same header.location /now includes the security headers again, as/assets/does, since its ownadd_headerdrops the server's./assets/keeps its immutable caching.make frontend-viewport-testfails unless/and a path that falls back to the page carryCache-Control: no-cacheandContent-Security-Policy, which comes from the same file as the other security headers - 2026-10-18:
README.mdsays what the page loses while reports are refused or the page is paused (#145), how fast one address can replace the stored reports (#146), and that in the image nginx answers a body over 1 MiB itself (#147). A sample in the history that no report has delivered is sent only as long as the history holds it: at the 1-second interval one refused or failed report loses the oldest 20 seconds of each host's samples, and a pause longer than the history loses every sample between the last delivered report and the pause. Samples an interval change cleared are kept apart, the last 100 per host, until they are delivered. At the defaults, one address sending 1 MiB reports made up to compress badly can fill the 1 GiB cap in about 17 minutes. nginx'sclient_max_body_sizeis also 1 MiB by default, so in the image it answers a larger body with 413 and its own HTML page, and the backend never sees or records it; the backend's own 413 sentences now say they apply to it run on its own. The reporter, the body limit and nginx are unchanged - 2026-10-18: a browser that cannot format the header's clocks still probes
(#149). The clocks name the
local time zone through
Intl.DateTimeFormat'sshortGenericoption, 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>=500msfor red, where it read>500ms, so 500 ms, which is red, is in one of its colors (#152). Unit tests check the clocks with a workingIntl.DateTimeFormatand with one that rejectsshortGeneric, and draw the page and check the legend against every entry ofCONFIG.latencyColors - 2026-10-18:
netwatch-server prepare-data-dirno longer changes the owner of a file outside/datathrough a hard link in the volume (#143). A file with a second name (hard link) keeps its owner, and the start logs its path. Owners are now set on the opened directory, file or link, never by name, so a hard link swapped in after the check is caught too; a symbolic link is opened without following it and given to the netwatch user itself, so the server can still follow one in a sticky directory that anyone can write to. Devices, named pipes and sockets keep their owner. Setting the owner on an open file that is not a directory is built only for Linux:prepare-data-dirruns only in the Linux image, and macOS, where the backend is also built for development, has neitherO_PATHnorAT_EMPTY_PATH. On any other system the package still builds, andprepare-data-dirfails at the first file that is not a directory. The test that runs as root hard-links a file, a symbolic link and a named pipe from outside the volume into it and finds their owners unchanged - 2026-10-18: from a trusted proxy, the backend takes the client address from
the right-most
X-Forwarded-Forentry that is not inTRUSTED_PROXIES(#144). It took the left-most entry, which the client writes itself behind a proxy that appends to the header, so a client could choose the address the rate limit counts and the request log records. An entry written as an address with a port is read as that address. With only trusted addresses in the header, or an entry that is not an address at all (such asunknown) met on the way, the direct peer is used.X-Real-IPis read only withoutX-Forwarded-For, and everyX-Forwarded-Forheader line is read, not only the first. The image's nginx, and theTRUSTED_PROXIES=127.0.0.1/32it starts the backend with, are unchanged; the comment inconfigs/nginx.confthat said the backend takes the first entry now says it takes the one address nginx passes on. Unit tests resolve the address from chains ending in an untrusted address, in trusted ones, of only trusted ones, with ports, with an entry that is not an address, and split over two header lines - 2026-10-18: after an interval change, the summary's Reachable slot reads
--/--in white, as at page load, until the second round since the change ends (#140). It used to read 0/26 in red from the change on, as no WAN host had a check recorded, for up to two intervals with the network fine. The summary update now writes--/--while every WAN host still waits for its first check. A unit test resets the hosts as the interval menu does and checks the slot; another checks that it reads 0/26 in red once every WAN host's check has failed - 2026-10-14: stopping the server waits for a report file write already under
way, the periodic flush's or the one
Appendstarts when 10 MiB are waiting, before the final flush (#122). It used to return at once, and the process exiting cut the write off, losing its reports. A write under way that fails puts its reports back in memory, so the final flush writes them; the stop fails, and the process exits non-zero, only if that flush fails too. Two tests hold the periodic write and the one at 10 MiB open and find the stop waiting until each is released; a third makes the held write fail and finds its report written by the final flush. They run in atesting/synctestbubble, so the periodic flush comes without waiting a minute - 2026-10-14:
make testno longer runs the frontend's production build, which the tests do not need (#118).yarn buildmoved out of the stage that runs the frontend's unit tests, now namedfrontend-test, into a newfrontend-buildstage, which starts from it and, like thebuilderstage, waits on the lint and test phases, so the image still cannot be built unless they pass. The runtime stage copiesdist/from it, and thegitthatvite.config.jsreads 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). With the panel open, each line written drew the panel again with every line before it, up to 1000, and a check that ends while the page draws is timed only when the drawing is done. Each line is now added at the end of the open panel, its first line is dropped once it holds more than 1000, and it scrolls to its newest line once a frame rather than once a line; the whole panel is drawn only when it is opened. A unit test writes more lines than the panel keeps and checks that the lines already shown are left as they are
- 2026-10-14: unset, the backend's own
TRUSTED_PROXIEStrusts10.0.0.0/8,172.16.0.0/12and192.168.0.0/16, and no longer loopback; a list replaces that; set and empty, it trusts no proxy, where it used to get the default, as viper reads an empty environment variable as unset (#129). A value it cannot parse still stops the start, and the image still starts the backend withTRUSTED_PROXIES=127.0.0.1/32. Tests load the config with it unset, empty and set to a list - 2026-10-14: while report files cannot be written,
POST /api/v1/reportsanswers 500, and storing recovers once writing works again, with no restart (#123). A failed write puts its reports back in the report buffer, still counted againstDATA_DIR_MAX_BYTES, where they used to be lost; the next flush, within a minute, tries them again. Until a write succeeds, every report is refused and nothing of it is kept, so the buffer does not grow while writes fail. A test posts reports through the report handler before, during and after a failure; others check that a report appended while a write runs is kept, behind that write's own, when the write fails, and that the cap still holds exactly once a failed write's reports are written - 2026-10-14: a request whose handler panics is logged with status 500 and
counted with
code500 in the metrics (#125), not 200.Recovereris still the first middleware;Write500OnPanic, after the request log and after the metrics, writes the 500 as the panic passes and lets it go on to Sentry andRecoverer, which now writes a 500 only when no status was written - 2026-10-14: with
TRUSTED_PROXIESunset, the container's nginx trusts the three private ranges of RFC 1918,10.0.0.0/8,172.16.0.0/12and192.168.0.0/16(#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 only127.0.0.1/32 - 2026-10-14:
POST /api/v1/reportstakes only a report sent withContent-Type: application/json, parameters such ascharsetallowed, and refuses any other with 415 and the usual error body, storing nothing (#126). A page on another origin can send atext/plainPOST 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 inCORS_ALLOWED_ORIGINSpasses. The page already sentapplication/json. A test posts a report astext/plain, asapplication/x-www-form-urlencoded, asmultipart/form-data, with noContent-Type, and asapplication/jsonwith and without a charset, and checks which are stored - 2026-10-14: changing the interval no longer drops the samples not yet reported
to the backend (#124). The
interval menu resets each host with the new
HostState.reset, which moves the history's entries toclearedHistory, keeping the last 100 of all it has cleared, andbuildReportreads 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, notpending. The page's history still starts over, and the per-host marks still leave out of a report the samples a delivered one had. Unit tests reset a host as the interval menu does and find its unreported samples in the next report, and only the last 100 after more were cleared;web/src/main.jsnow exportsReporterfor them - 2026-10-14: the repo has the layout
REPO_POLICIES.mdgives (#30). The Go module is at the root (go.mod,go.sum,.golangci.yml,cmd/netwatch-server/,internal/), so its pathsneak.berlin/go/netwatchnames the repository.go.modignoresnode_modules/, where npm packages can ship.gofiles thatmake tidywould otherwise read as this module's packages. The page's source is inweb/(index.html,src/), whichvite.config.jsmakes Vite's root; the build still goes todist/.nginx.confandsecurity-headers.confare inconfigs/.backend/is gone: itsLICENSEand.dockerignoreare deleted, its.gitignoreentries are in the root one, itsREADME.mdis folded into the rootREADME.md, and its scripts into the rootscript/:script/buildbuilds the server binary after the frontend,script/runandscript/cleancame frombackend/script/, andmake test,make lint,make fmtandmake fmt-checkdo what itstest,lint,fmtandfmt-checkdid.script/bootstrapno 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/promptsare its copies at commitdd4027b(#113), with this repository's own entries after the shared content in.gitignore,.dockerignoreand.editorconfig.make lintandmake testeach build one phase ofDockerfilewithout the cache:lintruns golangci-lint v2.14.0 overbackend/and, through thefrontend-lintstage, eslint;testruns the Go tests on the Debian Go image and, through thefrontendstage, the frontend's unit tests and build. The builder stage waits on both, and without aVERSIONbuild argument takes the version fromgit describeon the.gitin the build context. Neither linter nor the tests run on the host formake check;backend/script/lintruns the root one, andbackend/.golangci.ymlis no longer checked against a sha256. prettier formats only the JavaScript, CSS, HTML and Markdown, so the shared.golangci.ymlstays as fetched.script/fmtandscript/fmt-checkput~/.local/binonPATH, which the shared workflow no longer does.script/frontend-lintandscript/frontend-checkare gone - 2026-10-07: the six Hetzner targets answer again, and failed checks are
written to the browser console
(#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, withcache: 'no-store'as before. Each recorded check that fails writes oneconsole.errorline, and the same line to the debug log, with the target's name and URL, the time, what failed and how long the request took; a target that answers after failed checks writes oneconsole.infoline. 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): each check is a
fetch, the browser picks IPv4 or IPv6 for each WAN host, and the local targets are IPv4 addresses. The rest of the footer is unchanged - 2026-10-04:
README.md,TODO.mdandtest/viewport/README.mdsay what the tree does (issue #24). The README's Getting Started leads withmaketargets; a new Backend section says whatnetwatch-serverstores, its routes and how the image builds and runs it, and points tobackend/README.mdfor its settings; the checks are GET requests; the 26 WAN hosts, the four health states, the summary's figures and the features the list lacked are described as the page has them; and its TODO section points here, as does the one inbackend/README.md, whose open items moved to Future Steps. This file's Workflow branches fromnextand opens the PR againstnext, Status says where the repo stands, and Next Step and Future Steps hold only open work, linked to its issue where one exists. The viewport harness README names Node's test runner, notvitest - 2026-10-04: in
src/main.js(issue #102), a target's min, max, median and average latency come from one list of its answers, through the same function the summary's figures use, so the median is written once. The latency color limits are one table inCONFIG, read by both the figure's and the sparkline's color. The health thresholds, the debug log's length, the gateway check's timeout, the recovery probe's number of hosts and interval, how often the rows are sorted and the delay before the first sparkline resize areCONFIGentries too. A unit test checks the summary's figures. Nothing the page does or shows changed; the footer's color legend still writes the limits out as text - 2026-10-04: password guesses at
/metricsare rate limited (issue #104): each client address, resolved throughTRUSTED_PROXIESas for reports, may make 60 requests to/metricsa minute, counted bygo-chi/httprateapart from its reports; past that it gets 429 and its basic auth credentials are not checked. The limit is a constant inbackend/internal/server/routes.go. A test uses up one client's allowance on wrong passwords, gets 429 with the right one, and checks that another client behind the same nginx still gets in - 2026-10-04: the backend reports errors to Sentry (issue #95). With
SENTRY_DSNset, it sets upsentry-gowith the releasenetwatch-server-and its version, reports each panic in a handler throughsentryhttp, the last of the middleware every request goes through, which panics again so the request still gets the 500 from the panic recovery, and waits up to 2 seconds on shutdown for Sentry to finish sending. A DSN Sentry refuses stops the start with an error namingSENTRY_DSN. With it empty, Sentry is not set up and nothing is sent to it - 2026-10-04: a target's name and URL and a debug log message show as the
characters they are and are never read as HTML (issue #29): a host row escapes
the name and URL it writes into its markup, and the debug log sets each line
as text. A unit test checks a target whose name and URL hold
<,>,",&and'.README.mdno longer callsCONFIGfrozen: the interval menu sets itsupdateInterval, and the values computed from it follow.AppStatedeclares the recovery probe's two properties, the sparkline axis functions lose the parameters they did not use, and the comment on a target's history names both kinds of entry it holds. Nothing the page does changed - 2026-10-04: a dependency can be added without running yarn or go by hand
(issue #45):
make add-dependency PACKAGE=<name>@<version>shims to the newscript/add-dependency, which runsyarn add --dev, sopackage.jsonandyarn.lockchange together, thenyarn install --frozen-lockfile; the same command moves a package to another version.make tidyshims to the newscript/tidy, which runsgo mod tidyinbackend/: a Go module is added by importing it, or moved by editing itsrequireline, thenmake tidy.script/bootstrapstill installs with--frozen-lockfile - 2026-10-04: the backend serves Prometheus metrics (issue #94). With
METRICS_USERNAMEandMETRICS_PASSWORDboth set, it records request duration and response size throughgo-http-metricsand serves them, with Go's runtime and process metrics, atGET /metricsbehind basic auth with those credentials; nginx passes/metricsto it as it does/api/. With neither set there are no metrics and/metricsis 404; one without the other stops the start with an error naming both, and so does aMETRICS_USERNAMEcontaining:, with an error naming it. Only requests that reach the health check orPOST /api/v1/reportsare recorded, not/metricsitself and not every request asGO_HTTP_SERVER_CONVENTIONS.mdshows, because the labels are the request's path and method, which clients can make up without end. For that,POST /api/v1/reportsis now registered by its full path instead of inside a/api/v1route group; it answers as before - 2026-10-04:
script/andMakefilefollow the org models (issue #28):make devshims to the newscript/dev, the Vite dev server, and the newmake buildtoscript/build, the frontend production build..prettierignoreno longer leaves outbackend/, somake fmtandmake fmt-checkcoverbackend/README.md; it leaves outbackend/.golangci.ymlby name, the org standard file whose sha256backend/script/lintchecks.script/install-precommitand the date onscript/bootstrap's pins are the org model again;script/bootstrap,script/fmtandscript/fmt-checkeach say in a comment why they differ from it - 2026-10-04: a frontend build on Node 26 or newer, such as
make teston a host with Node 26, no longer prints Node's warning thatmodule.register()is deprecated (issue #32); the build inDockerfileruns on Node 22, which never printed it. The call was in@tailwindcss/node, which@tailwindcss/vitebrings in at its own exact version; tailwind 4.3.1 callsmodule.registerHooks()instead where Node has it.yarn.locknow has@tailwindcss/viteandtailwindcssat 4.3.3, inside the rangespackage.jsonalready allowed, and tailwind's own dependencies moved with them. The built CSS changes only in how it is written out, in tailwind's Firefox focus-ring rule, which no longer applies to iframes (the page has none, so nothing on it looks different), and in tailwind's default sans-serif font list, which the page does not use:bodysets a monospace font - 2026-10-03: the frontend's unit tests cover what the page computes (issue
#21):
humanDuration, the latency colours of a figure and of a sparkline either side of each boundary, a target's min, max, average and median latency over an empty history, an all-unreachable one and a mixed one, and each of the four health states either side of its thresholds.package.jsonhas atestscript, soyarn run testandnpm run testrun them, andscript/frontend-testruns it: quietly, and if a test fails, again with every test listed, and then fails.src/main.jsnow exportshumanDuration,HostState,latencyClassandlatencyHexfor the tests; nothing it does changed - 2026-10-03: the backend's logs are one stream (issue #27): fx logs its own
steps of starting and stopping through the backend's logger, so off a terminal
every line the backend's own logger and fx write is JSON, where fx used to
write plain text to stderr. A config file that is found but cannot be read now
stops the start with its error, logged as JSON like a bad setting, where it
used to end in a Go panic. A test runs the server as a child process and
checks both. The backend logs its name, version and architecture once at
start. The health check's uptime keys are now
uptime_secondsanduptime_human; its path, content type,"status":"ok"and 200 are unchanged.SENTRY_DSN,METRICS_USERNAMEandMETRICS_PASSWORDare still read and still unused, and left out ofbackend/README.md, until issues #94 and #95 wire them up - 2026-10-03: the frontend has a real linter (issue #47, and item 2 of issue
#28):
eslintwith its recommended rules, set ineslint.config.js, runs in a newfrontend-lintstage ofDockerfile, which the frontend stage waits on, as the builder stage waits on the Golintstage.script/lintbuilds both stages without the cache and runs no linter on the host.script/frontend-lintruns eslint where it used to repeat the prettier check thatscript/fmt-checkruns on the host, andscript/frontend-check, run by the frontend stage, is now the tests and the format check.script/bootstrapwants node 22.13.0 or newer, as eslint 10 does - 2026-10-03: the tap-target check in
make frontend-viewport-testexpects one visible pin button per WAN host row (issue #46), where it expected at least 10 of the 26, so pin buttons missing from only some rows now fail it. The host row count the harness gathers, which theapp-renderedcheck also reads, now counts only the WAN host rows: the local host rows have no pin button - 2026-10-03: each target's row shows its result as soon as its check ends (issue #91), where every row waited for the round's slowest check, up to 24 seconds at a 30-second interval. Every row is still redrawn, and sorting, the summary, the health box and offline detection still run, once, when the round's last check ends, so no row reads "paused" after a pause and resume during the round. A check that ends after the user pauses or after its round is given up shows nothing, and the first round is still discarded as a whole
- 2026-10-03: root no longer acts outside
/datawhen it preparesDATA_DIR(issue #80):bin/entrypoint.shrunsnetwatch-server prepare-data-dir, which refuses aDATA_DIRthat is not/dataor a path below it written in full, then createsDATA_DIR, gives/dataand everything in it tonetwatchand sets the modes, all through a Goos.Rootopened on/data. That refuses any path leading out of/data, so neither a symbolic link already there nor one a host process swaps in during the start can make root create or change anything elsewhere, andDATA_DIR=/etcno longer gives/etctonetwatch. TheREADME.mdsection "Running under upaas" says which values are accepted - 2026-10-03:
DATA_DIR_MAX_BYTESis now how much of the report files is kept (issue #54): when a report would take them past it, the oldest report files are deleted to make room, each deletion logged, and at start files already past it are deleted the same way. A file still being written is never deleted. A report is refused with 507 only when the reports waiting to be written fill the cap on their own, and then no file is deleted. The reports of a failed write stop counting, and the part of its file written is removed. A file that cannot be deleted still counts until the next start; one already deleted by hand counts as freed - 2026-10-03:
backend/script/lintsays what went wrong with its.golangci.ymlcheck (issue #34). On a hash mismatch it says to compare the file with the org standard: if they differ, restore the org standard; if they are the same, the org standard changed, so updateGOLANGCI_CONFIG_SHA256in 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 asha256sumthat is missing or prints no hash, each get their own message instead of being reported as a mismatch; every one still fails the lint - 2026-10-03: the Go tests run with the race detector and coverage (issue #88):
backend/script/testrunsgo test -timeout 30s -race -cover ./...and, if that fails, runs it again with-vand fails. Go's-timeoutbounds the tests, not their compile; the rootscript/testno longer puts one 30-second timeout around both halves, which a cold Go build cache could use up on compiling alone. The race detector needs a C compiler: the builder stage ofDockerfilehas gcc and musl-dev, andscript/bootstrapinstalls gcc, with the C library headers on apt and apk, when gcc is missing; the binary is still built withCGO_ENABLED=0. New tests cover the health check's answer, a valid report's answer, a report file's exact contents, and the flush when the buffer reaches 10 MiB; the handlers'TestImportstub is gone - 2026-10-03: each target check times out after 80% of the refresh interval
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
round started early, after an interval change or when the recovery probe finds
a target answering, gives up the last round's checks if they are still
waiting, so rounds never overlap; the recovery probe gives up its own checks
after half a second. The frontend has its first unit tests, run by
script/frontend-testwith Node's built-in test runner; for them,index.htmlnow linkssrc/styles.css, whichsrc/main.jsused to import - 2026-09-29: the container sets up its own data directory (issue #75):
bin/entrypoint.sh, still as root, createsDATA_DIRif missing and gives it and/datato thenetwatchuser with mode 750 before starting the backend as that user, so an empty host directory owned by root, or one holding files from another uid, works with no step on the host. It stops the start instead when a symbolic link is on the path toDATA_DIR, since root would change whatever the link points to. TheREADME.mdfirst-run step that created and chowned the host directory is gone, and the image no longer sets that ownership at build time - 2026-09-29: CI can no longer pass on checks that did not run (issue #37):
script/cibuildis now the org model, byte for byte. It runsscript/bootstrapandscript/check, then builds the image with--no-cacheand the version fromgit describeas theVERSIONbuild argument, where it used to be a plaindocker build .whose check steps could come from the build cache. The workflow puts~/.local/bin, where bootstrap links what it installs, on the step'sPATH, and bootstrap now installs its pinned node when the installed one is older than the frontend's dependencies need - 2026-09-29:
backend/.golangci.ymlre-vendored fromsneak/prompts(issue #41):gomodguard, deprecated in golangci-lint v2.12.0, is disabled and its successorgomodguard_v2enabled with the org block list, so lint runs print no deprecation warning. The new file also turnsdepguardon with itstest-supportrule, which keepsnet/http/httptestout of non-test code; netwatch adds no entries of its own to that rule.backend/script/lintchecks the new sha256 - 2026-09-29: nginx sends the security headers
REPO_POLICIES.mdrequires on every response (issue #18), including errors,/assets/and what it passes on from the backend, whose own copies it drops so each header goes out once. They live insecurity-headers.conf, whichnginx.confincludes. The content security policy allows no inline script or style, so the status dot's grey insrc/main.jsis now a class;connect-srcis*because probed hosts redirect to others, and the browser checks each redirect against it - 2026-09-29: the request log is bounded (issue #60): the method, URL, protocol,
User-Agent,Referer, request ID (which chi takes from the client'sX-Request-Idheader) and client address it writes are each cut to 128 bytes, the bound the report handler already used, so one request can no longer put about 1 MiB per field into a log line. That bound and its helper now live in theloggerpackage, shared by both - 2026-09-29: nginx takes the client address from
X-Forwarded-Foronly on requests from the reverse proxies named in the container'sTRUSTED_PROXIES(issue #64), and by default from none, where it trusted every RFC1918 address before, so a client could write a new address on each request and escape the rate limit.bin/entrypoint.shwrites oneset_real_ip_fromline per entry into/etc/nginx/trusted-proxies.conf, whichnginx.confincludes, refusing an entry that is not an IP address or CIDR, asnetwatch-server check-cidrfinds; it starts the backend withTRUSTED_PROXIES=127.0.0.1/32, since nginx is its only client - 2026-09-29: report file names can no longer collide (issue #61): each is
reports-<timestamp>-<number>.jsonl.zst, where the number goes up by one for each file the server starts to write, so two flushes in the same millisecond, such as a flush for size and the final flush at shutdown, each get a file of their own instead of the second one failing. A failed write uses up its number, leaving a gap if the file could not be created and otherwise a file under that number that may be incomplete. - 2026-09-29: ready to run under upaas (issue #59): the image has a
HEALTHCHECKthat requests/.well-known/healthcheckthrough nginx on the port fromPORT. The backend no longer reads a badPORTas 0 or a badDEBUGas false: those, and aBIND_ADDRESSthat is not an IP address, stop it from starting with an error naming the variable, as the limits,CORS_ALLOWED_ORIGINSand, now by name,TRUSTED_PROXIESalready did.bin/entrypoint.shalso refuses aPORToutside 1 to 65535, and8081, where the backend listens inside the container, namingPORT.README.mdhas a "Running under upaas" section, whose first-run steps create the host directory for/dataowned by uid 1000; the image does not change its owner - 2026-09-29: nginx listens on
PORT(issue #26), 8080 when unset or empty: the nginx image rendersnginx.confas a template at container start, filling inPORTand no other variable.bin/entrypoint.shrefuses to start whenPORTis not digits only.server_tokens offkeeps the nginx version out of responses.script/frontend-viewport-testrenders the template the same way. Gzip and a50x.htmlerror page are not added - 2026-09-29: bounded the report endpoint (issue #20):
POST /api/v1/reportsstill needs no credentials, but each client address, as resolved throughTRUSTED_PROXIES, may sendREPORTS_PER_MINUTE(default 60) reports a minute, counted bygo-chi/httprate, and past that gets 429 withRetry-After; the report files inDATA_DIR, counted from start with those already there, may total at mostDATA_DIR_MAX_BYTES(default 1 GiB), past which reports get 507; and the wildcard CORS is gone: no CORS headers unlessCORS_ALLOWED_ORIGINSlists origins, and an entry that is not a plainscheme://host[:port]origin,*included, stops the server from starting. Deleting report files frees room only at the next start; pruning is issue #54 - 2026-09-28: one container image (issue #52): the root
Dockerfilebuilds the only image, andDockerfile.backendis gone. nginx serves the frontend on port 8080 and proxies/api/and/.well-known/healthcheckto the backend, which listens on127.0.0.1:8081in the same container; the newBIND_ADDRESSsetting sets its listen address.bin/entrypoint.shstarts both, passes TERM and INT on to both, and exits non-zero when either exits on its own. The backend runs as usernetwatchand stores reports on the/datavolume.script/dockeris the org model again - 2026-09-28: unified the gate (issue #16): the root
make checkcovers 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/Makefileas shims, its duplicate hook installer removed);script/cibuildbuilds both images and is the workflow's only build step. The rootmake lintruns golangci-lint only in Docker, by building the lint stage ofDockerfile.backendwithout the cache.script/bootstrapinstalls the pinned Go unless the installed one is at least whatbackend/go.modasks for, links what it installs into~/.local/binwithout replacing anything it did not create, and installs no linter. Rootmake testruns both halves within one 30-second timeout. WhenVERSIONis unset or empty, the backend binary's version falls back togit describeinside a git checkout, then todev - 2026-09-28: frontend reporting client (issue #53): a
Reporterclass posts collected samples to/api/v1/reportseveryreportInterval(default 60s) as a per-host delta, with the report-building step a pure exported function of host state; a per-host mark advances only on a delivered POST; at most one report POST is pending at a time and it is abandoned after half the interval, so a slow POST never overlaps the next report and a mark never moves backwards; the samples of an abandoned POST are sent again at the next interval, so a backend that stored them but answered late receives them twice; the per-browser client id works in insecure (plain-HTTP) contexts;vite.config.jsproxies/apito the local backend foryarn dev - 2026-09-28: report ingest correctness (issue #23): a storage failure now
returns 500 instead of a false
ok; oversize bodies return 413 (distinguished from malformed JSON, which stays 400); aMaxBodyBytesmiddleware caps every route, not just the report route; the raw attacker-controlledgeoblob is no longer logged (only its length), andclient_id,timestampand decode error text are length-bounded before logging; adecodeJSONhandler helper was added; panic recovery now routes the stack through slog instead of chi's plain-text stderr; and writing a report file now returns its error, so a failed final flush on shutdown makes the process exit non-zero instead of losing the buffered reports silently - 2026-09-21: shutdown lifecycle correctness. The process now shuts down through
fx instead of
os.Exit, so every component'sOnStopruns and buffered reports are flushed to disk onSIGTERM— previously a full flush window of telemetry was silently lost on every restart. Thehttp.Serveris now built before its serving goroutine starts, so shutdown can no longer race or nil-deref it; a listen failure exits non-zero viafx.Shutdowner;reportbufOnStopis idempotent; andwriteTimeoutnow exceeds the chi per-request budget so that budget is actually reachable. DeadstartupTime,exitCode, andcancelFuncfields were removed - 2026-09-21: backend HTTP hardening (issue #19): added
ReadHeaderTimeoutandIdleTimeoutto the server, aSecurityHeadersmiddleware (HSTS, tight CSP, frame/sniff/referrer/permissions headers) registered before CORS, and trusted-proxy client IP resolution honouringX-Forwarded-For/X-Real-IPonly from aTRUSTED_PROXIESallowlist (loopback plus RFC1918 by default) - 2026-08-10: adopted the org-standard
backend/.golangci.ymlverbatim and moved the pinned golangci-lint from v2.7.2 to v2.12.2 (thelintstage ofDockerfile.backendnow pins thegolangci/golangci-lint:v2.12.2image by digest); the previous config declaredversion: "2"but used v1 schema keys, so every threshold in it was inert and its green result was meaningless.backend/Makefile'slinttarget now asserts the config's sha256 against the canonical file first, so drift from the org standard fails the build instead of silently degrading to defaults - 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
target (
.pin-btn,#interval-select, the debug-log label and, on narrow viewports,#pause-btn). The pin button's hit area grows via matching negative margins, so its layout footprint and row density are unchanged - 2026-08-10: per-host status line wraps below the 768px breakpoint instead of forcing horizontal page scroll at 320px
- 2026-08-09:
Dockerfile.backendreworked to the mandated Go multistage lint-stage pattern: separatelintstage on the hash-pinnedgolangci/golangci-lintimage,COPY --from=lintstage dependency,CGO_ENABLED=0static build driven byARG VERSION, and no moreCOPY .git - 2026-08-09: dotfile compliance — lifted
backend/.editorconfigto the repo root soroot = truecovers the frontend too, and replaced.gitignorewith the org model (OS, editor, node, and environment/secrets sections) plus this repo'sdist/and*.log..env,.env.*,*.pem, and*.keyare now ignored repo-wide, not just underbackend/. Excluding.gitfrom.dockerignorestays deferred: both images read git metadata at build time (COPY .gitinDockerfile.backend,git rev-parseinvite.config.js) - 2026-08-09: automated responsive-layout harness
(
make frontend-viewport-test): digest-pinned headless Chrome driven over CDP against the builtdist/, viewport widths derived from the breakpoints insrc/styles.css(#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 theapp-renderedcheck, which fails the run when the app did not render. Found two real layout defects, filed as #42 and #43 - 2026-07-07 Adopted scripts-to-rule-them-all:
script/entrypoints, Makefile shims, README Entrypoints section - 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow and backend repo standard files; backend Dockerfile fixed (Go 1.25, golangci-lint) and moved to repo root (feat/reportbuf-storage)
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing fixes; nginx config extracted; port hardcoded to 8080
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix, S3 Singapore endpoint added
- 2026-02-23: summary box redesign, host pinning, local and UTC clocks, checks counter
- 2026-02-23: hosts sorted by latency; GET instead of HEAD for latency; timeout derived from interval; Hetzner regional endpoints; 3s interval
- 2026-01-29: initial NetWatch network latency monitor
Future Steps
- A backend test that posts a report to
POST /api/v1/reportsand checks the compressed file it is written to - A backend route that decompresses the stored reports and answers queries on them
- Prometheus metrics for the backend's in-memory buffer: its size, the number of flushes and the number of reports
- A configurable host list (an environment variable or a config file)
- Export of the latency history (CSV or JSON)
- A notification when the health status changes to DEGRADED