4 Commits
Author SHA1 Message Date
clawbot 82f9962f18 README.md: name the rounds in which the rows are sorted
check / check (push) Canceled after 0s
Model: fable-5-1
2026-10-26 03:42:44 +00:00
clawbot 6172afa152 README.md rewritten sentence by sentence for a newcomer
check / check (push) Canceled after 0s
Every sentence is under about 30 words, with one idea each. The page,
its targets and what a figure measures come first, then reporting,
deployment, the settings reference, the design, and development last.
No tables; every identifier in backticks. The code on next decides
what is true.

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

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

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

Model: opus-5-5
2026-10-26 02:10:43 +01:00
5 changed files with 733 additions and 548 deletions
+587 -519
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -23,6 +23,20 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
# Completed Steps # Completed Steps
- 2026-10-26: a target whose home page answers with a redirect is checked at
`/robots.txt` on the same host, which answers without one
([#194](https://git.eeqj.de/sneak/netwatch/issues/194)). These are the AWS
Console, Akamai, B2 and the 8 S3 regions. The browser followed each redirect,
so their figures also timed the page it led to. The Google Cloud Console is
checked at `/robots.txt` too, as its home page redirects `curl`'s requests.
The S3 regions are checked with a HEAD request, as Chrome closes their
connection after most GET requests. `README.md` says what a figure includes
- 2026-10-26: the six Hetzner targets answer wherever the page is served from
([#191](https://git.eeqj.de/sneak/netwatch/issues/191)). They close the
connection without an answer when the request has a `Referer` header. The
browser sent one unless the page came with the image's
`Referrer-Policy: no-referrer` header, which a static host and `make dev` do
not send. Each check now sets `referrerPolicy: 'no-referrer'`
- 2026-10-19: the server's `OnStart` hook in `internal/server/server.go` starts - 2026-10-19: the server's `OnStart` hook in `internal/server/server.go` starts
`listenAndServe` with `go func() { s.listenAndServe() }()`, as the Go style `listenAndServe` with `go func() { s.listenAndServe() }()`, as the Go style
guide asks ([#162](https://git.eeqj.de/sneak/netwatch/issues/162)); it was guide asks ([#162](https://git.eeqj.de/sneak/netwatch/issues/162)); it was
+3 -4
View File
@@ -8,10 +8,9 @@ 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 browser checks each # in web/index.html. connect-src is * because the page checks every
# probe in web/src/main.js against it, and also every redirect the probe # target in web/src/main.js. A list of hosts here would have to change
# follows, and several of those hosts redirect to others; a list of # with every change to those targets. It also covers the reports the
# 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;
+85
View File
@@ -180,6 +180,91 @@ 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([
+44 -25
View File
@@ -67,51 +67,66 @@ 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. // CDN/hosting/other. A check follows a redirect and times the request to
// the new URL too. So a target whose home page redirects is checked at
// /robots.txt on its own host. That URL answers without a redirect.
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" }, { name: "AWS Console", url: "https://console.aws.amazon.com/robots.txt" },
{ 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" }, { name: "Akamai", url: "https://www.akamai.com/robots.txt" },
{ 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" }, { name: "B2", url: "https://api.backblazeb2.com/robots.txt" },
// Chrome keeps the connection to an S3 region open after a HEAD check,
// but closes it after most GET checks.
{ {
name: "S3 af-south-1 (Cape Town)", name: "S3 af-south-1 (Cape Town)",
url: "https://s3.af-south-1.amazonaws.com", url: "https://s3.af-south-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 eu-west-2 (London)", name: "S3 eu-west-2 (London)",
url: "https://s3.eu-west-2.amazonaws.com", url: "https://s3.eu-west-2.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 me-south-1 (Bahrain)", name: "S3 me-south-1 (Bahrain)",
url: "https://s3.me-south-1.amazonaws.com", url: "https://s3.me-south-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 ap-northeast-1 (Tokyo)", name: "S3 ap-northeast-1 (Tokyo)",
url: "https://s3.ap-northeast-1.amazonaws.com", url: "https://s3.ap-northeast-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 ap-southeast-1 (Singapore)", name: "S3 ap-southeast-1 (Singapore)",
url: "https://s3.ap-southeast-1.amazonaws.com", url: "https://s3.ap-southeast-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 ap-southeast-2 (Sydney)", name: "S3 ap-southeast-2 (Sydney)",
url: "https://s3.ap-southeast-2.amazonaws.com", url: "https://s3.ap-southeast-2.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 us-west-2 (Oregon)", name: "S3 us-west-2 (Oregon)",
url: "https://s3.us-west-2.amazonaws.com", url: "https://s3.us-west-2.amazonaws.com/robots.txt",
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", url: "https://s3.sa-east-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
// Hetzner regional speed test servers — genuine per-DC endpoints // Hetzner regional speed test servers — genuine per-DC endpoints
{ {
@@ -271,6 +286,7 @@ 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 }.
@@ -567,16 +583,18 @@ export class Reporter {
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
// Checks one target. The check times out after CONFIG.requestTimeout; the // Checks one target with the given HTTP method, GET when none is given. The
// caller can give it up sooner through the optional signal, which also ends // check times out after CONFIG.requestTimeout; the caller can give it up
// it as a timeout. A failed check's reason says what went wrong and after // sooner through the optional signal, which also ends it as a timeout. A
// how long; a check that answered has none. // failed check's reason says what went wrong and after how long; a check
// that answered has none.
// //
// The URL is fetched as written, with nothing added to it: the Hetzner // The Hetzner speed-test servers close the connection without an answer
// speed-test servers close the connection without an answer when the URL // when the URL has a query string or the request has a Referer header. So
// has a query string, and cache: "no-store" keeps the browser's cache out // the URL is fetched as written, and referrerPolicy: "no-referrer" sends
// of the measurement. // no Referer header, wherever the page is served from. cache: "no-store"
export async function measureLatency(url, signal) { // keeps the browser's cache out of the measurement.
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(),
@@ -588,9 +606,10 @@ export async function measureLatency(url, signal) {
try { try {
await fetch(url, { await fetch(url, {
method: "GET", method,
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);
@@ -1290,7 +1309,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); const r = await measureLatency(host.url, signal, host.method);
if (state.paused || signal.aborted || state.tickCount === 0) { if (state.paused || signal.aborted || state.tickCount === 0) {
return; return;
} }
@@ -1363,7 +1382,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).then((r) => { measureLatency(host.url, checks.signal, host.method).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);