A command line interface for LibreSpeed, written in Rust.
LibreSpeed ships two backend implementations — one in Go (speedtest-go) and one in Rust (speedtest-rust) — but the CLI existed only in Go. This is a port of librespeed/speedtest-cli to Rust, addressing issue #105.
It speaks the same protocol as the Go CLI and works against any LibreSpeed backend, Go or Rust.
Feature-complete with the Go CLI: every command line flag is implemented,
including telemetry/--share, JSON and CSV reports, ICMP and HTTP ping,
IPv4/IPv6 forcing, source-address, interface and firewall-mark socket binding,
custom CA bundles, and the server list filters.
Agreement with the Go client is a test rather than a claim. tests/parity
runs both binaries against the same in-process backend and compares their
output, their error output and their exit status. For every case it declares
either that the two agree or exactly which lines differ and which entry of the
list below accounts for them, so an undocumented difference fails the run and
so does one that quietly went away. CI builds the Go client from the commit
tests/parity/cases.rs names and runs the comparison against it.
What it shows, once the measured numbers are set aside -- those differ between
any two runs: --list, --csv-header, --simple, --csv, --json,
--json-stream and the --debug lines agree, and so do the usage errors, the
error lines and the exit status of a failed run. The text columns are the
exception, because what a server says about itself is rewritten on its way
into a report; the first entry below says how. In --help every option the Go
client has reads the same apart from a typo corrected in --telemetry-json,
--http2 is an option it does not have, and --version names this repository
instead of the Go one.
cargo build --releaseThe binary lands in target/release/librespeed-cli. Set SOURCE_DATE_EPOCH for
a reproducible build date in --version.
| Feature | Crypto | System dependency | Architectures |
|---|---|---|---|
rustls-tls (default) |
rustls + ring | none, statically linked | anything |
native-tls |
system OpenSSL | libopenssl | anything OpenSSL builds for |
vendored-openssl |
OpenSSL built from source | none, statically linked | anything OpenSSL builds for |
ring builds everywhere, falling back to portable C where it has no
hand-written assembly, which is everything outside x86, x86_64, aarch64 and
arm. On a core with no crypto instructions that costs throughput, so the
OpenSSL backend is the better choice there. Measured on the e500v2 in a
Turris 1.x, 8 KiB buffers:
| ring | OpenSSL | |
|---|---|---|
| ChaCha20-Poly1305 | 48.6 MB/s | 46.9 MB/s |
| AES-128-GCM | 8.5 MB/s | 18.8 MB/s |
ChaCha20 in portable C already matches the assembly; AES does not, and AES is what servers pick. An HTTPS run against a backend negotiating AES-256-GCM measured 138 Mbps with OpenSSL against 37 Mbps with rustls. Build the OpenSSL backend for such targets:
cargo build --release --no-default-features --features native-tlsThe Go CLI cannot run on 32-bit PowerPC at all — Go's toolchain only targets
ppc64 and ppc64le, which is why LibreSpeed's CLI is packaged for Turris
Omnia and MOX but not for Turris 1.x. Rust does reach that hardware through the
Tier 3 powerpc-unknown-linux-muslspe target that the OpenWrt build system
supports, so this port can go where the Go one cannot.
Build it against the OpenWrt SDK with the OpenSSL backend, for the throughput reason above and because it links against the libopenssl already in the image rather than carrying its own:
cargo build --release --no-default-features --features native-tls \
--target powerpc-unknown-linux-muslspeBeing a Tier 3 target, powerpc-unknown-linux-muslspe has no prebuilt std, so
it needs a nightly toolchain with -Z build-std — which is what OpenWrt's Rust
packaging already arranges. Point the openssl crate at the SDK's OpenSSL via
OPENSSL_DIR, or let pkg-config find it through the SDK environment.
librespeed-cli [OPTIONS]
Run librespeed-cli --help for the full list. The common ones:
# Test against the automatically selected fastest server
librespeed-cli
# Machine-readable output
librespeed-cli --json
librespeed-cli --csv --csv-header
# Pick servers explicitly
librespeed-cli --list
librespeed-cli --server 51 --server 94
# Use your own backend
librespeed-cli --server-json https://example.com/servers.json
librespeed-cli --local-json ./servers.json
cat servers.json | librespeed-cli --local-json -
# Bind the test to a specific egress path
librespeed-cli --source 192.0.2.10
librespeed-cli --interface eth1
librespeed-cli --fwmark 42 # Linux onlyThe protocol, the flags and the output formats match. These behaviours are deliberately different:
- Everything a server says is sanitized. Server names, sponsor strings, addresses and the backend's getIP answer are attacker-influenced text. The control characters (C0, DEL, C1), the line and paragraph separators, every Unicode format character — bidi overrides, zero-width joiners, the soft hyphen, the Arabic letter mark, the byte order mark — and the invisible tag block are dropped before any of it is printed, in terminal output, in CSV and in JSON alike. Spacing characters such as no-break space are kept: they render, and dropping them would run words together and misreport a name. The Go client cleans C0, DEL and C1 from its terminal output only, so its CSV and JSON carry escape sequences and bidi overrides straight through. A text column here is therefore a transformation of what the server sent rather than an escaping of it: the characters are removed, not encoded, and in CSV a field that would start a formula gains a leading apostrophe as well. Neither is reversible, which is the point -- nothing downstream can be steered by what a server called itself.
- CSV formulas are defused. A field starting with
=,+,-or@is prefixed with a single quote, because spreadsheets strip the CSV quoting and then evaluate what is left. - CSV quoting follows the csv crate. Go also quotes a field that starts
with a space or is exactly
\.; here only the delimiter, a quote or a line break causes quoting. - Response sizes are capped. A server list and a getIP answer are read to
8 MiB and a telemetry reply to 64 KiB, rather than to whatever the peer
decides to send, and the check that a backend is up reads no more than 8 KiB
of its answer. The cap bounds memory and not time: a peer that stays under it
while sending slowly is left to the request timeout, and
--timeout 0leaves no time bound at all. - A redirect may not leave https for http. A server reached over https
that answers with a
Locationon http is an error: the request was made over TLS deliberately, and following the downgrade would put the rest of the exchange on the wire in clear. Go follows it, leaving out only the Referer. A redirect that stays on http, and one that stays on https, is followed as it is in Go. - A request body is not sent on to another origin. A 307 or 308 that would send the telemetry POST to another scheme, host or port is refused, because the POST carries the measurement, the client's address and its ISP; Go sends the body on. A redirected request also carries no Referer.
- A telemetry reply other than 2xx fails the upload. The error names the HTTP status, and no share link is printed. Go does not check the status: it takes a share ID from any body with exactly one space in it, so a 500 reply can still yield a share link.
- HTTP/1.1 by default, HTTP/2 behind
--http2. HTTP/2 carries every stream over one TCP connection, so--concurrentwould stop meaning concurrent connections — and multiple connections is the standard way a speed test saturates a link. Go's client negotiates h2 whenever a server offers it.--http2enables it here too, with the flow-control windows raised to what Go's transport uses (4 MiB per stream, 1 GiB per connection); the protocol default of 64 KiB caps a stream at window/RTT, about 105 Mbps at 5 ms. - Scheme-less server URLs with a port work.
example.com/backendbecomeshttp://example.com/backendin both clients, but Go's URL parser takesexample.com:8080/backendfor a URL whose scheme isexample.com, and127.0.0.1:8080/makes it reject the whole list. Here both becomehttp://URLs. --interfaceworks on macOS viaIP_BOUND_IF; the Go version supports interface binding on Linux only.--fwmarkremains Linux-only (SO_MARK).- Numbers the Go client cannot act on are refused. It accepts any int64 and
then panics on
--upload-size -1or wraps a--durationpast thetime.Durationrange. Here a negative value, a duration or timeout past that range, and a count, size or mark past 32 bits are refused as out of range; Go instead gives a negative--concurrentits own message and ignores a negative--fwmark. A negative--serveror--excludeis still taken, so--server -1tests every server as it does in Go. Zero--duration,--chunksand--upload-sizerun as they do in Go. - TLS verification is stricter. rustls rejects a self-signed certificate
presented as both the leaf and its own trust anchor even when passed via
--ca-cert. A normal private CA works; use--skip-cert-verifyfor the degenerate case. - The process exits rather than returning from
main. Dropping the tokio runtime joins its worker threads, and on 32-bit PowerPC musl (Turris 1.x) that never completed: every successful run hung after printing its output.process::exit(0)at the end ofmainis a workaround, still under investigation; all output is flushed as it is written, so nothing is lost. - Go's command line quirks are not copied. The command line is parsed by
clap, so:
-jsonis not--json: long options take two dashes.- Numbers are decimal. Go reads
010as 8 and accepts0x10and1_000. - An argument that is not an option is refused. Go ignores it, and every option after it.
- An option other than
--serverand--excludegiven twice is refused, and--server 1,2is not split into two servers; Go keeps the last value and splits the list. - A boolean option takes no value, so
--json=falseis refused. --csv-delimitermust be one ASCII character other than". Go uses the first character of a longer value, and with"prints no header and no rows.--helpis answered as soon as it is seen, even if an unknown option follows; Go reports the unknown option.- Rarer usage errors read differently: a repeated option or
--ipv4 -4,---list,--json-streamwith--jsonor--csv, and--serverwith--exclude, which Go reports as "incompatible options" and "either --exclude or --server can be used" (--specificfor a local list). - A malformed or out-of-range
--serveror--excludevalue ends inparse errororvalue out of range, as any other number does; Go puts strconv's message there, such asstrconv.ParseInt: parsing "x": invalid syntax.
- Error causes use this client's libraries' words. The prefixes match Go
(
Error when fetching server list:,Terminated due to error:and the rest), but a cause such as a refused connection or a JSON syntax error reads differently after them. - A server URL's scheme keeps its case. Go lowercases
HTTP://when it parses the list;--listand the reports show it here as the list gave it.
cargo test # unit + end-to-end tests
cargo clippy --all-targets
cargo fmt --checktests/integration.rs starts an in-process LibreSpeed backend and drives the
built binary against it, covering the whole flow — server list, ping, download,
upload, telemetry and every output mode — with no network access, including the
Go client's error messages and what a backend full of hostile text does not get
to print. The unit tests cover the jitter estimator, Go-compatible path joining,
rounding, timestamps, CSV and JSON rendering, server list filtering and URL
scheme handling.
tests/golden.rs checks the report rendering against data Go itself wrote.
tools/golden/main.go runs encoding/json, strconv and time — the
packages the Go client renders its reports with — over a corpus of floats,
timestamps and whole documents, and the test feeds the same inputs to this
client. Only the recorded data is needed, so it runs everywhere; regenerating
it needs Go:
go run tools/golden/main.go tests/datatests/parity is the differential test the Status section describes. Without
LIBRESPEED_GO_BIN it skips the comparison and prints why, so a plain
cargo test still passes; CI sets it, and by hand it is:
# GO_COMMIT is the commit tests/parity/cases.rs names.
git clone https://github.com/librespeed/speedtest-cli go-client
git -C go-client checkout "$GO_COMMIT"
(cd go-client && go build -o librespeed-go .)
LIBRESPEED_GO_BIN=$PWD/go-client/librespeed-go cargo test --test parityA release is a tag. Bump version in Cargo.toml, commit it, then:
git tag -a v0.1.2 -m "librespeed-cli 0.1.2"
git push origin v0.1.2The tag starts release.yml, which runs in one chain and stops at the first step that fails:
- Resolve the commit turns the tag into a commit, once. Every step after it is handed that commit instead of resolving the tag again, so a tag that moves mid-release cannot split the run across two of them.
- Checks run ci.yml against that commit:
tests on three platforms, both TLS backends, the minimum supported Rust
version, lint, the fuzz targets, the differential test against the Go
client and
cargo package. Nothing is built or published until they pass. - Package the crate verifies the tag against
versioninCargo.tomland builds the.crateonce, recording its SHA-256. A tag that disagrees stops the run here, before a release exists to leave behind. - Build cross-builds the musl binaries.
- Publish release attaches the tarballs and
SHA256SUMSto a GitHub release. - Publish to crates.io checks the tag again against the commit it is
about to upload, rebuilds the crate and refuses to publish unless it comes
out byte for byte the one step 3 validated.
cargocannot upload a.crateit did not just build, so this comparison is what ties the two together. A dry run goes first, so a crate that cannot be published is distinguishable from an upload that failed.
Running the workflow by hand rebuilds the binaries for a tag that is already out; that path never publishes.
The tag is checked again before the GitHub release is cut and once more
immediately before the upload, because the approval on the crates-io
environment can hold the run for hours. That is detection, not a lock: a tag
can still move a moment after a check returns. Protecting v* tags against
force-pushes and deletion with a repository ruleset is what prevents it, and
these checks catch what slips past.
Releases are built with the compiler named in RELEASE_TOOLCHAIN, with
cross pinned to a version, and with every action pinned to a commit. CI
keeps using whatever stable is that day: the two answer different
questions, CI that the code has not fallen behind the compiler, a release
what it was actually built with. Moving a release to a newer compiler is
therefore a commit, not a Tuesday.
That makes a release repeatable in the ways this repository controls: the
same source, the same dependency versions from Cargo.lock, the same
compiler, the same cross and the same actions. It does not make the
binaries bit-for-bit reproducible — the cross container images and the
runner environment are outside it. SOURCE_DATE_EPOCH only fixes the build
date --version prints.
One-time setup, and only the account that owns the crate can do it:
- Create an API token at https://crates.io/settings/tokens with the
publish-newandpublish-updatescopes, restricted to thelibrespeed-clicrate. Once the first version is out,publish-updatealone is enough. - Add an environment named
crates-iounder Settings → Environments and store the token in it as the secretCARGO_REGISTRY_TOKEN. Required reviewers on that environment make a release wait for an approval before the token is handed out.
This is the setup for the first release only. Trusted Publishing is where
this should end up: crates.io mints a short-lived token for a workflow it
trusts, so the publish job needs no stored secret at all — it gains
id-token: write permission and takes its token from
rust-lang/crates-io-auth-action@v1. It can only be configured for a crate
that already exists, which is why the first version goes out with a token.
Once librespeed-cli is on the registry, switch and delete the secret.
GNU Lesser General Public License v3.0, the same as the Go implementation. See LICENSE.
- LibreSpeed — Copyright (C) 2016-2020 Federico Dossena
- librespeed-cli — Copyright (C) 2020 Maddie Zhan