What the licence gates#
A Wheelhouse licence unlocks configuration changes on a router, and nothing else. Without a usable licence the agent still signs you in, still serves every page, every counter, every log and the whole audit log — and refuses to stage, commit, roll back, install an app or run the reconcile loop, saying so in one line with a link to the Licence page. Losing sight of your router because a card expired would be a worse product than losing the ability to change it, so the gate is on the write plane only.
Where the gate sits#
The write plane is wrapped twice. Every route in it is
writeable(requireLicense(handler)) — the operator role first,
then the licence (agent/main.go, routes();
agent/license.go, requireLicense).
| Needs a licence | Does not |
|---|---|
POST /api/stage, /api/stage/remove, /api/commit, /api/commit/confirm, /api/discard | Every GET, including /api/config, /api/audit, /api/history and the telemetry stream |
POST /api/configure, /api/rollback, /api/config/save, /api/config/load | /api/auth/* — sign-in, sign-out, password change, TOTP enrolment, session listing |
POST /api/reconcile, /api/capture | GET /api/license itself, and GET /api/auth/status, which carries a licensed flag so the sign-in screen can say so |
Every /api/apps/* write | GET /metrics |
POST /api/diagnostics/traceroute and POST /api/ids/ips/plan — operator, but commands and plans rather than configuration changes | |
wheelhouse-agent plan, which reads and prints and changes nothing |
The refusal is a 402 Payment Required whose body names the fix rather than just refusing:
{"error": "a licence is required to change configuration; enter one under Administration → Licence",
"license": "unlicensed"}When the licence exists but has a problem, the message is that problem — the licence expired on 2026-08-14 — followed by configuration changes are disabled.
The key, and why it works offline#
A key is WHL1.<kid>.<payload>.<signature>: an Ed25519 signature over a small JSON
payload naming the licence id, the customer, the plan, the features, the router limit,
the issue time, the expiry and the grace window. The agent verifies it against a public
key compiled into its own binary, so a router with no route to the internet still
knows what it holds (agent/license.go, parseLicense;
ADR-003).
kid identifies which public key signed it, so a rotation ships the new public key in
a release and keeps the old one for existing tokens. A build carrying no key can
verify nothing and refuses every licence — a build without keys is a build that cannot
be licensed, not one licensed for free.
The states#
GET /api/license reports one of these, and the Licence page shows it
(agent/license.go, licenseState.view):
| State | Writes | Meaning |
|---|---|---|
unlicensed | closed | No key. |
valid | open | Inside the token's expiry. |
grace | open | Past expiry, inside the grace window. The page says when changes stop. |
expired | closed | Past expiry and past grace. |
revoked | closed | The server said so on a refresh. |
invalid | closed | The stored key does not parse or does not verify — a bad signature, or a key id this build does not know. |
clock-unverified | open | The router's clock reads earlier than the licence was issued. |
That last one is worth understanding. A clock that reads before the issue time cannot
be trusted to judge expiry either, and a forward jump from a broken or hostile time
source would otherwise close the write plane — with set service ntp behind that same
gate, so the fix would be locked behind the fault. The agent stays open and says why:
this router's clock reads before the licence was issued; set the time (System → NTP)
so expiry can be judged.
The grace window is a field in the token, in seconds, not a constant in the agent. The
licence server issues seven days (PRICING.md).
The daily refresh#
While the licence server is reachable, the agent refreshes: about 20 seconds after
start, then every 24 hours, retrying hourly after a failure, and immediately after a
new key is entered so the page can show the server's answer
(agent/license.go, runLicenseRefresher).
The request is a POST to /v1/refresh at the licence server —
https://license.rhymelikedi.me by default, changeable with --license-server, and
disabled entirely by --license-server ''. It carries four fields and nothing else:
the key, a fingerprint of this installation, the router's host name and the agent
version. The fingerprint is 16 hexadecimal characters — the first eight bytes of
SHA-256 over /etc/machine-id plus a fixed string — so the server can count how
many routers are using one licence without learning the machine id. Every field, and
what the connection itself unavoidably reveals, is set out in
docs/privacy.md.
What the answers do:
| Answer | Effect |
|---|---|
| A fresh token | Stored and used from then on; its expiry follows what has been paid for. |
| Network failure | Nothing changes. The current token stays good until its expiry plus grace. |
403 | Revoked. The write plane closes at once, and the page says why. |
402, 404, 409, 400 | Recorded, with the server's reason, so the page can show it. Whatever token is held keeps whatever validity it had. |
Features within a plan#
Some capabilities are named in the token's features list rather than gated by
expiry. Today that is fleet: /api/fleet/{id}/* is wrapped in
requireFeature("fleet", …), which is requireLicense plus a check on the list, and
answers 402 with the fleet feature is not part of this licence's plan. Fleet
configuration loads regardless of the licence; the routes are what is gated.
Entering and removing a key#
- In the UI: Administration → Licence, which is
PUT /api/admin/licenseand needs the admin role. The key is verified before it is stored, so a typo is refused with the reason. - On the command line:
--license-key-file /config/wheelhouse/license-key, a 0600 file. The agent refuses to read a secret file that is group- or world-readable. A key given by flag is also saved, so the next start without the flag still holds it.
Either way the key is stored in the agent's state file with the settings, and the settings endpoints never return it. Setting and removing a key are both audited.
What this gate is not#
The licence also does not touch the GPL boundary. It covers Wheelhouse's own agent and UI; the router underneath is GPL and its corresponding source is published — see Wheelhouse and VyOS.
See also#
- Read plane, write plane, admin plane — the other gate on the same routes.
- Licence and plans — the plans, and what an unlicensed router still does.
- Privacy — the daily refresh, field by field.
- Entering a licence — the task.
Checked against#
agent/license.go ·
agent/main.go ·
docs/adr/003-licensing.md ·
docs/privacy.md ·
docs/security.md ·
docs/deploy.md ·
PRICING.md