Release 1.4.0
A feature release worked out of the upstream issue backlog: MQTT and Home Assistant, email alerts, a timezone for the schedule, and hosting under a path prefix — fifteen upstream reports answered, alongside the fixes a whole-project review of 1.3.5 turned up. Upgrading is drop-in: no migrations, no configuration changes, every new setting off until it is set. One thing to know first: the speedtest CLIs are now checked against a pinned digest before they are unpacked, so an instance fetching them through a mirror that repackages the archives will refuse the download and say why.
✨ New
- Results are published to MQTT (#1190) — the flat JSON the webhook, the CSV export and the API already use, so a sensor reads
value_json.download. Retain is offered, because without it an entity reads "unknown" from every restart until the next test; failures go to their own topic,<topic>/error, because a sensor fed both payload shapes goes unavailable. - Home Assistant finds the sensors by itself (#807) — seven retained discovery messages: the two throughputs, ping, jitter, packet loss and the two loaded latencies, each with a unit and
state_class: measurementso long-term statistics work at all. Off unless asked for. - Alerts by email (#1259) — the one channel missing from eight integrations, and it takes more than one recipient. A list is refused whole if any address in it is malformed; loopback and LAN relays stay allowed, and the address guard, the deadline and the activity note apply as to every other send.
- Telegram notifications can land in a topic (#1176) — without
message_thread_idevery one of them arrives in a supergroup's lobby. - The schedule keeps its own timezone (#1115, #748) — the cron, the quiet window and the countdown all read it, where before they ran on whatever clock the host kept and the Docker image pins
TZ=Etc/UTC. Picked in the pause dialog beside those hours; an instance that sets none behaves exactly as it did. - The whole application can be served from a subdirectory (#771) —
BASE_PATH, for a proxy that routes on a prefix without stripping it off, and the only setting involved: the client works the prefix out for itself and a request without one passes through untouched. Fixes the PWA manifest's three absolute icons with it. - A server ID can be typed without picking one from the list first (#1455) — the field appeared only once a listed server had been selected, and the twenty on offer are the twenty nearest the instance.
- A database that cannot be read says what to do about it (#1549) — an integrity check ahead of the migrations, reported rather than fatal, naming the three ways out: restore a backup, run sqlite's own
.recover, and only then delete the file. Exiting is what produced that report's 138 restarts.
🔒 Security
- The downloaded speedtest CLIs are verified against a pinned digest. All 27 platform entries carry the SHA-256, checked between download and extraction and failing closed. They are spawned on a schedule, so a replaced upstream asset was arbitrary code on the host once an hour; 1.3.5 stopped them running as root, and this is the other half. It catches a later change, not an archive that was never trustworthy.
- The installer no longer gives up its privilege separation quietly. Under root's 027 or 077 umask it created an installation directory the service account could not enter and fell back to
root, one line of text as the only sign;uninstall.sh --keep-datarecreated the same mask. Both state the mode now. - A URL credential no longer leaves by three side doors — the
configUpdatedevent carried the LibreSpeed URL verbatim to the webhook and Discord modules, a run against a custom LibreSpeed backend left it indata/servers/libre_custom.json, and the InfluxDB endpoint shipped in clear beside the token that had just been blanked. - A LibreSpeed URL is judged by its protocol, not by whether
new URL()can parse it — which is true ofjavascript:,data:andfile:. - The IPv6 metadata address is refused. AWS answers IMDS at
fd00:ec2::254, Unique-Local rather than link-local, so it walked past every guard that allows the RFC1918 ranges on purpose. - An outbound post no longer follows a redirect, which delivered the request, method and body intact to a destination the guard never saw.
- The container's ownership pass no longer follows symlinks, which handed a link's target to the account the server runs as.
FRAME_ANCESTORSis checked before it reaches the header. A semicolon appends a whole directive to the policy; a newline threw on every request — 500 to everything, the health endpoint included.- The failed-password throttle no longer evicts a lockout that is still running.
🐛 Fixes
- A speed below zero is refused instead of recorded: one impossible figure beside two good ones dragged the average, coloured a grade, went out in the CSV export and raised an outage that never happened (#875, #792).
- A Cloudflare run that measured nothing no longer fabricates a zero for the direction that never ran — 0 Mbps is what an outage looks like.
- A provider answering "too many requests" is held fifteen minutes, doubling to a two-hour cap, instead of one refused request and one alert per minute for as long as the limit stands (#846, #1092). Never for a run started by hand.
- A CLI the boot could not fetch is fetched on the next run, instead of failing every test for the life of the process.
- The shutdown kill no longer spawns a replacement that outlives the server and writes into a closed database handle.
- English travels with the bundle, so a locale that fails to load can no longer render an error page that reloads into the same failed request (#725, #1330). A missing other locale falls back as configured.
- The dashboard survives a browser refusing session storage — Incognito, third-party cookies off, the embed in Homepage or Heimdall that
FRAME_ANCESTORSexists for. - A session expiring on a node asks for the password instead of dropping you on an empty node list.
- Signing in no longer reports success for a 401, or a 503 from a proxy in front of a stopped container.
- Hovering a failed test says why it failed. The tooltip filter dropped the only dataset with a point at that index, so the branch naming the reason could never run.
- A Cloudflare row with no latency average is no longer averaged as a 0 ms reading, which the alert gate had refused all along.
- One failed request no longer blanks a statistics page whose other half arrived.
- A node address ending in a slash works. Concatenation asked the child for
//api/config, so every proxied request 404'd and a healthy node read as broken. - A node backup fits through the relay, which was refusing the one endpoint whose size grows with faithful use behind a bare "Internal server error".
- A node card stops polling a hidden tab — some 4,300 requests an hour for six nodes.
- The
noneinterface sentinel is read as unset, not as a pinned adapter: it earned a three-round protection wait that failed every scheduled test for three hours. - The ping threshold keeps its fraction. "Use recommended" on a fibre line stored 0 instead of 0.4 — a threshold no latency is ever under — behind a dialog saying the change applied.
- The welcome wizard stopped rewriting all three thresholds with an integer parse on its way past.
- A factory reset is one transaction, so it can no longer half-happen and leave nodes polling behind a configuration that says "factory fresh".
- InfluxDB lines carry packet loss and the two loaded latencies, which the stored row and the webhook payload had all along.
- A long Telegram template whose formatting the 4096-character trim cut in half is sent unformatted rather than not at all.
- One unreadable value no longer answers 500 to every Prometheus scrape until a newer test arrives, which reads as the exporter being down.
- Restoring a history no longer freezes the server.
awaitbetween rows never left the microtask queue, so nothing else was served — including the healthcheck that then restarts the container mid-write. - The history export is streamed, instead of built as one allocation beside every row it read.
- The two routes that read every row are capped like the date-ranged export beside them, and a restored configuration cannot pack a million recommendations into one 50 MB body.
- The progress fill glides again, instead of being hard-stepped once a second by the toolbar's fit walk.
- The status bar is driven from memory, not by polling two database queries, three config reads and a cron computation every second. Its responses can no longer overtake each other and resurrect a finished run.
install.sh -d myspeedno longer hangs, and a relative path no longer writes a unit systemd refuses.- The uninstaller stops choosing its whole removal on a substring — a
MySpeedBackupcontainer sent a native systemd host down the Docker branch. - A host holding both a container and a service now gets both, instead of "MySpeed has been uninstalled" over a service still running and enabled through every reboot.
- Unit files are removed even where
systemctl --alldoes not list the service, and a second run no longer reports a failure on a host it just cleaned. - The install-time address lookup discards a failed attempt whole, instead of gluing a captive portal's error page onto half an address.
- The expanded chart is a real dialog, with a name, a role and focus management.
- The storage dialog's switchers are tabs, so its configuration half — factory reset included — exists for a keyboard at all.
- The date picker joins the Escape treaty, instead of answering every Escape in the app with a reset for a popover that was not open.
- The offset switch, the expandable cards and the two standalone toggles are reachable and announce their state.
- A calendar day names its whole date, not a bare number appearing three times in the grid.
- The three settings dialogs refuse a second submit while one is in flight.
ga.jsonis gone — 24 KB of Irish served from every deployment, absent from the language list so nothing could request it, and 148 keys short with no flag asset. A test notices the next one.- New strings are English and German; the other thirteen languages fall back until Crowdin catches up.
Downloads
| Platform | x86-64 | ARM64 |
|---|---|---|
| Windows | EXE · MSI · EXE (no AVX2) · MSI (no AVX2) | |
| Linux | Binary · No AVX2 | Binary |
| macOS | Binary | Binary |
Source Distribution
- ZIP Archive (requires Bun runtime)
Docker Images
i7gamer/myspeed:latesti7gamer/myspeed:1.4.0
What's Changed
- Fix/import blocks event loop by @i7Gamer in #20
- Fix/toolbar walk cancels progress transition by @i7Gamer in #21
- Fix/toolbar walk cancels progress transition by @i7Gamer in #22
- Review follow-ups: installer honesty, Escape treaty, stream coverage, policy layering by @i7Gamer in #23
- Fix/review follow ups by @i7Gamer in #27
- chore(deps): bump cron-parser from 5.8.1 to 5.10.0 in the server-minor-and-patch group by @dependabot[bot] in #24
- chore(deps): bump the client-minor-and-patch group in /client with 2 updates by @dependabot[bot] in #25
- Fix/toolbar walk cancels progress transition by @i7Gamer in #26
- Fix/privilege and credential hardening by @i7Gamer in #28
- Feat/upstream issue backlog by @i7Gamer in #29
Full Changelog: v1.3.5...v1.4.0