1 Commits
Author SHA1 Message Date
sneak 84213bf29f 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(Appname, Version,
os.Args[1:], cli.RunServer)): Main takes the function that runs the
server, so its tests call it with one that fails them. Appname and
Version stay in main, where the build sets Version. Each subcommand
writes its error to a writer it is given, and 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 04:48:51 +00:00
31 changed files with 734 additions and 1263 deletions
+1 -4
View File
@@ -73,12 +73,9 @@
# This repository's own entries, after the shared content above: the # This repository's own entries, after the shared content above: the
# frontend build, the viewport test's output, the binary `make build` # frontend build, the viewport test's output, the binary `make build`
# writes and the reports `make run` stores, and Go test binaries, # writes and the reports `make run` stores, and log files at the root.
# coverage output and log files at the root.
/dist /dist
/tmp /tmp
/netwatch-server /netwatch-server
/data /data
/*.test
/*.out
/*.log /*.log
+2 -3
View File
@@ -11,8 +11,7 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
# This repository's own entries, after the shared content above.
[*.go] [*.go]
indent_style = tab indent_style = tab
# This repository's own sections, such as one for another language it
# uses, go below this comment, and a re-vendor keeps them.
-11
View File
@@ -1,20 +1,9 @@
name: check name: check
on: [push] on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: 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
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild - run: script/cibuild
+2 -4
View File
@@ -27,7 +27,7 @@ node_modules/
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are # convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds # re-included below. A repository that commits any other template adds
# its own negation at the end of this file, for example `!.env.example`. # its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV] *.[eE][nN][vV]
.[eE][nN][vV].* .[eE][nN][vV].*
.[eE][nN][vV][rR][cC] .[eE][nN][vV][rR][cC]
@@ -46,9 +46,7 @@ node_modules/
[iI][dD]_[eE][dD]25519 [iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK] [iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, such as its build outputs, go below # This repository's own entries, after the shared content above.
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
# Build output # Build output
dist/ dist/
-2
View File
@@ -25,8 +25,6 @@ linters:
# silenced by disabling that name, not by enabling the successor. # silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2 - gomodguard # Deprecated, replaced by gomodguard_v2
# Misses findings at random in v2.14.0; back once a pinned release fixes it
- canonicalheader
settings: settings:
lll: lll:
line-length: 88 line-length: 88
+29 -65
View File
@@ -41,13 +41,11 @@ The scripts cover both the frontend and the Go backend, so `make check` fails if
either one is broken. We provide: either one is broken. We provide:
- `script/bootstrap` — install all dependencies (the pinned node via nvm unless - `script/bootstrap` — install all dependencies (the pinned node via nvm unless
the `node` on `PATH` has the pinned major version and is new enough for the one new enough for the frontend's dependencies is installed, yarn via
frontend's dependencies, yarn `YARN_VERSION` via corepack, or npm where there corepack, `yarn install --frozen-lockfile`, Go `GO_VERSION` unless the `go` on
is no corepack, unless the `yarn` on `PATH` is exactly that version, `PATH` is exactly that version, and the Go modules), linking what it installs
`yarn install --frozen-lockfile`, Go `GO_VERSION` unless the `go` on `PATH` is itself into `~/.local/bin`, which has to be on `PATH`. It installs no Go
exactly that version, and the Go modules), linking what it installs itself linter and not Docker: `make test` and `make lint` run in Docker
into `~/.local/bin`, which has to be on `PATH`. It installs no Go linter and
not Docker: `make test` and `make lint` run in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the - `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook git pre-commit hook
- `script/dev` — run the Vite dev server, which proxies `/api` to a locally - `script/dev` — run the Vite dev server, which proxies `/api` to a locally
@@ -55,18 +53,11 @@ either one is broken. We provide:
- `script/build` — build the frontend for production into `dist/`, then the - `script/build` — build the frontend for production into `dist/`, then the
static `netwatch-server` binary into the repo root with its version stamped static `netwatch-server` binary into the repo root with its version stamped
in: `VERSION` from the environment, or when that is unset or empty, in: `VERSION` from the environment, or when that is unset or empty,
`git describe --tags --always --dirty` inside a git checkout, as `git describe` inside a git checkout, then `dev`
`script/docker` does, then `dev`
- `script/run` — build with `script/build`, then run `netwatch-server` with its - `script/run` — build with `script/build`, then run `netwatch-server` with its
defaults (see [Settings](#settings)) defaults (see [Settings](#settings))
- `script/clean` — remove what `script/build` writes - `script/clean` — remove what `script/build` writes
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/yarn` — run yarn with the given arguments under the node
`script/bootstrap` would use: with `~/.local/bin` first on `PATH`, the first
`node` on that `PATH` when it has the pinned major version and is new enough
for the frontend's dependencies, and otherwise the pinned node in nvm. Every
script that runs yarn on the host runs it through this one, except
`script/bootstrap`, which picks its own node and yarn
- `script/test` — build the `test` phase of `Dockerfile`, writing no image: the - `script/test` — build the `test` phase of `Dockerfile`, writing no image: the
frontend's unit tests in its `frontend-test` stage, and the backend's Go tests frontend's unit tests in its `frontend-test` stage, and the backend's Go tests
with the race detector and coverage. The frontend's production build is left with the race detector and coverage. The frontend's production build is left
@@ -208,9 +199,7 @@ stops. Its routes:
address may send a limited number a minute, and the report files are capped in address may send a limited number a minute, and the report files are capped in
size, the oldest deleted first size, the oldest deleted first
- `GET /.well-known/healthcheck` — answers 200 with `"status":"ok"`, the - `GET /.well-known/healthcheck` — answers 200 with `"status":"ok"`, the
server's version and its uptime; while reports cannot be stored, as the last server's version and its uptime
write of a report file failed, it answers 503 with `"status":"error"` and the
same fields (see [Report storage](#report-storage))
- `GET /metrics` — Prometheus metrics behind basic auth, only when - `GET /metrics` — Prometheus metrics behind basic auth, only when
`METRICS_USERNAME` and `METRICS_PASSWORD` are set; each client address may `METRICS_USERNAME` and `METRICS_PASSWORD` are set; each client address may
make a limited number of requests to it a minute make a limited number of requests to it a minute
@@ -220,8 +209,7 @@ packages are in `internal/`:
- **`cli`**: Runs `netwatch-server` from its command line: the `check-cidr` and - **`cli`**: Runs `netwatch-server` from its command line: the `check-cidr` and
`prepare-data-dir` subcommands, which `bin/entrypoint.sh` runs before it `prepare-data-dir` subcommands, which `bin/entrypoint.sh` runs before it
starts the server, and, given no arguments, the server, with the `fx` wiring. starts the server, and otherwise the server, with the `fx` wiring.
It refuses any other arguments with a usage message and exit code 2.
- **`config`**: Loads configuration from environment variables and config files - **`config`**: Loads configuration from environment variables and config files
via Viper. via Viper.
- **`handlers`**: HTTP request handlers for the API (health check, report - **`handlers`**: HTTP request handlers for the API (health check, report
@@ -289,11 +277,10 @@ The reports of a failed write stay in memory, and the next flush, within a
minute, tries again to write them. The server writes one file at a time, so a minute, tries again to write them. The server writes one file at a time, so a
file holds its reports in the order they arrived, those of a failed write file holds its reports in the order they arrived, those of a failed write
included. From the failed write until a write succeeds, `POST /api/v1/reports` included. From the failed write until a write succeeds, `POST /api/v1/reports`
refuses every report with 500 and keeps nothing of it, and the health check refuses every report with 500 and keeps nothing of it. The page sends the
answers 503 with `"status":"error"`. The page sends the refused samples again in refused samples again in its next report, as long as it still holds them: at the
its next report, as long as it still holds them: at the 1-second interval one 1-second interval one refused report already loses some of those in the history
refused report already loses some of those in the history (see (see [Reporting](#reporting)). Reports already answered 200 are lost only if the
[Reporting](#reporting)). Reports already answered 200 are lost only if the
server stops before a write succeeds; the final flush at stop tries them too, server stops before a write succeeds; the final flush at stop tries them too,
and if it fails, the process exits non-zero. and if it fails, the process exits non-zero.
@@ -413,36 +400,20 @@ Local hosts are tracked separately from WAN stats.
### Latency measurement ### Latency measurement
Each check is a GET request to the target's URL as written, with GET requests to each target's URL as written, with `mode: 'no-cors'` and
`mode: 'no-cors'`, `cache: 'no-store'` and `referrerPolicy: 'no-referrer'`. Its `cache: 'no-store'`, timed with `performance.now()`. No query string is added:
figure is the time from sending the request until the answer's headers arrive, the Hetzner speed-test servers close the connection without an answer when the
timed with `performance.now()`. Unlike ping, this includes the time the server URL has one, and `no-store` keeps the browser's cache out of the measurement.
takes to answer. It also includes the DNS lookup, connection setup and TLS Each check times out after 80% of the refresh interval (24 seconds at 30
whenever the browser opens a new connection. Chrome opens one for every check of seconds) and is then recorded as a timeout, so a round's checks have all
the Hetzner speed-test servers, and for more targets at longer intervals. finished before the next round is due. When no WAN host answers, a recovery
probe checks 4 WAN hosts, picked at random when it starts, every half second,
The S3 regions are checked with a HEAD request instead. Chrome keeps the giving up the checks it started half a second before. As soon as one answers, a
connection to an S3 region open after a HEAD request, but closes it after most new round starts at once, as it does after an interval change. A round started
GET requests. The Hetzner speed-test servers do not answer a HEAD request. early gives up the last round's checks if they are still waiting, and that round
records nothing more, so rounds never overlap. The browser chooses between IPv4
The browser follows every redirect, and the figure then includes the request to and IPv6 for each target, as for any request; the local targets are IPv4
the new URL too. So a target whose home page answers with a redirect is checked addresses.
at `/robots.txt` on the same host, which answers without one. An S3 region
answers it with 403, and any answer counts as reachable.
The Hetzner speed-test servers close the connection without an answer when the
URL has a query string or the request has a `Referer` header. So no query string
is added, and no `Referer` header is sent, wherever the page is served from.
`no-store` keeps the browser's cache out of the measurement. Each check times
out after 80% of the refresh interval (24 seconds at 30 seconds) and is then
recorded as a timeout, so a round's checks have all finished before the next
round is due. When no WAN host answers, a recovery probe checks 4 WAN hosts,
picked at random when it starts, every half second, giving up the checks it
started half a second before. As soon as one answers, a new round starts at
once, as it does after an interval change. A round started early gives up the
last round's checks if they are still waiting, and that round records nothing
more, so rounds never overlap. The browser chooses between IPv4 and IPv6 for
each target, as for any request; the local targets are IPv4 addresses.
Each recorded check that fails writes one line to the browser console with Each recorded check that fails writes one line to the browser console with
`console.error`, and the same line to the debug log: the target's name and URL, `console.error`, and the same line to the debug log: the target's name and URL,
@@ -583,16 +554,9 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
stops the container. Empty, the backend sends nothing to Sentry stops the container. Empty, the backend sends nothing to Sentry
- **Health check:** the image's `HEALTHCHECK` requests - **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless `/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless
both nginx and the backend answer. It also fails while reports cannot be both nginx and the backend answer. upaas reads the container's health 60
stored, as when the volume is full or `DATA_DIR` cannot be written: the health seconds after a deploy and fails the deploy unless it is `healthy`. The
check answers 503 from a failed write of a report file until a write succeeds, container also stops when either process exits.
and the backend tries the write again every minute. Three failed checks in a
row make the container `unhealthy`, and one that passes makes it `healthy`
again. upaas reads the container's health once, 60 seconds after a deploy, and
fails the deploy unless it is `healthy`. A container that turns `unhealthy`
after that is neither restarted nor reported by upaas, whose `unless-stopped`
restart policy ignores health; only `docker ps` and `docker inspect` show it.
The container also stops when either process exits.
## Browser Compatibility ## Browser Compatibility
+520 -558
View File
File diff suppressed because it is too large Load Diff
+63 -91
View File
@@ -13,27 +13,24 @@ set -u
# address and start anyway, and reports a bad port without naming # address and start anyway, and reports a bad port without naming
# PORT, so a value that is not a usable port stops the container here, # PORT, so a value that is not a usable port stops the container here,
# before either process starts. # before either process starts.
check_port() { export PORT="${PORT:-8080}"
export PORT="${PORT:-8080}" case "$PORT" in
case "$PORT" in *[!0-9]*)
*[!0-9]*) echo "entrypoint: PORT must be a port number, not '$PORT'" >&2
echo "entrypoint: PORT must be a port number, not '$PORT'" >&2
exit 1
;;
esac
# The length is checked first because, for a number too big for it,
# the shell's test prints an error and is false, so the range checks
# alone would let it through.
if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2
exit 1 exit 1
fi ;;
if [ "$PORT" -eq 8081 ]; then esac
echo "entrypoint: PORT cannot be 8081, netwatch-server" \ # The length is checked first because, for a number too big for it,
"listens there" >&2 # the shell's test prints an error and is false, so the range checks
exit 1 # alone would let it through.
fi if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
} echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2
exit 1
fi
if [ "$PORT" -eq 8081 ]; then
echo "entrypoint: PORT cannot be 8081, netwatch-server listens there" >&2
exit 1
fi
# TRUSTED_PROXIES names the reverse proxies in front of the container, # TRUSTED_PROXIES names the reverse proxies in front of the container,
# as IP addresses or CIDRs separated by commas. nginx takes the client # as IP addresses or CIDRs separated by commas. nginx takes the client
@@ -50,23 +47,21 @@ check_port() {
# TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one # TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one
# below, naming the entry as written. set -f keeps a * in an entry from # below, naming the entry as written. set -f keeps a * in an entry from
# becoming a list of file names. # becoming a list of file names.
write_trusted_proxies() { TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" set -f
set -f for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do case "$proxy" in
case "$proxy" in */*) cidr="$proxy" ;;
*/*) cidr="$proxy" ;; *:*) cidr="$proxy/128" ;;
*:*) cidr="$proxy/128" ;; *) cidr="$proxy/32" ;;
*) cidr="$proxy/32" ;; esac
esac if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then
if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \
echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \ "separated by commas; '$proxy' is neither" >&2
"separated by commas; '$proxy' is neither" >&2 exit 1
exit 1 fi
fi echo "set_real_ip_from $cidr;"
echo "set_real_ip_from $cidr;" done > /etc/nginx/trusted-proxies.conf
done > /etc/nginx/trusted-proxies.conf
}
# netwatch-server keeps its report files in DATA_DIR, on the /data # netwatch-server keeps its report files in DATA_DIR, on the /data
# volume, which may be a host directory owned by root or by another # volume, which may be a host directory owned by root or by another
@@ -78,10 +73,12 @@ write_trusted_proxies() {
# naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it # naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it
# acts on nothing outside /data, whatever symbolic or hard links it # acts on nothing outside /data, whatever symbolic or hard links it
# meets there. # meets there.
prepare_data_dir() { export DATA_DIR="${DATA_DIR:-/data/reports}"
export DATA_DIR="${DATA_DIR:-/data/reports}" netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
} # A stop signal is only noted here; the loop below acts on it.
stop_requested=""
trap 'stop_requested=yes' TERM INT
# netwatch-server runs as the netwatch user and listens on loopback # netwatch-server runs as the netwatch user and listens on loopback
# only, on a port other than the public one; configs/nginx.conf proxies # only, on a port other than the public one; configs/nginx.conf proxies
@@ -90,11 +87,9 @@ prepare_data_dir() {
# the container has. The netwatch user has no login shell, hence -s # the container has. The netwatch user has no login shell, hence -s
# /bin/sh. busybox su replaces itself with the command instead of # /bin/sh. busybox su replaces itself with the command instead of
# staying on as its parent, so $! is the server's own PID. # staying on as its parent, so $! is the server's own PID.
start_backend() { BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \
BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \ su -s /bin/sh netwatch -c 'exec netwatch-server' &
su -s /bin/sh netwatch -c 'exec netwatch-server' & backend=$!
backend=$!
}
# nginx starts through the nginx image's own entrypoint, which applies # nginx starts through the nginx image's own entrypoint, which applies
# the image's start-up configuration and then replaces itself with # the image's start-up configuration and then replaces itself with
@@ -103,11 +98,9 @@ start_backend() {
# that rendering to PORT: a variable nginx itself uses, such as $uri, # that rendering to PORT: a variable nginx itself uses, such as $uri,
# would otherwise be replaced by an environment variable of the same # would otherwise be replaced by an environment variable of the same
# name. # name.
start_nginx() { NGINX_ENVSUBST_FILTER='^PORT$' \
NGINX_ENVSUBST_FILTER='^PORT$' \ /docker-entrypoint.sh nginx -g 'daemon off;' &
/docker-entrypoint.sh nginx -g 'daemon off;' & nginx=$!
nginx=$!
}
running() { running() {
kill -0 "$1" 2>/dev/null kill -0 "$1" 2>/dev/null
@@ -116,47 +109,26 @@ running() {
# POSIX sh cannot wait for whichever of two children exits first, so # POSIX sh cannot wait for whichever of two children exits first, so
# look once a second. The shell collects a child that has exited while # look once a second. The shell collects a child that has exited while
# it runs sleep, and running() is false for that child from then on. # it runs sleep, and running() is false for that child from then on.
wait_for_stop_or_exit() { while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do sleep 1
sleep 1 done
done
}
# Stop both, then wait until neither is left. # Stop both, then wait until neither is left.
stop_both() { kill -TERM "$backend" "$nginx" 2>/dev/null
kill -TERM "$backend" "$nginx" 2>/dev/null while running "$backend" || running "$nginx"; do
while running "$backend" || running "$nginx"; do sleep 1
sleep 1 done
done
} wait "$backend"
backend_status=$?
wait "$nginx"
nginx_status=$?
echo "entrypoint: netwatch-server exited $backend_status," \
"nginx exited $nginx_status"
# Success is a requested stop that both processes exited cleanly from. # Success is a requested stop that both processes exited cleanly from.
exit_with_status() { if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] &&
wait "$backend" [ "$nginx_status" -eq 0 ]; then
backend_status=$? exit 0
wait "$nginx" fi
nginx_status=$? exit 1
echo "entrypoint: netwatch-server exited $backend_status," \
"nginx exited $nginx_status"
if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] &&
[ "$nginx_status" -eq 0 ]; then
exit 0
fi
exit 1
}
main() {
check_port
write_trusted_proxies
prepare_data_dir
# A stop signal is only noted here; wait_for_stop_or_exit acts on it.
stop_requested=""
trap 'stop_requested=yes' TERM INT
start_backend
start_nginx
wait_for_stop_or_exit
stop_both
exit_with_status
}
main "$@"
+1 -1
View File
@@ -15,5 +15,5 @@ var (
) )
func main() { func main() {
os.Exit(cli.Main(os.Stderr, Appname, Version, os.Args[1:], cli.RunServer)) os.Exit(cli.Main(Appname, Version, os.Args[1:], cli.RunServer))
} }
+4 -3
View File
@@ -8,9 +8,10 @@ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" alway
# Scripts and styles load only from the page's own origin. Inline ones # Scripts and styles load only from the page's own origin. Inline ones
# are blocked, style attributes in markup included, so style elements # are blocked, style attributes in markup included, so style elements
# through classes or element.style. data: images are for the favicon # through classes or element.style. data: images are for the favicon
# in web/index.html. connect-src is * because the page checks every # in web/index.html. connect-src is * because the browser checks each
# target in web/src/main.js. A list of hosts here would have to change # probe in web/src/main.js against it, and also every redirect the probe
# with every change to those targets. It also covers the reports the # follows, and several of those hosts redirect to others; a list of
# hosts here would block those probes. It also covers the reports the
# page sends to its own origin. # page sends to its own origin.
add_header Content-Security-Policy "default-src 'self'; connect-src *; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; connect-src *; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
+15 -30
View File
@@ -1,12 +1,13 @@
// Package cli runs netwatch-server from its command line: the // Package cli runs netwatch-server from its command line: the
// check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh // check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh
// runs before it starts the server, and, given no arguments, the // runs before it starts the server, and otherwise the server, with the
// server, with the fx wiring. // fx wiring.
package cli package cli
import ( import (
"fmt" "fmt"
"io" "io"
"os"
"os/user" "os/user"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
@@ -22,23 +23,16 @@ import (
"go.uber.org/fx/fxevent" "go.uber.org/fx/fxevent"
) )
// usageExitCode is what Main returns for a command line it does not
// understand, the usual exit code for a usage error.
const usageExitCode = 2
// Main runs netwatch-server with args, its command line after the // Main runs netwatch-server with args, its command line after the
// program's name, and returns the exit code. With no args, Main runs // program's name, and returns the exit code. Unless args are
// the server by calling serve with appname and version, the name and // "check-cidr CIDR" or "prepare-data-dir DATA_DIR", Main runs the
// version the server logs and reports. With "check-cidr CIDR" or // server by calling serve with appname and version, the name and
// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its // version the server logs and reports. main passes RunServer; the
// errors to stderr. Any other args get a one-line usage message on // tests pass a function that fails them, so that no test starts the
// stderr and exit code 2. main passes os.Stderr and RunServer; the // server. When serve is RunServer and the server fails to start or to
// tests pass a buffer and a function of their own, so that no test // stop, fx's Run exits the process with status 1 and Main never
// starts the server. When serve is RunServer and the server fails to // returns.
// start, to listen or to stop, fx's Run exits the process with status 1
// and Main never returns.
func Main( func Main(
stderr io.Writer,
appname, version string, appname, version string,
args []string, args []string,
serve func(appname, version string), serve func(appname, version string),
@@ -47,7 +41,7 @@ func Main(
// this server would refuse CIDR in its TRUSTED_PROXIES. // this server would refuse CIDR in its TRUSTED_PROXIES.
// bin/entrypoint.sh runs it on each entry it gives nginx. // bin/entrypoint.sh runs it on each entry it gives nginx.
if len(args) == 2 && args[0] == "check-cidr" { if len(args) == 2 && args[0] == "check-cidr" {
return checkCIDR(stderr, args[1]) return checkCIDR(os.Stderr, args[1])
} }
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready // "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
@@ -55,16 +49,7 @@ func Main(
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root // reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
// before it starts this server as that user. // before it starts this server as that user.
if len(args) == 2 && args[0] == "prepare-data-dir" { if len(args) == 2 && args[0] == "prepare-data-dir" {
return prepareDataDir(stderr, "/data", args[1], "netwatch") return prepareDataDir(os.Stderr, "/data", args[1], "netwatch")
}
// Any other command line is refused, so that a mistyped one does
// not start the server.
if len(args) > 0 {
_, _ = fmt.Fprintln(stderr,
"usage: netwatch-server [check-cidr CIDR | prepare-data-dir DATA_DIR]")
return usageExitCode
} }
serve(appname, version) serve(appname, version)
@@ -110,8 +95,8 @@ func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
// RunServer runs the server until it is told to stop, with appname and // RunServer runs the server until it is told to stop, with appname and
// version as the name and version it logs and reports. If the server // version as the name and version it logs and reports. If the server
// fails to start, to listen or to stop, fx's Run exits the process // fails to start or to stop, fx's Run exits the process with status 1
// with status 1 itself. // itself.
func RunServer(appname, version string) { func RunServer(appname, version string) {
globals.Appname = appname globals.Appname = appname
globals.Version = version globals.Version = version
+17 -91
View File
@@ -13,106 +13,32 @@ import (
"sneak.berlin/go/netwatch/internal/reportbuf" "sneak.berlin/go/netwatch/internal/reportbuf"
) )
// TestMainRunsCheckCIDR: Main given check-cidr returns 0, writing // TestMainRunsCheckCIDR: Main given check-cidr returns 0 for a CIDR the
// nothing, for a CIDR the server takes in its TRUSTED_PROXIES, and 1, // server takes in its TRUSTED_PROXIES and 1 for one it refuses, and
// with an error naming TRUSTED_PROXIES and the CIDR, for one it // does not start the server.
// refuses, and does not start the server.
func TestMainRunsCheckCIDR(t *testing.T) { func TestMainRunsCheckCIDR(t *testing.T) {
t.Parallel() t.Parallel()
var stderr bytes.Buffer for cidr, want := range map[string]int{"10.0.0.0/8": 0, "1.2.3/32": 1} {
code := cli.Main("netwatch", "test", []string{"check-cidr", cidr},
code := cli.Main(&stderr, "netwatch", "test", failIfServerStarts(t))
[]string{"check-cidr", "10.0.0.0/8"}, failIfServerStarts(t)) if code != want {
if code != 0 || stderr.Len() != 0 { t.Errorf("check-cidr %q: exit %d, want %d", cidr, code, want)
t.Errorf("check-cidr 10.0.0.0/8: exit %d, stderr %q; want exit 0 and no output", }
code, stderr.String())
}
stderr.Reset()
code = cli.Main(&stderr, "netwatch", "test",
[]string{"check-cidr", "1.2.3/32"}, failIfServerStarts(t))
want := `TRUSTED_PROXIES "1.2.3/32": `
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
t.Errorf("check-cidr 1.2.3/32: exit %d, stderr %q; want exit 1 and %q...",
code, stderr.String(), want)
} }
} }
// TestMainRunsPrepareDataDir: Main given prepare-data-dir runs the // TestMainRunsPrepareDataDir: Main given prepare-data-dir returns 1 for
// prepare-data-dir subcommand for the netwatch user, and does not start // a DATA_DIR outside /data, and does not start the server. The test
// the server. The test image has no netwatch user, so that subcommand // image has no netwatch user, so there the 1 comes from looking that
// exits 1 with the error from looking the user up; // user up; TestPrepareDataDirRefusesPathOutsideVolume checks the path.
// TestPrepareDataDirRefusesPathOutsideVolume checks the path.
func TestMainRunsPrepareDataDir(t *testing.T) { func TestMainRunsPrepareDataDir(t *testing.T) {
t.Parallel() t.Parallel()
var stderr bytes.Buffer code := cli.Main("netwatch", "test", []string{"prepare-data-dir", "/etc"},
failIfServerStarts(t))
code := cli.Main(&stderr, "netwatch", "test", if code != 1 {
[]string{"prepare-data-dir", "/etc"}, failIfServerStarts(t)) t.Errorf("prepare-data-dir /etc: exit %d, want 1", code)
want := user.UnknownUserError("netwatch").Error() + "\n"
if code != 1 || stderr.String() != want {
t.Errorf("prepare-data-dir /etc: exit %d, stderr %q; want exit 1 and %q",
code, stderr.String(), want)
}
}
// TestMainRunsServerWithNoArguments: Main given no arguments starts the
// server with the name and version it was given, writes nothing and
// returns 0.
func TestMainRunsServerWithNoArguments(t *testing.T) {
t.Parallel()
var (
stderr bytes.Buffer
gotAppname, gotVersion string
)
code := cli.Main(&stderr, "netwatch", "test", []string{},
func(appname, version string) {
gotAppname, gotVersion = appname, version
})
if code != 0 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
code, stderr.String())
}
if gotAppname != "netwatch" || gotVersion != "test" {
t.Errorf("server started with %q %q; want %q %q",
gotAppname, gotVersion, "netwatch", "test")
}
}
// TestMainRefusesOtherCommandLines: Main given a subcommand with too
// few or too many values, or a misspelt subcommand, writes the usage
// message to stderr, returns 2 and does not start the server.
func TestMainRefusesOtherCommandLines(t *testing.T) {
t.Parallel()
want := "usage: netwatch-server " +
"[check-cidr CIDR | prepare-data-dir DATA_DIR]\n"
for _, commandLine := range []string{
"netwatch-server check-cidr",
"netwatch-server check-cidr 192.0.2.0/24 198.51.100.0/24",
"netwatch-server prepare-data-dir",
"netwatch-server prepare-data-dir /data/reports /data/other",
"netwatch-server check-cdir 192.0.2.0/24",
} {
var stderr bytes.Buffer
// Main takes the command line after the program's name.
args := strings.Fields(commandLine)[1:]
code := cli.Main(&stderr, "netwatch", "test", args, failIfServerStarts(t))
if code != 2 || stderr.String() != want {
t.Errorf("%q: exit %d, stderr %q; want exit 2 and %q",
commandLine, code, stderr.String(), want)
}
} }
} }
+4 -4
View File
@@ -13,8 +13,8 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
// requireConfigError builds the config as cli.RunServer does and fails // requireConfigError builds the config as main does and fails the
// the test unless that fails with an error naming setting. It uses // test unless that fails with an error naming setting. It uses
// fx.New, because fxtest.New fails the test itself on an error. // fx.New, because fxtest.New fails the test itself on an error.
func requireConfigError(t *testing.T, setting string) { func requireConfigError(t *testing.T, setting string) {
t.Helper() t.Helper()
@@ -31,8 +31,8 @@ func requireConfigError(t *testing.T, setting string) {
} }
} }
// requireTrustedProxies builds the config as cli.RunServer does and // requireTrustedProxies builds the config as main does and fails the
// fails the test unless its TrustedProxies are want. // test unless its TrustedProxies are want.
func requireTrustedProxies(t *testing.T, want []string) { func requireTrustedProxies(t *testing.T, want []string) {
t.Helper() t.Helper()
+2 -10
View File
@@ -3,17 +3,9 @@ package handlers
import "net/http" import "net/http"
// HandleHealthCheck returns a handler for the health check // HandleHealthCheck returns a handler for the health check
// endpoint. It answers 200 while the status is "ok" and 503 otherwise, // endpoint.
// so that the image's HEALTHCHECK fails.
func (s *Handlers) HandleHealthCheck() http.HandlerFunc { func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
resp := s.hc.Healthcheck() s.respondJSON(w, r, s.hc.Healthcheck(), http.StatusOK)
code := http.StatusOK
if resp.Status != "ok" {
code = http.StatusServiceUnavailable
}
s.respondJSON(w, r, resp, code)
} }
} }
+9 -22
View File
@@ -9,19 +9,17 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers" "sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck" "sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
) )
// newStartedHandlers builds Handlers with a real health check for the // newStartedHandlers builds Handlers with a real health check for the
// server named in g, and a report buffer in a temporary directory, and // server named in g, and starts them, which records the time the
// starts them, which records the time the uptime counts from. // uptime counts from.
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers { func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Helper() t.Helper()
@@ -32,23 +30,14 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Fatalf("logger: %v", err) t.Fatalf("logger: %v", err)
} }
buf, err := reportbuf.New(lc, reportbuf.Params{
Config: &config.Config{DataDir: t.TempDir(), DataDirMaxBytes: 1 << 20},
Logger: log,
})
if err != nil {
t.Fatalf("report buffer: %v", err)
}
hc, err := healthcheck.New(lc, hc, err := healthcheck.New(lc,
healthcheck.Params{Buffer: buf, Globals: g, Logger: log}) healthcheck.Params{Globals: g, Logger: log})
if err != nil { if err != nil {
t.Fatalf("health check: %v", err) t.Fatalf("health check: %v", err)
} }
h, err := handlers.New(lc, handlers.Params{ h, err := handlers.New(lc,
Buffer: buf, Globals: g, Healthcheck: hc, Logger: log, handlers.Params{Globals: g, Healthcheck: hc, Logger: log})
})
if err != nil { if err != nil {
t.Fatalf("handlers: %v", err) t.Fatalf("handlers: %v", err)
} }
@@ -59,12 +48,10 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
return h return h
} }
// TestHandleHealthCheck checks the health check's answer at start, when // TestHandleHealthCheck checks the health check's answer: 200, a JSON
// no write of a report file has failed: 200, a JSON content type, and a // content type, and a JSON object with exactly the fields of
// JSON object with exactly the fields of healthcheck.HealthcheckResponse, // healthcheck.HealthcheckResponse, carrying this server's name and
// carrying "status":"ok", this server's name and version and an uptime // version and an uptime counted from its start.
// counted from its start. The answer while writes fail is checked in
// internal/reportbuf, whose tests can make a write fail.
func TestHandleHealthCheck(t *testing.T) { func TestHandleHealthCheck(t *testing.T) {
t.Parallel() t.Parallel()
+2 -10
View File
@@ -10,7 +10,6 @@ import (
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -19,7 +18,6 @@ import (
type Params struct { type Params struct {
fx.In fx.In
Buffer *reportbuf.Buffer
Config *config.Config Config *config.Config
Globals *globals.Globals Globals *globals.Globals
Logger *logger.Logger Logger *logger.Logger
@@ -70,18 +68,12 @@ func New(
return s, nil return s, nil
} }
// Healthcheck returns the current health status of the application: // Healthcheck returns the current health status of the application.
// "ok", or "error" while report files cannot be written.
func (s *Healthcheck) Healthcheck() *HealthcheckResponse { func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
status := "ok"
if s.params.Buffer.LastWriteFailed() {
status = "error"
}
return &HealthcheckResponse{ return &HealthcheckResponse{
Appname: s.params.Globals.Appname, Appname: s.params.Globals.Appname,
Now: time.Now().UTC().Format(time.RFC3339Nano), Now: time.Now().UTC().Format(time.RFC3339Nano),
Status: status, Status: "ok",
UptimeHuman: s.uptime().String(), UptimeHuman: s.uptime().String(),
UptimeSeconds: int64(s.uptime().Seconds()), UptimeSeconds: int64(s.uptime().Seconds()),
Version: s.params.Globals.Version, Version: s.params.Globals.Version,
-10
View File
@@ -255,16 +255,6 @@ func (b *Buffer) Append(v any) error {
return nil return nil
} }
// LastWriteFailed reports whether the last write of a report file to
// finish failed: false at start until a write fails, and false again
// once a write succeeds. While it is true, Append refuses reports.
func (b *Buffer) LastWriteFailed() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.writeErr != nil
}
// deleteOldestFiles deletes report files, oldest first, until n more // deleteOldestFiles deletes report files, oldest first, until n more
// bytes fit under maxBytes. It deletes none when the reports waiting // bytes fit under maxBytes. It deletes none when the reports waiting
// to be written leave no room for n even with every file gone, since // to be written leave no room for n even with every file gone, since
+7 -85
View File
@@ -334,9 +334,9 @@ func stopWhileHeld(
return <-stopped return <-stopped
} }
// startApp starts a Buffer through fx, as cli.RunServer does, with the // startApp starts a Buffer through fx, as main does, with the DATA_DIR
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set, and returns // and DATA_DIR_MAX_BYTES the calling test has set, and returns it with
// it with the app, for tests that stop the app themselves. // the app, for tests that stop the app themselves.
func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) { func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
t.Helper() t.Helper()
@@ -358,8 +358,8 @@ func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
return buf, app return buf, app
} }
// startBuffer starts a Buffer through fx, as cli.RunServer does, with // startBuffer starts a Buffer through fx, as main does, with the
// the DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set. // DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
func startBuffer(t *testing.T) *reportbuf.Buffer { func startBuffer(t *testing.T) *reportbuf.Buffer {
t.Helper() t.Helper()
@@ -1074,54 +1074,9 @@ func TestReportsRefusedWhileWritesFail(t *testing.T) {
} }
} }
// TestHealthCheckFailsWhileWritesFail asks the health check handler
// for its answer around a failed write. It answers 200 with
// "status":"ok" at start, 503 with "status":"error" from the failed
// write until a write succeeds, and 200 with "status":"ok" again after.
func TestHealthCheckFailsWhileWritesFail(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
buf, h := startBufferWithHandlers(t)
checkHealthCheck(t, h, "at start", http.StatusOK, "ok")
// A flush writes a file only when a report is waiting.
err := buf.Append(map[string]string{"id": "kept"})
if err != nil {
t.Fatalf("append: %v", err)
}
// With the data directory gone, the write has nowhere to go.
err = os.Remove(dir)
if err != nil {
t.Fatalf("remove data dir: %v", err)
}
err = buf.Flush()
if err == nil {
t.Fatal("flush succeeded with the data directory gone")
}
checkHealthCheck(t, h, "after the failed write",
http.StatusServiceUnavailable, "error")
err = os.Mkdir(dir, 0o750)
if err != nil {
t.Fatalf("make data dir: %v", err)
}
err = buf.Flush()
if err != nil {
t.Fatalf("flush with the data directory back: %v", err)
}
checkHealthCheck(t, h, "after a write succeeded", http.StatusOK, "ok")
}
// startBufferWithHandlers starts a Buffer and the Handlers that store // startBufferWithHandlers starts a Buffer and the Handlers that store
// reports in it through fx, as cli.RunServer does, with the DATA_DIR // reports in it through fx, as main does, with the DATA_DIR the calling
// the calling test has set. // test has set.
func startBufferWithHandlers( func startBufferWithHandlers(
t *testing.T, t *testing.T,
) (*reportbuf.Buffer, *handlers.Handlers) { ) (*reportbuf.Buffer, *handlers.Handlers) {
@@ -1166,39 +1121,6 @@ func postReport(t *testing.T, h *handlers.Handlers, clientID string) int {
return rec.Code return rec.Code
} }
// checkHealthCheck asks the health check handler for its answer, and
// fails the test, saying when it asked, unless the answer has status
// code wantCode and a "status" field of wantStatus.
func checkHealthCheck(
t *testing.T,
h *handlers.Handlers,
when string,
wantCode int,
wantStatus string,
) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
h.HandleHealthCheck().ServeHTTP(rec, req)
var body struct {
Status string `json:"status"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("health check %s: body not JSON: %v (%q)",
when, err, rec.Body.String())
}
if rec.Code != wantCode || body.Status != wantStatus {
t.Fatalf("health check %s: %d with status %q, want %d with %q",
when, rec.Code, body.Status, wantCode, wantStatus)
}
}
// TestConcurrentAppendsStopAtCap appends from many goroutines at once // TestConcurrentAppendsStopAtCap appends from many goroutines at once
// with room for exactly roomFor reports: exactly that many must be // with room for exactly roomFor reports: exactly that many must be
// taken, which holds only if Append checks and counts each report // taken, which holds only if Append checks and counts each report
+3 -3
View File
@@ -22,9 +22,9 @@ import (
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
) )
// newServer builds a Server from the same constructors as // newServer builds a Server from the same constructors as main,
// cli.RunServer, configured from the environment. It is never started, // configured from the environment. It is never started, so nothing
// so nothing listens. // listens.
func newServer(t *testing.T) *server.Server { func newServer(t *testing.T) *server.Server {
t.Helper() t.Helper()
+1 -1
View File
@@ -79,7 +79,7 @@ func New(
s.SetupRoutes() s.SetupRoutes()
s.httpServer = s.newHTTPServer() s.httpServer = s.newHTTPServer()
go func() { s.listenAndServe() }() go s.listenAndServe()
return nil return nil
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "netwatch", "name": "netwatch",
"private": true, "version": "1.0.0",
"description": "Real-time network latency monitor SPA", "description": "Real-time network latency monitor SPA",
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -23,8 +23,8 @@ main() {
"" | -* | *[[:space:]]*) usage ;; "" | -* | *[[:space:]]*) usage ;;
esac esac
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" add --dev "$1" yarn add --dev "$1"
"$ROOT/script/yarn" install --frozen-lockfile yarn install --frozen-lockfile
} }
main "$@" main "$@"
+14 -55
View File
@@ -3,14 +3,11 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is # or apk (detected in that order); assumes nothing is present. Node is
# used directly only if it has the major version of NODE_VERSION and is # used directly if it is at least NODE_MIN_VERSION; otherwise it is
# at least NODE_MIN_VERSION; otherwise NODE_VERSION is installed via nvm # installed at a pinned version via nvm (installing nvm itself first,
# (installing nvm itself first, from a hash-verified release archive, # from a hash-verified release archive, never curl | sh). Go, with its
# never curl | sh). Yarn is used directly only if it is exactly # gofmt, is used directly only if it is exactly GO_VERSION; otherwise
# YARN_VERSION; otherwise that version is prepared through corepack, # that release is installed from its hash-verified archive.
# or installed with npm where there is no corepack.
# Go, with its gofmt, is used directly only if it is exactly GO_VERSION;
# otherwise that release is installed from its hash-verified archive.
# #
# What this script installs outside the system package manager lives # What this script installs outside the system package manager lives
# under $HOME and is linked into ~/.local/bin, where make and the git # under $HOME and is linked into ~/.local/bin, where make and the git
@@ -145,63 +142,32 @@ ensure_nvm() {
rm -rf "$tmp" rm -rf "$tmp"
} }
# node_ok: the node on PATH has the major version of NODE_VERSION and is # node_ok: the node on PATH is at least NODE_MIN_VERSION. node itself
# at least NODE_MIN_VERSION. Only the major is compared with the pin, # compares the two: major, then minor, then patch.
# as REPO_POLICIES.md asks. node itself compares the versions: major,
# then minor, then patch. A node that fails does not pass.
# script/bootstrap and script/yarn carry this function unchanged.
node_ok() { node_ok() {
if ! command -v node >/dev/null 2>&1; then return 1; fi if missing node; then return 1; fi
node -e ' node -e '
const have = process.versions.node.split(".").map(Number); const have = process.versions.node.split(".").map(Number);
const want = process.argv[1].split(".").map(Number); const want = process.argv[1].split(".").map(Number);
const pinned = process.argv[2].split(".").map(Number);
if (have[0] !== pinned[0]) process.exit(1);
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1); if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1);
} }
' "$NODE_MIN_VERSION" "$NODE_VERSION" ' "$NODE_MIN_VERSION"
} }
# ensure_node: unless node_ok, install NODE_VERSION and link its node. # ensure_node: unless node_ok, install NODE_VERSION and link its node.
# Afterwards node is looked up through PATH again and must pass node_ok.
ensure_node() { ensure_node() {
if node_ok; then if node_ok; then return 0; fi
echo "bootstrap: using node $(node --version)"
return 0
fi
ensure_nvm ensure_nvm
nvm_sh "nvm install $NODE_VERSION" nvm_sh "nvm install $NODE_VERSION"
link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node
hash -r
if ! node_ok; then
echo "bootstrap: after installing node $NODE_VERSION through nvm," >&2
echo " the node on PATH, $(command -v node), is not it" >&2
exit 1
fi
echo "bootstrap: installed node $(node --version)"
} }
# yarn_ok: yarn --version on PATH prints exactly YARN_VERSION, compared # ensure_yarn: corepack writes its shims (pnpm and yarnpkg as well as
# over the whole version. A yarn that fails or prints anything else does # yarn) into $TOOLCHAIN rather than next to itself, and the npm fallback
# not pass. # installs there too; only yarn is linked.
yarn_ok() {
if missing yarn; then return 1; fi
version="$(yarn --version 2>/dev/null)" || return 1
[ "$version" = "$YARN_VERSION" ]
}
# ensure_yarn: unless yarn_ok, prepare YARN_VERSION and link its yarn.
# corepack writes its shims (pnpm and yarnpkg as well as yarn) into
# $TOOLCHAIN rather than next to itself, and the npm fallback installs
# there too; only yarn is linked. Afterwards yarn is looked up through
# PATH again and must pass yarn_ok, so another yarn that hides the link
# stops bootstrap.
ensure_yarn() { ensure_yarn() {
if yarn_ok; then if ! missing yarn; then return 0; fi
echo "bootstrap: using yarn $(yarn --version)"
return 0
fi
shims="$TOOLCHAIN/corepack-shims" shims="$TOOLCHAIN/corepack-shims"
mkdir -p "$shims" mkdir -p "$shims"
if ! missing corepack; then if ! missing corepack; then
@@ -216,13 +182,6 @@ ensure_yarn() {
shims="$TOOLCHAIN/npm-global/bin" shims="$TOOLCHAIN/npm-global/bin"
fi fi
link_bin "$shims/yarn" yarn link_bin "$shims/yarn" yarn
hash -r
if ! yarn_ok; then
echo "bootstrap: after installing yarn $YARN_VERSION in $shims," >&2
echo " the yarn on PATH, $(command -v yarn), does not report it" >&2
exit 1
fi
echo "bootstrap: installed yarn $(yarn --version)"
} }
# go_ok: the go on PATH has its gofmt beside it (a Go release ships the # go_ok: the go on PATH has its gofmt beside it (a Go release ships the
+4 -5
View File
@@ -8,12 +8,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" build yarn build
# VERSION comes from the environment. Unset or empty, it is # VERSION comes from the environment. Unset or empty, it is git
# `git describe --tags --always --dirty`, as in script/docker, or # describe, or "dev" where there is no git or no repository history.
# "dev" where there is no git or no repository history. version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}"
version="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
CGO_ENABLED=0 go build -trimpath \ CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.Version=$version" \ -ldflags "-s -w -X main.Version=$version" \
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" dev yarn dev
} }
main "$@" main "$@"
+1 -1
View File
@@ -10,7 +10,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" prettier --write '**/*.{js,css,html,md}' yarn prettier --write '**/*.{js,css,html,md}'
} }
main "$@" main "$@"
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" prettier --check '**/*.{js,css,html,md}' yarn prettier --check '**/*.{js,css,html,md}'
} }
main "$@" main "$@"
+3 -5
View File
@@ -14,14 +14,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
NODE_OPTIONS=--test-reporter=dot \ NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || {
timeout 90 "$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 yarn --silent run test
timeout 90 "$ROOT/script/yarn" --silent run test
exit 1 exit 1
} }
timeout 30 "$ROOT/script/yarn" build timeout 30 yarn build
} }
main "$@" main "$@"
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# script/yarn: run yarn with the given arguments under the node
# script/bootstrap would use. Every script that runs yarn on the host
# runs it through this one, except script/bootstrap, which picks its
# own node and yarn.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pins in script/bootstrap.
NODE_VERSION="22.17.0"
NODE_MIN_VERSION="22.13.0"
# node_ok: the node on PATH has the major version of NODE_VERSION and is
# at least NODE_MIN_VERSION. Only the major is compared with the pin,
# as REPO_POLICIES.md asks. node itself compares the versions: major,
# then minor, then patch. A node that fails does not pass.
# script/bootstrap and script/yarn carry this function unchanged.
node_ok() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
node -e '
const have = process.versions.node.split(".").map(Number);
const want = process.argv[1].split(".").map(Number);
const pinned = process.argv[2].split(".").map(Number);
if (have[0] !== pinned[0]) process.exit(1);
for (let i = 0; i < 3; i++) {
if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1);
}
' "$NODE_MIN_VERSION" "$NODE_VERSION"
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH passes node_ok, and otherwise yarn under the pinned node
# in nvm. nvm is a bash script, hence the subshell.
run_yarn() {
if node_ok && command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if node_ok || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "script/yarn: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
# script/bootstrap links the node and yarn it installs into
# ~/.local/bin, which the shell that called it may not have on PATH.
PATH="$HOME/.local/bin:$PATH"
run_yarn "$@"
}
main "$@"
-85
View File
@@ -180,91 +180,6 @@ test("a check fetches the target's URL as written, with no query string added",
); );
}); });
test("a check sends no Referer header, wherever the page is served from", async (t) => {
mockTargets(t, () => 10);
const check = measureLatency("https://fsn1-speed.hetzner.com");
t.mock.timers.tick(10);
assert.notEqual(await settled(check), "still waiting");
assert.equal(
fetch.mock.calls[0].arguments[1].referrerPolicy,
"no-referrer",
);
});
// These targets' home pages answer with a redirect. A check follows it and
// times the request to the new URL too, often on another host. The Google
// Cloud Console redirects only requests that do not say they come from
// another site, such as curl's. Each target is checked at /robots.txt on
// its own host, which answers without a redirect. An S3 region answers it
// with 403, and any answer counts as reachable.
test("a target whose home page redirects is checked at /robots.txt on its own host", () => {
const checked = new Map(
new AppState([]).wan.map((host) => [host.name, host.url]),
);
for (const [name, url] of [
["AWS Console", "https://console.aws.amazon.com/robots.txt"],
["Google Cloud Console", "https://console.cloud.google.com/robots.txt"],
["Akamai", "https://www.akamai.com/robots.txt"],
["B2", "https://api.backblazeb2.com/robots.txt"],
[
"S3 af-south-1 (Cape Town)",
"https://s3.af-south-1.amazonaws.com/robots.txt",
],
[
"S3 eu-west-2 (London)",
"https://s3.eu-west-2.amazonaws.com/robots.txt",
],
[
"S3 me-south-1 (Bahrain)",
"https://s3.me-south-1.amazonaws.com/robots.txt",
],
[
"S3 ap-northeast-1 (Tokyo)",
"https://s3.ap-northeast-1.amazonaws.com/robots.txt",
],
[
"S3 ap-southeast-1 (Singapore)",
"https://s3.ap-southeast-1.amazonaws.com/robots.txt",
],
[
"S3 ap-southeast-2 (Sydney)",
"https://s3.ap-southeast-2.amazonaws.com/robots.txt",
],
[
"S3 us-west-2 (Oregon)",
"https://s3.us-west-2.amazonaws.com/robots.txt",
],
[
"S3 sa-east-1 (São Paulo)",
"https://s3.sa-east-1.amazonaws.com/robots.txt",
],
]) {
assert.equal(checked.get(name), url);
}
});
// Chrome keeps the connection to an S3 region open after a HEAD check, but
// closes it after most GET checks. The Hetzner servers do not answer HEAD.
test("a round checks each S3 region with a HEAD request and every other target with a GET request", async (t) => {
const state = new AppState([
{ name: "Local CPE", url: "http://192.168.100.1" },
]);
mockTargets(t, () => 10);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(10);
assert.notEqual(await settled(round), "still waiting");
const methods = new Map(
fetch.mock.calls.map((call) => [
call.arguments[0],
call.arguments[1].method,
]),
);
for (const host of state.allHosts) {
const method = host.name.startsWith("S3 ") ? "HEAD" : "GET";
assert.equal(methods.get(host.url), method, host.name);
}
});
test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => { test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
+25 -44
View File
@@ -67,66 +67,51 @@ export const CONFIG = {
// WAN endpoints to monitor. These are used for the aggregate health/stats // WAN endpoints to monitor. These are used for the aggregate health/stats
// display (min/max/avg). Ordered: personal, LLM APIs, big-3 cloud, then // display (min/max/avg). Ordered: personal, LLM APIs, big-3 cloud, then
// CDN/hosting/other. A check follows a redirect and times the request to // CDN/hosting/other.
// the new URL too. So a target whose home page redirects is checked at
// /robots.txt on its own host. That URL answers without a redirect.
const WAN_HOSTS = [ const WAN_HOSTS = [
{ name: "datavi.be", url: "https://datavi.be" }, { name: "datavi.be", url: "https://datavi.be" },
{ name: "Anthropic API", url: "https://api.anthropic.com" }, { name: "Anthropic API", url: "https://api.anthropic.com" },
{ name: "OpenAI API", url: "https://api.openai.com" }, { name: "OpenAI API", url: "https://api.openai.com" },
{ name: "AWS Console", url: "https://console.aws.amazon.com/robots.txt" }, { name: "AWS Console", url: "https://console.aws.amazon.com" },
{ { name: "Google Cloud Console", url: "https://console.cloud.google.com" },
name: "Google Cloud Console",
url: "https://console.cloud.google.com/robots.txt",
},
{ name: "Microsoft Azure", url: "https://portal.azure.com" }, { name: "Microsoft Azure", url: "https://portal.azure.com" },
{ name: "Cloudflare", url: "https://www.cloudflare.com" }, { name: "Cloudflare", url: "https://www.cloudflare.com" },
{ name: "Fastly CDN", url: "https://www.fastly.com" }, { name: "Fastly CDN", url: "https://www.fastly.com" },
{ name: "Akamai", url: "https://www.akamai.com/robots.txt" }, { name: "Akamai", url: "https://www.akamai.com" },
{ name: "Google", url: "https://www.google.com" }, { name: "Google", url: "https://www.google.com" },
{ name: "GitHub", url: "https://github.com" }, { name: "GitHub", url: "https://github.com" },
{ name: "B2", url: "https://api.backblazeb2.com/robots.txt" }, { name: "B2", url: "https://api.backblazeb2.com" },
// Chrome keeps the connection to an S3 region open after a HEAD check,
// but closes it after most GET checks.
{ {
name: "S3 af-south-1 (Cape Town)", name: "S3 af-south-1 (Cape Town)",
url: "https://s3.af-south-1.amazonaws.com/robots.txt", url: "https://s3.af-south-1.amazonaws.com",
method: "HEAD",
}, },
{ {
name: "S3 eu-west-2 (London)", name: "S3 eu-west-2 (London)",
url: "https://s3.eu-west-2.amazonaws.com/robots.txt", url: "https://s3.eu-west-2.amazonaws.com",
method: "HEAD",
}, },
{ {
name: "S3 me-south-1 (Bahrain)", name: "S3 me-south-1 (Bahrain)",
url: "https://s3.me-south-1.amazonaws.com/robots.txt", url: "https://s3.me-south-1.amazonaws.com",
method: "HEAD",
}, },
{ {
name: "S3 ap-northeast-1 (Tokyo)", name: "S3 ap-northeast-1 (Tokyo)",
url: "https://s3.ap-northeast-1.amazonaws.com/robots.txt", url: "https://s3.ap-northeast-1.amazonaws.com",
method: "HEAD",
}, },
{ {
name: "S3 ap-southeast-1 (Singapore)", name: "S3 ap-southeast-1 (Singapore)",
url: "https://s3.ap-southeast-1.amazonaws.com/robots.txt", url: "https://s3.ap-southeast-1.amazonaws.com",
method: "HEAD",
}, },
{ {
name: "S3 ap-southeast-2 (Sydney)", name: "S3 ap-southeast-2 (Sydney)",
url: "https://s3.ap-southeast-2.amazonaws.com/robots.txt", url: "https://s3.ap-southeast-2.amazonaws.com",
method: "HEAD",
}, },
{ {
name: "S3 us-west-2 (Oregon)", name: "S3 us-west-2 (Oregon)",
url: "https://s3.us-west-2.amazonaws.com/robots.txt", url: "https://s3.us-west-2.amazonaws.com",
method: "HEAD",
}, },
{ {
name: "S3 sa-east-1 (São Paulo)", name: "S3 sa-east-1 (São Paulo)",
url: "https://s3.sa-east-1.amazonaws.com/robots.txt", url: "https://s3.sa-east-1.amazonaws.com",
method: "HEAD",
}, },
// Hetzner regional speed test servers — genuine per-DC endpoints // Hetzner regional speed test servers — genuine per-DC endpoints
{ {
@@ -286,7 +271,6 @@ export class HostState {
constructor(host, pinned = false) { constructor(host, pinned = false) {
this.name = host.name; this.name = host.name;
this.url = host.url; this.url = host.url;
this.method = host.method;
// Each entry is either a check's result, { timestamp, latency, // Each entry is either a check's result, { timestamp, latency,
// error }, or a round skipped while paused, { timestamp, // error }, or a round skipped while paused, { timestamp,
// latency: null, paused: true }. // latency: null, paused: true }.
@@ -583,18 +567,16 @@ export class Reporter {
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
// Checks one target with the given HTTP method, GET when none is given. The // Checks one target. The check times out after CONFIG.requestTimeout; the
// check times out after CONFIG.requestTimeout; the caller can give it up // caller can give it up sooner through the optional signal, which also ends
// sooner through the optional signal, which also ends it as a timeout. A // it as a timeout. A failed check's reason says what went wrong and after
// failed check's reason says what went wrong and after how long; a check // how long; a check that answered has none.
// that answered has none.
// //
// The Hetzner speed-test servers close the connection without an answer // The URL is fetched as written, with nothing added to it: the Hetzner
// when the URL has a query string or the request has a Referer header. So // speed-test servers close the connection without an answer when the URL
// the URL is fetched as written, and referrerPolicy: "no-referrer" sends // has a query string, and cache: "no-store" keeps the browser's cache out
// no Referer header, wherever the page is served from. cache: "no-store" // of the measurement.
// keeps the browser's cache out of the measurement. export async function measureLatency(url, signal) {
export async function measureLatency(url, signal, method = "GET") {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout( const timeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
@@ -606,10 +588,9 @@ export async function measureLatency(url, signal, method = "GET") {
try { try {
await fetch(url, { await fetch(url, {
method, method: "GET",
mode: "no-cors", mode: "no-cors",
cache: "no-store", cache: "no-store",
referrerPolicy: "no-referrer",
signal: controller.signal, signal: controller.signal,
}); });
const latency = Math.round(performance.now() - start); const latency = Math.round(performance.now() - start);
@@ -1309,7 +1290,7 @@ export async function tick(state, signal, onOffline) {
// the round started. // the round started.
await Promise.all( await Promise.all(
state.allHosts.map(async (host) => { state.allHosts.map(async (host) => {
const r = await measureLatency(host.url, signal, host.method); const r = await measureLatency(host.url, signal);
if (state.paused || signal.aborted || state.tickCount === 0) { if (state.paused || signal.aborted || state.tickCount === 0) {
return; return;
} }
@@ -1382,7 +1363,7 @@ function startRecoveryProbe(state, startRounds) {
const checks = new AbortController(); const checks = new AbortController();
state._recoveryProbeChecks = checks; state._recoveryProbeChecks = checks;
for (const host of canaries) { for (const host of canaries) {
measureLatency(host.url, checks.signal, host.method).then((r) => { measureLatency(host.url, checks.signal).then((r) => {
if (r.error !== null || checks.signal.aborted) return; if (r.error !== null || checks.signal.aborted) return;
log.notice("Recovery probe: connectivity detected"); log.notice("Recovery probe: connectivity detected");
stopRecoveryProbe(state); stopRecoveryProbe(state);