Instance License Onboarding (Runbook)

How to mint, deliver, renew, and verify a signed instance license for a Range Day Pro deployment — staging, production, or a white-label customer.

Background: Instance Verification and Licensing · White-Label Deployment Work item: roadmap/items/instance-verification

Mental model

  • The license is a signed instance.v1.License file (license.bin): raw LicenseClaims protobuf bytes + an Ed25519 signature over those exact bytes.
  • Claims carry: customer, domains[] (the licensed hosts), issued_at/expires_at, capability flags (public_signup, billing, drill_catalog), max_users (0 = unlimited), and a license_id (UUIDv7, for ops correlation).
  • Minting is offline and human-only. The Ed25519 private key is custodied by T.REX (James) and never enters CI. Only the public key ships in libs/core, and only the signed, public license.bin flows through the pipeline. Clients download the license bytes via the unauthenticated GetInstanceInfo handshake, so the file is not a secret — it is signed, not confidential.
  • The server is validate-and-warn: a missing/expired/invalid license never stops the process. But clients hard-block on a server that serves no valid license (“Not a verified Range Day Pro server”), so onboarding is not complete until a valid license is delivered.
  • Environments are separated only by the domains claim (and capabilities). One private key signs staging, production, and every white-label customer.

Canonical hosts

EnvAPI host (--domain)PWA web_origin (WEB_ORIGIN)
Productionserver.range-day.apphttps://my.range-day.app
Stagingstaging-server.range-day.apphttps://staging-my.range-day.app
White-labelcustomer’s API hostcustomer’s PWA host (must be a licensed domain)

Staging’s API host is staging-server.range-day.appnot the marketing host staging.range-day.app. Licensing the wrong one makes the client’s connect-host fall outside the signed domainsDomainMismatch → every client blocks.

web_origin is unsigned config; the client validates its host against the signed domains and falls back to https://my.range-day.app if it isn’t licensed. So every host you put in WEB_ORIGIN must also be one of the --domain values.


1 — Mint the license (offline, on James’s machine)

The private key is a PKCS#8 PEM (-----BEGIN PRIVATE KEY-----) or a 32-byte hex seed; the helper auto-detects. Build the CLI once (cargo build -p rangeday) and:

# Production
rangeday license issue \
  --key-file ~/.secrets/trex-instance-ed25519.pem \
  --customer "T.REX ARMS" \
  --domain server.range-day.app \
  --domain my.range-day.app \
  --expires 2028-06-08 \
  --public-signup \
  --max-users 0 \
  --out prod-license.bin
 
# Staging (same key, staging domains)
rangeday license issue \
  --key-file ~/.secrets/trex-instance-ed25519.pem \
  --customer "T.REX ARMS (staging)" \
  --domain staging-server.range-day.app \
  --domain staging-my.range-day.app \
  --expires 2028-06-08 \
  --public-signup \
  --out staging-license.bin
 
# White-label example: invite-only, capped seats
rangeday license issue \
  --key-file ~/.secrets/trex-instance-ed25519.pem \
  --customer "Springfield PD" \
  --domain training.springfieldpd.gov \
  --expires 2028-06-08 \
  --max-users 250 \
  --out springfield-license.bin

Flag → claim mapping:

FlagEffect
--domain <host> (repeatable, required)Licensed hosts. The connecting API host must match one.
--expires YYYY-MM-DDInvalid from 00:00:00 UTC on this date.
--public-signupOmit for invite-only (hides Register).
--billing / --drill-catalogEnable those capabilities (omit = off).
--max-users NSeat cap; 0/omit = unlimited.
--license-id <uuid>Pin an id for deterministic re-issuance; omit = fresh UUIDv7.

Record the license_id it prints against the customer/domains/expiry — it’s your ops handle for renewal and (future) revocation. Also record the customer’s renewal date (typically expires_at minus a lead time) wherever customer accounts are tracked: a white-label customer’s server is theirs (often air-gapped), so renewal is a minting-side process, not something our monitoring can reach. (T.REX’s own deployments are minted far-future, so the in-cluster <30-day Grafana alert is the net for our envs; see license-expiry-monitoring.)

Verify before delivering — full offline check (signature against the embedded public key, domain, expiry) of the exact file you’re about to ship. The --domain is the env’s API host (the table above), because that’s the host clients verify against; this is precisely the check that catches a wrong-host mis-mint:

rangeday license verify prod-license.bin    --domain server.range-day.app
rangeday license verify staging-license.bin --domain staging-server.range-day.app

Exit 0 = valid; 1 = verification failed (wrong domain, expired, bad signature, empty); 2 = the check itself broke (unreadable file). rangeday license inspect <file> prints the decoded claims + status when you need to see what’s inside. The deploy pipelines run this same check automatically — pre-apply on the secret and post-deploy against the served claims — so a mis-mint fails CI before rollout.

T.REX’s own unlimited deployment is expressed as --max-users 0, never a hardcoded customer-name bypass in code.


2 — Deliver to a managed (k8s) environment

