Wheelhouse docs

Privacy#

A Wheelhouse router makes exactly one outbound request that you did not ask for: a licence refresh, once a day, four fields, to one host. There is no telemetry, no analytics, no crash reporting, no usage metrics and no update check. Nothing counts your rules, your interfaces, your clients or your traffic and sends the number anywhere. This page states the request field by field, names the code that builds it, and shows how to turn it off — because a claim like that is worth nothing unless you can check it.

The licence refresh#

What is sent#

agent/license.go builds the body in refresh() and posts it. Four keys, and no others — with example values, since three of the four differ per router:

json
{
  "key": "WHL1.<kid>.<payload>.<signature>",
  "fingerprint": "9f3c1d2a4b5e6f70",
  "hostname": "edge",
  "version": "0.5.1"
}
FieldWhat it isWhy it is there
keyYour current licence token, the WHL1.… stringIt is the thing being refreshed
fingerprint16 hexadecimal characters derived from this installation's machine id — the formula is belowIt lets the server count how many routers are using one licence, which is what a plan's router limit means. It is a one-way hash; the server never learns the machine id
hostnameThe router's host name as hostname reports it — whatever you typed at install. If the system cannot answer, the literal wheelhouse is sentSo the licence page can say which of your routers an activation is, rather than showing you a list of hashes
versionThe agent's version string, for example 0.5.1So a support conversation starts from what you are actually running

That is the complete body. Nothing else is added, and there is no separate telemetry request. The functions are refresh(), licenseFingerprint() and hostnameOrDefault(); the version is the version constant in agent/main.go.

The fingerprint is licenseFingerprint(), and it is exactly this:

first 8 bytes of SHA-256( trim(contents of /etc/machine-id) + "|wheelhouse" ), in hex

If /etc/machine-id is empty or unreadable, the router's host name is hashed in its place. The machine id itself never leaves the router.

The connection itself reveals what any HTTPS request reveals: your router's public IP address, and the time of the request. The licence server sees those the way any web server sees them.

Where it goes#

A POST to /v1/refresh on the licence server, over HTTPS, with Content-Type: application/json and a 20-second timeout. The server is a build-time default — https://license.rhymelikedi.me — settable with --license-server, and the value in use is shown in the web UI on the Licence page under Licence server, so the router tells you where it is talking to rather than you having to believe a document. It is also in GET /api/license, along with the fingerprint the router will send.

How often#

WhenWhat
About 20 seconds after the agent startsThe first attempt (licenseRefreshInitial)
Every 24 hours after a successThe routine refresh (licenseRefreshEvery)
Every hour after a failureRetry until one succeeds (licenseRefreshRetry)
Immediately after a key is storedSo the Licence page can show the server's answer straight away (handleLicenseSet)

What comes back, and what the agent does with it#

The response is JSON: a fresh token, a status, an expires_at, a message, an error and a features list. The agent reads at most 64 KiB of it. If a new token is present and verifies, it replaces the stored one — in state.json, so the next start holds it. The token's expiry is the sooner of your paid-through date and 30 days ahead, which is what lets an offline router keep working (ADR-003).

AnswerEffect on the router
200The new token is adopted; the status and message are shown on the Licence page
403The licence is recorded as revoked and the write plane closes at once
400, 402, 404, 409The refusal and its reason are recorded and displayed; the existing token keeps working until its own expiry
Anything else, or no answer at allNothing changes. The failure is recorded as cannot reach the licence server: … and the current token stays good until its expiry plus its grace period

A network that eats the request cannot cost you your router. That is deliberate: losing the ability to see a router because a refresh failed would be a worse product than losing the ability to change it.

What happens if it never runs at all#

Nothing breaks. A licence key is a signed statement the agent verifies offline, against an Ed25519 public key compiled into the binary. A router that has never reached the licence server and never will still knows what it holds, and keeps working until the token's expiry plus the grace period carried in the token — currently a 7-day policy on the server side (PRICING.md).

Refresh exists so that a renewal extends your token without you touching the router, and so that a revoked licence stops working within a day. Neither is required for the router to run.

Turning it off#

Pass an empty licence server. With one, refresh() returns immediately and no request is ever made.

