Repository navigation
Release History
Versions are three-part X.Y.Z. Releases below 1.0.0 were marked as GitHub
pre-releases; from 1.0.0 they are full releases. The 10 GbE saturation check
was waived, and "no external traffic" is proven by an end-to-end test on every
push rather than by a manual packet capture.
Entries below are a record of what shipped at the time. Some of them describe the Proxmox provisioning tool that was part of this project through 0.4.0–1.5.1 and has since been removed as out of scope: the project now ships the application and the coturn relay configuration, and how the host comes to exist is a separate concern. Those entries are left as they were written rather than rewritten to match the present.
Where you were standing when you ran it.
- Location tags on runs. A chip row under the run controls offers "No location" and every tag the server has seen before, most recently used first, plus an inline field to add or reuse one — a near-match is folded into the existing spelling rather than starting a second one. Optional, trimmed and capped at 64 characters; a whitespace-only entry is stored as absent. The choice is remembered per browser and the chip row is hidden entirely when history is off, since a tag has nowhere to go without a database.
-
GET /api/locationslists distinct tags, most recently used first, capped at 50. Read-only, so — like the other reads — it is not behind the same-origin guard. -
POST /api/resultstakes an optionallocation, and every run served back — on/api/history,/api/results/{id}, and in the history UI — includes it.GET /api/history?location=Xfilters exactly, composing withclient. Therunstable gains a nullablelocationcolumn; existing databases migrate in place at startup. -
The history table gains a Location column and filter, and a stored
run's own page shows
location: <tag>in the footer when one was set — omitted rather than shown empty, since most runs have none. -
Location is deliberately not a Prometheus label. A free-text tag is
unbounded cardinality; the latest run per client is still exported to
/metrics, without it.
Housekeeping: the database looks after its own disk, and the API checks who is asking.
-
Pruning now returns the disk it frees. Databases run with
auto_vacuum = INCREMENTAL, and one that predates the change is converted at startup by a single fullVACUUM— SQLite reads that setting once, when the first table is created, and silently ignores the assignment afterwards. The daily pass hands freed pages back to the filesystem and truncates the WAL, neither of which is visible in a row count.PRAGMA incremental_vacuumemits one row per page freed and stops there. Stepped once, it reclaimed a single 4 KiB page of a 5 MiB prune and reported success — a file that grew steadily while the deployment deleted runs every day. Both pragmas are drained to completion now, and the bytes reclaimed are measured rather than inferred. -
Optional history snapshots, via
server.history_backup_dir. The daily pass writeshistory-backup.dbthere withVACUUM INTO: a consistent, compacted copy of a database that is still being served, which a plain file copy of an open SQLite database is not. It is written under a temporary name and renamed into place, so a process killed halfway leaves the last good copy untouched. Off by default, and a destination that would overwrite the live database — or one named while history is switched off — refuses to start rather than quietly doing nothing. -
Two new Prometheus metrics:
speedtest_bufferbloat_factorper direction, the fractional latency increase under load; andspeedtest_quality_scoreper AIM service, on the engine's own five-level scale rather than an invented one. -
A same-origin guard on the three state-changing routes. A cross-origin or
nullOriginheader is refused with403; a request carrying noOriginat all — curl, the test suite, anything that is not a browser — passes through unchanged. This is a DNS-rebinding guard, not authentication: the tool stays deliberately unauthenticated on a trusted LAN.
The project becomes something someone else can use: a licence, and the name it ships under today.
-
LICENSE(MIT) andTHIRD-PARTY-NOTICES.md, served at/LICENSEand/THIRD-PARTY-NOTICES.md. The project had shipped with no licence file and a README that said it was not licensed for redistribution. -
Renamed to
lan-speedtestthroughout — repository, image (ghcr.io/sremich/lan-speedtest), crate and binary. The previous name was derived from a private network and had no business in a public repository. - A non-affiliation statement, in the README and the wiki: built on Cloudflare's open-source engine, and not a Cloudflare product.
- The Proxmox provisioner is no longer part of the project. Standing up the host is a separate concern from the service that runs on it; the coturn configuration stays, because the packet-loss stage still needs the relay. Certificate issuance and renewal, previously automated and verified by the provisioner, became the operator's job.
- Documentation reorganised, with new pages — Quick Start, HTTP API, History and Metrics — and real screenshots taken against a running backend rather than mocked or drawn.
-
/metricsanswered with the app shell instead of 404 when disabled. The route was left unmounted, and the SPA fallback answers anything unrouted — so a scrape read an HTML page as an exposition. Always routed now, refusing from the handler. The test that should have caught it pointedstatic_dirat a missing directory, so the fallback failed and produced the 404 it wanted.
What measured it, whether to measure at all, and what changed.
Built from an external audit's feature requests, verified against the code rather than taken at face value.
-
Every run records its build. Runs from before 1.3.1 carry up to 40 ms of
the
TCP_NODELAYstall in their latency and were indistinguishable from correct ones. They are now marked, in the history and on the run's own page. The stored number is never adjusted — it is what was measured. -
Auto-start is refusable:
?autostart=0for a visit, a remembered toggle for the browser,server.autostartfor the deployment. On by default. Changing the profile now clears the result instead of launching a run. -
A compare page,
/compare.html?a=N&b=M, reached by picking two rows in the history. Differences are signed by improvement rather than by arithmetic, with a 2% noise floor; latency rows are marked when either run predates 1.3.1. -
/metricsin Prometheus format, off by default. The latest run per client, in base units, omitting figures that were never measured. - Retention, separately for runs and for their sample blobs. Both off by default.
-
A
lan-2.5gprofile, a light theme with a three-state toggle, and a web manifest so the page can live on a phone's home screen.
The run, described and explained.
- A description per run — where you were, on what device, what you were testing — editable from the history table and from the run's own page. Per-run rather than per-client: it is exactly what differs between two runs from the same machine. Capped at 280 characters, counted in characters.
- A live stage line above the step strip: a spinner, the stage's name in its own colour, and the payload it is moving. Tied to the engine's running state, so pausing stops it and a finished run hides it.
-
The distribution tooltip is written in words — a sentence explaining what
the marks mean, then "25th percentile" rather than "p25". Styled rather than
a native
title, which could hold neither the explanation nor the styling. A row is now hovered anywhere along its band, not only on its marks. - The step strip spreads across the width, bounded by how many chevrons the profile implies, and wraps rather than compressing on a narrow screen.
- The "90th percentile" label sat at different heights on the two traces. It was clamped while the line it labels was not. It is now pinned to the line with a fixed offset, identical on every chart.
The latency figures become true.
-
TCP_NODELAYwas never set on the TLS listener, so small HTTPS responses stalled up to 40 ms on Nagle waiting for a delayed ACK. The engine's latency probe is exactly such a response, so the stall was reported as network latency. Idle, on the deployed guest: HTTPS 41.9 ms mean against plain HTTP 0.6 ms; 0.7 ms after the fix. - This was the "unexplained asymmetric bufferbloat" recorded since 1.0.0. It was neither asymmetric nor bufferbloat.
- Invisible on loopback, where the round trip is too short for an
acknowledgement to be delayed — which is why every test tier passed
throughout. The guard is now the return type of
net::tls_acceptor. - Latency stored by earlier versions is inflated by up to 40 ms.
Whose run it was, and how to get back to it.
-
Permalinks. Every run stores its samples, and
/result.html?id=Nredraws it — the same traces, distributions and ratings — through the same renderer the live page uses. History rows link to it; a finished run links to itself. -
Reverse DNS, off by default and restricted to configured private ranges
even when on. The lookup runs after a run is stored, never on the request
path, and both hits and misses are cached. Enabled for the deployed guest via
guest.reverse_dnsinprovision.toml. - Editable client names, which beat a resolved hostname, which beats the address. The address stays visible either way.
-
Trusted-proxy
X-Forwarded-Forviaserver.trusted_proxies, believed only from a peer named there. - The address is classified — loopback, LAN, Tailscale or carrier NAT, link-local, public — with a note explaining what that means for the number shown. See Client Identity, which also records what cannot be recovered at all.
The measurement made legible.
- A step strip replaces the progress bar: one chevron per request the profile will issue, drawn before the run starts, with per-stage detail and results on hover.
- Hover detail on the traces — speed, payload, round trip and request duration for the individual sample under the pointer.
- A profile picker with
Auto. The profile was fixed server-side, which is why every stored run saidlan-1g. - Traces are monotone cubic curves: smooth, but unable to overshoot between samples.
- The headline is one band across the top. Measured against speed.cloudflare.com at six viewport widths, which showed the reference caps at 1200px and centres rather than filling the window.
A name of its own, and a layout that survives being resized.
- The heading and tab title come from
server.site_name(SPEEDTEST_SITE_NAME, orguest.site_nameinprovision.toml), so two installations on one LAN are tellable apart and renaming one is a restart rather than a rebuild. - The page widens to 1320px, and the headline moves through three arrangements instead of one. The two bandwidth cards stay side by side down to 620px.
- Charts are drawn at their real pixel size rather than scaled to fit, which had been scaling their labels along with them — the same plot rendered 6px text in a narrow window and 17px on a wide monitor.
Running in production, with the interface reworked against the current speed.cloudflare.com.
- Live bandwidth traces per direction with the reported percentile marked.
- Loaded latency and jitter shown per direction; packet loss as a received bar.
- Sample counts on every distribution, pause/resume, measured-at, client address.
- No server-location map: its tiles come from an external host, which this project must not contact. The client address replaces the useful half.
Nine defects found by the first live provisioning run against a real node — a credential reaching an error message, an ACME hook aborting before the renewal cron was installed, certificate permissions that would have failed at the next renewal, and a coturn configuration that was never read. See the changelog.
The detail view and raw throughput.
- Box plots per transfer size and per latency phase: min, max, mean, median, p25/p75, whiskers to 1.5×IQR and outliers as dots. The headline figures are single percentiles and hide how consistent a run was, which is exactly what exposes a failing cable or a duplex mismatch.
- Bandwidth is grouped by transfer size, never pooled — a small transfer measures round-trip overhead more than throughput.
- Parallel-stream raw throughput, on demand, reported as a separate number with an explanation of why it is not comparable to the engine's figure.
Results history.
- Every completed run is stored in SQLite with its client, profile, full summary and ratings; the raw engine summary is kept verbatim so nothing is lost to a missing column.
-
/historylists runs and charts the download/upload trend, filterable by client. Hand-drawn SVG rather than a charting library. - Client attribution comes from the connection, never from a header — a spoofable header must not decide who a run belongs to.
- History is optional and degrades quietly when disabled, so the front end need not know whether this deployment keeps results.
Provisioning and TLS.
- Idempotent provisioner driving the node over SSH with
pct/pvesh. Creates the guest, installs Docker and coturn, issues the certificate, deploys the application, and waits for it to be healthy. - Guests are tagged on creation, and the tool refuses to modify any guest that does not carry that tag — checked in every mode, before any mutating command.
- MAC pinned and derived from the VMID, so a DHCP reservation survives a
rebuild.
macprints it without changing anything, to be run first. - The service terminates TLS itself; no reverse proxy, because a proxy hop would sit inside the measured path.
- Certificate renewal and its reload hook are verified after installation rather than trusted — the predecessor host is in exactly the failure state that catches.
Milestones 0 through 3 in one pass: the scaffold resolved, the backend, the front end, and the packet-loss relay.
Backend
- Rust/Axum service implementing the
@cloudflare/speedtest1.13.1 contract:/__down,/__up, and the zero-byte latency ping. - Download payloads are refcounted slices of one pre-allocated buffer — no
per-request allocation, and an exact size hint so a real
content-lengthis sent instead of chunked framing. - Uploads are drained frame by frame and answered only once complete, because upload speed is derived from time-to-first-byte alone.
-
server-timing: cfRequestDuration;dur=N, the only spelling the engine's parser accepts. - Serves the built front end;
/api/statusexposes version and git SHA,/api/profileserves the engine configuration.
Front end
- TypeScript/Vite, no framework. Auto-starts on load, updates live, and shows download, upload, idle and loaded latency, jitter, packet loss and duration.
- AIM ratings for streaming, gaming and video calls, read from the engine's own scoring.
- Dark theme; version and git SHA in the footer.
- Refuses to start a run whose configuration could reach off-LAN, naming the specific problem.
- States plainly when latency readings sit at the browser's timing resolution rather than implying precision that does not exist.
Packet loss
- coturn configuration written from scratch, with an idempotent installer that refuses to leave placeholders unsubstituted.
- Relay denied to loopback, link-local and multicast peers.
- Containerised relay for the e2e suite, including an automated Trickle-ICE
equivalent that fails if no
relaycandidate is gathered.
Configuration
- Named measurement profiles in one TOML file, selectable without a rebuild.
-
loaded_request_min_durationandloaded_latency_throttleexposed and tuned per profile. The engine's defaults silently remove loaded latency and every quality rating on a LAN; see Configuration. - Unknown keys are a hard error, so a typo cannot look like a working setting.
Testing
- Tier 1: 33 backend tests covering config parsing, the payload body, and the engine contract driven over a real socket.
- Tier 2: Playwright in Chromium and Firefox, including an assertion that no request leaves the origin for an entire run; a packet-loss suite against real coturn; a loopback throughput floor; and a CI job that boots the built image and probes it.
Known limitations
- Browser-reported download speed is bounded by the engine's single-stream,
r.text()-decoding design rather than by the link. A separate parallel-stream harness is planned for 0.6.0 and will be reported as a distinct number. - Guest provisioning and TLS automation are not yet implemented (0.4.0).
- Results are not persisted (0.5.0).
See also: Home · Deployment · Testing
Getting it running
Using it
Working on it