JavaScript-free, Tor-friendly overlay for LibreSpeed (LinuxServer image): /nojs fallback + onion service
  • PHP 76.2%
  • Shell 21.6%
  • Dockerfile 2.2%
Find a file
Paul Chauvat 5aaa51edd2 Measure no-JS upload via nginx $request_time (sub_filter), not php://input
The LinuxServer nginx build buffers the request body before PHP runs, despite
fastcgi_request_buffering off, so php://input timing read ~0 (bogus multi-Gbps).
Instead ul.php emits a meta-refresh page with a __NRT__ placeholder that nginx
rewrites to $request_time via sub_filter; result.php divides uploaded bytes by
it. Validated on a NAS scratch stack: upload tracks the throttle (19 Mbps @
~2.4 MB/s, 95 Mbps @ ~11.8 MB/s), download ~38-40 Mbps @ 5 MiB/s throttle,
onion served end-to-end over Tor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:36:22 +02:00
nginx Measure no-JS upload via nginx $request_time (sub_filter), not php://input 2026-06-16 01:36:22 +02:00
nojs Measure no-JS upload via nginx $request_time (sub_filter), not php://input 2026-06-16 01:36:22 +02:00
tor Initial no-JS + Tor overlay for LibreSpeed 2026-06-16 01:08:55 +02:00
.gitignore Initial no-JS + Tor overlay for LibreSpeed 2026-06-16 01:08:55 +02:00
deploy.sh deploy.sh: chown overlay to container PUID so php-fpm can read .secret 2026-06-16 01:14:11 +02:00
docker-compose.yml Initial no-JS + Tor overlay for LibreSpeed 2026-06-16 01:08:55 +02:00
README.md Measure no-JS upload via nginx $request_time (sub_filter), not php://input 2026-06-16 01:36:22 +02:00

librespeed-nojs

A JavaScript-free, Tor-friendly overlay for LibreSpeed running on the LinuxServer.io image.

Browsers with scripting disabled — Tor Browser at the "Safest" level, NoScript users — get bounced from / to a /nojs/ test that works with pure HTML. The JavaScript test at / is untouched.

Why a no-JS version is inherently limited

Measuring throughput means counting bytes against a clock on the client, and the only clock + byte counter a web page has is JavaScript. Plain HTML has none. So this overlay is an honest, degraded fallback, not a replacement:

Metric No-JS approach Quality
Download Browser fetches a large incompressible payload; a trailing <meta refresh> fires only once the whole payload has arrived. Server brackets the transfer with two request timestamps and subtracts a one-hop latency baseline. Estimate (includes overhead)
Upload A form POSTs an incompressible body; nginx records the request duration ($request_time) and injects it into the response via sub_filter; result.php divides bytes by that time. Good
Ping / jitter Not possible without a client clock. Only a rough round-trip latency is shown

How the flow works

All state is carried in the URL as an HMAC-signed token — no cookies, no server-side sessions (deliberate, for Tor users with restricted storage).

/nojs/             pick a profile (Quick / Standard / Large)
  -> start.php     stamp t0,            302 ->
  -> calib.php     stamp t1; base = t1-t0 (one empty hop ≈ 1 RTT), 302 ->
  -> dl.php        stamp t_d0; stream N bytes of random base64 in a <head>
                   comment; trailing <meta refresh> ->
  -> dl_done.php   stamp t_d1; download ≈ (t_d1-t_d0) - base; show upload form ->
  -> ul.php        count body bytes; emit meta-refresh page with __NRT__ ->
  -> result.php    download (est.) / upload (bytes ÷ nginx $request_time) / latency

The download payload is random base64: gzip and Tor compression can't shrink it, and base64 never contains - or <, so it can't break out of the HTML comment. nginx gzip is also disabled on dl.php.

For upload, PHP can't time the transfer because this nginx build buffers the request body before the script runs (despite fastcgi_request_buffering off). Instead, nginx's sub_filter rewrites the __NRT__ placeholder in ul.php's response to $request_time (the true request duration), and result.php divides the uploaded byte count by it. If sub_filter is unavailable, upload is reported as not measured rather than wrong.

Layout

nojs/            PHP overlay (lives in the persistent /config volume)
  _lib.php       signed tokens, formatting, page chrome
  index/start/calib/dl/dl_done/ul/result.php
  .user.ini      raises post_max_size, disables eager POST reading
nginx/nojs.conf  nginx location overlay (root /config, buffering/gzip tuning)
tor/             alpine+tor onion-service sidecar (built locally, auditable)
deploy.sh        idempotent installer into a /config volume + noscript injector
docker-compose.yml  librespeed + tor onion sidecar

Deploy

# 1. Bring up the stack (seeds ./config from the image on first run).
NOJS_SECRET=$(openssl rand -hex 32) docker compose up -d

# 2. Install the overlay into the config volume (re-run after image updates).
./deploy.sh ./config librespeed

# 3. (Tor) read the generated onion address, then re-run deploy.sh with it so
#    clearnet responses advertise it via the Onion-Location header.
docker exec librespeed-tor cat /var/lib/tor/hs_speedtest/hostname
./deploy.sh ./config librespeed <that-hostname-without-.onion>

deploy.sh is reversible: it backs up index.html and the nginx site-conf before editing, and the overlay block is fenced by # >>> librespeed-nojs >>> markers.

Notes for Tor

  • Over Tor you measure your circuit's throughput (3 relays + the onion path), not your own link. Use the Quick profile.
  • Served as an onion service, the client's IP is never exposed to the server, so the results page reports "IP hidden" instead of an address.
  • The .onion address is persisted in the tor-data volume; back it up if you want it to survive a volume reset.

Upstream

Based on LibreSpeed 6.1.0 (LGPL-3.0). This overlay adds only new files plus a fenced nginx block and a single <noscript> line; it changes none of the upstream JavaScript test.