Repository navigation
HTTP API
Every route the backend serves. There is no authentication on any of them: this is a LAN tool with no accounts, and the same is already true of submitting a result. Put it behind whatever your network already does.
Everything is same-origin with the front end, which is why no CORS or
Timing-Allow-Origin header appears anywhere — and why none should be added.
See Engine Contract.
The three routes that change stored state — POST /api/results,
POST /api/results/{id}/note, POST /api/clients/{ip}/name — refuse a request
whose Origin header names anywhere other than the host it was served from.
The answer is 403 cross-origin request refused.
| The request carries | Result |
|---|---|
No Origin header at all |
Allowed. curl, the test suite, anything that is not a browser |
Origin matching the request's host |
Allowed. This is every request the page itself makes |
| A different host or port | 403 |
Origin: null — a sandboxed iframe, a file:// page |
403 |
The scheme is not compared: the same deployment answers on both http and https,
and which one a browser used says nothing about where the page came from. The
port is compared, because a different port on the same host is a different
origin to a browser and should be one here too; a port that is the default for
its scheme is dropped from both sides first, so https://speedtest.example and
speedtest.example:443 are recognised as one origin.
This is a DNS-rebinding and CSRF guard, not authentication. Anyone who can
reach the service can still record a run, and that remains the intended design.
What it stops is a page on an unrelated origin silently driving these
endpoints through a visitor's browser — a script cannot forge Origin, so its
cross-site POST is refused rather than filing a run or renaming a client under
the visitor's address. A request with no Origin is allowed through because
refusing on absence would break every non-browser caller while adding nothing:
the case being defended against always carries the header.
A fourth mutating endpoint has to be added to the same guarded group to inherit this. Nothing applies it automatically.
These two exist to satisfy @cloudflare/speedtest. Their exact behaviour is
contractual — read Engine Contract before changing
either.
| Route | Purpose |
|---|---|
GET /__down?bytes=N |
N bytes of throwaway payload. bytes=0 is the engine's latency ping — there is no separate ping endpoint |
POST /__up?bytes=N |
N bytes read and discarded. The response is withheld until the whole body has been drained |
Both:
- carry
cache-control: no-store, without which the browser answers repeat requests from cache and the engine measures the cache; - carry
server-timing: cfRequestDuration;dur=N, which is the only spelling the engine's parser accepts; - ignore unknown query parameters, because the engine appends its own
(
during=downloadwhile measuring loaded latency, for example); - refuse a
bytesvalue aboveserver.max_transfer_bytes.
| Route | Answers with |
|---|---|
GET /api/health |
ok. Used by the container health check |
GET /api/status |
Site name, version, git SHA, active profile, whether history is on, the requesting client's address and what kind of address it is, and the deployment's auto-start default |
GET /api/profiles |
The default profile name and every profile the picker may offer: name, description, nominalBps, autoSelectable
|
GET /api/profile?name=X |
One full engine configuration. Omitting name serves the configured default; an unknown name is a 400, never a silent fallback |
GET /api/profile is where the settings that keep traffic local are pinned —
logAimApiUrl and logMeasurementApiUrl are always null, and the endpoint
URLs are always relative. The packet-loss stage is dropped from the response
when no relay is configured, so the engine does not stall on a connection that
cannot be established.
All of these degrade quietly when history is disabled: the list endpoints
return []. POST /api/results returns 202 Accepted with history is disabled — the front end posts a result at the end of every run and should not
have to know whether this deployment keeps them. The other two writes return
404.
| Route | Answers with |
|---|---|
GET /api/history?limit=N&client=X&location=Y |
Stored runs, newest first. limit defaults to 100. client accepts an address, all (the default), or mine — which means the requesting address, so the page can offer "just this machine" without the browser needing to know its own LAN address. location restricts to an exact-match tag, composing with client; omitted or empty means no filter |
GET /api/results/{id} |
One run in full, including its samples, which is what makes a permalink redraw rather than summarise |
POST /api/results |
Store a completed run. The front end posts this when a run finishes. Body includes an optional location — trimmed, capped at 64 characters, and stored as absent when it is whitespace-only |
POST /api/results/{id}/note |
{"note": "..."} — annotate a run, or clear it with an empty string. Capped in characters rather than bytes, so the limit does not depend on which alphabet it is written in |
GET /api/clients |
Who has run tests, most recently active first |
GET /api/locations |
Every distinct location tag in use, most recently used first, capped at 50. Feeds the tag picker on the run form and the filter on the history page |
POST /api/clients/{ip}/name |
{"name": "..."} — name a client, or clear the name. The path segment must parse as an IP address; anything else is a 400
|
The three POST routes here are the ones behind the same-origin guard above; a
cross-origin caller gets a 403 before any of this applies. GET /api/locations is a read, like the others in this table, so it is not one of
them — a picker that cannot load its options would be the only thing a guard
on it achieved.
Every run object — from GET /api/history and GET /api/results/{id} alike —
carries location: the tag as stored, or null when the run was never
tagged.
A run is attributed to an address the server works out for itself, never to one the request claims — see Client Identity.
| Route | Answers with |
|---|---|
GET /metrics |
Prometheus text format, the most recent run per client |
Off by default. When disabled it returns 404 from the handler, rather than
being left unmounted — an unmounted route falls through to the single-page-app
fallback and would answer a scrape with 200 text/html. See
History and Metrics.
Everything else is the built front end: / (the test), /history.html,
/result.html?id=N, /compare.html?a=N&b=M, plus the icon and web manifest.
Unmatched paths fall back to the app shell.
The image also serves /LICENSE and /THIRD-PARTY-NOTICES.md. The front-end
bundle carries a banner comment pointing at the latter, so that link has to
keep resolving — do not move or rename those files.
See also: Engine Contract · Architecture · History and Metrics · Client Identity
Getting it running
Using it
Working on it