bash
sudo systemctl edit wheelhouse-agent
ini
[Service]
ExecStart=
ExecStart=/usr/bin/wheelhouse-agent \
  --api-url https://127.0.0.1 \
  --api-key-file /config/wheelhouse/api-key \
  --admin-token-file /config/wheelhouse/admin-token \
  --license-key-file /config/wheelhouse/license-key \
  --license-server '' \
  --addr 0.0.0.0:8443 \
  --tls-self-signed \
  --ui-dir /usr/share/wheelhouse/ui \
  --data-dir /config/wheelhouse \
  --log-level info

A systemd override has to restate the whole ExecStart, so copy the rest of the line from /lib/systemd/system/wheelhouse-agent.service first — packaging/wheelhouse-agent.service. An image upgrade replaces the unit file and keeps your override.

The Licence page shows refresh disabled under Licence server when it is off, so the state is visible rather than assumed.

What is stored on the router about you#

Two files under /config/wheelhouse/, both mode 0600 — agent/store.go.

state.json — what the router itself cannot remember for the agent:

  • Accounts. User names, roles, Argon2id password hashes and salts, TOTP secrets, when each account was created and when it last signed in.
  • Email addresses and display names, but only for accounts created through single sign-on, and only what your own identity provider asserted.
  • Live sessions. The session id, the account, the source address, the browser's user-agent string, and — for SSO sessions — the ID token, kept so that signing out can also sign you out at the provider.
  • API tokens. Label, role, a SHA-256 of the token, who created it, when it was last used. Never the token itself.
  • Settings, including your licence key.

audit.jsonl — the audit log, one JSON object per line, appended and synced as each entry is written. Every entry carries the actor, their role, their source address, the operation, the configuration path, the exact commands and whether it succeeded. The file rotates at 8 MiB and one previous generation is kept as audit.jsonl.1, so the log costs at most twice that on disk. The audit_retention setting — 2000 entries by default — bounds the window the agent holds in memory and serves from GET /api/audit; older entries stay in the file.

None of that leaves the router. There is no mechanism in the agent to export the audit log anywhere, which is a gap for anyone who wants it in a SIEM and a privacy property in the meantime — What is finished, and what is not.

Other things the router talks to, because you asked it to#

None of these is Wheelhouse phoning home. Each is something you configured.

WhatWhenTo whom
Your identity providerEvery SSO sign-in, and discovery at start-upThe issuer URL you configured
A container registryWhen you install or update an appDocker Hub or GHCR, depending on the app; the router pulls the image directly
DNS and NTPContinuouslyWhatever the router's configuration names. The image's default NTP servers are pool.ntp.org
A URL you pasteWhen you install a system imageWhatever host that URL names
The internet at largeWhenever you route a packetIt is a router

What is held about you elsewhere#

Whatever a licence purchase creates — an account, an email address, a subscription — lives on the licence server and with the payment processor, not on the router. Billing is Stripe's; the licence server never sees a card (PRICING.md).

How long licence-refresh records are retained, and the formal privacy notice a data protection officer will ask for, are not written yet: <TODO: owner>.

What this website collects#

These pages are static files. There is no analytics, no tracking pixel, no embedded third party and no cookie. Nothing on a page loads from another host: the one typeface, the stylesheet, the search index and about 11 KB of JavaScript all come from the same origin, which is why the site can be served under a content security policy of default-src 'none' with 'self' and nothing else — site/deploy/Caddyfile.snippet.

  • Search runs in your browser. The index is built at build time and fetched from this host; no query is ever sent anywhere.
  • The light/dark toggle writes one key, wh-theme, to your browser's local storage. It never leaves the browser.
  • The web server keeps request logs in Caddy's JSON format, as almost every web server does. What is kept and for how long is <TODO: owner>.

Where to check this yourself#

Everything on this page above the website section is one file. The payload is built in refresh(), the fingerprint in licenseFingerprint(), the schedule in runLicenseRefresher() — all in agent/license.go. If you have the source, read it. If you do not, watch it from the router: the request is one POST a day and it is the only unprompted one there is.

See also#

Checked against#

agent/license.go · agent/main.go · agent/store.go · docs/privacy.md · docs/security.md · docs/adr/003-licensing.md · PRICING.md · packaging/wheelhouse-agent.service · site/deploy/Caddyfile.snippet

Updated 2026-09-02 privacy licence telemetry