Skip to content

HTTP API

Stephen edited this page Aug 26, 2026 · 4 revisions

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 same-origin guard

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.

Measurement endpoints

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=download while measuring loaded latency, for example);
  • refuse a bytes value above server.max_transfer_bytes.

Application endpoints

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.

History endpoints

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.

Metrics

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.

Static content

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

Clone this wiki locally