Subscriptions
This page is about the commercial subscription, not the software licence. The software licence is the GNU Affero General Public License v3 or later (
LICENSEat the root of the source tree,/usr/share/doc/simplevirt/LICENSEon an installed appliance).docs/LICENSING.mdcovers how to mint subscription keys with your own signing key. This page covers what a key means, what the product does with it, and the contract for the optional online validation server.
1. What a subscription is, and what it is not
SimpleVirt is AGPL-3.0 and every feature in the build works with no subscription at all. There is no community edition, no feature matrix, no "enterprise" build. A subscription buys exactly two things, both of which are obligations on the vendor rather than switches inside the software:
- Access to the enterprise update channel.
- Support entitlement — the right to open a ticket and be answered.
Concretely, this is what the product does and does not do with a key:
Reports active / expired / none, with tier, who it was issued to, expiry and host count |
✅ |
| Records the entitlement so support can confirm it | ✅ |
| Shows a dismissible notice when there is no subscription | ✅ |
| Gates a feature, endpoint, button, host count or VM count | ❌ never |
| Phones home by default | ❌ never |
| Degrades, nags repeatedly, or blocks work when unsubscribed | ❌ never |
Two tests enforce the first "never" and will fail the build if anyone changes their mind quietly:
TestEveryFeatureWorksWithoutASubscription(internal/commandserver) runs a battery of representative reads and a mutation against two controllers — one subscribed, one not — and requires identical answers.TestNoHandlerConsultsTheSubscriptionToDecideBehaviourfails if any file outside the subscription's own handlers reads the subscription state.subscription.test.mjsdoes the same sweep over the SPA.
Not implemented, deliberately: the product does not consume the key for anything. There
is no package repository client, and the in-product updater is ISO-based (see
docs/installation.md), so "enterprise update channel access" today is something the vendor
grants out of band — not a channel the controller authenticates to. If that changes, the key
is already where it needs to be; nothing in the UI claims it is happening now.
2. The key
A subscription key is the same Ed25519-signed token docs/LICENSING.md describes:
SIMPLEVIRT-<base64url(payload)>.<base64url(signature)>The payload is JSON:
{"tier": "Enterprise", "to": "Acme Corp", "exp": 1790000000, "hosts": 8}| field | meaning |
|---|---|
tier |
any string the vendor uses, e.g. Standard, Enterprise. Blank defaults to Standard. |
to |
the licensed-to name, shown in the UI. |
exp |
Unix seconds. Omitted = perpetual. |
hosts |
how many hosts the subscription covers. Omitted = the key states no host count, which is not the same as zero and is displayed as "Not stated by this key". |
hosts is new and optional; tokens minted before it exist unmarshal to 0 and keep verifying
byte for byte. Mint one with make license TIER=… TO=… DAYS=… or:
go run ./cmd/simplevirt-license -tier Enterprise -to "Acme Corp" -days 365 -hosts 8The appliance carries only the public key and can verify but never mint (internal/license).
Where it is stored
<data_dir>/subscription.json on the controller, mode 0600, written atomically
(tmp + fsync + rename + parent-dir fsync). The token is sealed at rest with the
controller's envelope-encryption key (internal/secret, AES-256-GCM, key at
/etc/simplevirt-command/db-key) — the same treatment as the LDAP bind password. With no
encryption key configured it degrades to a plaintext 0600 file, exactly as the other
secrets do in file mode, and the existing "secrets are not encrypted" banner already covers
that condition.
The file also holds an opaque install id (16 random bytes) and the last online verdict.
Its JSON field for the token is named subscription_token so the support-bundle redactor
masks it by field name if the file is ever added to a bundle. It is not in a bundle today.
Restore note. In Postgres mode the controller's domain state is in the database and a backup is a
pg_dump; the subscription is a data-dir file (the release froze the DB schema), so it rides in the file half of the backup tarball. If a controller is rebuilt from a database dump alone, re-enter the key — it takes ten seconds and the token is re-issuable.
3. Status, and how it is decided
Three states. none is normal, not an error: a client who has not bought support is
running a supported configuration of a free product, and the UI says so in those words.
| status | meaning |
|---|---|
active |
a key is stored, verifies, and has not expired (within the clock-skew tolerance) |
expired |
a key is stored and verifies, but its term has ended — or the vendor revoked it |
none |
no key stored; or a stored key that cannot be decrypted or no longer verifies (the message says which) |
The signed token alone decides this. The optional online endpoint can confirm it and can revoke it; it can never be what makes a subscription work.
Clock skew
Expiry is a date comparison on a machine whose clock nobody controls — an appliance on an
isolated management LAN often has no NTP, and a guest's RTC can slip. So expiry carries a
24-hour tolerance (license.ClockSkewTolerance): a key that ran out four hours ago by
this controller's clock still reports active; one a day past its term reports expired.
The error is biased towards the customer on purpose. Telling a paying client their support
lapsed because a VM's clock drifted is a worse failure than reporting active for one extra
day. When an online check happens and the response carries server_time, the difference is
recorded and surfaced (check.skew_seconds) once it exceeds five minutes, so an operator
finds out their clock is wrong here rather than somewhere more expensive.
4. The online validation endpoint — the contract
This endpoint does not exist yet. The product ships the client side of it behind an
interface (license.Validator) whose default implementation, license.OfflineValidator,
makes no network call ever. This section is the specification: implement a server that
answers it and wire it in (§4.8) and nothing else in the product has to change.
4.1 Endpoint and method
POST <endpoint>
Content-Type: application/json
Accept: application/json<endpoint> is a full URL configured by the deployment's owner. The client appends no path
of its own — whatever is configured is what is called. Suggested vendor path:
https://subscription.example.com/v1/subscription/validate.
There is no default endpoint anywhere in the product. A controller with none configured never contacts anybody.
4.2 Authentication
None. The subscription key in the body is the credential, and it is unforgeable (Ed25519). Do not require an API token: it would be one more secret to distribute, rotate and leak, and it would protect nothing the signature does not already protect. Serve the endpoint over TLS; the client uses Go's standard verification against the system trust store.
4.3 Request
{
"key": "SIMPLEVIRT-eyJ0aWVy….c2ln",
"key_id": "a1b2c3d4",
"product": "simplevirt-command",
"version": "1.0.0+2026.09.17.1200.abc1234",
"install_id": "9d2f4c8e1b7a05f36d4e8c2a91f5b073",
"checked_at": 1790000000
}| field | why it is sent |
|---|---|
key |
the thing being validated |
key_id |
its SHA-256 prefix, so the server can log which key was checked without writing a credential into its own logs |
product |
which component is asking |
version |
the build, so support knows what is actually running |
install_id |
16 opaque random bytes generated locally on first use; lets the vendor tell "one customer, two clusters" from "one key, many customers" |
checked_at |
the client's clock, so the server can measure skew |
Privacy — what is NOT sent, and will not be added quietly. No host names, no IP or MAC
addresses, no cluster or VM names, no host or VM counts, no storage or network topology, no
licensed-to name (the vendor issued the key and already knows it), no telemetry of any kind.
A client running a private datacentre is right to object to an inventory phone-home, so there
isn't one. TestValidationRequestCarriesNoFleetData pins the exact field set: adding a field
fails that test, which makes it a privacy decision taken in the open rather than a line of
plumbing.
If the vendor later wants host counts for compliance, that is a separate, opt-in field that must be documented here and disclosed in the UI before it ships. It must not become a precondition for anything, because nothing is gated.
4.4 Response
200 OK:
{
"verdict": "valid",
"message": "Enterprise, 8 hosts, renewed 2026-03-01",
"server_time": 1790000000,
"recheck_after": 86400
}| field | |
|---|---|
verdict |
required: valid | expired | revoked | unknown |
message |
optional, one sentence, shown to the operator verbatim — write it for a human |
server_time |
optional Unix seconds; used only to report clock skew |
recheck_after |
optional seconds; a hint, never a command. The client's own floor is 24h |
Verdict semantics — this is the part that matters:
valid— the server stands behind the key. Recorded; the status is unchanged (the token already said so).expired— the term has ended. Reported asexpired.revoked— the key was withdrawn (refund, chargeback, reissue). This is the only case where the server overrides the signed token, and the reason travels with it into the UI. It still gates nothing; it changes what the product says.unknown— the server has never heard of this key. Deliberately not treated as a revocation. A signed token stands on its signature, and a vendor database that lost a row, or a key minted by a fork's own signing key, must never silently unsubscribe a paying customer.
4.5 Status codes
| code | client behaviour |
|---|---|
200 |
decode the body. A recognised verdict is used. An unrecognised or unparseable body is treated as no answer at all (unreachable) — that is what a captive portal or a proxy error page looks like |
404 |
verdict unknown, no retry. The server does not know the key; the token still stands |
429 |
transient. Honour Retry-After (seconds form, capped at 30s), then retry |
5xx |
transient. Retry |
other 4xx |
no retry; recorded as no answer. Our request was wrong — a client bug, not a subscription event |
| network error / TLS failure / timeout | transient. Retry, then no answer |
4.6 Timeouts, retries, cadence
- Timeout: 5 s per attempt, end to end (
license.defaultValidateTimeout). - Retries: 3 attempts total, only for transient failures.
- Backoff: exponential with full jitter — attempt n waits a uniform random delay in
[0, 1s × 2ⁿ⁻¹). Jitter is not decoration: a fleet of appliances that lose the endpoint at the same moment must not return as a thundering herd. - Cadence: at most once per 24 h (
license.CheckInterval). Never on page load, sign-in or navigation — a check per operator action would be a phone-home in all but name. Today the only thing that triggers a check is an administrator pressing Check now; there is no background tick, because with the offline default there would be nothing for it to call. Wiring an endpoint means adding that tick — see §4.8. - Worst case per check: three 5 s attempts plus jitter — bounded well under a minute, and it runs behind an explicit button or a daily tick, never in a request an operator is waiting on.
4.7 What is cached, and what happens when the endpoint is unreachable
Cached in subscription.json: the last verdict, when it was received, the timestamp of the
last real answer, the server's message, and server_time. Nothing else.
Unreachability never changes the status. This is the load-bearing rule of the whole design:
- A subscription that was
activestaysactive— for a day, a month, forever. An air-gapped datacentre is a normal SimpleVirt deployment, and it must be indistinguishable from a connected one in everything except this one note. - After 30 days without a real answer (
license.GraceWindow), the product adds a line: "This subscription has not been confirmed with the subscription server for over 30 days. It is unchanged — only the confirmation is missing." It still does not change the status. Thirty days was chosen because it is longer than any plausible outage or maintenance window and shorter than the shortest subscription term, so it can only ever produce a note, never a surprise. - Consecutive failures keep the first failure's timestamp, so the grace window dates the outage rather than being reset by every retry.
TestUnreachableEndpointNeverExpiresAValidSubscriptionis the regression guard.
Nothing else in the product is affected by a failed check. No feature pauses, no screen changes, no operation is refused.
4.8 Wiring a real server in
One construction site, no call sites (cmd/simplevirt-command/serve.go):
commandserver.Options{
…
SubscriptionValidator: license.NewHTTPValidator(cfg.Subscription.Endpoint, nil),
}nil leaves it on license.OfflineValidator, which is what ships. Add the config key
(subscription.endpoint, empty by default) at the same time, so turning it on is an explicit
act by the deployment's owner and is visible in config.yaml — never a default that arrives
with an upgrade.
Two things go with it, neither of which exists yet because neither has anything to do:
- The daily tick. A goroutine started beside the other controller loops
(
cmd/simplevirt-command/serve.gostartsRunHAEngine,RunAlarmEngineand the rest) that calls the validator no more often thanlicense.CheckIntervaland hands the result toSubscriptionStore.RecordCheck. It must be a no-op when the validator is the offline one. - A check after a key is entered, so an operator gets confirmation while they are looking at the screen rather than a day later.
Both are a handful of lines against the interface that is already here; they are left out on purpose rather than shipped as machinery that ticks against nothing.
5. The HTTP surface
All five routes require the admin.users privilege. No new privilege was added, so an
existing customer's role catalog is unchanged; the subscription is an administrator's concern
and the notice is only worth showing to people who can act on it. A non-administrator gets
403 and their console simply shows no notice.
| method | path | |
|---|---|---|
GET |
/api/v1/subscription |
the state. Always 200 — no key, an undecryptable key and a key that no longer verifies are all states with a sentence attached, not request failures |
POST |
/api/v1/subscription |
{"key": "SIMPLEVIRT-…"}. 200 on success, 400 with an actionable message when the signature does not verify |
DELETE |
/api/v1/subscription |
remove the key; returns the (now none) state |
POST |
/api/v1/subscription/check |
ask the endpoint now. Always 200: an unreachable server is knowledge about the network, not a failed request |
POST |
/api/v1/subscription/notice/dismiss |
dismiss the notice for the calling operator |
Response body:
{
"status": "active",
"tier": "Enterprise",
"issued_to": "Acme Corp",
"expires": 1790000000,
"days_left": 210,
"hosts": 8,
"key_id": "a1b2c3d4",
"activated_at": 1789000000,
"message": "",
"check": {
"mode": "offline",
"verdict": "",
"at": 0,
"message": "",
"stale": false,
"skew_seconds": 0
},
"notice": { "show": false, "state": "active", "dismissed": false }
}The key is never returned by any endpoint. It goes in once, on the activate request; from
then on only key_id (its SHA-256 prefix) leaves the controller, which is what a support
ticket should quote.
subscription.activate, subscription.remove and subscription.check are audit-logged (the
activate entry records the tier and the fingerprint, never the key). Dismissing the notice is
not audited: it is a reading preference, and logging it would bury the events that matter.
6. The notice
When there is no subscription, or it has expired, the console shows one line below the top bar:
No support subscription — SimpleVirt is free software and every feature on this controller is available. A subscription adds vendor support and access to the enterprise update channel. [View subscription] [Dismiss]
Rules it obeys, each of which is a test:
- Never a modal. Proxmox opens a dialog on every sign-in that must be clicked away before the console can be used. This is a strip in the page flow, above the top bar, that overlays nothing.
- Never blocks anything. No disabled control, no intercepted click, no focus steal (it is
role="status"). - Drawn once per sign-in, not per navigation.
- Dismissible, and the dismissal persists per operator — server-side, keyed by username, so it does not come back on the next machine they sign in from. Browser storage would be per-device, which is not the same thing.
- Per state. Dismissing "no subscription" does not pre-dismiss "expired": those are different things to tell someone, and swallowing the second would make the product quietly unhelpful exactly when it matters.
- Copy that does not lie. No "unlock", no "limited mode", no "unlicensed". The product is not limited, and saying otherwise to sell support would be both false and beneath the product.
The full picture lives at Administration → Subscription: status, tier, who it was issued to, expiry with days remaining, hosts covered, key fingerprint, how this controller validates (offline by default, and it says so), the field to paste a key into, and a two-step in-place Remove key.
7. Operator FAQ
Does anything stop working when a subscription expires? No. Nothing changes except what the console reports.
Does the appliance call home? No. Not on install, not on sign-in, not on expiry. It contacts a subscription server only if the deployment's owner configured an endpoint, and even then at most once a day — and what it sends is listed in §4.3 in full.
Can I run SimpleVirt in an air-gapped datacentre? Yes, with or without a subscription. Offline verification is the default and the only mode that ships.
My controller's clock is wrong — will my subscription break? There is a 24-hour tolerance either way, and a large skew is reported once a check runs. Fix the clock; nothing will have stopped in the meantime.
Someone told me my key does not verify. It was minted by a different signing key than the
build verifies against (a fork's own keypair, or a rotated vendor key — see
docs/LICENSING.md §2). The product says so and asks for the key you were issued; it does not
pretend nothing was ever entered.
I lost the key. Ask the vendor to re-issue it. Tokens are signed, not secret — they are safe to email — but the controller stores yours encrypted anyway, because it is what authenticates this deployment to the vendor.