1 Commits
Author SHA1 Message Date
clawbot f7b5acf210 Targets whose home page redirects are checked at /robots.txt (closes #194)
check / check (push) Canceled after 0s
The browser follows a redirect, so the figures for the AWS Console, Akamai,
B2 and the S3 regions also timed the page it led to, for B2 and S3 on
another host. The Google Cloud Console redirects only requests that do not
say they come from another site; browsers say so and get a 403 directly.
Each of these targets is now checked at /robots.txt on its own host, which
answers without a redirect. README.md says what a figure includes. Chrome
opens a new connection for most S3 checks, so far regions now read higher.

Model: opus-5-5
2026-10-26 01:42:36 +00:00
5 changed files with 28 additions and 61 deletions
+1 -5
View File
@@ -419,11 +419,7 @@ figure is the time from sending the request until the answer's headers arrive,
timed with `performance.now()`. Unlike ping, this includes the time the server timed with `performance.now()`. Unlike ping, this includes the time the server
takes to answer. It also includes the DNS lookup, connection setup and TLS takes to answer. It also includes the DNS lookup, connection setup and TLS
whenever the browser opens a new connection. Chrome opens one for every check of whenever the browser opens a new connection. Chrome opens one for every check of
the Hetzner speed-test servers, and for more targets at longer intervals. the Hetzner speed-test servers and for most checks of the S3 regions.
The S3 regions are checked with a HEAD request instead. Chrome keeps the
connection to an S3 region open after a HEAD request, but closes it after most
GET requests. The Hetzner speed-test servers do not answer a HEAD request.
The browser follows every redirect, and the figure then includes the request to The browser follows every redirect, and the figure then includes the request to
the new URL too. So a target whose home page answers with a redirect is checked the new URL too. So a target whose home page answers with a redirect is checked
+9 -5
View File
@@ -26,11 +26,15 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
- 2026-10-26: a target whose home page answers with a redirect is checked at - 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 `/robots.txt` on the same host, which answers without one
([#194](https://git.eeqj.de/sneak/netwatch/issues/194)). These are the AWS ([#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, Console, Akamai, B2 and the 8 S3 regions, and the Google Cloud Console, whose
so their figures also timed the page it led to. The Google Cloud Console is home page redirects a request that does not say it comes from another site.
checked at `/robots.txt` too, as its home page redirects `curl`'s requests. The browser follows a redirect, so their figures also timed the page it led
The S3 regions are checked with a HEAD request, as Chrome closes their to, for B2 and the S3 regions on another host. Chrome opens a new connection
connection after most GET requests. `README.md` says what a figure includes for most checks of an S3 region's `/robots.txt`, so those figures now include
connection setup and TLS, where they included a request to `aws.amazon.com`.
`README.md` says what a figure is: the time until the answer's headers arrive,
which includes the server's time to answer, and the DNS lookup, connection
setup and TLS whenever the browser opens a new connection
- 2026-10-26: the six Hetzner targets answer wherever the page is served from - 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 ([#191](https://git.eeqj.de/sneak/netwatch/issues/191)). They close the
connection without an answer when the request has a `Referer` header. The connection without an answer when the request has a `Referer` header. The
+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;
+6 -28
View File
@@ -191,12 +191,12 @@ test("a check sends no Referer header, wherever the page is served from", async
); );
}); });
// These targets' home pages answer with a redirect. A check follows it and // These targets' home pages answer with a redirect, the Google Cloud
// times the request to the new URL too, often on another host. The Google // Console's when the request does not say it comes from another site. A
// Cloud Console redirects only requests that do not say they come from // check follows it and times the request to the new URL too, often on
// another site, such as curl's. Each target is checked at /robots.txt on // another host. Each is checked at /robots.txt on the host its name names,
// its own host, which answers without a redirect. An S3 region answers it // which answers without a redirect: an S3 region with 403, which counts as
// with 403, and any answer counts as reachable. // reachable.
test("a target whose home page redirects is checked at /robots.txt on its own host", () => { test("a target whose home page redirects is checked at /robots.txt on its own host", () => {
const checked = new Map( const checked = new Map(
new AppState([]).wan.map((host) => [host.name, host.url]), new AppState([]).wan.map((host) => [host.name, host.url]),
@@ -243,28 +243,6 @@ test("a target whose home page redirects is checked at /robots.txt on its own ho
} }
}); });
// 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([
+8 -20
View File
@@ -86,47 +86,37 @@ const WAN_HOSTS = [
{ 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/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/robots.txt", 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/robots.txt", 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/robots.txt", 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/robots.txt", 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/robots.txt", 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/robots.txt", 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/robots.txt", 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/robots.txt", 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
{ {
@@ -286,7 +276,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 +572,17 @@ 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 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 // when the URL has a query string or the request has a Referer header. So
// the URL is fetched as written, and referrerPolicy: "no-referrer" sends // the URL is fetched as written, and referrerPolicy: "no-referrer" sends
// no Referer header, wherever the page is served from. cache: "no-store" // no Referer header, wherever the page is served from. cache: "no-store"
// keeps the browser's cache out of the measurement. // keeps the browser's cache out of the measurement.
export async function measureLatency(url, signal, method = "GET") { export async function measureLatency(url, signal) {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout( const timeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
@@ -606,7 +594,7 @@ 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", referrerPolicy: "no-referrer",
@@ -1309,7 +1297,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 +1370,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);