The license rides through the existing deploy pipeline as a per-environment GitHub secret; CI base64-decodes it into the server-license k8s secret, which the Deployment mounts at /etc/rangeday/license.bin (LICENSE_FILE).

  1. Base64-encode the file (single line, no wrapping):
    base64 -w0 prod-license.bin   # macOS: base64 -i prod-license.bin | tr -d '\n'
  2. In GitHub → Environment (staging / production) → Secrets — scope these per-environment, never repo-level. A repo-level LICENSE_B64 / WEB_ORIGIN feeds the same value to both staging and production, so one env serves the other’s signed domains / web_originDomainMismatch → every client hard-blocks.
    • LICENSE_B64 = the base64 string from step 1.
    • WEB_ORIGIN = the PWA origin for that env (see table). Required for every environment except production. If unset, the server falls back to the pinned production default (https://my.range-day.app), which is wrong for staging and any white-label host, so the PWA’s origin check fails.
  3. Trigger the deploy:
    • Staging: push to master (paths under deploy/k8s/**, .github/actions/**, apps/server/** … trigger it) or run Deploy Server via workflow_dispatch.
    • Production: push a server-v* tag (rangeday release server <bump>) or run Release Server via workflow_dispatch.

CI runs apply-secrets (writes WEB_ORIGIN into server-env) and apply-license (writes server-license) before the kustomize apply, so the rolled pod mounts the new license. Both steps skip cleanly if their input is empty — a deploy without LICENSE_B64 still succeeds and the server serves an empty license (clients block).

Once LICENSE_B64 is set, delete the local *.bin — the GitHub secret is now the source of record, and the exact license is reproducible by re-running license issue with the same --license-id. (Licenses aren’t committed, so there’s no ignore rule to rely on; just don’t leave artifacts lying around.)

Editing only .github/workflows/** does not trip the Deploy Server path filter. The k8s/action changes here do — or use workflow_dispatch.


3 — Deliver to a white-label (docker-compose) deployment

The customer (or T.REX, on their behalf) drops license.bin next to the compose file and bind-mounts it. Capabilities come from the license, not config.yaml.

services:
  server:
    volumes:
      - ./license.bin:/etc/rangeday/license.bin:ro
    environment:
      - LICENSE_FILE=/etc/rangeday/license.bin
      - WEB_ORIGIN=https://<their-pwa-host>   # must be a licensed --domain

docker compose up -d (or restart the server) to pick up the file. See White-Label Deployment for the full compose/config layout.


4 — Renew / change capabilities

Expiry is a hard outage, not a soft warning. The client gate rejects an expired license exactly like an invalid one (VerifyOutcome::Rejected → Blocked, gate.rs), so every client — including existing users at the login screen — blocks the moment expires_at passes. There is no server-side grace period that keeps things working past expiry. Renew well before expires_at (the license-expiry-monitoring item tracks the <30-day alert that should back this up). To renew or change a seat cap / capability:

  1. Re-run rangeday license issue with the new --expires (and/or new flags). Reuse --license-id <uuid> if you want continuity of the ops id.
  2. Replace the delivery artifact:
    • k8s: update the LICENSE_B64 secret, then re-run the deploy (push/tag or workflow_dispatch). The deploy stamps the new license’s sha256 as a pod-template annotation, so the rollout self-triggers even when the image is unchanged, and the post-deploy verify step confirms the new license is actually served. (Only if you edit the server-license secret out-of-band — bypassing the pipeline — must you kubectl rollout restart deployment/server -n <ns> yourself, because the server reads the license only at boot.)
    • white-label: swap license.bin and restart the server. No rebuild.
  3. Verify (below).

5 — Verify

After deploy/restart, confirm the server actually serves a valid license:

  1. Boot log shows the authentic path (json logs):
    instance license valid: customer="…" domains=[…] expires_at=…
    
    A bad license logs instance license INVALID (…) — serving it anyway; an absent one logs no license_file configured.
  2. Handshake serves a verifying license (unauthenticated — full check, not just non-empty):
    curl -s https://server.range-day.app/instance.v1.InstanceService/GetInstanceInfo \
      -H 'content-type: application/json' -d '{}' \
      | rangeday license verify --instance-json - --domain server.range-day.app
    Exit 0 ⇒ signature/domain/expiry all good. Empty license (exit 1) ⇒ the secret/file didn’t land — recheck step 2/3. Swap verify for inspect to see the decoded claims (customer, domains, expiry, capabilities, license_id) and confirm the right webOrigin in the raw JSON.
  3. Client signs in: the PWA at the env’s web_origin reaches the login screen (instead of the “Not a verified Range Day Pro server” block) and shows the right branding / Register visibility.

Guardrails recap

  • Private key never in CI; mint offline. The license is public (signed, not secret).
  • The server-license secret mount is optional: true — do not remove it, or any env without a license fails to schedule.
  • Don’t deliver the license via a kustomize secretGenerator (this repo hit the generator empty-data bug); keep the explicit apply-license create-or-apply.
  • WEB_ORIGIN host must be one of the signed --domains, or the client ignores it and falls back to the default.