Docs / Subscriptions

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 (LICENSE at the root of the source tree, /usr/share/doc/simplevirt/LICENSE on an installed appliance). docs/LICENSING.md covers 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:

  1. Access to the enterprise update channel.
  2. 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.
  • TestNoHandlerConsultsTheSubscriptionToDecideBehaviour fails if any file outside the subscription's own handlers reads the subscription state. subscription.test.mjs does 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 8

The 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 as expired.
  • 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 active stays active — 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.
  • TestUnreachableEndpointNeverExpiresAValidSubscription is 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:

  1. The daily tick. A goroutine started beside the other controller loops (cmd/simplevirt-command/serve.go starts RunHAEngine, RunAlarmEngine and the rest) that calls the validator no more often than license.CheckInterval and hands the result to SubscriptionStore.RecordCheck. It must be a no-op when the validator is the offline one.
  2. 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.

This page is part of the SimpleVirt 1.0.0 documentation set. The same guides ship inside the appliance images at /usr/share/doc/simplevirt.