Compare commits

...
58 Commits
Author SHA1 Message Date
mrhid6 3d59836d0c feat: dual list box for workflow target servers
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 5m19s
2026-08-04 17:13:50 +01:00
mrhid6 d9184312aa fix: schedule card placement, preview state, and scheduled-workflow docs 2026-08-04 17:08:11 +01:00
mrhid6 b9802e6b04 docs: Updated docs 2026-08-04 17:03:29 +01:00
mrhid6 c2635ed51a fix: Fixed schedule workflow col
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 41s
2026-08-04 14:42:10 +01:00
mrhid6 b21ac05547 feat: show workflow schedules in the list
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m34s
2026-08-04 14:16:13 +01:00
mrhid6 484b620867 feat: schedule editor on the workflow page 2026-08-04 14:13:31 +01:00
mrhid6 439bc2ed7d feat: schedule methods on the web api client 2026-08-04 14:10:04 +01:00
mrhid6 a1e6986a64 feat: fire scheduled workflow runs from the housekeeping leader 2026-08-04 13:53:42 +01:00
mrhid6 d0e1cc4ad6 feat: cron arithmetic and persisted workflow schedules 2026-08-04 13:51:10 +01:00
mrhid6 b877024365 docs: server tags and workflow tag targeting 2026-08-04 13:44:16 +01:00
mrhid6 2de7ac116b feat: filter the fleet by tag and target workflows by tag selector 2026-08-04 13:42:47 +01:00
mrhid6 fa1fd14ed1 feat: view and edit server tags 2026-08-04 13:38:42 +01:00
mrhid6 d1b3cd2f74 feat: target workflow runs by tag selector 2026-08-04 13:36:43 +01:00
mrhid6 e00a0da5d9 feat: tag endpoints for servers 2026-08-04 13:34:22 +01:00
mrhid6 fef0b7c7a1 feat: read and write server tags, resolve targets from the database 2026-08-04 13:33:00 +01:00
mrhid6 efd29dc259 feat: parse tag filters and resolve targets as ids union tag selector 2026-08-04 13:31:04 +01:00
mrhid6 13cd41d202 feat: validate server tags and add the model field 2026-08-04 13:30:06 +01:00
mrhid6 3530ce6cb7 docs: implementation plans for server tags and scheduled workflows 2026-08-04 13:26:04 +01:00
mrhid6 09522c2566 docs: design for server tags and scheduled workflows 2026-08-04 13:13:12 +01:00
mrhid6 80f0afb28b feat: Updated monitors pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m25s
2026-08-04 12:19:15 +01:00
mrhid6 287bd9657b fix: Fixed paddle relink sub
Chart Release / chart (push) Successful in 27s
Server Deploy / deploy (push) Successful in 1m17s
2026-08-03 17:34:39 +01:00
mrhid6 b5f684c4fe fix: Fixed paddle subs
Chart Release / chart (push) Successful in 28s
Server Deploy / deploy (push) Successful in 1m17s
2026-08-03 15:32:01 +01:00
mrhid6 1f08e90009 feat: Removed email alert settings
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 5m15s
2026-08-03 14:40:15 +01:00
mrhid6 6881d92d0a fix: local-login toggle no longer reverts unsaved settings edits
Chart Release / chart (push) Successful in 26s
Server Deploy / deploy (push) Successful in 4m8s
server/internal/services/settings.go SaveSettings takes alerts and
email as required (non-pointer) values and writes them unconditionally
- absent fields would blank stored settings, not just leave them
alone. onLocalLoginChange was building its payload from the stale
loaded settings object instead of the in-progress form state
(thresholdMinutes/logRetentionDays) that handleSubmit uses, so editing
the offline threshold and then flipping the toggle silently reverted
the edit. Both paths now submit the same in-progress values.
2026-08-03 14:13:05 +01:00
mrhid6 5e016c6584 fix: audit ack_notice and stop misreporting DB errors as lockouts
ackAuthProviderNotice mutated callback_notice with no audit event; it
now writes auth_provider.ack_notice like create/update/delete.

guardProviderChange's callers turned any error from
CountEnabledAuthProviders into a 409 last_provider, so a transient
Mongo error was reported to the operator as an unremovable lockout.
Only services.ErrLockout now produces the 409; anything else is a 500.
2026-08-03 14:12:21 +01:00
mrhid6 537b8758ff fix: purge auth_providers when reaping an instance
auth_providers was missing from ScopedCollections, so reap.go's
scopedCollectionsForPurge() (derived from that list) never deleted an
instance's providers, leaving orphaned rows holding encrypted client
secrets forever. Verified migration 0004's $rename over org_id->instance_id
is a no-op here since auth_providers never carried org_id.
2026-08-03 14:11:42 +01:00
mrhid6 c03360333b fix: single source of truth for local-login lockout rescue
HandleLocalLogin and HandleListPublicProviders each computed their own
answer to whether password sign-in must stay available, and they could
disagree: an instance with local login off and a licence that lapses
loses its only provider and its password form in the same moment, with
no endpoint left to recover. services.LocalLoginPermitted is now the
one predicate both call.
2026-08-03 14:11:22 +01:00
mrhid6 fa7c5d341d docs: fix stale auth-provider references in rest-api and licensing docs 2026-08-03 11:08:22 +01:00
mrhid6 b6fc8c3f77 docs: document multiple auth providers and the callback URL change 2026-08-03 11:05:40 +01:00
mrhid6 37f2c1457e feat: manage multiple sign-in providers from settings 2026-08-03 11:00:25 +01:00
mrhid6 3a626922a5 feat: render one login button per configured auth provider 2026-08-03 10:56:03 +01:00
mrhid6 dde47de145 feat: auth provider REST API and public provider discovery 2026-08-03 10:51:35 +01:00
mrhid6 f3b9f6f286 feat: add GitHub OAuth2 provider branch 2026-08-03 10:48:19 +01:00
mrhid6 f1c3f67864 feat: per-provider SSO start and callback routes 2026-08-03 10:45:23 +01:00
mrhid6 8f5873afca refactor: carry provider id in the OIDC state token 2026-08-03 10:41:30 +01:00
mrhid6 e22faebfcd feat: migrate instance_oidc into auth_providers (0005) 2026-08-03 10:38:52 +01:00
mrhid6 e2b01b62a5 feat: add local_login_enabled setting with absent-means-on default 2026-08-03 10:36:48 +01:00
mrhid6 0858693d57 feat: add auth provider service layer and lockout guard 2026-08-03 10:33:36 +01:00
mrhid6 45f7c0c393 feat: add AuthProvider model and identity provider presets 2026-08-03 10:30:21 +01:00
mrhid6 c56bfb7270 docs: implementation plan for multiple auth providers 2026-08-03 10:23:35 +01:00
mrhid6 eb45072031 feat: Removed unused test units 2026-08-03 10:18:01 +01:00
mrhid6 1e2132c1a1 docs: Cleanup old specs and plans 2026-08-03 10:15:54 +01:00
mrhid6 19ef773690 docs: drop legacy OIDC callback from multi-provider design 2026-08-03 10:13:26 +01:00
mrhid6 c5aae0614a docs: design for multiple auth providers 2026-08-03 10:09:13 +01:00
mrhid6 17d97aaf52 feat: More logging for command stream
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m24s
Agent Release / build (push) Successful in 10m37s
Agent Release / msi (push) Successful in 36s
2026-07-31 17:20:35 +01:00
mrhid6 1fb9bd827f feat: Added ping command
Chart Release / chart (push) Successful in 18s
Agent Release / build (push) Successful in 39s
Server Deploy / deploy (push) Successful in 55s
Agent Release / msi (push) Successful in 40s
2026-07-31 17:10:59 +01:00
mrhid6 8699dc5b7e fix: Renew presence on sub/pub
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 56s
2026-07-31 16:58:50 +01:00
mrhid6 71240f183c fix: Fixes to server shutdown stream
Chart Release / chart (push) Successful in 21s
Server Deploy / deploy (push) Successful in 1m2s
Agent Release / build (push) Successful in 43s
Agent Release / msi (push) Successful in 49s
2026-07-31 16:44:47 +01:00
mrhid6 01e8b0ba44 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m25s
2026-07-31 16:31:19 +01:00
mrhid6 2aa4784518 feat: Better debugging for console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 59s
2026-07-31 16:13:51 +01:00
mrhid6 f611cae438 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 16:00:58 +01:00
mrhid6 1eb98ef962 feat: Better debugging for console
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 15:51:26 +01:00
mrhid6 6f86496f10 fix: Ffixes to console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m9s
2026-07-31 15:05:21 +01:00
mrhid6 57a9b18102 fix: Guacd connection ip
Server Deploy / deploy (push) Successful in 9s
Chart Release / chart (push) Successful in 11s
2026-07-31 14:52:20 +01:00
mrhid6 36995fa62b fix: Fixed install and update scripts
Chart Release / chart (push) Successful in 9s
Server Deploy / deploy (push) Successful in 1m20s
2026-07-31 12:10:32 +01:00
mrhid6 9121fc461f fix: Fixed chart api routes for update
Server Deploy / deploy (push) Successful in 15s
Chart Release / chart (push) Successful in 10s
2026-07-31 12:03:45 +01:00
mrhid6 fc56bae5f9 chore: Bump chart version
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 8s
Agent Release / build (push) Successful in 38s
Agent Release / msi (push) Successful in 57s
2026-07-31 11:53:08 +01:00
mrhid6 ac75b3ef76 feat: chart deployment Type added
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 35s
2026-07-31 11:52:31 +01:00
132 changed files with 11962 additions and 37217 deletions
+17
View File
@@ -59,6 +59,23 @@ jobs:
--set server.replicaCount=3 \
--set web.replicaCount=3 > /dev/null
# The reaper deletes whole instances, so "does this env appear only
# in cloud mode" is worth asserting rather than eyeballing.
- name: Check the reaper is cloud-only
run: |
set -eu
if helm template test "$CHART_DIR" | grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is set on a self-hosted render"
exit 1
fi
if ! helm template test "$CHART_DIR" \
--set server.env.deploymentType=cloud \
| grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is missing from a cloud render"
exit 1
fi
echo "ok: reaper configured in cloud mode only"
- name: Render against external Redis and MongoDB
run: |
helm template test "$CHART_DIR" \
+114 -7
View File
@@ -124,6 +124,71 @@ A library of reusable **steps** (bash or PowerShell scripts with declared inputs
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Scheduled workflows
A workflow may carry `schedule{enabled, cron, tz}` — standard **5-field** cron
and an IANA zone name, both validated at save time. `next_run_at` is
**persisted on the document, not held in memory**: a leader handover between
computing an occurrence and firing it would otherwise lose it or fire it twice,
the same argument that put `workflow_log_seq` in MongoDB.
`server/internal/workflowsched` ticks every 30s inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched` and the sweepers —
one role, one lock. **The atomic claim, not the lock, is what prevents a double
fire**: the `UpdateOne` matches on the document *and* its current `next_run_at`
while setting the recomputed one, so a second process reaching the same workflow
matches nothing and does nothing. The lock only makes it cheap.
`workflowsched` **must not import `services`**`services` already imports it
for `SetSchedule`'s call to `NextOccurrence`, and Go has no cycles.
`TriggerWorkflow` and `LogEvent` are therefore injected as `workflowsched.Deps`
from `main.go`. Firing goes through the same `TriggerWorkflow` a person uses,
with `"schedule"` as the actor, so there is no second dispatch path and the run
detail page needed no changes.
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
builds on Alpine, which ships no zone database, so without it
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
back to UTC — an hour wrong for half the year, in the direction nobody notices
until a maintenance window lands in business hours. It works on a developer
machine either way, which is exactly why it gets forgotten.
Skips are recorded and surfaced, not just logged: past the 1h grace window is
`missed`, an active run is `already_running`, and a schedule that no longer
parses is disabled rather than left spinning the loop every 30 seconds forever.
### Server tags and workflow targeting
A server carries `tags map[string]string` — lowercase `[a-z0-9_-]`, key ≤32,
value ≤64, 20 per server, `sys:` reserved. **There is no `tags` collection**: a
tag is a property of a server, not an entity, so `KnownTags` aggregates over
`servers` rather than reading a registry that would need reference counting to
know when a tag stopped existing. `PUT /api/servers/:id/tags` replaces the whole
map — last-write-wins over a small map beats merge semantics between two people
editing one server. The index is `{instance_id: 1, "tags.$**": 1}`, wildcard
because the queried key is chosen by the user at request time and cannot be named
in advance; `EnsureServerIndexes` warns rather than being fatal, since a missing
index degrades tag filtering to a scan of a small collection and is no reason to
refuse to serve the fleet list.
`services.ResolveTargets` is the **single** answer to which servers a workflow
touches — the run path and validation both go through it, so the readout and the
dispatch cannot disagree. It is the distinct union of `target_server_ids` and
`target_tags` (AND across keys), ordered by the fleet rather than by the
arguments, so two runs naming the same servers differently are still comparable
line by line. **An empty selector matches nothing** on purpose: "matches
everything" turns a cleared field in the designer into a fleet-wide run. Both
empty is `ErrNoTargets` (400), not a success over zero servers. Offline servers
are **not** filtered out — the dispatcher already answers 503 per server, and a
patch run that silently omits an unreachable machine is worse than one that
visibly fails on it.
`web/app/(app)/workflows/[id]/page.tsx` **duplicates that match logic in
TypeScript** to draw the resolved count without a round trip, since the browser
already holds the fleet. It is a second implementation of `UnionTargets` /
`MatchesTags` and must change in the same commit as the Go one — the same shape
of hazard as the mirrored token blocks.
### Monitors
HTTP, TCP, ICMP and TLS checks. Each monitor has a `runner`: `"server"` (executed by the server-side scheduler) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
@@ -150,6 +215,13 @@ server is behind NAT on a private address. It also means the console now
**requires a live agent** on every deployment: `consoleConnect` answers 409
`agent_offline` rather than hanging.
**guacd's Service is headless on purpose.** The server resolves `GUACD_ADDR` to
build the allow-list of sources permitted to claim a relay listener; a ClusterIP
resolves to the Service's virtual address while guacd connects from its *pod*
IP, so every relay connection is rejected and every session dies with
`waiting for guacd: i/o timeout`. Compose is immune — there the name resolves to
the address that connects.
SSH connections authenticate with a stored private key; RDP/VNC credentials are
encrypted, single-use, and consumed when the tunnel opens. None of them reach
the agent — the session is negotiated end-to-end between guacd and the target
@@ -171,10 +243,10 @@ rare cross-pod branch that only fails under load.
| Concern | How it crosses replicas |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Which pod owns an agent | `vantage:agent:<server_id>` holds the owner's node ID with a 30s TTL, renewed every 10s. `Dispatcher.IsConnected` is an `EXISTS` on it |
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue |
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue. The envelope carries `node`, the presence holder resolved at publish time, and a pod ignores envelopes addressed elsewhere: the channel is a fan-out, and during a reconnect a half-open stream's pod is still subscribed. Unaddressed, it could ack first and queue the command onto a dead stream — the operator told it worked, the agent never seeing it. Presence renewal is owner-only (`RenewPresence`) for the same reason: a blind `SET` let the stale pod steal the key back every 10s |
| Step results | the owner pod publishes to `vantage:res:<command_id>`; the pod driving the run subscribes **before** dispatching, or a fast agent answers into a channel nobody has joined |
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself — unmasked bytes stay off the bus |
| Console relay | the envelope asks the owner pod to bind the listener, and the ack returns **that pod's** address for guacd. The relay's failure reason comes back on `vantage:proxyend:<proxy_id>` |
| Console relay | **not routed to the owner pod at all.** A `ProxyStream` is its own HTTP/2 request and an L7 proxy balances requests, not connections, so it does not follow the command stream — the listener therefore cannot be bound in advance. Whichever pod receives the stream binds it and announces **its own** address on `vantage:proxyaddr:<proxy_id>`; `vantage:proxypending:<proxy_id>` (30s, consumed atomically) is what authorises the claim, and the failure reason comes back on `vantage:proxyend:<proxy_id>` |
| Background jobs | `bus.RunAsLeader` — one Redis lock named `housekeeping` |
**Workflow logs are in MongoDB** (`workflow_log_lines`, one document per line,
@@ -188,6 +260,25 @@ marker is written and the rest is dropped. Without that cap a `yes` in a step
is a database incident. **Nothing writes to `/data` any more**, which is why
`server.persistence` now defaults to off and `VANTAGE_WORKFLOW_LOG_DIR` is gone.
**Shutdown order is load-bearing.** `main` traps SIGTERM, stops gRPC
(`GracefulStop`, 10s cap) and only then drains HTTP. Each `CommandStream`
handler releases its agent's presence claim on return, so a killed process
leaves `vantage:agent:<server_id>` behind for the rest of its 30s TTL — during
which other replicas dispatch to a pod that has exited and the caller sees
`agent offline` for a perfectly healthy agent. Draining HTTP first would hold
those claims for the length of the drain, which is why gRPC goes first. The
chart's `server.terminationGracePeriodSeconds` (30s) must stay above the
10s + 10s the stop sequence needs, or the kubelet SIGKILLs mid-shutdown and the
handling buys nothing.
The agent side of the same failure: `runCommandStream` resets its backoff only
after a stream that survived `streamHealthyAfter`. `connectAndHandleStream`
returns an error on *every* stream end, healthy ones included, so without that
reset the backoff only ever climbed — an agent pinned itself at the ceiling
after a handful of ordinary deploys and stayed there. The ceiling is 30s, not
minutes, because while the stream is down the agent still polls `SyncKeys` and
still reads as `active` in the fleet list while answering no commands at all.
**The leader lock is not an optimisation.** N replicas each running the monitor
scheduler means each check fires N times, each incident notification reaches the
customer N times, and each hourly rollup is written N times; N reapers race to
@@ -336,7 +427,8 @@ password-change endpoint at all, so there is no competing writer for the hash.
- **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
- **Local auth** — email + password (bcrypt), `POST /auth/login`.
- **OIDC** — configured _per org_ (`org_oidc`), issuer + client ID + encrypted client secret. `/auth/oidc/start``/auth/oidc/callback`.
- **Auth providers** — configured _per instance_ in `auth_providers`, any number of them, each named and independently enabled. Issuer, client ID and an encrypted client secret per provider. `/auth/oidc/:providerId/start``/auth/oidc/:providerId/callback`. Presets (Entra, Google, Okta, GitHub) are a Go table in `server/internal/auth/presets.go` and expand to a real issuer on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes its own branch, requiring an address that is both primary **and** verified — an unverified address is not proof of control.
- **Local login**`settings.local_login_enabled`, a `*bool` because absent must mean enabled; a plain bool would disable password sign-in fleet-wide at upgrade. `services.CheckLockout` refuses any change leaving neither local login nor an enabled provider, and is enforced in the service layer so the settings path and the provider path cannot disagree.
- **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
- **Roles**`owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
- **Host/org guard**`APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
@@ -369,7 +461,18 @@ service Vantage {
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
The server sends one every 20s on an otherwise idle command stream; the agent
treats 70s of silence as a dead stream and reconnects. Keepalive cannot do this
job behind an L7 proxy: the agent's HTTP/2 connection terminates at the proxy,
which answers pings on its own behalf, so a control-plane pod that dies leaves
the agent blocked in `Recv` on a stream that never delivers another message and
never errors — commands dispatched into it are silently lost while `SyncKeys`
keeps succeeding and the fleet list still shows the server `active`. The agent's
watchdog arms only **after** it has seen a first ping, so an older server that
sends none is treated as working rather than put into a reconnect loop.
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `proto/vantage/v1/vantage.proto`.
@@ -385,7 +488,8 @@ GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me /auth/oidc/start /auth/oidc/callback
GET /auth/me
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} — no issuer, client ID or secret
GET /api/secrets/:group/values # bearer token (ESO)
```
@@ -413,7 +517,8 @@ agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
licence GET /license · POST /license (POST: self-hosted only)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin)
```
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
@@ -487,7 +592,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
@@ -499,6 +604,7 @@ Notes that are not obvious from the structs:
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
- `auth_providers.provider_id` is a short random identifier, not the Mongo `_id`: it appears in the callback URL a customer pastes into their IdP, and an `_id` there would publish a database key. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed.
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
@@ -513,6 +619,7 @@ Admin's own database is separate and holds `accounts` · `admin_instances` · `l
- `0001_default_org_backfill`
- `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
- `0003_missed_org_scopes`
- `0005_auth_providers` — copies each `instance_oidc` document into `auth_providers`, ciphertext verbatim rather than decrypted and re-encrypted, so it does not need `KEY_ENCRYPTION_KEY` and cannot strand an instance's SSO configuration that has none set.
Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn.
+15 -10
View File
@@ -11,6 +11,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
@@ -245,19 +246,23 @@ func claimPlaceholderLink(c *gin.Context) {
placeholderID := inst.InstanceID
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{
"instance_id": body.InstanceID,
"status": models.StatusActive,
"placeholder": false,
}}); err != nil {
bson.M{
"$set": bson.M{
"instance_id": body.InstanceID,
"status": models.StatusActive,
"placeholder": false,
},
"$addToSet": bson.M{"previous_instance_ids": placeholderID},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-point the subscription from the placeholder id to the real UUID so
// billing.IssueForInstance (and every later webhook) finds it.
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
// Re-point the subscription rows from the placeholder id to the real UUID so
// billing.IssueForInstance finds it, and rewrite Paddle's own copy of
// custom_data — written at checkout, it still names the placeholder, and every
// later event on this subscription is decoded from it.
if err := licensing.RepointSubscriptions(ctx, placeholderID, body.InstanceID, inst.AccountID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+40 -11
View File
@@ -2,6 +2,7 @@ package billing
import (
"context"
"errors"
"fmt"
"time"
@@ -14,6 +15,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -67,9 +69,21 @@ func handleSubscription(ctx context.Context, ev Event) error {
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
}
// Resolve BEFORE recording. A self-hosted subscription's custom_data is
// written at checkout and names the placeholder; the claim rewrote the
// instance's identity to the install's real UUID and patched Paddle, but that
// patch is best-effort and any event already in flight still carries the old
// id. Writing it straight through would revert the linked subscription row and
// then fail to find the instance, wedging every renewal.
instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID)
if err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
sub := models.Subscription{
AccountID: d.CustomData.AccountID,
InstanceID: d.CustomData.InstanceID,
InstanceID: instanceID,
PaddleSubscriptionID: d.ID,
Tier: match.Tier,
Term: match.Term,
@@ -88,13 +102,6 @@ func handleSubscription(ctx context.Context, ev Event) error {
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
// Placeholders are the payment-first path: the instance does not exist until
// this confirmed-payment event. A cloud placeholder is provisioned here and
// then issued (first term). A self-hosted placeholder has no UUID to bind to
@@ -116,6 +123,27 @@ func handleSubscription(ctx context.Context, ev Event) error {
return promoteAndIssue(ctx, &inst, match, reason)
}
// resolveInstance finds the instance a webhook's custom_data names, following the
// identity trail when the id is one a placeholder claim or a relink has since
// replaced. It returns the instance's CURRENT id, which is the only id anything
// else should be written against.
func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) {
var inst models.Instance
err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": customDataID}).Decode(&inst)
if err == nil {
return inst.InstanceID, inst, nil
}
if !errors.Is(err, mongo.ErrNoDocuments) {
return "", inst, err
}
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"previous_instance_ids": customDataID}).Decode(&inst); err != nil {
return "", inst, err
}
return inst.InstanceID, inst, nil
}
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
// licence from granted. This is the only promotion path other than the staff
// grant, and it exists because a webhook is a confirmed payment.
@@ -280,9 +308,10 @@ func billingEmailFor(ctx context.Context, accountID string) string {
// instanceNameFor is a best-effort display name for an email subject.
func instanceNameFor(ctx context.Context, instanceID string) string {
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
// Alias-aware: a cancellation can name a placeholder id, and "your instance"
// in place of the name the customer chose reads like the wrong email.
_, inst, err := resolveInstance(ctx, instanceID)
if err != nil || inst.Name == "" {
return "your instance"
}
return inst.Name
+57 -1
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -57,6 +59,48 @@ func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*mod
return &inst, nil
}
// RepointSubscriptions follows an instance identity rewrite: it moves every
// subscription row from the old id to the new one, then rewrites Paddle's copy
// of custom_data so future webhooks decode to the new id.
//
// The local rewrite is returned as an error — issuance reads the subscription
// back, so a half-moved row is worth failing on. The Paddle patch only logs: the
// customer must not be blocked from linking or relinking by an outbound API
// failure, and the caller has already recorded the old id in
// previous_instance_ids, which is what makes the webhook path correct whether or
// not the patch lands.
func RepointSubscriptions(ctx context.Context, oldID, newID, accountID string) error {
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}}); err != nil {
return fmt.Errorf("repoint %s -> %s: %w", oldID, newID, err)
}
cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": newID})
if err != nil {
log.Printf("repoint %s -> %s: read subscriptions: %v", oldID, newID, err)
return nil
}
var subs []models.Subscription
if err := cur.All(ctx, &subs); err != nil {
log.Printf("repoint %s -> %s: decode subscriptions: %v", oldID, newID, err)
return nil
}
for _, s := range subs {
if s.PaddleSubscriptionID == "" {
continue
}
// Paddle replaces the whole custom_data object on a PATCH, so account_id
// is sent alongside rather than dropped.
if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID,
map[string]string{"account_id": accountID, "instance_id": newID}); err != nil {
log.Printf("repoint %s -> %s: patch custom_data on %s: %v",
oldID, newID, s.PaddleSubscriptionID, err)
}
}
return nil
}
// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
@@ -96,13 +140,25 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
bson.M{
"$set": bson.M{"instance_id": newID},
"$inc": bson.M{"relink_count": 1},
"$addToSet": bson.M{"previous_instance_ids": oldID},
}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, ErrAlreadyLinked
}
return nil, fmt.Errorf("relink: %w", err)
}
// A relink rewrites the instance's identity exactly as a placeholder claim
// does, so the same two things have to follow it: the subscription rows that
// named the old id, and Paddle's own copy of custom_data. Without this a
// renewal after a relink cannot find its instance and the term never extends.
if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil {
return nil, err
}
actor := accountID
if staff {
actor = "staff"
+69
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
@@ -12,6 +13,7 @@ import (
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
@@ -210,6 +212,73 @@ func Backfill(ctx context.Context) error {
if err := backfillEntitlements(ctx); err != nil {
return err
}
// Pass 6: instances whose identity was rewritten before previous_instance_ids
// existed carry no trail, and Paddle's custom_data still names the id they
// were rewritten FROM — so their next webhook resolves to nothing. Both
// rewrites wrote an audit entry naming the old id, which is the only surviving
// record of it, so reconstruct the trail from those.
if err := backfillInstanceIDHistory(ctx); err != nil {
return err
}
return nil
}
// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
// the two identity rewrites leave behind: a placeholder claim
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
// ("instance.relinked", detail "was <id>").
//
// $addToSet is what makes it idempotent, and it also means a chain of relinks
// accumulates rather than the last one winning. Entries are walked NEWEST first,
// matching on the current id or an already-recovered one: an instance relinked
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
// record B before the B entry has anything to attach A to.
func backfillInstanceIDHistory(ctx context.Context) error {
prefixes := map[string]string{
"instance.placeholder_linked": "from placeholder ",
"instance.relinked": "was ",
}
actions := make(bson.A, 0, len(prefixes))
for action := range prefixes {
actions = append(actions, action)
}
cur, err := db.Admin("admin_audit").Find(ctx,
bson.M{"action": bson.M{"$in": actions}},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return err
}
var entries []AuditEntry
if err := cur.All(ctx, &entries); err != nil {
return err
}
recorded := 0
for _, e := range entries {
prefix := prefixes[e.Action]
if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
continue
}
oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
if oldID == "" || oldID == e.Target {
continue
}
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"$or": bson.A{
bson.M{"instance_id": e.Target},
bson.M{"previous_instance_ids": e.Target},
}},
bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
if err != nil {
return err
}
recorded += int(res.ModifiedCount)
}
if recorded > 0 {
log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
}
return nil
}
+8
View File
@@ -147,6 +147,14 @@ type Instance struct {
// checkout has something to attach custom_data to, before the customer has
// pasted their install's real UUID. Cleared when the instance is linked.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
// PreviousInstanceIDs is every id this row has carried before its current one.
// A self-hosted row's identity is rewritten twice over its life — once when a
// paid placeholder is claimed, and again on each relink to a rebuilt server —
// and Paddle keeps its own copy of custom_data written at checkout. That copy
// is patched on each rewrite, but the patch is best-effort and any event
// already in flight still names an old id, so this is what lets a webhook
// resolve to the right instance instead of erroring as unknown.
PreviousInstanceIDs []string `bson:"previous_instance_ids,omitempty" json:"-"`
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
// remembered so the confirmed-payment webhook can provision the instance with
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
+4
View File
@@ -25,6 +25,10 @@ type Client interface {
// immediately by Paddle. This is the one outbound mutation, used when a
// customer changes their server count or features on an existing plan.
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
// UpdateSubscriptionCustomData replaces a subscription's custom_data. Used
// when a self-hosted placeholder is claimed: the checkout attached the
// placeholder id, and every later webhook must name the real install UUID.
UpdateSubscriptionCustomData(ctx context.Context, paddleSubscriptionID string, data map[string]string) error
// PortalSession returns a customer-portal URL for managing billing.
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
// Env is "sandbox" or "production", the same value catalogue price lookups
+11
View File
@@ -99,6 +99,17 @@ func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string,
}, nil)
}
// UpdateSubscriptionCustomData patches custom_data only. Paddle replaces the
// whole object, so callers pass every key they want to keep.
func (c *httpClient) UpdateSubscriptionCustomData(ctx context.Context, subID string, data map[string]string) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, struct {
CustomData map[string]string `json:"custom_data"`
}{CustomData: data}, nil)
}
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
if customerID == "" {
return "", fmt.Errorf("paddle: empty customer id")
-20
View File
@@ -1,20 +0,0 @@
package config
import (
"runtime"
"strings"
"testing"
)
func TestConfigDirByOS(t *testing.T) {
d := ConfigDir()
if runtime.GOOS == "windows" {
if !strings.Contains(strings.ToLower(d), "programdata") {
t.Fatalf("windows config dir = %q, want ProgramData path", d)
}
} else {
if d != "/etc/vantage" {
t.Fatalf("unix config dir = %q, want /etc/vantage", d)
}
}
}
+6
View File
@@ -166,8 +166,14 @@ type ServerCommand struct {
RunStep *RunStepCmd `json:"run_step,omitempty"`
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
Ping *PingCmd `json:"ping,omitempty"`
}
// PingCmd is a server-originated liveness beat. It carries nothing and expects
// no reply: its arrival is the entire message. See the .proto for why gRPC
// keepalive is not sufficient on its own.
type PingCmd struct{}
type CleanupWorkspaceCmd struct {
+155 -17
View File
@@ -118,9 +118,36 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return nil
}
// How long a command stream must survive before it counts as having worked.
// Past this, the next drop is treated as a fresh incident rather than as the
// continuation of a run of failures.
const streamHealthyAfter = time.Minute
// Stream staleness. The server beats every 20s, so 70s tolerates three missed
// beats before the stream is written off — high enough that a slow network or a
// briefly busy server does not cost a reconnect, low enough that an agent is
// not uncommandable for minutes after a control-plane restart.
const (
streamStaleAfter = 70 * time.Second
streamStaleCheck = 10 * time.Second
// How often a healthy stream reports itself. Also the interval at which an
// agent talking to a control plane too old to send heartbeats says so —
// that agent is running without a watchdog, and the journal should not be
// silent about it.
pingSummaryInterval = 5 * time.Minute
)
func runCommandStream(ctx context.Context, cfg *config.Config) {
backoff := time.Second
const maxBackoff = 2 * time.Minute
// Two minutes was the old ceiling, and it was reached far too easily. The
// command stream is what makes this agent controllable at all: while it is
// down, workflows and console sessions fail as "agent offline" even though
// SyncKeys keeps polling happily and the fleet list still shows the server
// active. A shorter ceiling costs a few reconnect attempts; the old one cost
// two minutes of an agent that looks fine and answers nothing.
const maxBackoff = 30 * time.Second
for {
select {
@@ -129,22 +156,45 @@ func runCommandStream(ctx context.Context, cfg *config.Config) {
default:
}
if err := connectAndHandleStream(ctx, cfg); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
}
} else {
started := time.Now()
err := connectAndHandleStream(ctx, cfg)
if ctx.Err() != nil {
return
}
// A stream that stayed up is evidence the control plane is reachable,
// whatever ended it. Without this the backoff only ever climbed:
// connectAndHandleStream returns an error on *every* stream end,
// including a healthy one dropped by a routine deploy, so an agent
// pinned itself at the ceiling after a handful of ordinary restarts and
// stayed there for the rest of its life.
if time.Since(started) >= streamHealthyAfter {
backoff = time.Second
}
// The uptime is in the line because it is what distinguishes a stream
// that never worked from one that ran for hours and was dropped by a
// deploy — and it is the same measure that decides whether the backoff
// resets, so a reader can see why the delay is what it is.
up := time.Since(started).Truncate(time.Second)
if err != nil {
log.Printf("command stream error after %s: %v, reconnecting in %s", up, err, backoff)
} else {
log.Printf("command stream closed after %s, reconnecting in %s", up, backoff)
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
@@ -155,7 +205,13 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
}
defer client.Close()
stream, err := client.CommandStream(ctx)
// Cancelling this context is what unblocks Recv when the stream has gone
// quiet. Without it the watchdog below would have no way to interrupt a
// read that is never going to return.
streamCtx, abandon := context.WithCancel(ctx)
defer abandon()
stream, err := client.CommandStream(streamCtx)
if err != nil {
return fmt.Errorf("open stream: %w", err)
}
@@ -168,7 +224,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
return fmt.Errorf("send auth: %w", err)
}
log.Println("command stream connected")
log.Printf("command stream connected to %s", cfg.ServerURL)
var sendMu sync.Mutex
send := func(msg *pb.AgentMessage) error {
@@ -177,11 +233,93 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
return stream.Send(msg)
}
// Stream liveness, tracked here rather than left to gRPC keepalive.
//
// Keepalive operates on the transport, and behind an L7 proxy the transport
// ends at the proxy: it answers pings whether or not the server behind it
// is still running. A control-plane pod that dies therefore leaves this
// agent blocked in Recv on a stream that will never deliver another message
// and never error, while the control plane dispatches commands into it and
// the operator watches nothing happen.
//
// The watchdog only arms once a ping has actually been seen. A server too
// old to send them must not be treated as dead — that would put the agent
// in a reconnect loop against a control plane that is working perfectly.
var (
lastMu sync.Mutex
lastRecv = time.Now()
pinged bool
beats int
)
markRecv := func(isPing bool) {
lastMu.Lock()
lastRecv = time.Now()
if isPing {
beats++
// Logged once per stream, because it is the moment the agent starts
// holding the control plane to account: before this the watchdog is
// disarmed and a dead stream would go unnoticed indefinitely.
if !pinged {
pinged = true
log.Printf("command stream heartbeat detected, watchdog armed (%s threshold)", streamStaleAfter)
}
}
lastMu.Unlock()
}
go func() {
t := time.NewTicker(streamStaleCheck)
defer t.Stop()
// Reported periodically rather than per beat: at one every 20s the
// journal would be nothing else. The count is what makes a partial
// failure visible — beats arriving but fewer than expected is a
// different problem from beats stopping altogether.
summary := time.NewTicker(pingSummaryInterval)
defer summary.Stop()
for {
select {
case <-streamCtx.Done():
return
case <-summary.C:
lastMu.Lock()
n, armed := beats, pinged
beats = 0
lastMu.Unlock()
if armed {
log.Printf("command stream healthy, %d heartbeats in the last %s", n, pingSummaryInterval)
} else {
log.Printf("command stream up but sending no heartbeats; "+
"control plane predates them, watchdog stays disarmed (last message %s ago)",
time.Since(lastRecv).Truncate(time.Second))
}
case <-t.C:
lastMu.Lock()
idle, armed := time.Since(lastRecv), pinged
lastMu.Unlock()
if armed && idle > streamStaleAfter {
log.Printf("command stream silent for %s (threshold %s), assuming it is dead and reconnecting",
idle.Truncate(time.Second), streamStaleAfter)
abandon()
return
}
}
}
}()
for {
cmd, err := stream.Recv()
if err != nil {
return fmt.Errorf("recv: %w", err)
}
markRecv(cmd.Ping != nil)
// Pings carry nothing and are not acknowledged; being received is their
// whole purpose.
if cmd.Ping != nil {
continue
}
if cmd.GenerateKey != nil {
go handleGenerateKey(cfg, cmd)
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: vantage
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
type: application
version: 1.0.3
appVersion: "1.0.3"
version: 1.0.7
appVersion: "1.0.7"
+6 -5
View File
@@ -72,11 +72,12 @@ both read it.
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
- name: PROXY_LISTEN_HOST
value: {{ .Values.server.env.proxyListenHost | quote }}
# The address guacd dials to reach a console relay. It must name one pod, not
# the Service: the relay listener is bound by whichever pod holds that agent's
# command stream, and a Service would send guacd to a different one. POD_IP
# takes precedence over PROXY_ADVERTISE_HOST in the server for exactly this
# reason, so the setting above stays meaningful only outside Kubernetes.
{{- if eq .Values.server.env.deploymentType "cloud" }}
- name: VANTAGE_DEPLOYMENT
value: "cloud"
- name: FREE_INSTANCE_REAP_AFTER
value: {{ .Values.server.env.freeInstanceReapAfter | quote }}
{{- end }}
- name: POD_IP
valueFrom:
fieldRef:
@@ -36,6 +36,9 @@ metadata:
app.kubernetes.io/component: guacd
spec:
type: {{ .Values.guacd.service.type }}
{{- if eq .Values.guacd.service.type "ClusterIP" }}
clusterIP: None
{{- end }}
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: guacd
@@ -48,6 +48,13 @@ spec:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
# The server stops gRPC before draining HTTP, so that every CommandStream
# handler returns and releases its agent's presence claim. A claim left
# behind outlives the pod for its 30s TTL, and during that window other
# replicas dispatch commands to a process that has exited — surfacing to
# the operator as "agent offline" on an agent that is perfectly healthy.
# 10s for gRPC plus 10s for the HTTP drain, with headroom.
terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }}
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
+8
View File
@@ -41,6 +41,10 @@ guacd:
server:
replicaCount: 1
# Must exceed the server's own stop sequence (10s gRPC GracefulStop + 10s
# HTTP drain) or the kubelet SIGKILLs mid-shutdown, which is exactly the
# abrupt exit that leaves agent presence claims stranded in Redis.
terminationGracePeriodSeconds: 30
migrationJob:
enabled: true
backoffLimit: 0
@@ -90,6 +94,10 @@ ingress:
paths:
- /api
- /auth
- /update
- /install
- /update.ps1
- /install.ps1
grpc:
enabled: true
host: ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,989 +0,0 @@
# Cloud Instance Creation — Phase 1: Identity
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the control plane's global unique index on `users.email` with a per-instance one, and scope every lookup that relied on the global index, so one address can belong to several instances.
**Architecture:** The index change is safe only because the two unscoped `FindOne({email})` lookups are scoped in the same binary that performs the swap. The new compound index is created **before** the old one is dropped, so a failure at any point leaves a working constraint in place. The unscoped helper is deleted rather than left unused, and admin's one unscoped control-plane lookup — which has no instance to scope by — is removed entirely.
**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, `shared/indexes`, `shared/models`, `shared/provision`.
## Global Constraints
- **No automated Go tests.** Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step below is a command with expected output. This matches plans 0a through 4.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper from earlier plans:
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
golang:1.26 "$@"
```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry".
- **`shared/` is consumed through `replace` directives** in `server`, `admin` and `sitesvc`. A change to `shared/` reaches all three on their next build; there is no version to bump.
- **All three service images must ship together.** An older image booting after this change would recreate `email_1`. `.gitea/workflows/server-deploy.yml` rebuilds every image on every push to `main`, so this is automatic — the hazard is only a partial manual rollout on the host.
- **This migration is one-way.** Once two users share an address across instances, `email_1` cannot be recreated. There is no rollback; fixes go forward.
- Nothing in this phase projects users, creates instances, or adds UI. Those are phases 2 and 3.
## Context this plan inherits
`CLAUDE.md` currently states that the unique index on user email is "a security property, not an optimisation", because `GetUserByEmail` does an unscoped `FindOne`. That statement is true today and stops being true in Task 1. Task 7 updates it in the same series of commits, and the replacement property is stronger: a scoped query cannot be ambiguous, whereas an index merely prevents the ambiguity from arising.
Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), phase 1.
---
## File Structure
**Modified:**
| Path | Change |
| ----------------------------------- | -------------------------------------------------------------------------- |
| `shared/indexes/indexes.go` | compound `(instance_id, email)` unique index; idempotent drop of `email_1` |
| `shared/models/user.go` | `HQUserID` field, `AuthLocal`/`AuthOIDC`/`AuthHQ` constants |
| `server/internal/services/users.go` | `GetUserByEmail` deleted, `GetUserInInstanceByEmail` added |
| `server/internal/auth/local.go` | `resolveLoginInstance`, scoped sign-in |
| `server/internal/auth/oidc.go` | scoped lookup, cross-instance guard deleted |
| `admin/internal/auth/cloud.go` | **deleted** |
| `admin/internal/api/routes.go` | `/auth/login` points at `HandleCustomerLogin`; new staff route |
| `admin/internal/api/staff.go` | `staffCreateAccountUser` |
| `CLAUDE.md` | the index security-property paragraph, and the auth section |
**Created:** none.
---
### Task 1: Compound index and the drop
**Files:**
- Modify: `shared/indexes/indexes.go`
**Interfaces:**
- Consumes: nothing new.
- Produces: `indexes.EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error` — unchanged signature, new behaviour. Called at boot by `server`, `sitesvc` and `admin`.
- [ ] **Step 1: Replace the body of `EnsureCoreIndexes` and add the drop helper**
Replace the whole file with:
```go
// Package indexes declares the MongoDB indexes more than one Vantage service
// depends on.
package indexes
import (
"context"
"errors"
"fmt"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// legacyUserEmailIndex is the global unique index on users.email that this
// package used to declare. It is dropped on sight.
const legacyUserEmailIndex = "email_1"
// indexNotFound is MongoDB's IndexNotFound error code. Two services booting at
// once can both decide to drop the legacy index; the loser must not treat that
// as a failure.
const indexNotFound = 27
// EnsureCoreIndexes declares the unique indexes on users and instances.
//
// users is unique on (instance_id, email), NOT on email alone. One address is
// one user WITHIN an instance; the same address may hold a user in several
// instances, because an account's people are projected into each instance they
// are granted access to.
//
// This is a security property, not an optimisation, and it is only sufficient
// because every lookup by email is scoped by instance. There is deliberately no
// unscoped lookup by email anywhere in the codebase: an unscoped FindOne would
// return an arbitrary one of several matching users, which on the login path
// means signing someone into a tenant that is not theirs. If you are about to
// add one, you are about to reintroduce that bug.
//
// Creating an index that already exists with the same specification is a no-op,
// so this is safe to call at every boot from every service.
func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
// Create the replacement BEFORE dropping the legacy index. A failure here
// leaves the old constraint in place, which is safe; a failure after the
// drop would leave the collection unconstrained, which is not.
if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "email", Value: 1}},
Options: options.Index().SetUnique(true).SetName("instance_email_unique"),
}); err != nil {
return fmt.Errorf("users.instance_id+email index: %w", err)
}
if err := dropIndexIfExists(ctx, db.Collection("users"), legacyUserEmailIndex); err != nil {
return fmt.Errorf("drop users.%s: %w", legacyUserEmailIndex, err)
}
if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("instances.slug index: %w", err)
}
return nil
}
// dropIndexIfExists drops name, treating "it was not there" as success whether
// that is discovered by listing or by racing another service to the drop.
func dropIndexIfExists(ctx context.Context, col *mongo.Collection, name string) error {
cur, err := col.Indexes().List(ctx)
if err != nil {
return err
}
var existing []struct {
Name string `bson:"name"`
}
if err := cur.All(ctx, &existing); err != nil {
return err
}
found := false
for _, i := range existing {
if i.Name == name {
found = true
break
}
}
if !found {
return nil
}
err = col.Indexes().DropOne(ctx, name)
if err == nil {
return nil
}
var srvErr mongo.ServerError
if errors.As(err, &srvErr) && srvErr.HasErrorCode(indexNotFound) {
return nil
}
return err
}
```
- [ ] **Step 2: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh shared go build ./...
```
Expected: no output.
- [ ] **Step 3: Confirm the legacy index is not declared anywhere else**
Run:
```sh
grep -rn '"email"' --include=*.go shared/ server/ sitesvc/ admin/ | grep -i index
```
Expected: no matches. If sitesvc or the server declares its own `users.email` index, it would recreate what Task 1 drops.
- [ ] **Step 4: Commit**
```bash
git add shared/indexes/indexes.go
git commit -m "feat(shared): unique users index is (instance_id, email)
One address is one user within an instance, not globally, so an account's
people can be projected into every instance they are granted.
The replacement index is created before email_1 is dropped, so a failure
at any point leaves a working constraint. The drop is idempotent and
tolerates two services racing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 2: `hq` fields on the user document
**Files:**
- Modify: `shared/models/user.go`
- Modify: `server/internal/models/user.go`
**Interfaces:**
- Consumes: nothing.
- Produces:
- `shared/models.AuthLocal = "local"`, `AuthOIDC = "oidc"`, `AuthHQ = "hq"`
- `shared/models.User.HQUserID string` — bson `hq_user_id,omitempty`
- the same three constants re-exported from `server/internal/models`, which is a thin alias file over `shared/models` and is what server code imports
Nothing writes `AuthHQ` or `HQUserID` in this phase. They land now so phases 2 and 3 do not have to change the shared module and rebuild every service again.
- [ ] **Step 1: Add the constants and the field**
In `shared/models/user.go`, after the `ValidRole` function, add:
```go
// Auth sources. A user's auth_source says who owns the row.
const (
AuthLocal = "local"
AuthOIDC = "oidc"
// AuthHQ marks a user projected from a Vantage HQ account. Its role,
// password and existence are owned by HQ, and the instance API refuses to
// change any of them locally — a role editable in two places is a role with
// two answers.
AuthHQ = "hq"
)
```
And in the `User` struct, add `HQUserID` immediately after `AuthSource`:
```go
type User struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
UserID string `bson:"user_id" json:"user_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
Role string `bson:"role" json:"role"`
AuthSource string `bson:"auth_source" json:"auth_source"`
// HQUserID is the customer_users.user_id this row was projected from,
// absent on locally-created users.
HQUserID string `bson:"hq_user_id,omitempty" json:"hq_user_id,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
}
```
- [ ] **Step 2: Re-export the constants from the server's alias file**
`server/internal/models/user.go` is a thin alias over `shared/models`, and server code imports that rather than the shared package directly. Add the auth sources alongside the roles it already re-exports:
```go
package models
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type User = shared.User
const (
RoleOwner = shared.RoleOwner
RoleAdmin = shared.RoleAdmin
RoleMember = shared.RoleMember
)
const (
AuthLocal = shared.AuthLocal
AuthOIDC = shared.AuthOIDC
AuthHQ = shared.AuthHQ
)
func ValidRole(role string) bool { return shared.ValidRole(role) }
```
- [ ] **Step 3: Confirm both compile**
Run:
```sh
sh /tmp/gorun.sh shared go build ./...
sh /tmp/gorun.sh server go build ./...
```
Expected: no output from either.
- [ ] **Step 4: Commit**
```bash
git add shared/models/user.go server/internal/models/user.go
git commit -m "feat(shared): auth_source constants and hq_user_id on User
Nothing writes them yet. They land now so phases 2 and 3 do not require a
second rebuild of every service that consumes the shared module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 3: Scoped lookup in the user service
**Files:**
- Modify: `server/internal/services/users.go:65-75`
**Interfaces:**
- Consumes: `shared/indexes` from Task 1.
- Produces: `services.GetUserInInstanceByEmail(instanceID, email string) (*models.User, error)`.
- Removes: `services.GetUserByEmail`. Tasks 4 and 5 fix its two callers; the build will be red between this task and Task 5, which is expected and is why they are adjacent.
- [ ] **Step 1: Replace `GetUserByEmail`**
In `server/internal/services/users.go`, delete the whole `GetUserByEmail` function and put this in its place:
```go
// GetUserInInstanceByEmail finds a user by address WITHIN one instance.
//
// There is deliberately no unscoped lookup by email. users is unique on
// (instance_id, email), not on email alone, so an unscoped FindOne would return
// an arbitrary one of several matching users — which on the login path means
// signing someone into a tenant that is not theirs.
func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var u models.User
err := db.Col("users").FindOne(ctx, bson.M{
"instance_id": instanceID,
"email": email,
}).Decode(&u)
if err != nil {
return nil, err
}
return &u, nil
}
```
- [ ] **Step 2: Confirm the unscoped helper is gone and the build is red for the expected reason**
Run:
```sh
grep -rn "GetUserByEmail" --include=*.go .
```
Expected: exactly two matches, both call sites — `server/internal/auth/local.go` and `server/internal/auth/oidc.go`. No definition.
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: FAIL with `undefined: services.GetUserByEmail` at those two call sites. Any other error means something else was broken.
- [ ] **Step 3: Do not commit yet**
The build is red. Commit at the end of Task 5, when both callers are fixed. A commit that does not build is a commit nobody can bisect through.
---
### Task 4: Scoped local login
**Files:**
- Modify: `server/internal/auth/local.go:25-49`
**Interfaces:**
- Consumes: `services.GetUserInInstanceByEmail` from Task 3, `services.CountInstances` and `services.FirstInstance` from `server/internal/services/instances.go:57` and `:63`, `auth.InstanceFromHost` from `server/internal/auth/instancehost.go:53`.
- Produces: `resolveLoginInstance(c *gin.Context) (string, error)`, unexported, used only by this file.
**Behaviour change worth knowing:** signing in at the bare apex host stops working when more than one instance exists. Cloud sign-in is always on `<slug>.vantage.<tld>` — `APP_LOGIN_URL` fills `{slug}` in, so every link already points there — and self-hosted has exactly one instance, so both supported paths keep working. A bookmark to the apex login page on a multi-instance deployment will now get a 400 that names the cause.
- [ ] **Step 1: Add `resolveLoginInstance` and rewrite `HandleLocalLogin`**
In `server/internal/auth/local.go`, add `"fmt"` to the imports if it is not already there, then add above `HandleLocalLogin`:
```go
// resolveLoginInstance decides which instance a sign-in attempt belongs to.
//
// Cloud always answers from the host: every instance has its own subdomain, and
// APP_LOGIN_URL fills the slug in, so every sign-in link already points at one.
// Self-hosted has no subdomain and exactly one instance, because a licence
// binds one instance UUID.
//
// Anything else is refused rather than guessed. Picking an instance on someone's
// behalf is how you sign them into the wrong tenant.
func resolveLoginInstance(c *gin.Context) (string, error) {
if inst, ok := InstanceFromHost(c); ok {
return inst.InstanceID, nil
}
n, err := services.CountInstances()
if err != nil {
return "", err
}
if n != 1 {
return "", fmt.Errorf(
"cannot tell which instance this sign-in is for: %d instances exist and the host %q names none of them; sign in at your instance's own address",
n, c.Request.Host)
}
inst, err := services.FirstInstance()
if err != nil {
return "", err
}
return inst.InstanceID, nil
}
```
Then replace the body of `HandleLocalLogin` between the JSON bind and `SaveSession` with:
```go
instanceID, err := resolveLoginInstance(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := services.GetUserInInstanceByEmail(instanceID, body.Email)
if err != nil || !services.VerifyPassword(u, body.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
```
The `SaveSession` call below it is unchanged: it already reads `u.InstanceID`.
- [ ] **Step 2: Confirm only the OIDC caller is left broken**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: FAIL with `undefined: services.GetUserByEmail` at `internal/auth/oidc.go:130` only.
---
### Task 5: Scoped OIDC callback
**Files:**
- Modify: `server/internal/auth/oidc.go:129-141`
**Interfaces:**
- Consumes: `services.GetUserInInstanceByEmail` from Task 3.
- Produces: nothing new.
The cross-instance guard is deleted because it becomes unreachable: the lookup is now scoped to `instanceID`, so a user belonging to another instance is simply not found, and the OIDC callback provisions a new member — which is correct. OIDC is configured per instance, so only that instance's identity provider can reach this code with that instance's state.
- [ ] **Step 1: Replace the lookup and delete the guard**
In `server/internal/auth/oidc.go`, replace:
```go
email := strings.ToLower(claims.Email)
u, err := services.GetUserByEmail(email)
if err != nil {
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
return
}
} else if u.InstanceID != instanceID {
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
return
}
```
with:
```go
email := strings.ToLower(claims.Email)
// Scoped to the instance the callback state names, so an address that also
// exists in another instance is invisible here. That scoping replaces the
// cross-instance guard this code used to need: there is no longer a way for
// the lookup to return a user belonging to somebody else.
u, err := services.GetUserInInstanceByEmail(instanceID, email)
if err != nil {
u, err = services.CreateUser(instanceID, email, "", models.RoleMember, models.AuthOIDC)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
return
}
}
```
`services.CreateUser`'s signature is `CreateUser(instanceID, email, password, role, authSource string)` — the argument order above matches it, with the two string literals the old code passed replaced by the constants Task 2 added.
`oidc.go` already imports `gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models`; confirm it before relying on the constants:
```sh
grep -n "server/internal/models" server/internal/auth/oidc.go
```
If that returns nothing, add the import rather than reverting to string literals — Task 2 exists so these two values have one spelling.
- [ ] **Step 2: Confirm the build is green**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: no output.
- [ ] **Step 3: Confirm no unscoped email lookup survives anywhere in the server**
Run:
```sh
grep -rn "GetUserByEmail" --include=*.go .
```
Expected: no matches at all.
Run:
```sh
grep -rn 'FindOne(ctx, bson.M{"email"' --include=*.go server/
```
Expected: no matches.
**Coverage note.** The spec's phase-1 test 6 exercises this path end to end, which needs a working identity provider and is not reproducible in the container harness Task 7 uses. It is verified here by inspection and by the greps in Step 3 instead: the lookup is scoped by `instanceID`, which comes from `ConsumeStateInstance` and not from user input, and the deleted guard was the only other consumer of the unscoped helper. The first real OIDC sign-in after deployment is the confirming evidence — check that an existing SSO user still lands in their own instance before considering this closed.
- [ ] **Step 4: Commit Tasks 3, 4 and 5 together**
```bash
git add server/internal/services/users.go server/internal/auth/local.go server/internal/auth/oidc.go
git commit -m "feat(server): scope every user lookup by instance
users is unique on (instance_id, email) now, so an unscoped FindOne could
return an arbitrary one of several matching users. On the login path that
means signing someone into a tenant that is not theirs.
GetUserByEmail is deleted rather than left unused. Local sign-in resolves
its instance from the host, falling back to the single instance a
self-hosted deployment has, and refuses to guess otherwise. The OIDC
cross-instance guard goes: a scoped lookup cannot return another
instance's user, which is a stronger guarantee than the check it replaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 6: Remove admin's unscoped control-plane login
**Files:**
- Delete: `admin/internal/auth/cloud.go`
- Modify: `admin/internal/api/routes.go:30`, `admin/internal/api/routes.go:50-52`
- Modify: `admin/internal/api/staff.go`
**Interfaces:**
- Consumes: `auth.CreateCustomerUser(ctx, accountID, email, password string) error` from `admin/internal/auth/customer.go:32`.
- Produces: `POST /api/staff/accounts/:id/users`.
`HandleCloudLogin` authenticates against control-plane `users` with an unscoped `FindOne({email})`, and unlike the server's two lookups there is no instance in context to scope it by — HQ sign-in is not per-instance. It already falls through to `HandleCustomerLogin` whenever a `customer_users` row exists, which after phase 2 is every customer. Legacy cloud customers get an HQ login from staff, which is what the new endpoint is for; staff already attach those instances by hand per the spec README.
- [ ] **Step 1: Delete the file**
```sh
git rm admin/internal/auth/cloud.go
```
- [ ] **Step 2: Point `/auth/login` at the customer handler**
In `admin/internal/api/routes.go`, replace:
```go
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
```
with:
```go
// Every customer authenticates against admin's own customer_users. There is
// deliberately no path that looks a customer up in the control plane by
// email alone: HQ sign-in names no instance, so such a lookup could not be
// scoped, and users.email is no longer globally unique.
r.POST("/auth/login", auth.HandleCustomerLogin)
```
- [ ] **Step 3: Add the staff route**
In `admin/internal/api/routes.go`, inside the `staff` group, immediately after the `staff.GET("/accounts/:id", staffGetAccount)` line, add:
```go
staff.POST("/accounts/:id/users", staffCreateAccountUser)
```
- [ ] **Step 4: Add the handler**
At the end of `admin/internal/api/staff.go`, add:
```go
// staffCreateAccountUser gives an account an HQ login.
//
// This is how a legacy cloud customer — one whose instance predates HQ accounts
// — gets into the portal, alongside the manual instance attach the spec README
// describes. It reuses CreateCustomerUser, so the row is unverified until the
// emailed link is opened and is rolled back if that email cannot be sent.
func staffCreateAccountUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "email and a password of at least 12 characters are required"})
return
}
ctx := c.Request.Context()
accountID := c.Param("id")
if n, err := db.Admin("accounts").CountDocuments(ctx,
bson.M{"account_id": accountID}); err != nil || n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
c.JSON(http.StatusCreated, gin.H{"pending": true})
}
```
Confirm `strings` is imported in `staff.go`; add it if not:
```sh
grep -n '"strings"' admin/internal/api/staff.go
```
- [ ] **Step 5: Confirm the build is green and nothing still references the deleted handler**
Run:
```sh
grep -rn "HandleCloudLogin" --include=*.go .
```
Expected: no matches.
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output. If `sharedmodels` is now an unused import in some file, remove that import line.
- [ ] **Step 6: Confirm admin has no unscoped control-plane user lookup left**
Run:
```sh
grep -rn 'db.Control("users")' --include=*.go admin/
```
Expected: no matches.
- [ ] **Step 7: Commit**
```bash
git add -A admin/
git commit -m "feat(admin): drop the unscoped control-plane login branch
HQ sign-in names no instance, so a lookup of control-plane users by email
alone cannot be scoped — and users.email is no longer globally unique, so
it would return an arbitrary match. Every customer authenticates against
customer_users instead.
Legacy cloud customers get an HQ login from staff via the new
POST /api/staff/accounts/:id/users, alongside the manual instance attach
the spec README already describes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 7: Documentation and end-to-end verification
**Files:**
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: everything above.
- Produces: nothing.
This is the task that proves the change. With no test suite, this transcript is the only evidence, so run it in full rather than skimming it.
- [ ] **Step 1: Update `CLAUDE.md`**
In the **Auth and Orgs** section, replace the paragraph beginning "Unique indexes on user email and org slug are a **security property**" with:
```markdown
Unique indexes are a **security property**, not an optimisation. `users` is
unique on `(instance_id, email)` — one address is one user _within_ an instance,
and the same address may hold a user in several instances, because an account's
people are projected into each instance they are granted. This is sufficient only
because **every lookup by email is scoped by instance**; there is deliberately no
unscoped lookup anywhere, and adding one would let the login path return an
arbitrary one of several matching users. Instance slug, settings instance and ESO
token hash remain globally unique.
```
In the **Security** section, replace the "Unique indexes on user email, org slug…" bullet with:
```markdown
- Unique indexes on `(instance_id, email)`, instance slug, settings instance and the ESO token hash are load-bearing for tenant isolation. So is the absence of any unscoped lookup by email.
```
In the **MongoDB Collections** notes, add:
```markdown
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
```
- [ ] **Step 2: Build both images**
```sh
MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:test .
MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:test .
```
Expected: two image IDs. A "missing go.sum entry" failure here means `go mod tidy` was run in workspace mode.
- [ ] **Step 3: Start a scratch Mongo and Redis, and seed the OLD index**
Redis is not optional here: the server stores sessions in it, so every sign-in below fails without it.
```sh
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-redis -p 6389:6379 redis:7
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-mongo -p 27023:27017 mongo:7
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'db.users.createIndex({email:1},{unique:true}); db.getCollection("users").getIndexes().map(i=>i.name)'
```
Expected: output includes `email_1`. This reproduces a database that predates the change.
- [ ] **Step 4: Boot the server and confirm the swap**
```sh
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-server -p 8091:8080 \
-e MONGO_URI=mongodb://host.docker.internal:27023 -e MONGO_DB=vantage_idx \
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6389 \
--add-host host.docker.internal:host-gateway vantage-server:test
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'db.getCollection("users").getIndexes().map(i=>({name:i.name,key:i.key,unique:i.unique}))'
```
Expected: `instance_email_unique` present with key `{instance_id:1, email:1}` and `unique:true`; **no `email_1`**.
- [ ] **Step 5: Confirm a second boot is a no-op**
```sh
MSYS_NO_PATHCONV=1 docker restart vantage-idx-server
sleep 5
MSYS_NO_PATHCONV=1 docker logs vantage-idx-server 2>&1 | grep -i "index\|fatal" | tail -5
```
Expected: no index error and no fatal. The drop must tolerate the index already being gone.
- [ ] **Step 6: Bootstrap instance A and capture its user's password hash**
```sh
curl -s -X POST http://localhost:8091/auth/bootstrap \
-H 'Content-Type: application/json' \
-d '{"instance_name":"Alpha","email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: JSON with `instance_id` and `"slug":"alpha"`.
```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'const u=db.users.findOne({email:"shared@example.com"}); print(u.user_id); print(u.password_hash)'
```
Expected: a UUID and a bcrypt hash. Keep both.
- [ ] **Step 7: Create instance B with the SAME address — the case that was impossible before**
```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval '
const a = db.users.findOne({email:"shared@example.com"});
const bId = UUID().toString().replace(/[{}]/g,"");
db.instances.insertOne({instance_id:bId, name:"Beta", slug:"beta", created_at:new Date()});
db.users.insertOne({
user_id: UUID().toString().replace(/[{}]/g,""),
instance_id: bId,
email: "shared@example.com",
password_hash: a.password_hash,
role: "owner",
auth_source: "local",
created_at: new Date()
});
print("beta instance " + bId);
print("users with that address: " + db.users.countDocuments({email:"shared@example.com"}));
'
```
Expected: `users with that address: 2`. Under the old global index this insert would have failed with E11000 — that failure is exactly what this phase removes.
- [ ] **Step 8: Confirm the compound index still refuses a duplicate WITHIN one instance**
```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval '
const a = db.users.findOne({email:"shared@example.com"});
try {
db.users.insertOne({user_id:"dup", instance_id:a.instance_id,
email:"shared@example.com", role:"member", auth_source:"local", created_at:new Date()});
print("FAIL: duplicate accepted");
} catch (e) { print("refused as expected: " + (e.code === 11000)); }
'
```
Expected: `refused as expected: true`. A `FAIL` line means the compound index is missing or not unique.
- [ ] **Step 9: Confirm each host signs in to its own instance — the whole point of the phase**
```sh
curl -s -X POST http://localhost:8091/auth/login -H 'Host: alpha.vantage.test' \
-H 'Content-Type: application/json' -c /tmp/alpha.jar \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8091/auth/me -H 'Host: alpha.vantage.test' -b /tmp/alpha.jar
```
Expected: `{"ok":true}`, then a body whose `instance` is **Alpha**.
```sh
curl -s -X POST http://localhost:8091/auth/login -H 'Host: beta.vantage.test' \
-H 'Content-Type: application/json' -c /tmp/beta.jar \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8091/auth/me -H 'Host: beta.vantage.test' -b /tmp/beta.jar
```
Expected: `{"ok":true}`, then a body whose `instance` is **Beta**, with a different `instance_id` from the Alpha response.
Two sign-ins, one address, one password, two different tenants. If both responses name the same instance, the lookup is not scoped.
- [ ] **Step 10: Confirm the apex host refuses rather than guesses**
```sh
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/login \
-H 'Host: vantage.test' -H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: `400`. Then read the message:
```sh
curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
-H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: an error naming both the instance count and the host. A `200` here would mean an arbitrary tenant was chosen.
- [ ] **Step 11: Confirm a wrong password still fails, on the right host**
```sh
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/login \
-H 'Host: alpha.vantage.test' -H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"wrongwrongwrong"}'
```
Expected: `401`.
- [ ] **Step 12: Confirm a single-instance deployment still signs in on a bare host**
```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'const b=db.instances.findOne({slug:"beta"}); db.users.deleteMany({instance_id:b.instance_id}); db.instances.deleteOne({slug:"beta"}); print(db.instances.countDocuments({}))'
```
Expected: `1`.
```sh
curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
-H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: `{"ok":true}`. This is the self-hosted path, and it must keep working.
- [ ] **Step 13: Confirm admin boots and its login route still works**
```sh
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-admin -p 8093:8083 \
-e ADMIN_MONGO_URI=mongodb://host.docker.internal:27023/vantage_idx_admin \
-e CONTROL_MONGO_URI=mongodb://host.docker.internal:27023/vantage_idx \
-e REDIS_ADDR=host.docker.internal:6389 \
-e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" \
-e PUBLIC_URL=http://localhost:8093 -e ADMIN_ORIGIN=http://localhost:3004 \
--add-host host.docker.internal:host-gateway vantage-admin:test
sleep 5
curl -s http://localhost:8093/healthz
```
Expected: `{"ok":true}`. A boot failure here most likely means an unused-import error that `go build` caught but the image build did not, or a missing env var.
```sh
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8093/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"nobody@example.com","password":"hunter2hunter2"}'
```
Expected: `401`, not `500`. This proves `/auth/login` is wired to a live handler after `HandleCloudLogin` was deleted.
- [ ] **Step 14: Tear the scratch environment down**
```sh
MSYS_NO_PATHCONV=1 docker rm -f vantage-idx-server vantage-idx-admin vantage-idx-mongo vantage-idx-redis
```
- [ ] **Step 15: Commit**
```bash
git add CLAUDE.md
git commit -m "docs: users is unique per instance, not globally
The old index was load-bearing because two lookups were unscoped. Both
are scoped now and the unscoped helper is gone, so the property that
matters is the absence of any unscoped lookup by email. Says so, and
documents auth_source hq.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Done when
- `instance_email_unique` exists on `users`, `email_1` does not, and a second boot is a no-op.
- Two users share one address across two instances, and each signs in to their own.
- A duplicate address within one instance is still refused.
- The apex host refuses to guess when several instances exist, and still works when only one does.
- `grep -rn "GetUserByEmail"` and `grep -rn "HandleCloudLogin"` both return nothing.
- `admin` boots and `/auth/login` answers `401` rather than `500`.
- `CLAUDE.md` no longer claims `users.email` is globally unique.
**Not proven by this plan:** the OIDC sign-in path, which needs a real identity provider. Verify it manually on the first SSO sign-in after deployment — an existing SSO user must still land in their own instance.
## Not in this phase
`POST /api/instances`, the Free lifecycle, renewal, the notices, the reaper, the sitesvc cutover, account roles, invitations, instance membership, password propagation, and every UI change. Phases 2 and 3 get their own plans once this one lands.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,928 +0,0 @@
# Control plane mobile responsiveness — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `web/` (the Vantage control plane UI) usable on a phone — the sidebar becomes a hamburger-driven offcanvas below 1024px, tables become card stacks below 640px, and every fixed desktop layout collapses.
**Architecture:** A new client component `AppShell` owns the responsive chrome so `app/(app)/layout.tsx` stays a server component. `Sidebar.tsx` splits into a shared `SidebarContent` plus two containers (permanent aside, offcanvas drawer) so the nav exists in exactly one copy. The table card-stack lives in the `ui/Table.tsx` primitives via Tailwind `max-sm:` variants, so pages keep one markup tree and opt in with a `label` prop per cell.
**Tech Stack:** Next.js 16 (App Router), React 18, Tailwind 3.4, `clsx`. **No new dependencies.**
## Global Constraints
- **Scope is `web/` only.** Do not touch `site/`, `adminsite/`, `server/`, `admin/` or any Go code.
- **No hex colours anywhere.** Tailwind maps `var(--…)` tokens only. Use `bg-surface`, `border-border`, `text-text-secondary` etc. A literal `#` in a class is a defect. (`bg-black/60` is the one existing exception, already used by `Modal.tsx` for its backdrop — reuse it, do not introduce others.)
- **Breakpoints:** sidebar collapses below `lg` (1024px). Tables card-stack below `sm` (640px). Do not invent other breakpoints.
- **No new dependencies.** No headless-ui, no framer-motion.
- **Presentation only.** No API, route, query-key or data-shape changes.
- **Radius:** `rounded`, `rounded-lg`, `rounded-md` and `rounded-xl` all resolve to 46px via `tailwind.config.ts`. Prefer `rounded` in new code.
- Use `dvh`, not `vh`, for any new viewport-height value — mobile browser chrome makes `vh` overshoot.
- Indentation follows the file you are editing. `web/` is mixed: some files use 4 spaces (`Sidebar.tsx`, `keys/page.tsx`), others 2 (`servers/page.tsx`, `ui/*`). Match the file, do not reformat it.
- **There is no test framework in this repo.** No jest, no vitest, no playwright. Verification is `npx next lint`, `npx next build`, and targeted `grep` audits. Do not add a test framework.
- Run all commands from `d:\Development\Websites\vantage\web`.
---
### Task 1: Responsive table primitives
The card stack goes in the primitives, not the pages. Six pages render tables; giving each one a second markup tree would double the markup and drift on the first edit.
**Files:**
- Modify: `web/components/ui/Table.tsx` (whole file)
**Interfaces:**
- Consumes: nothing.
- Produces: `Td` gains an optional prop `label?: string`. Below `sm`, a `Td` with a `label` renders `<span>{label}</span>` before its children; a `Td` without one renders children alone, right-aligned. `Table`, `Thead`, `Tbody`, `Tr`, `Th` keep their existing signatures. Task 4 consumes `label`.
- [ ] **Step 1: Rewrite `web/components/ui/Table.tsx`**
Replace the entire file with:
```tsx
import { clsx } from "clsx";
import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
/*
* Below sm the table stops being a table: the head is hidden, each row becomes
* a bordered card and each cell becomes a label/value pair. That lives here
* rather than in the six pages that render tables — two markup trees per page
* would drift apart on the first edit, and every one of those trees would mean
* the same thing.
*
* The mobile label uses Th's exact keyed-label idiom (mono, small, widely
* tracked, dimmed) because a key beside a value on a phone is the same device
* as a column head above it on a desktop.
*/
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table
className={clsx("w-full border-collapse text-sm max-sm:block", className)}
{...props}
>
{children}
</table>
</div>
);
}
export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
{children}
</thead>
);
}
export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody
className={clsx(
"divide-y divide-border",
"max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3",
className
)}
{...props}
>
{children}
</tbody>
);
}
export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={clsx(
"transition-colors hover:bg-surface-2/50",
"max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3",
className
)}
{...props}
>
{children}
</tr>
);
}
export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={clsx(
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
// A column head is a key, not prose.
// text-secondary, not tertiary: a column head is how you navigate the
// table, and tertiary lands under 4.5:1 at this size.
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
className
)}
{...props}
>
{children}
</th>
);
}
interface TdProps extends TdHTMLAttributes<HTMLTableCellElement> {
/**
* The column head this cell belongs to, shown beside the value below sm
* where the real head is hidden. Omit on a trailing action cell — an action
* needs no key, and the button then sits alone on its own row in the card.
*/
label?: string;
}
export function Td({ className, label, children, ...props }: TdProps) {
return (
<td
className={clsx(
"px-4 py-3 text-text-primary",
"max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
// Exactly one justify class — clsx picks it. Emitting both and relying
// on string order would not work: Tailwind's output order decides which
// of two same-property utilities wins, not the order in this array.
label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
className
)}
{...props}
>
{label && (
<span className="hidden font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">
{label}
</span>
)}
{children}
</td>
);
}
```
- [ ] **Step 2: Verify it compiles and lints**
```bash
npx tsc --noEmit
npx next lint
```
Expected: both clean. `tsc` may take ~30s. If `tsc --noEmit` errors on pre-existing issues unrelated to `Table.tsx`, note them and move on — only new errors matter.
- [ ] **Step 3: Commit**
```bash
git add web/components/ui/Table.tsx
git commit -m "feat(web): card-stack tables below sm"
```
---
### Task 2: Offcanvas sidebar
**Files:**
- Modify: `web/components/Sidebar.tsx` (whole file)
- Create: `web/components/AppShell.tsx`
- Modify: `web/app/(app)/layout.tsx` (whole file)
**Interfaces:**
- Consumes: `useAuth()` from `@/components/AuthProvider` returning `{ user, instance, isAdmin }`; `auth.logout()` from `@/lib/api`; `Logo` from `@/components/Logo`.
- Produces:
- `Sidebar.tsx` exports `SidebarContent({ onNavigate }: { onNavigate?: () => void })`, `Sidebar()` (permanent aside) and `SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void })`.
- `AppShell.tsx` exports `AppShell({ children }: { children: React.ReactNode })`.
- No later task depends on these names.
- [ ] **Step 1: Rewrite `web/components/Sidebar.tsx`**
Keep every icon component and the `navItems` array **exactly as they are** — do not retype the SVG path data, it is long and easy to corrupt. Change only from `export function Sidebar()` (line 135) to the end of the file, replacing it with the following. The file uses 4-space indentation.
```tsx
/** Shared by the permanent aside and the offcanvas drawer — one copy of the nav. */
export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { user, instance, isAdmin } = useAuth();
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
if (!matches) return best;
return best === null || item.href.length > best.length ? item.href : best;
}, null);
async function handleLogout() {
try {
await auth.logout();
} catch {}
window.location.href = "/login";
}
return (
<>
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-5">
<Logo className="h-8 w-8 text-logo" />
<div className="min-w-0">
<span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
{instance && (
<span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
)}
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
// The active marker is an accent bar, the same device
// site/ uses to mark the chosen plan. A filled pill
// reads as a button you can press again.
className={clsx(
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
isActive
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="shrink-0 border-t border-border px-4 py-3">
{user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
<p className="truncate text-xs text-text-secondary">
{user.email}
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">Vantage v1.0</p>
{user && (
<button type="button" onClick={handleLogout} className="text-xs text-text-secondary transition-colors hover:text-danger">
Logout
</button>
)}
</div>
</div>
</>
);
}
/** The permanent sidebar. Below lg the drawer takes over. */
export function Sidebar() {
return (
<aside className="hidden h-screen w-60 shrink-0 flex-col border-r border-border bg-surface lg:flex">
<SidebarContent />
</aside>
);
}
/**
* The offcanvas below lg. Always mounted so the slide runs in both directions;
* closed it is inert (invisible + pointer-events-none) rather than unmounted.
*/
export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
panelRef.current?.focus();
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);
return (
<div
className={clsx(
"fixed inset-0 z-50 lg:hidden",
open ? "visible" : "invisible pointer-events-none",
)}
>
<div
aria-hidden="true"
onClick={onClose}
className={clsx(
"absolute inset-0 bg-black/60 transition-opacity duration-200",
open ? "opacity-100" : "opacity-0",
)}
/>
<div
ref={panelRef}
id="app-sidebar-drawer"
role="dialog"
aria-modal="true"
aria-label="Navigation"
tabIndex={-1}
className={clsx(
"absolute inset-y-0 left-0 flex w-72 max-w-[85%] flex-col border-r border-border bg-surface outline-none transition-transform duration-200 ease-out",
open ? "translate-x-0" : "-translate-x-full",
)}
>
<SidebarContent onNavigate={onClose} />
</div>
</div>
);
}
```
Then update the import line at the top of the file (currently line 4) so `useEffect` and `useRef` are available:
```tsx
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
```
- [ ] **Step 2: Create `web/components/AppShell.tsx`**
```tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { LicenseBanner } from "@/components/LicenseBanner";
import { Logo } from "@/components/Logo";
import { Sidebar, SidebarDrawer } from "@/components/Sidebar";
import { useAuth } from "@/components/AuthProvider";
function MenuIcon() {
return (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
);
}
/**
* Owns the responsive chrome so app/(app)/layout.tsx can stay a server
* component. Above lg this is the layout it always was; below lg the sidebar
* becomes an offcanvas behind the top bar's hamburger.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const buttonRef = useRef<HTMLButtonElement>(null);
const { instance } = useAuth();
// A drawer that survives navigation would cover the page you just asked for.
useEffect(() => {
setOpen(false);
}, [pathname]);
function close() {
setOpen(false);
buttonRef.current?.focus();
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<SidebarDrawer open={open} onClose={close} />
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-surface px-3 lg:hidden">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(true)}
aria-label="Open navigation"
aria-expanded={open}
aria-controls="app-sidebar-drawer"
className="-ml-1 rounded p-2 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
>
<MenuIcon />
</button>
<Logo className="h-7 w-7 shrink-0 text-logo" />
<div className="min-w-0">
<span className="block text-sm font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
{instance && (
<span className="block truncate font-mono text-[0.62rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
)}
</div>
</header>
<main className="flex min-w-0 flex-1 flex-col">
<LicenseBanner />
{children}
</main>
</div>
</div>
);
}
```
- [ ] **Step 3: Rewrite `web/app/(app)/layout.tsx`**
```tsx
import { AuthProvider } from "@/components/AuthProvider";
import { AppShell } from "@/components/AppShell";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<AppShell>{children}</AppShell>
</AuthProvider>
);
}
```
`LicenseBanner` and `Sidebar` are no longer imported here — `AppShell` renders both.
- [ ] **Step 4: Verify**
```bash
npx tsc --noEmit
npx next lint
npx next build
```
Expected: all three succeed. `next build` is the one that matters — it catches a client component imported into a server component boundary.
- [ ] **Step 5: Sanity-check the scroll container**
Read `web/app/(app)/servers/[id]/console/page.tsx` around line 153 and 168. It uses `h-full`, which now resolves against `<main class="flex min-w-0 flex-1 flex-col">` rather than the old `<main class="flex-1 overflow-y-auto">`. Confirm the console page still has a height to fill; if `h-full` no longer resolves, change those two wrappers to `flex-1` instead. Task 7 revisits this file, so a note is acceptable here if you prefer to fix it there — but write the note down.
- [ ] **Step 6: Commit**
```bash
git add web/components/Sidebar.tsx web/components/AppShell.tsx "web/app/(app)/layout.tsx"
git commit -m "feat(web): offcanvas sidebar with hamburger below lg"
```
---
### Task 3: Page padding and header rows
**Files:**
- Modify: all 21 files under `web/app` and `web/components` containing `p-8`
- Modify: the title-plus-action header rows listed below
**Interfaces:**
- Consumes: nothing. Produces: nothing. Pure class edits.
- [ ] **Step 1: List every occurrence**
```bash
cd web && grep -rn "p-8" app components
```
Expected: 30 occurrences across 21 files.
- [ ] **Step 2: Replace each page-level `p-8` with `p-4 sm:p-6 lg:p-8`**
Apply to every occurrence **except** these two, which Task 6 and Task 7 handle and which need different values:
- `app/(app)/workflows/[id]/page.tsx:331` (the canvas `<main>`) — leave for Task 6.
- `app/(app)/servers/[id]/console/page.tsx:168` — leave for Task 7.
The inline loading states (`<div className="p-8 text-text-secondary">Loading…</div>`) get the same treatment: `className="p-4 text-text-secondary sm:p-6 lg:p-8"`.
Do this file by file with `Edit`. A blind `sed` would also hit `p-8` inside strings or unrelated contexts — check each match.
- [ ] **Step 3: Make title-plus-action header rows stack**
In each of these, change `className="mb-6 flex items-center justify-between"` to
`className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"`:
- `app/(app)/servers/page.tsx:75`
- `app/(app)/keys/page.tsx:112`
- `app/(app)/monitors/page.tsx:35`
- `app/(app)/workflows/page.tsx:32`
- `app/(app)/secrets/page.tsx:105`
- `app/(app)/secrets/[group]/page.tsx:251`
- `app/(app)/settings/notifications/page.tsx:153`
Leave `flex items-center justify-between` rows that are *inside* a card header or a table cell — those hold two small items and are fine at 390px. Only the page-top title/action rows change.
- [ ] **Step 4: Verify no unprefixed `p-8` survives**
```bash
cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
```
Expected: exactly two lines — the two deferred to Tasks 6 and 7.
- [ ] **Step 5: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add web/app web/components
git commit -m "feat(web): responsive page padding and stacking page headers"
```
---
### Task 4: Label every table cell
**Files:**
- Modify: `web/app/(app)/servers/page.tsx:116-145`
- Modify: `web/app/(app)/keys/page.tsx:149-169`
- Modify: `web/app/(app)/monitors/page.tsx:79-98`
- Modify: `web/app/(app)/secrets/page.tsx:142-157`
- Modify: `web/app/(app)/secrets/[group]/page.tsx:141-150`
- Modify: `web/app/(app)/workflows/page.tsx:73-86`
- Modify: `web/app/(app)/workflows/[id]/runs/page.tsx:54-68`
- Modify: `web/app/(app)/audit/page.tsx:80-93`
- Modify: `web/app/(app)/keys/[id]/page.tsx:388-420`
- Modify: `web/app/(app)/servers/[id]/page.tsx:272-280` and `:588-605`
- Modify: `web/app/(app)/monitors/[id]/page.tsx:183-195`
- Modify: `web/components/settings/MembersCard.tsx:118-145`
**Interfaces:**
- Consumes: `Td`'s `label?: string` prop from Task 1.
- Produces: nothing.
- [ ] **Step 1: Add `label` to each `Td`, matching its `Th`**
For every table, the Nth `<Td>` in a `<Tr>` takes the text of the Nth `<Th>`. Where the `Th` is empty (`<Th />` — the trailing action column), the matching `Td` gets **no** `label`.
The mapping, `Th` order per file:
| File | Column labels, in order |
| --- | --- |
| `servers/page.tsx` | Hostname · IP Address · OS · Status · Last Seen · *(none)* |
| `keys/page.tsx` | Label · Fingerprint · Source · Assignments · Created · *(none)* |
| `monitors/page.tsx` | Name · Type · Target · Status · Latency · Last check |
| `secrets/page.tsx` | Group · Keys · Last Updated · *(none)* |
| `secrets/[group]/page.tsx` | Key · Value · Updated · *(none)* |
| `workflows/page.tsx` | Name · Targets · Steps · *(none)* |
| `workflows/[id]/runs/page.tsx` | Run · Status · Started · By · Servers |
| `audit/page.tsx` | Time · Event · Actor · Details |
| `keys/[id]/page.tsx` | Server · IP Address · Status · Assigned · Revoked · *(none)* |
| `servers/[id]/page.tsx` (updates table) | Package · Current · Available |
| `servers/[id]/page.tsx` (keys table) | Label · Fingerprint · Source · Status · Assigned · *(none)* |
| `monitors/[id]/page.tsx` | Started · Resolved · Cause |
| `MembersCard.tsx` | Email · Role · Sign-in · Last login · Actions |
Worked example — `servers/page.tsx` lines 116145 become:
```tsx
<Td label="Hostname">
<span className="font-medium text-text-primary">
{server.hostname}
</span>
</Td>
<Td label="IP Address">
<span className="font-mono text-text-secondary">
{server.ip_address}
</span>
</Td>
<Td label="OS">
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td label="Status">
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td label="Last Seen">
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
</Td>
<Td>
<Link href={`/servers/${server.server_id}`}>
<Button variant="ghost" size="sm">
View →
</Button>
</Link>
</Td>
```
Note the last `Td` is unchanged — no `label`, so the "View →" button sits alone on its own row at the bottom of the card.
Second worked example — `MembersCard.tsx` line 142143, where `Td` already carries a `className`. Both props coexist:
```tsx
<Td label="Last login" className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td label="Actions" className="text-right">
```
`MembersCard`'s last column has a real `Th` ("Actions"), so unlike the others it **does** take a label.
- [ ] **Step 2: Verify no `Td` was missed**
```bash
cd web && grep -rn "<Td" app components | grep -v "label="
```
Expected: only the trailing action cells listed as *(none)* above — 7 of them (`servers`, `keys`, `secrets`, `secrets/[group]`, `workflows`, `keys/[id]`, `servers/[id]` keys table). Any other bare `<Td` is a miss.
- [ ] **Step 3: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 4: Commit**
```bash
git add web/app web/components
git commit -m "feat(web): label table cells for the mobile card stack"
```
---
### Task 5: Modal bottom sheet and shared-component grids
**Files:**
- Modify: `web/components/ui/Modal.tsx:28-31`
- Modify: `web/components/monitors/MonitorForm.tsx:76,98,123,144`
- Modify: `web/components/workflows/StepPickerModal.tsx:132,168`
- Modify: `web/components/ui/Card.tsx:27`
**Interfaces:**
- Consumes: nothing. Produces: nothing.
- [ ] **Step 1: Make `Modal` a bottom sheet below `sm`**
In `web/components/ui/Modal.tsx`, replace lines 2834:
```tsx
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
>
```
The `max-w-*` gains an `sm:` prefix so the sheet is full-width on a phone. `dvh` rather than `vh` because mobile browser chrome makes `vh` overshoot.
- [ ] **Step 2: Collapse the grids in `MonitorForm.tsx`**
- Line 76: `grid grid-cols-4 gap-2``grid grid-cols-2 gap-2 sm:grid-cols-4`
- Lines 98, 123, 144: `grid grid-cols-2 gap-4``grid grid-cols-1 gap-4 sm:grid-cols-2`
- [ ] **Step 3: Collapse the grids in `StepPickerModal.tsx`**
Lines 132 and 168: `grid grid-cols-2 gap-2.5``grid grid-cols-1 gap-2.5 sm:grid-cols-2`
- [ ] **Step 4: Let `CardHeader` wrap**
`web/components/ui/Card.tsx` line 27: `"mb-4 flex items-center justify-between"``"mb-4 flex flex-wrap items-center justify-between gap-2"`. Card headers hold a title and an action; at 390px they need to be allowed to wrap rather than crush the title.
- [ ] **Step 5: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add web/components/ui/Modal.tsx web/components/ui/Card.tsx web/components/monitors/MonitorForm.tsx web/components/workflows/StepPickerModal.tsx
git commit -m "feat(web): bottom-sheet modals and collapsing component grids"
```
---
### Task 6: Workflow builder
**Files:**
- Modify: `web/app/(app)/workflows/[id]/page.tsx:305-324` (header), `:329` (grid), `:331` (canvas), `:340` (column), `:372` (node), `:403` (inspector)
**Interfaces:**
- Consumes: nothing. Produces: nothing.
Below `lg` the fixed-height two-column grid is dropped entirely: single column, natural page flow. The `100dvh` arithmetic only makes sense at `lg`, where there is no mobile top bar above it.
- [ ] **Step 1: Let the header wrap (line 305)**
```tsx
<div className="flex flex-wrap items-center gap-3 border-b border-border bg-surface px-4 py-3">
```
and on line 312 change `className="ml-auto flex items-center gap-2"` to
`className="ml-auto flex flex-wrap items-center gap-2"`.
- [ ] **Step 2: Make the shell single-column below lg (line 329)**
```tsx
<div className="flex flex-1 flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]">
```
`h-[calc(100vh-53px)]` becomes `lg:h-[calc(100dvh-53px)]``lg:` because the mobile top bar changes the arithmetic, and `dvh` because `vh` overshoots on mobile.
- [ ] **Step 3: Canvas padding (line 331)**
```tsx
<main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-4 sm:p-6 lg:p-8">
```
- [ ] **Step 4: Let the node column and nodes be fluid (lines 340 and 372)**
Line 340:
```tsx
<div className="mx-auto flex w-full max-w-[340px] flex-col items-center">
```
Line 372 — the node itself. The wrapping `<div key={wfIdx} className="w-full">` on line 349 already constrains it, so the node just fills:
```tsx
className={`w-full cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`}
```
- [ ] **Step 5: Turn the inspector into a bottom panel below lg (line 403)**
```tsx
<aside
className={`overflow-auto border-border bg-surface p-4 lg:block lg:border-l ${
selected === null || !selectedRef ? "hidden" : "block border-t max-lg:max-h-[60dvh]"
}`}
>
```
Below `lg` the inspector is hidden until a step is selected — an empty "Select a step to configure it" panel is noise on a phone — and when shown it sits under the canvas with a top border and a capped height. Above `lg` it is the left-bordered right rail it always was, always visible.
- [ ] **Step 6: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 7: Commit**
```bash
git add "web/app/(app)/workflows/[id]/page.tsx"
git commit -m "feat(web): single-column workflow builder below lg"
```
---
### Task 7: Remaining fixed layouts
**Files:**
- Modify: `web/app/(app)/servers/[id]/page.tsx:164,495`
- Modify: `web/app/(app)/secrets/page.tsx:53`
- Modify: `web/app/(app)/workflows/[id]/runs/[runId]/page.tsx:~250`
- Modify: `web/app/(app)/servers/[id]/console/page.tsx:168` and its header rows
**Interfaces:**
- Consumes: nothing. Produces: nothing.
- [ ] **Step 1: `servers/[id]/page.tsx` line 164 — inventory grid**
`className="grid grid-cols-3 gap-2"``className="grid grid-cols-2 gap-2 sm:grid-cols-3"`
- [ ] **Step 2: `servers/[id]/page.tsx` line 495 — install one-liner**
`className="relative flex-1 min-w-64 rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm"` → replace `min-w-64` with `min-w-0 overflow-x-auto`.
`min-w-64` is 256px of floor on a flex child; combined with a sibling copy button it pushes the row past a 390px viewport and scrolls the whole page sideways. `min-w-0` lets the box shrink and scroll its own content instead. Also check the parent flex row a few lines above and give it `flex-wrap` if the copy button ends up crushed.
- [ ] **Step 3: `secrets/page.tsx` line 53**
`className="grid grid-cols-2 gap-3"``className="grid grid-cols-1 gap-3 sm:grid-cols-2"`
- [ ] **Step 4: `workflows/[id]/runs/[runId]/page.tsx` — the step matrix**
Read the file around lines 240290. The matrix `<table>` has a `<th className="min-w-[240px] …">`. It is a genuine two-dimensional matrix (steps × servers) and must keep scrolling horizontally rather than stacking — stacking would destroy the information.
Confirm the `<table>` sits inside a wrapper with `overflow-x-auto`. If it does not, wrap it:
```tsx
<div className="overflow-x-auto">
<table …>
</table>
</div>
```
If a wrapper already exists, leave it alone and note that in the commit body.
- [ ] **Step 5: `servers/[id]/console/page.tsx`**
Line 168: `className="flex h-full flex-col p-8"``className="flex h-full min-h-0 flex-1 flex-col p-4 sm:p-6 lg:p-8"`.
`flex-1` is added because Task 2 changed the parent `<main>` from `flex-1 overflow-y-auto` to `flex min-w-0 flex-1 flex-col`, so `h-full` alone may no longer resolve to anything. If Task 2 Step 5 recorded a note about this file, resolve it here.
Line 161's error state also has a bare `p-8` — Task 3 should already have handled it. Confirm it reads `p-4 sm:p-6 lg:p-8`.
Then read the connected-state toolbar below line 220 and add `flex-wrap` to any `flex items-center` row that holds three or more controls, so the console's chrome wraps instead of overflowing.
- [ ] **Step 6: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 7: Commit**
```bash
git add web/app
git commit -m "feat(web): collapse remaining fixed layouts on small screens"
```
---
### Task 8: Final audit
**Files:** none modified unless the audit finds a miss.
- [ ] **Step 1: No unprefixed `p-8` remains**
```bash
cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
```
Expected: no output.
- [ ] **Step 2: No unprefixed multi-column grid remains**
```bash
cd web && grep -rnoE '(class|className)="[^"]*(^|[" ])grid-cols-[2-9]' app components
```
Every hit must be a `grid-cols-2` that is genuinely fine at 390px (two short items side by side). Check each one and note the justification. Anything holding form controls or long text must gain a `grid-cols-1 sm:` prefix.
- [ ] **Step 3: No fixed pixel width escapes a breakpoint prefix**
```bash
cd web && grep -rnoE '(^|[" ])(w|min-w|max-w)-\[[0-9]{3,}px\]' app components
```
Expected: only `lg:`-prefixed hits, plus `max-w-[340px]` and `max-w-[1180px]` and `max-w-[300px]`, which are all *maximums* and shrink freely. A bare `w-[NNNpx]` or `min-w-[NNNpx]` without a prefix is a defect — except `min-w-[240px]` in the run-detail matrix, which is deliberate (Task 7 Step 4).
- [ ] **Step 4: No hex colours were introduced**
```bash
cd web && git diff main --stat && git diff main -- app components | grep -nE '^\+.*#[0-9a-fA-F]{3,8}\b'
```
Expected: no output from the grep. Tailwind in this app maps `var(--…)` tokens only.
- [ ] **Step 5: Full build and lint**
```bash
npx next lint
npx next build
```
Expected: both succeed with no new warnings.
- [ ] **Step 6: Read the diff end to end**
```bash
git diff main -- web/
```
Check for: an accidentally deleted SVG path, a `Td` whose `label` does not match its `Th`, indentation reformatted in a file that used the other convention.
- [ ] **Step 7: Commit any fixes**
```bash
git add web
git commit -m "fix(web): mobile audit corrections"
```
If the audit found nothing, skip this step — do not create an empty commit.
---
## Self-review notes
**Spec coverage:** shell → Task 2; tables → Tasks 1 and 4; padding and headers → Task 3; modal → Task 5; workflow builder → Task 6; remaining fixed layouts → Task 7; verification → Task 8 plus a verify step in every task.
**Known limitation:** there is no test framework and no running backend in this environment, so no task can prove a page *looks* right — only that it compiles, lints, and contains no pattern known to break at 390px. The first person to open this on a phone should expect to find something. That is a property of the verification approach chosen in the spec, not a gap in the plan.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,410 +0,0 @@
# Spec 3 — Admin Backend
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
Ships: with spec 4 (the site it serves). Blocks specs 4 and 5.
## Context
A fourth Go service, `admin/`, owning customers, instances, licences and
subscriptions. It is the only service that holds the signing key.
Its data model separates two things the control plane deliberately does not know
about:
- **Account** — a paying customer. Holds a Paddle customer, a billing email, and
one or more instances.
- **Instance** — one deployment. Cloud instances mirror a control-plane
`Instance` row; self-hosted instances exist only here, because the customer's
database is theirs and we cannot see it.
## Goals
1. Issue, store and re-issue licences, with full history.
2. Inject licences into cloud instances.
3. Serve both staff and customers, with the right things hidden from each.
4. Never be a runtime dependency of a Vantage instance. If admin is down,
every instance keeps working; only purchasing and renewals stop.
## Non-goals
- The UI. Spec 4.
- Paddle. Spec 5. This spec defines the `subscriptions` table and the issuance
functions that spec 5's webhooks call, and nothing more.
- Rebuilding billing management. Card changes, invoices and cancellation go to
Paddle's own customer portal.
## Design
### Module
```
admin/
├── go.mod # replace => ../shared
├── cmd/main.go
└── internal/
├── api/ # gin handlers
├── auth/ # staff, cloud-customer and local-customer sessions
├── db/ # two connections: admin DB and control-plane DB
├── models/ # admin-owned documents
├── licensing/ # issuance, renewal, relink
├── inject/ # control-plane writes
├── mail/ # licence delivery
└── paddle/ # spec 5 lands here
```
Port `8083`. In `deploy/docker-compose.site.yml` only — like sitesvc, admin is
**excluded from the self-hosted deployment**. A self-hosted customer runs
instances, not the licensing authority.
### Two database connections
The service holds two:
- `ADMIN_MONGO_URI` — its own database, `vantage_admin`. Sole owner.
- `CONTROL_MONGO_URI` — the control plane's database, used to write licence
fields onto cloud instance documents and to authenticate cloud customers.
The control-plane connection uses the `Instance` and `User` structs from
`shared` (spec 0a). This is what makes direct writes safe: there is no admin-side
copy of the document shape to drift, which is the coupling hazard sitesvc used to
carry.
Admin's control-plane access is **narrow by construction**: it reads
`instances` and `users`, and it writes exactly three fields on `instances`. The
Mongo credential it is given should be scoped to that where the deployment allows
it. It must never write to any other collection.
### Data model
```go
type Account struct {
ID bson.ObjectID
AccountID string // uuid
Name string
BillingEmail string
PaddleCustomerID string // empty until first checkout
Status string // active | suspended
CreatedAt time.Time
}
type Instance struct {
ID bson.ObjectID
InstanceID string // for cloud: equals the control-plane instance_id
// for self-hosted: the UUID the customer pasted
AccountID string
Name string
Slug string // cloud only; the subdomain label
Deployment string // cloud | self_hosted
Tier string
Status string // awaiting_link | active | lapsed | cancelled
CurrentLicense string // licence ID
RelinkCount int // reset each term
CreatedAt time.Time
}
type License struct {
ID bson.ObjectID
LicenseID string
InstanceID string
AccountID string
Tier string
Deployment string
Limits license.Limits // snapshot
Features []string // snapshot
IssuedAt time.Time
ExpiresAt time.Time
Blob string
SupersededBy string // licence ID, when replaced
IssuedBy string // staff user, "system", or "paddle:<event id>"
Reason string // new | renewal | tier_change | relink | manual
}
type Subscription struct {
ID bson.ObjectID
SubscriptionID string
AccountID string
InstanceID string
PaddleSubscriptionID string
PaddlePriceID string
Tier string
Term string // monthly | annual
Status string // active | past_due | cancelled | awaiting_link
CurrentPeriodEnd time.Time
}
type Plan struct {
Tier string
Name string
Deployment string
Limits license.Limits
Features []string
PaddleProductID string
PaddlePriceIDs map[string]string // "monthly" | "annual"
Active bool
}
```
Collections: `accounts`, `admin_instances`, `licenses`, `subscriptions`,
`plans`, `staff_users`, `customer_users`, `admin_audit`.
Unique indexes: `accounts.account_id`, `admin_instances.instance_id`,
`licenses.license_id`, `subscriptions.paddle_subscription_id`, `plans.tier`,
`staff_users.email`, `customer_users.email`.
`admin_instances.instance_id` unique is load-bearing: it is what stops the same
self-hosted UUID being linked to two accounts.
**Licences are append-only.** A renewal writes a new row and sets
`SupersededBy` on the old one. Nothing is ever edited or deleted. When a support
question arrives about why a customer's instance stopped working on a given
date, the answer is in the table.
`plans` holds tier contents so they change without a deploy, seeded from the
table in spec 1. Every issued licence snapshots the plan, so editing a plan never
changes an existing licence — the same rule as `workflow_runs.steps_snapshot`.
### Issuance
```go
func Issue(ctx, instanceID, tier, term, reason, issuedBy string) (*models.License, error)
```
1. Load the instance and its account.
2. Load the plan for `tier`; refuse if `plan.Deployment != instance.Deployment`.
**This is the check that makes Free cloud-only** — Free's plan is
`deployment: cloud`, so it can never be issued to a self-hosted instance.
3. Build the payload with the instance's UUID bound in, `ExpiresAt` from the term
plus a **3-day grace** so a renewal webhook arriving slightly late does not
create a gap.
4. Sign with `LICENSE_SIGNING_KEY`.
5. Insert the licence row; set `SupersededBy` on the previous one; update
`instance.CurrentLicense` and `instance.Tier`.
6. If cloud, inject. If self-hosted, email the blob and make it downloadable.
7. Write an `admin_audit` entry.
Steps 5 and 6 are not transactional. Order matters: **record first, deliver
second.** A licence recorded but not delivered is recoverable — the customer
downloads it. A licence delivered but not recorded is a support mystery.
### Free tier rule
One Free instance per account, enforced in `Issue`: refuse a second Free instance
for an account that already has one that is not `cancelled`. Additional
instances must be paid.
### Injection
```go
func InjectCloud(ctx, instanceID string, lic *models.License) error
```
Writes `license_blob`, `license_tier`, `license_expiry` onto the control-plane
`instances` document via a single `UpdateOne`. Idempotent, retryable, and safe to
re-run.
Retries three times with backoff; on final failure the licence stays recorded and
`instance.Status` is set to `active` regardless, with the failure logged and
surfaced as a staff alert. A **reconciliation job runs every 15 minutes**,
comparing each cloud instance's `CurrentLicense` against the blob actually stored
in the control plane, and re-injecting on mismatch. That job, not the webhook, is
what guarantees eventual consistency.
The control-plane instance caches licence state for 60 seconds (spec 2), so an
injection takes effect within a minute without a restart.
### Self-hosted linking
The flow, end to end:
```
Customer runs /setup on their own install → instance UUID generated and shown
Customer buys Self Hosted in the admin site → subscription created,
status awaiting_link
Customer pastes the UUID into the admin site → admin_instances row created,
status active
Admin issues the licence with that UUID bound in
Customer downloads the .lic file or copies the blob
Customer pastes it into /settings/license on their install
```
Validation on link: the UUID must parse as a UUID, must not already exist in
`admin_instances`, and must not collide with a cloud instance ID. A duplicate
returns "That instance ID is already linked to an account" without revealing
which — it is a small enumeration surface but there is no reason to leave it
open.
### Relink
A rebuilt server has a new UUID. `POST /api/instances/:id/relink` with the new
UUID:
- Allowed **3 times per term**, `RelinkCount` reset on renewal.
- Updates `admin_instances.instance_id`, issues a replacement licence for the
**remaining term** with `reason: relink`, supersedes the old one.
- The old licence is not revoked — it cannot be, offline verification has no
revocation. It simply no longer matches any UUID the customer controls, and its
binding stops it being useful on a different machine anyway.
- Beyond 3, the endpoint returns a message directing the customer to support, and
staff can relink without limit.
`RelinkCount` is the abuse signal, not the abuse prevention. Its real job is to
put a human in front of the fourth attempt.
### Authentication
Three identities, three paths, one session store (Redis, `admin_session`
cookie, 24h).
**Staff** — `staff_users`, local email plus bcrypt. Full access. Created by CLI
only; there is no staff signup.
**Cloud customers** — authenticate against the control plane's `users`
collection with the credentials they already use. Admin looks the user up
by email, checks bcrypt, resolves their control-plane instance, then resolves the
account that owns it.
Two consequences, stated plainly because they are real:
1. A cloud user's control-plane password now also unlocks billing. Any password
change or compromise has a wider blast radius than before.
2. Only users with control-plane role `owner` may sign in to the admin site.
`admin` and `member` are refused. Billing is an owner concern.
Mitigations: rate-limit to 5 attempts per email per 15 minutes and 20 per IP per
hour; log every attempt to `admin_audit`; return an identical error for unknown
email and wrong password.
**Self-hosted customers** — `customer_users`, local email plus bcrypt at cost 12,
created during purchase, scoped to one account. Email verification reuses the
pattern sitesvc already proved: 32 random bytes, only the SHA-256 hash stored,
24-hour expiry, TTL index.
A single email address could in principle be both a cloud user and a
self-hosted customer user. `customer_users` is checked first; if it matches, that
identity wins. Documented so the behaviour is chosen rather than emergent.
### API
Staff:
```
GET /api/staff/accounts list, search
POST /api/staff/accounts
GET /api/staff/accounts/:id
GET /api/staff/instances filter by account, deployment, status, expiry
POST /api/staff/instances/:id/issue manual issue or reissue
POST /api/staff/instances/:id/relink no limit
GET /api/staff/licenses full history, filterable
GET /api/staff/plans
PUT /api/staff/plans/:tier
GET /api/staff/audit
GET /api/staff/health/injection reconciliation status and failures
```
Customer:
```
GET /api/account own account and instances
POST /api/instances/link self-hosted UUID link
POST /api/instances/:id/relink rate-limited
GET /api/instances/:id/license current licence metadata
GET /api/instances/:id/license/download .lic file
GET /api/subscriptions status, next renewal
POST /api/billing/portal Paddle portal redirect (spec 5)
```
Every customer handler resolves the account from the session and scopes by it.
The scoping is enforced by a helper every handler calls, not by each handler
remembering — the same deny-by-default reasoning as spec 2's middleware.
### Configuration
| Variable | Required | Notes |
|---|---|---|
| `ADMIN_MONGO_URI` | yes | admin's own database; name read from the URI path, refused if absent |
| `CONTROL_MONGO_URI` | yes | control-plane database, for injection and cloud auth |
| `REDIS_ADDR` | yes | sessions |
| `LICENSE_SIGNING_KEY` | yes | ECDSA P-384 private key, base32 (lk PrivateKey.ToB32String). **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy |
| `PUBLIC_URL` | yes | for verification and licence links |
| `SMTP_*` | yes | licence delivery |
| `ADMIN_ORIGIN` | yes | CORS allow-list |
| `TRUST_PROXY` | no | only behind a proxy that overwrites `X-Forwarded-For` |
| Paddle variables | spec 5 | |
### Backfill
Licences issued by `lkctl` during the spec 12 period exist only as blobs.
A one-shot `admin backfill --from=blobs.json` parses each with
`license.Parse`, creates the account, instance and licence rows, and marks them
`reason: manual`. Run once when admin goes live.
## Testing
**Issuance:**
1. `Issue` produces a licence that `license.Verify` accepts for that instance.
2. Deployment mismatch (Free plan, self-hosted instance) is refused.
3. A second Free instance for the same account is refused; a third paid one is
allowed.
4. Renewal supersedes the previous licence and leaves it in the table.
5. The issued licence snapshots the plan; editing the plan afterwards does not
change the issued licence.
6. Grace period: `ExpiresAt` is term end plus 3 days.
**Injection:**
7. `InjectCloud` writes all three fields; the control plane then reports `valid`.
8. Injection is idempotent across two calls.
9. Injection failure leaves the licence recorded and flags the instance.
10. The reconciliation job detects a control-plane blob that does not match
`CurrentLicense` and re-injects.
**Linking and relink:**
11. Linking an unknown UUID succeeds; linking one already linked is refused.
12. Relink issues a licence for the *remaining* term, not a fresh full term.
13. The fourth relink in a term is refused for a customer and allowed for staff.
14. `RelinkCount` resets on renewal.
**Auth:**
15. Cloud owner signs in with control-plane credentials; `admin` and `member`
roles are refused.
16. Unknown email and wrong password return identical errors and timing is not a
meaningful oracle.
17. Rate limits trigger at the documented thresholds.
18. Self-hosted customer cannot sign in before verifying their email.
19. A customer requesting another account's instance gets `404`, not `403`
no existence disclosure.
**Scoping:**
20. Every customer endpoint, called with a session for account A against a
resource of account B, returns `404`. Written as a table-driven test over the
route list so a new endpoint that forgets to scope fails the build.
## Verification before merge
1. Full suite green, including the scoping table test (test 20).
2. End to end, cloud: create account → create instance → issue Professional →
confirm the control-plane instance reports `valid` within 60 seconds with no
restart.
3. End to end, self-hosted: run `/setup` on a scratch install, copy the UUID,
link it, issue, download, paste, confirm `valid`.
4. Confirm admin's control-plane credential cannot write to `servers`, `keys` or
any collection other than `instances`.
5. Kill the admin service and confirm every Vantage instance keeps working
entirely normally.
## Risks
| Risk | Mitigation |
|---|---|
| Admin becomes a runtime dependency | Verification step 5; instances verify offline and never call admin |
| Signing key exposure | Single service, single variable, never in an image; rotation path from spec 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, audited, and stated in the release notes |
| Injection silently fails | Reconciliation every 15 minutes plus a staff health endpoint |
| Admin writes outside its remit in the control plane | Narrow code path; scoped Mongo credential; reviewed on every change |
| Self-hosted UUID squatted by another account | Unique index plus a non-disclosing error |
@@ -1,203 +0,0 @@
# Spec 4 — Admin Site
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 3 (`admin-backend`)
Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API
is stable.
## Context
A fifth Next.js app, `adminsite/`, serving two audiences from one codebase:
- **Staff** — internal operators. Accounts, instances, licence history, plan
editing, injection health, audit.
- **Customers** — their own account, instances, licences and subscription state.
They share auth plumbing and a component library but almost no screens. The
split is by route group, so a customer route can never accidentally render a
staff view.
## Goals
1. A customer can buy, link a self-hosted instance, download a licence, and see
when it expires — without contacting anyone.
2. Staff can answer "why did this customer's instance stop working" in one screen.
3. Nothing about the marketing site or the control-plane UI changes.
## Non-goals
- Rebuilding billing management. Card details, invoices, payment methods and
cancellation all deep-link into Paddle's customer portal.
- Server management. This is not a second control plane; there is exactly one
link out to the instance and no data about servers, keys or workflows.
- Public signup for cloud. That stays on the marketing site (moving to admin's
backend in spec 5, but the *form* stays where customers already find it).
## Design
### App
Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind
3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`,
published as `3002`. In `docker-compose.site.yml` only.
`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be
**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting
this wrong is the single most common deployment failure in this repo's history —
`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app
renders an explicit "not connected" state rather than failing silently.
```
adminsite/
├── app/
│ ├── login/
│ ├── signup/ # self-hosted customer account creation
│ ├── verify/
│ ├── (customer)/
│ │ ├── page.tsx # account overview
│ │ ├── instances/[id]/
│ │ ├── instances/link/
│ │ ├── billing/
│ │ └── layout.tsx # customer nav, account guard
│ └── (staff)/staff/
│ ├── page.tsx # operations dashboard
│ ├── accounts/[id]/
│ ├── instances/[id]/
│ ├── licenses/
│ ├── plans/
│ └── layout.tsx # staff nav, staff guard
├── components/
└── lib/
```
Route-group layouts do the guarding. A customer session hitting `/staff/*` gets
redirected, not a 403 page — there is nothing to tell them about.
### Customer screens
**Overview** — the account, its instances as cards. Each card: name, cloud or
self-hosted, tier, licence state, expiry with days remaining, and a link either
to the instance's subdomain (cloud) or to its licence page (self-hosted).
Licence state is colour-coded and blunt: green valid, amber under 14 days, red
expired. An expired card says what still works — "servers and monitors are still
running; changes are disabled" — because that is the first thing a worried
customer wants to know.
**Instance detail** — tier, limits, features, subscription status, next renewal
date. For self-hosted: the linked UUID, a **Download licence** button, the blob
in a copy-to-clipboard box, and step-by-step paste instructions with the target
route named (`Settings → Licence` on their own install). A **Relink** action
showing the remaining allowance ("2 of 3 relinks remaining this term").
**Link an instance** — the self-hosted activation screen. Explains where to find
the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the
paste, validates the format client-side, and on success issues the licence and
lands the customer directly on the download.
The whole flow — buy, link, download, paste — should be completable without
reading documentation. That is the bar for this screen.
**Billing** — subscription list with status and renewal date, plus a button to
Paddle's portal. Deliberately thin.
### Staff screens
**Dashboard** — the operational answers, not vanity metrics: licences expiring
in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more
than 48 hours, and **failed injections** from the reconciliation job. Each row
links straight to the thing that needs doing.
**Accounts** — searchable by name, email, Paddle customer ID and instance UUID.
Searching by UUID matters: a support email arrives containing a UUID and nothing
else.
**Account detail** — instances, subscriptions, customer users, audit trail.
**Instance detail** — everything about one instance, with the **full licence
history as a timeline**: issued, superseded, renewed, relinked, each with a
timestamp, reason and who did it. This is the screen that answers "why did this
stop working on the 14th". Actions: issue, reissue, relink without limit, and a
live view of the control-plane injection state for cloud instances.
**Licences** — global history, filterable by tier, deployment, expiry window and
issuance reason.
**Plans** — edit limits and features per tier. Two guard rails, because this
screen changes what every future customer gets:
- A confirmation step naming exactly what changes and stating that existing
licences are unaffected until reissued.
- The deployment field is not editable. Moving Free to `self_hosted` would break
the cloud-only rule that spec 1 leans on; changing it is a code review, not a
form field.
**Audit** — every mutating action, filterable.
### Design language
Visually distinct from `web/`. Staff regularly have both open, and a moment of
"which app am I in" before clicking Reissue is worth designing out. Different
accent colour and a persistent environment badge in the header (sandbox or
production, from a build-time flag) — clicking Issue against the wrong Paddle
environment should be hard.
Shared component patterns with `web/` where they exist; this is not a reason to
invent a second design system.
### Error and empty states
- Backend unreachable: a page-level "not connected" state naming
`ADMIN_API_URL`, matching the pattern the marketing site already uses.
- No instances yet: a customer-facing explanation of the two paths — buy cloud,
or buy self-hosted and link.
- `awaiting_link`: a prominent prompt on the overview, since a customer who has
paid and not linked is a customer who has paid for nothing yet.
- Licence download failure: show the blob inline as a fallback so the customer is
never blocked by a file download.
## Testing
Component and integration tests with mocked API responses. The repo has no
frontend test setup today; this is where one starts, scoped to the flows that
lose money or leak data when broken.
1. Customer session on `/staff/*` redirects; staff session reaches it.
2. Instance card renders correctly for each licence state, including expired,
and the expired copy names what still works.
3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught
client-side; already-linked UUID surfaces the backend's message.
4. Relink shows the remaining allowance and disables at zero with the support
message.
5. Licence download failure falls back to the inline blob.
6. Not-connected state renders when the API is unreachable.
7. Staff dashboard renders each alert category and links to the right resource.
8. Plan edit requires confirmation and shows the "existing licences unaffected"
wording.
9. Instance search by UUID returns the instance.
10. Licence history timeline renders every reason type in order.
## Verification before merge
1. Test suite green.
2. Full manual pass, self-hosted purchase to working licence, using only the UI
and no documentation — timed, and if it takes more than five minutes the flow
needs work.
3. Full manual pass, cloud: buy, confirm the licence appears in the control plane
within a minute, confirm the instance's own settings page agrees.
4. Staff pass: find an account by instance UUID, read its licence history,
reissue, confirm the control plane picks it up.
5. Responsive check at mobile width — a customer hit by an expiry email will open
this on a phone.
6. `docker build` from the repo root succeeds and the image runs with
`ADMIN_API_URL` baked in.
## Risks
| Risk | Mitigation |
|---|---|
| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun |
| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions |
| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification |
| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this |
@@ -1,353 +0,0 @@
# Spec 2 — Instance Licensing and Enforcement
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
Ships: independently, with licenses issued by hand via `lkctl`. No admin site
needed.
## Context
Spec 1 defines what a license is. This spec makes the control plane hold one,
act on it, and let a self-hosted operator paste one in.
The guiding rule: **an expired license must never break a running fleet.** Agents
keep their keys, monitors keep watching, alerts keep firing. What stops is
growth and change. A customer whose card fails should be inconvenienced, not
paged at 3am because their monitoring went dark when Vantage decided to sulk.
## Goals
1. A license lives on the instance document and is verified on read.
2. Enforcement is deny-by-default: a new mutating route is gated because of where
it is mounted, not because someone remembered.
3. Degraded mode is obvious in the UI and reversible by pasting a valid license.
4. Self-hosted operators get an instance UUID they can hand to the admin site.
## Non-goals
- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3).
- Any outbound network call. Verification is offline, permanently.
- Per-user or per-role licensing. The unit is the instance.
## Design
### Instance identity
Every install already has an `Instance` document with an `InstanceID` UUID. For
cloud instances this is created by signup; for self-hosted it is created by
`/setup`.
Change to `/setup`: after bootstrapping the first instance and its owner, the
setup page **displays the instance UUID** with a copy button and the text that
it is needed to activate a license. It is also shown permanently on
`/settings/license`.
No new identifier is invented. The instance UUID is the licensing identity.
### Storage
`shared/models.Instance` gains:
```go
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
```
The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised
cache for listing and for the admin site's queries, rewritten from the verified
payload every time a blob is accepted. Nothing reads them for enforcement.
`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense —
it is signed public data — but there is no reason to spray it through API
responses.
### Runtime state
```go
type State struct {
Status license.State // valid | expired | invalid
Reason string
Tier string
ExpiresAt *time.Time
Limits license.Limits
Features map[string]bool
}
```
Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds
alongside the existing instance cache and invalidated immediately when a blob is
stored.
Three inputs, in precedence order:
1. `Instance.LicenseBlob`.
2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no
stored blob**. This lets an automated self-hosted deployment ship a license
without a human pasting one. A blob stored through the UI always wins
afterwards, so an operator is never locked out by a stale environment value.
3. Neither → `Status: invalid`, `Reason: no_license`.
The verifier is called with `InstanceID` from the instance document and
`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure,
`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters:
an operator who removes the variable gets the stricter mode, not the looser one.
`invalid` and `expired` degrade identically. They differ only in the message.
### Enforcement
Three layers, deliberately separate because they answer different questions.
**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on
the `/api` group, applying to every request whose method is not `GET` or `HEAD`.
```go
api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense())
```
Non-`valid``403 {"error":"license_required","state":"expired","reason":"..."}`.
Mounting at the group means **a route added tomorrow is gated by default**. That
is the whole point of putting it here rather than on individual handlers.
Explicit exemptions, allow-listed by path because they must work in degraded
mode:
| Route | Why |
|---|---|
| `POST /api/license` | Pasting a valid license is how you recover |
| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity |
| `DELETE` on any resource | Deleting is how you get back under a limit |
| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled |
The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10
servers must be able to remove 7 of them. Blocking deletes would trap them.
**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need
it:
- `console``POST /api/console/connect`, `GET /api/console/tunnel`
- `oidc``GET,PUT /api/instance/oidc`
Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`.
OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated
and outside `/api`. They check the feature directly and, if unavailable, redirect
to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC
sessions are not terminated** — losing the feature stops new SSO logins, it does
not evict people mid-session.
**Layer 3 — limits.** Enforced in the service layer, because a limit needs a
count that middleware does not have:
| Limit | Checked in |
|---|---|
| `max_servers` | `services.CreateServer` / `POST /api/servers/new` |
| `max_secret_groups` | `services.CreateSecretGroup` |
| `max_channels` | `services.CreateChannel` |
`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.
Counts are of live rows: revoked assignments and deleted servers do not count.
**Over-limit instances are never truncated.** A Professional instance with 20
servers that lapses to Free keeps all 20 running; it simply cannot add a 21st.
Deleting resources is always permitted. Silently disabling a customer's servers
because their card expired is not a behaviour this system will have.
### Background work in degraded mode
This is where "read-only" needs to be specific, because these paths do not go
through gin at all.
| Subsystem | Degraded behaviour |
|---|---|
| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. |
| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). |
| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. |
| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. |
| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. |
| Inventory, heartbeat, update reporting | Unaffected. |
| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. |
| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. |
| Log retention sweep, offline sweep | Unaffected. |
Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It
is the single most important line in this spec: **billing state must not take
away a customer's ability to know their infrastructure is on fire.**
### API
```
GET /api/license any authenticated user
POST /api/license owner only
```
`GET` returns:
```json
{
"instance_id": "…",
"state": "valid",
"reason": "",
"tier": "professional",
"expires_at": "2027-07-24T00:00:00Z",
"days_remaining": 365,
"limits": { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 },
"features": { "console": true, "oidc": true },
"usage": { "servers": 12, "secret_groups": 4, "channels": 2 },
"source": "stored"
}
```
`usage` is included so the UI can render "12 of 3 servers" honestly when an
instance is over its limit, rather than pretending.
`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and
deployment mode, and on success stores the blob, refreshes the cache, and writes
an audit event. On failure it returns `400` with the specific reason:
| Reason | Message |
|---|---|
| `bad_signature` | This licence key is not valid. Check it was copied in full. |
| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. |
| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is `<uuid>`. |
| `expired` | This licence expired on `<date>`. |
An **expired** blob is still stored if it is otherwise valid, so the UI can show
what expired and when. An **invalid** blob is rejected and the previous one kept.
Rate-limited to 10 attempts per instance per hour. There is no oracle here worth
protecting, but an unbounded verify endpoint is an unbounded CPU endpoint.
### Frontend
`useLicense()` hook over `GET /api/license`, cached by TanStack Query and
invalidated after a successful paste.
- **Banner, persistent, top of every page** when `state != valid`:
- `expired` — "Your Vantage licence expired on `<date>`. Your servers and
monitors are still running, but changes are disabled until it is renewed."
with a link to the admin site.
- `invalid` / `no_license` — "This instance has no valid licence. Add one in
Settings → Licence."
- **Warning banner** in the final 14 days of a valid term, dismissible per
session.
- **Gated features render disabled with an upgrade tooltip, not hidden.** A
customer cannot buy what they cannot see, and a feature that vanishes reads as
a bug.
- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3
servers used" with the create button disabled at the cap.
- `/settings/license`: current state, tier, expiry, limits with live usage, the
instance UUID with a copy button, and a textarea plus file upload for a new
blob. Owner-only; other roles see the state read-only.
### Grandfathering existing tenants
Migration `0005_grandfather_licenses`, cloud only, guarded on
`VANTAGE_DEPLOYMENT == "cloud"`:
For every instance with no `license_blob`, issue a Professional license expiring
**one year** from the migration date and store it.
The migration cannot sign — the server has no private key and, per spec 1, no
signing code. So the blobs are **generated ahead of time with `lkctl`** and
supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of
instance ID to blob. The migration stores what it is given, verifies each blob
against its instance before storing, and logs any instance it had no blob for.
Clumsy, and correct. The alternative is putting a signing key in the control
plane, which is the thing this design most wants to avoid.
Self-hosted installs are not grandfathered. On upgrade they land in `no_license`
and read-only until an operator pastes a key — which is the intended behaviour
for a paid product, and is why the release notes must lead with it.
## Testing
**Unit, no database:**
1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`;
environment used when no blob; neither → `no_license`.
2. Feature map construction from the payload's `Features` slice.
3. Limit comparison with `-1`, with zero, and with a count exactly at the cap.
**Middleware, with a stub state:**
4. `GET` passes in every state.
5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when
`invalid` — except `DELETE`, which passes in all states.
6. `POST /api/license` passes when `expired` (the recovery path).
7. `POST /api/servers/:id/apply-updates` passes when `expired`.
8. `RequireFeature("console")` passes with the feature, `403`s without it.
9. **Coverage test:** enumerate every registered route and assert that every
non-`GET` route is either behind `RequireActiveLicense` or on the exemption
allow-list. This test is what stops layer 1 rotting as routes are added.
**Service layer, against MongoDB:**
10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds.
11. Over-limit instance can still `DELETE` a server, and can create again once
back under the cap.
12. Deleted and revoked rows do not count toward limits.
**Degraded background behaviour:**
13. Monitor scheduler executes checks for an instance with an expired license.
14. An incident opened during degraded mode still dispatches notifications.
15. `SyncKeys` for an expired instance returns the same key set as before expiry.
16. A new agent registering against an over-limit instance is refused; an
existing agent re-registers successfully.
17. A workflow run in flight when the license expires completes its remaining
steps.
**API:**
18. `POST /api/license` with a valid blob stores it and flips state to `valid`.
19. Each rejection reason returns its own message and leaves the stored blob
untouched.
20. An expired-but-well-formed blob is stored and reported as `expired`.
21. Non-owner `POST``403`.
**Migration:**
22. `0005` stores and verifies supplied blobs, skips instances that already have
one, logs instances with no blob supplied, and is a no-op when
`VANTAGE_DEPLOYMENT != "cloud"`.
## Verification before merge
1. Full test suite green, including the route-coverage test (test 9).
2. Manual pass on a scratch instance: issue a Professional license with `lkctl`,
paste it, confirm everything works. Issue one expiring in 60 seconds, wait,
confirm the banner appears, mutations `403`, **monitors keep firing**, and
pasting a fresh license restores normal operation without a restart.
3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC
are visibly disabled with upgrade tooltips, and that a 4th server is refused
with a clear message.
4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install
with `deployment_mismatch`.
5. Confirm a license issued for another instance is rejected with
`instance_mismatch` and the message shows the correct local UUID.
## Rollout
1. Generate grandfather blobs with `lkctl` for every existing cloud instance.
2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs.
3. Verify every cloud instance reports `valid`, Professional, one year out.
4. Unset the variable on the next deploy — it is single-use.
5. Release notes for self-hosted must state plainly that upgrading requires a
licence key, and how to get one.
## Risks
| Risk | Mitigation |
|---|---|
| A mutating route added later without a gate | Route-coverage test (test 9) fails the build |
| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page |
| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state |
@@ -1,240 +0,0 @@
# Spec 0b — Org to Instance Rename
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`)
Ships: independently, before any licensing code
## Context
The licensing model separates two concepts that the codebase currently conflates
under one word:
- **Account** — a paying customer. Lives only in the admin control plane
(spec 3). The control plane never learns about it.
- **Instance** — one deployment of Vantage: its own subdomain, its own users,
its own servers, keys, workflows, monitors and secrets. One license attaches
to one instance.
Today's control-plane `Org` **is** an Instance. An Account may hold several,
some cloud and some self-hosted, and the self-hosted ones have no row in the
cloud database at all.
Keeping the name `Org` would leave the control plane using a word that means
something different in the admin site, in Paddle, and in every support
conversation. This spec renames it everywhere, including on disk.
This is the highest-risk change in the programme: `org_id` is the tenant
isolation key on every document in every collection. It is done alone, before
anything else, so that nothing else is in flight when it deploys.
## Goals
1. `Instance` is the only word for a tenant, in code, API, UI and database.
2. No document is lost and no tenant scoping is weakened.
3. The migration is reversible.
## Non-goals
- Any behaviour change. Same routes' semantics, same permissions, same data.
- Introducing Accounts. The control plane never gets them.
- Touching the agent. It talks gRPC and has no concept of a tenant.
## Design
### Naming map
| Today | After |
|---|---|
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` (all collections) | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` |
| UI copy "Organisation" | "Instance" |
Reserved slugs gain no new entries here, but note `admin` is already reserved,
which the admin site relies on later.
### Collections carrying `org_id`
All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`,
`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`,
`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`,
`audit_logs`, plus `orgs` itself. `migrations` does not carry one.
`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes
`instance_name` for consistency; it is sitesvc-private so this is free.
The migration must derive this list from a constant in code, not from a
hand-written list in a runbook, so that a collection added between design and
deploy is not silently missed:
```go
var scopedCollections = []string{ /* the list above */ }
```
A boot-time assertion (spec 2 onwards) checks that no collection outside this
list contains an `org_id` field. Cheap insurance against a future collection
being added without being renamed.
### Migration `0004_org_to_instance`
Recorded in `migrations` like the existing three. Runs after
`0003_missed_org_scopes`.
**The migration only renames. It never deletes and never drops.** A bad deploy
is recovered by running the inverse rename, not by restoring a backup.
Steps, in order:
1. **Guard.** If collection `instances` already exists and `orgs` does not, the
migration has already run against this database by an earlier binary; record
the marker and return. Idempotency matters because the marker write and the
data work are not in one transaction.
2. **Rename collections.** `orgs``instances`, `org_oidc``instance_oidc`,
via `adminCommand{renameCollection}`. Fails loudly if the target exists.
3. **Rename the field.** For each collection in `scopedCollections`:
`UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`.
Record `matched` and `modified` per collection in the log.
4. **Verify.** For each collection, assert
`CountDocuments({org_id: {$exists: true}}) == 0` and
`CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch
aborts before the marker is written, leaving the migration to retry.
5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec:
unique `settings.instance_id`, the ESO token-hash index, and any compound
scoping indexes. Unique `instances.slug` and `users.email` are unaffected by
the field rename but are re-declared idempotently.
6. **Write the marker.**
Steps 24 are not atomic across collections. Mongo multi-document transactions
would require a replica set, which is not guaranteed for self-hosted installs.
Instead the migration is written to be **safely re-runnable**: `$rename` on a
document that has already been renamed matches nothing, and the collection
rename is guarded in step 1.
Rollback, if ever needed, is the same code with the rename reversed, shipped as
a one-shot command rather than a migration — deliberately manual, because the
only reason to run it is a decision to revert the release.
### Version skew
`sitesvc` and `server` write the same documents. A skew where one writes
`org_id` and the other reads `instance_id` creates tenants that are invisible to
the application — the exact failure `CLAUDE.md` warns about.
After spec 0a both read the shape from `shared`, so the skew window is a
deployment-ordering problem rather than a code-drift problem:
- Both images are built from the same commit and deployed together.
- The migration runs from the `server` container at boot, as the existing three
do.
- `sitesvc` at boot asserts that collection `instances` exists and refuses to
start otherwise, with the message
`instances collection not found; deploy the control plane first`. Failing to
start is strictly better than provisioning into a collection nobody reads.
The self-hosted deployment runs no sitesvc, so it sees only the server change.
### API and frontend
REST route renames are **breaking**, but every consumer is first-party (`web/`)
and ships in the same release. No compatibility aliases — a permanent dual path
in the tenant-scoping layer is worse than a coordinated release.
`web/` changes: the API client's paths, the `useMe` shape, all UI copy from
"Organisation" to "Instance", and the settings route `/settings/org`
`/settings/instance`.
`site/` marketing copy changes where it says "organisation" about a tenant. Where
it means the customer, it becomes "account" — that word now has a specific
meaning and the marketing site is the first place a customer meets it.
## Testing
**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a.
This is the change where that costs the most: it moves the tenant isolation key
across 17 collections, and a mistake orphans a customer's entire fleet rather
than breaking a build. The compensating controls are therefore not optional, and
the implementation plan makes each a mandatory step:
1. **Dry run against a restored copy** before the code is even committed —
migrate a `mongorestore`d duplicate of production and read the per-collection
rename counts.
2. **Idempotency by hand** — run the dry run twice; the second must complete
with no error and nothing left to rename.
3. **Interrupted-run recovery by hand** — rename `orgs` manually, then run the
migration; it must complete and leave every document carrying `instance_id`.
4. **Count comparison against a production snapshot** — record every
collection's document count before and after; any difference stops the
release.
5. **Per-tenant isolation comparison** — for three real tenants, count rows in
`servers`, `keys`, `workflows`, `monitors`, `secrets` and `audit_logs` by
`org_id` before and by `instance_id` after. Identical, or the release stops.
This is the check that proves tenant isolation survived.
6. **Stale-field sweep** — assert no collection anywhere still holds an
`org_id`.
7. **Rollback rehearsal** — migrate a third copy, run `rename-rollback`, confirm
the counts return to baseline and the pre-release binary boots against it.
Deploying without having done this is not permitted.
8. **Boot guard, both directions** — sitesvc must refuse an unmigrated database
and start normally against a migrated one.
`AssertNoScopedCollectionMissed` runs at every boot and is fatal. With no test
suite it is the standing protection against a future collection being added
without being added to `ScopedCollections`.
## Verification before merge
Run against a **restored production snapshot**, not a synthetic database:
1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before.
2. Run the migration.
3. Assert every count is identical afterwards.
4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`.
5. Pick three real tenants; run the same scoped query before (by `org_id`) and
after (by `instance_id`) and confirm identical result sets. This is the test
that proves tenant isolation survived.
6. Boot the server against the migrated snapshot; log in as a real user; confirm
servers, keys, workflows, monitors and secrets all list correctly.
7. Boot sitesvc against the migrated snapshot; complete a signup end to end.
8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start
with the expected message.
## Rollout
1. Take a database backup. Not optional — this is the one change where the
inverse rename is the recovery path and the backup is the second.
2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together.
3. Server boots, migration runs, marker recorded.
4. Watch for the sitesvc guard message; if it appears, sitesvc started first and
will restart cleanly.
Expect a short window during the server restart where the API is unavailable.
Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID.
## Risks
| Risk | Mitigation |
|---|---|
| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable |
| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion |
| sitesvc deployed before server | Boot guard refuses to start |
| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff |
| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change |
| Frontend missed a renamed route | Full manual pass over every route in the UI before release |
## Follow-on
With `Instance` established, spec 1 (`licensing-core`) can define a license
payload that binds to `instance_id` without inventing a word the codebase does
not use.
@@ -1,294 +0,0 @@
# Spec 1 — Licensing Core
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`)
Ships: independently. Adds a package and a CLI; changes no running behaviour.
## Context
Licenses are **offline-verified signed blobs**. A Vantage server checks a
signature and an expiry date and asks nobody's permission. That choice buys
self-hosted installs that work in air-gapped networks and a control plane with no
licensing availability dependency.
It costs revocation. Once issued, a license is valid until it expires, whatever
Paddle later says. Every other decision in the programme follows from accepting
that: Self Hosted is annual-only so the unenforceable window is bounded, and
cancellation takes effect at term end rather than immediately (spec 5).
This spec defines the payload, the signing and verification, and a CLI to issue
licenses by hand. It deliberately lands before the admin site so that specs 1+2
together give working licensing with no new service to operate.
## Goals
1. One struct, in `shared`, read identically by the verifier and the issuer.
2. Verification that needs no network, no clock sync beyond a rough one, and no
configuration.
3. A hand-issuance path good enough to run production on until spec 3 lands.
## Non-goals
- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance
history.
- Deciding tier contents. Tiers are data; the values in this spec are the
initial seed, and spec 3's `plans` table becomes their home.
- Any phone-home, revocation list or online check. There is none, anywhere, by
design.
## Design
### Package
`shared/license/`, inside the module created by spec 0a:
```
shared/license/
├── license.go # License, Limits, feature constants
├── sign.go # Sign, build-tagged out of the server binary
├── verify.go # Verify, Parse
├── keys.go # trustedPublicKeys
└── license_test.go
```
Uses `github.com/hyperboloide/lk` (ECDSA P-384 with SHA-256, base32 encoding).
### Payload
```go
package license
type License struct {
ID string `json:"id"` // uuid, for support and audit
InstanceID string `json:"instance_id"` // the instance this license is bound to
AccountID string `json:"account_id"` // admin-side customer, informational
InstanceName string `json:"instance_name"` // display only
Tier string `json:"tier"` // "free" | "professional" | "self_hosted"
Deployment string `json:"deployment"` // "cloud" | "self_hosted"
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
Limits Limits `json:"limits"`
Features []string `json:"features"`
}
type Limits struct {
MaxServers int `json:"max_servers"` // -1 means unlimited
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
}
const (
FeatureConsole = "console" // browser SSH/RDP/VNC
FeatureOIDC = "oidc" // per-instance single sign-on
)
const (
TierFree = "free"
TierProfessional = "professional"
TierSelfHosted = "self_hosted"
DeploymentCloud = "cloud"
DeploymentSelfHosted = "self_hosted"
)
```
`InstanceID` is **always populated**. There is no unbound license: the
self-hosted purchase flow (spec 4) links the instance UUID before the license is
issued, so binding happens at signing time. This removes the claim endpoint, the
best-effort phone-home and the multi-claim reconciliation that an unbound design
would have needed.
**The server never branches on `Tier`.** It reads `Limits` and `Features` only.
`Tier` exists for display, support and analytics. Adding a tier, or changing what
a tier includes, must never require a server release.
### Tier seed values
Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into
each license at issue, so changing the table never rewrites an issued license —
the same principle as `workflow_runs.steps_snapshot`.
| | Free | Professional | Self Hosted |
|---|---|---|---|
| `deployment` | `cloud` | `cloud` | `self_hosted` |
| `max_servers` | 3 | -1 | -1 |
| `max_secret_groups` | 1 | -1 | -1 |
| `max_channels` | 1 | -1 | -1 |
| `console` | no | yes | yes |
| `oidc` | no | yes | yes |
| billing term | monthly, £0 | monthly or annual | **annual only** |
Free is cloud-only. A self-hosted install can never hold a valid Free license
because Free is only ever signed with `deployment: "cloud"`, and verification
rejects a deployment mismatch. There is no server-side flag to edit.
### Signing
```go
//go:build !noSign
func Sign(l License, privateKeyHex string) (string, error)
```
Marshals to canonical JSON, signs with lk, returns the base32 blob.
`Sign` is excluded from the server binary with a build tag. The server has no
reason to hold signing code and there is no reason to ship it into a customer's
data centre.
The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the
CLI now, the admin backend from spec 3. It is never in the repo, never in an
image, never in the control plane's environment.
### Verification
```go
type VerifyOpts struct {
InstanceID string // required: the verifier's own instance
Deployment string // required: "cloud" or "self_hosted"
Now time.Time // injectable for tests
}
type Result struct {
License License
State State // Valid, Expired, Invalid
Reason string
}
const (
StateValid State = "valid"
StateExpired State = "expired"
StateInvalid State = "invalid"
)
func Verify(blob string, opts VerifyOpts) Result
```
Checks, in order, stopping at the first failure:
1. Blob decodes and the signature verifies against one of `trustedPublicKeys`.
Failure → `Invalid`, reason `bad_signature`.
2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason
`deployment_mismatch`. This is the check that makes Free cloud-only.
3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason
`instance_mismatch`.
4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`.
5. Otherwise `Valid`.
**`Expired` and `Invalid` are distinct states and the caller treats them
differently in messaging** (spec 2), even though both degrade the instance the
same way. A customer whose card failed and a customer who pasted the wrong blob
need different words.
`Parse(blob) (License, error)` verifies the signature only, ignoring binding and
expiry. Used by the admin site to display a license and by support to inspect a
blob a customer has emailed in. Never used for enforcement.
Clock skew: no tolerance is applied. Terms are a month or a year; a server whose
clock is wrong by enough to matter has bigger problems, and a tolerance window is
a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future,
which is the signal that a clock is badly off.
### Key management
```go
// trustedPublicKeys is ordered. Index 0 is the current signing key.
// To rotate: prepend the new key, ship a server release, then reissue.
// Remove a retired key only after every license signed with it has expired.
var trustedPublicKeys = []string{
"<base32 ECDSA P-384 public key>",
}
```
A slice from day one even though it holds one entry, because retrofitting a
single-key verifier into a multi-key one during an incident is not a thing to
plan for.
Public keys are compiled in. They are not configurable, because a configurable
trust root is a licensing bypass: a self-hosted operator could point it at a
keypair they generated.
Key generation is a documented one-off:
```
go run ./shared/license/cmd/lkgen keypair
```
prints a private key (base32) for the vault and a public key (base32) to paste into
`keys.go`. The private key is stored in a password manager and in the admin
service's environment. **If it is lost, no new licenses can be issued for any
existing customer without a server release.** Back it up in two places.
### CLI issuer
`shared/license/cmd/lkctl`, built only for internal use:
```
lkctl keypair
lkctl issue --instance-id=<uuid> --instance-name="Acme" \
--tier=professional --deployment=cloud \
--term=1y [--account-id=<id>] [--out=acme.lic]
lkctl inspect <file-or-blob>
```
`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table
compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an
explicit `--expires=RFC3339`.
This is the production issuance path until spec 3 ships. It is kept afterwards
for support and disaster recovery — if the admin service is down and a customer's
license expires, a blob can still be cut by hand.
Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its
`licenses` table from `inspect` output when it takes over.
## Testing
`shared/license` is pure and needs no database, so this suite is fast and
thorough. Written test-first.
1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload.
2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`.
3. Tampering with intent: re-sign a payload with a *different* keypair →
`Invalid`. This is the test that proves an attacker cannot mint licenses.
4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the
future → `Valid`.
5. Deployment mismatch: a Free (`cloud`) license verified with
`Deployment: "self_hosted"``Invalid`, `deployment_mismatch`.
6. Instance mismatch: correct signature, different `InstanceID``Invalid`,
`instance_mismatch`.
7. Check order: a blob that is both expired *and* instance-mismatched reports
`instance_mismatch`, not `Expired`. Order is part of the contract because the
reason drives the message.
8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed
with a key not in the slice does not.
9. `Parse` returns the payload for an expired and for a mismatched license, and
errors for a bad signature.
10. Unicode and long instance names survive the round trip.
11. Golden blob: a fixture blob checked into the repo, signed with a **test-only**
keypair, must keep verifying. This catches an accidental change to the
canonical JSON encoding, which would silently invalidate every issued
license in the field.
Test 11 matters more than it looks. The encoding is part of the wire format.
## Verification before merge
1. `go test ./shared/license/...` passes, including the golden fixture.
2. `lkctl keypair``lkctl issue``lkctl inspect` round trips at the command
line.
3. `go build -tags noSign ./server/...` succeeds and
`go tool nm` on the resulting binary shows no `license.Sign` symbol.
4. The production keypair is generated, the private half stored in two places,
and the public half committed in `keys.go`.
## Risks
| Risk | Mitigation |
|---|---|
| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist |
| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire |
| Canonical encoding changes | Golden fixture test |
| Signing code shipped to customers | Build tag plus a symbol check in verification |
| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only |
@@ -1,271 +0,0 @@
# Spec 5 — Paddle Billing
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 3 (`admin-backend`)
Ships: after spec 3. Can be developed in parallel with spec 4.
## Context
Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the
customer billing portal. This spec connects Paddle's subscription lifecycle to
the licence issuance functions spec 3 defines, and moves cloud signup off
sitesvc.
The central constraint, restated because every table below follows from it:
**licences are offline-verified, so nothing Paddle says can revoke one early.**
Cancellation takes effect when the licence expires. Self Hosted is annual-only to
bound that window; the alternative — a customer holding a valid key for eleven
months after cancelling a monthly plan — is not acceptable.
## Goals
1. A catalog in Paddle sandbox, promotable to production by configuration alone.
2. Webhooks that issue and renew licences reliably, including under retries and
out-of-order delivery.
3. Cloud signup owned by one service instead of two.
## Non-goals
- Building any part of billing Paddle already provides.
- Usage-based or metered pricing. Tiers are flat.
- Proration logic. Paddle handles money; we react to the resulting subscription
state.
## Design
### Catalog
Three products, created in **sandbox** first. Production is a configuration
change: the same `plans` rows carry different `paddle_product_id` and
`paddle_price_ids`, selected by `PADDLE_ENV`.
| Product | Prices | Notes |
|---|---|---|
| Vantage Free | monthly, £0 | Yes, a real £0 subscription. It gives every account a Paddle customer, a lifecycle, and an upgrade path with no special-case code. |
| Vantage Professional | monthly, annual | Cloud |
| Vantage Self Hosted | **annual only** | No monthly price exists, so the offline-revocation window is at most a year |
**No price ID is ever hard-coded.** They live in `plans.paddle_price_ids` and are
edited through the staff UI. A price change in Paddle is a data edit, not a
deploy.
`custom_data` on every checkout carries `{ account_id, instance_id, tier }`. This
is what lets a webhook route without a lookup table, and it is why the
self-hosted flow creates the instance record *before* checkout completes.
### Checkout
Paddle Checkout, overlay mode, in the admin site.
**Cloud upgrade** — instance exists, `instance_id` in `custom_data`, existing
Paddle customer reused.
**Self-hosted purchase** — the instance does not exist yet. Order:
```
Customer creates an admin-site account (verified email)
Account row created, then an admin_instances row with status awaiting_link
and a generated placeholder instance record
Checkout opened with account_id and that instance row's id in custom_data
subscription.created fires → subscription recorded, status awaiting_link,
NO licence issued
Customer pastes their install's UUID → instance_id set, status active
→ licence issued and delivered
```
The instance row exists before payment so the webhook has something to attach to.
The licence is not issued until the UUID is known, because a licence with no
instance to bind to cannot be signed — spec 1 has no unbound licence.
A customer who pays and never links has a subscription and no licence. Spec 4's
staff dashboard flags `awaiting_link` older than 48 hours, and a reminder email
goes out at 24 hours and 72 hours. This is the most likely place for a paying
customer to get stuck, so it gets active chasing rather than a support queue.
### Webhooks
`POST /api/paddle/webhook`, signature-verified with `PADDLE_WEBHOOK_SECRET`.
An unsigned or badly signed request is rejected `401` and logged — never
processed.
**Idempotency is mandatory.** Paddle retries. Every event ID is recorded in
`paddle_events` with a unique index before processing; a duplicate returns `200`
without acting. `200` on duplicates matters — returning an error would make
Paddle retry a message we have already handled, forever.
| Event | Action |
|---|---|
| `subscription.created` | Record the subscription. Cloud: issue and inject. Self-hosted: leave `awaiting_link`, issue nothing. |
| `subscription.updated` | Tier or term changed: issue a replacement licence at the new tier, supersede the old. Cloud injects; self-hosted emails a new blob and flags the site. Reflects Paddle's resulting state; no proration maths here. |
| `subscription.canceled` | Mark `cancelled`. **No licence action.** The current licence runs to expiry, then the instance degrades per spec 2. |
| `subscription.past_due` | Mark `past_due`, notify the customer, flag for staff. Licence untouched. Dunning is Paddle's job; ours is not to punish a retryable card failure. |
| `transaction.completed` where the transaction is a subscription renewal | Issue the next term's licence, supersede, inject or email. Reset `RelinkCount`. |
| `transaction.payment_failed` | Record for staff visibility. No licence action. |
| `customer.updated` | Sync `billing_email` onto the account. |
Out-of-order delivery is handled by making every handler a function of the
subscription's *current* state as reported in the event payload, rather than of
the transition. An `updated` arriving before its `created` creates the
subscription row and proceeds.
Renewal licences are issued with a **3-day grace** past the period end (spec 3),
so a webhook delayed by hours never produces a gap in coverage.
**Webhook failures must be visible.** Every failed handler writes to
`admin_audit` and appears on the staff dashboard. A licence that silently failed
to issue is a customer who paid and got nothing.
### Cancellation, stated plainly
When a customer cancels:
- Paddle stops billing at period end.
- We issue no further licences.
- Their current licence keeps working until it expires — up to a month for
Professional monthly, up to a year for Self Hosted.
- On expiry the instance degrades per spec 2: monitors keep running, changes stop.
This is documented in the terms and shown on the cancellation confirmation
screen, because a customer who cancels and sees their instance keep working
should understand why rather than assume the cancellation failed.
### Signup migration off sitesvc
Cloud signup currently lives in sitesvc: `site_pending_signups`, a verification
email, and provisioning on link click. It now needs to also create an Account, a
Paddle customer, a Free subscription and a licence.
**Signup moves to the admin backend.** The form stays on the marketing site where
customers find it, but it posts to admin instead of sitesvc. sitesvc keeps the
contact form only.
The reason is the one `CLAUDE.md` already names: provisioning logic duplicated
across services drifts. Spec 0a removed the second copy; adding signup to admin
while leaving it in sitesvc would create a third.
New flow, preserving every property of the current one:
```
Marketing site form → POST /api/signup on admin
→ pending record, password bcrypt cost 12, token 32 random bytes,
only the SHA-256 hash stored, 24h expiry, TTL index
→ verification email
Link opened → FindOneAndDelete the pending record (atomic, before provisioning)
→ shared.CreateInstance + shared.CreateUser in the control plane
→ Account created
→ Paddle customer created, Free subscription created
→ Free licence issued and injected
→ redirect to APP_LOGIN_URL with {slug} filled in
```
Properties that must survive, verified by test:
- Nothing written to `instances` or `users` until the link is opened.
- `FindOneAndDelete` before provisioning, so a double-clicked link cannot create
two instances.
- Instance rollback if the owner insert fails, refusing to delete an instance
that has users.
- Re-submitting for the same address replaces the pending record.
- Rate limited to 3 signups per IP per hour, plus the honeypot field.
Two failure modes are new, because provisioning now spans two systems:
- **Paddle customer creation fails** — the instance and user are already created.
Complete the signup, record the account with an empty `PaddleCustomerID`, issue
the Free licence anyway, and flag for staff. A new customer must never be
blocked from signing in by a billing-system hiccup.
- **Licence issuance fails** — the instance exists with no licence and is
read-only. Flagged for staff, and the 15-minute reconciliation job (spec 3)
retries. The customer can log in and sees the licence banner.
Both resolve toward "the customer gets in", because a signup that half-fails
silently is worse than either outcome.
sitesvc changes: signup, verify, `site_pending_signups` and the provisioning
calls are deleted. `SITE_API_URL` gains a sibling for the admin endpoint, or the
marketing site posts signup to `ADMIN_API_URL` directly — the latter, so the two
form targets are explicit rather than implied.
### Configuration
| Variable | Required | Notes |
|---|---|---|
| `PADDLE_ENV` | yes | `sandbox` or `production`; selects which price IDs the plans table serves |
| `PADDLE_API_KEY` | yes | server-side API |
| `PADDLE_CLIENT_TOKEN` | yes | browser checkout; baked into the admin site build |
| `PADDLE_WEBHOOK_SECRET` | yes | signature verification. Boot fails without it — an unverified webhook endpoint is an endpoint anyone can issue licences through |
| `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template |
### Cutover
Signup migration is the only user-visible switch:
1. Deploy admin with signup enabled; sitesvc still serving its own.
2. Point the marketing site's form at admin. Deploy.
3. Let sitesvc's outstanding pending signups expire naturally — 24 hours — while
its verify endpoint stays live. **Do not delete the collection until it is
empty**, or someone's verification link breaks.
4. Deploy sitesvc with signup removed.
## Testing
**Webhooks:**
1. Each event type produces its documented action against a mock Paddle payload.
2. Replaying an event ID is a no-op returning `200`.
3. A bad signature is rejected `401` and processes nothing.
4. `subscription.updated` before `subscription.created` creates the subscription
and applies the update.
5. `subscription.canceled` issues nothing and leaves the current licence intact.
6. `past_due` leaves the licence intact and flags the account.
7. Renewal issues the next term, supersedes, resets `RelinkCount`, and the new
`ExpiresAt` is period end plus 3 days.
8. A handler failure writes to `admin_audit` and surfaces on the dashboard.
**Checkout:**
9. `custom_data` round-trips account, instance and tier through to the webhook.
10. Self-hosted checkout leaves the instance `awaiting_link` with no licence.
11. Linking after checkout issues the licence.
**Signup:**
12. Nothing is written to `instances` or `users` before the link is opened.
13. A double-clicked verification link creates exactly one instance.
14. Owner-insert failure rolls the instance back; rollback refuses an instance
with users.
15. Re-submitting replaces the pending record and invalidates the earlier link.
16. Rate limit and honeypot both reject.
17. Paddle customer creation failure still completes signup and issues the Free
licence.
18. Licence issuance failure still lets the user log in, showing the banner.
19. Expired pending records are dropped by the TTL index.
## Verification before merge
1. Full suite green.
2. Against Paddle **sandbox**, end to end for each tier: checkout with a test
card, confirm the licence is issued, confirm the instance reports `valid`.
3. Trigger a sandbox renewal and confirm the next term's licence arrives and is
injected.
4. Cancel in sandbox and confirm the licence keeps working to expiry, then the
instance degrades correctly — monitors still running.
5. Replay every webhook from Paddle's dashboard and confirm no duplicate licences
are created.
6. Full signup end to end through admin, then confirm the new user can log into
their control-plane instance and sees a valid Free licence.
7. Confirm sitesvc's pending-signup collection is empty before its signup code is
removed.
## Risks
| Risk | Mitigation |
|---|---|
| Duplicate licences from webhook retries | Unique index on event ID, checked before processing |
| Webhook missed entirely | 15-minute reconciliation job (spec 3) compares subscription state against issued licences |
| Cancellation not enforceable until expiry | Accepted, bounded by term; Self Hosted annual-only; stated in terms and on the cancellation screen |
| Signup cutover breaks in-flight verification links | Staged cutover; sitesvc's verify stays live until its collection is empty |
| Sandbox price IDs reaching production | `PADDLE_ENV` selects them from the plans table; environment badge in the admin site |
| Webhook endpoint unauthenticated | Signature verification mandatory; boot fails without the secret |
| Customer pays and never links | Reminder emails at 24h and 72h, staff dashboard alert at 48h |
@@ -1,286 +0,0 @@
# Spec 0a — Shared Module Extraction
Date: 2026-07-24
Status: Design approved, not implemented
Ships: independently. No dependency on any other licensing spec.
## Context
Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no
root `go.mod` and no `go.work`.
`sitesvc` writes into the same MongoDB collections the control plane reads, but
cannot import the control plane, so it carries hand-copied duplicates:
- `sitesvc/internal/models/models.go``Org` and `User` mirrored field for field
- `sitesvc/internal/provision/provision.go``Slugify`, `ReservedSlugs`,
`MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules
Both files carry comments saying they must be changed in lockstep with the
control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the
match. **The duplication has already drifted.** The control plane's `CreateOrg`
resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop,
while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)`
helper. They currently agree by luck, not by construction.
The licensing programme adds a fourth service (`admin`) that writes the license
blob onto the same tenant document. Adding a third copy of these rules is not
acceptable. This spec removes the duplication before any licensing code is
written.
This spec is a **pure refactor**. No database document changes. No behaviour
changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in
spec 0b, deliberately kept separate so that a failed deploy has one suspect
rather than two.
## Goals
1. One authoritative definition of every document shape written by more than one
service.
2. One authoritative definition of provisioning rules (slug, bcrypt cost,
creation, rollback).
3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not
on the control plane. The original design intent survives; only the copying
dies.
4. The agent is untouched.
## Non-goals
- Renaming anything. That is spec 0b.
- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`,
`server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`,
`audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control
plane touches them, and hoisting them would make `shared` a dumping ground.
- Merging the repo into a single module.
## Design
### Module layout
```
vantage/
├── go.work # NEW: server, sitesvc, shared (NOT agent)
├── shared/ # NEW module: gitea.hostxtra.co.uk/mrhid6/vantage/shared
│ ├── go.mod
│ ├── models/
│ │ ├── org.go # Org
│ │ ├── user.go # User, RoleOwner/RoleAdmin/RoleMember, ValidRole
│ │ └── settings.go # Settings, AlertSettings, EmailSettings, SecretsSettings
│ ├── provision/
│ │ ├── slug.go # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits
│ │ ├── org.go # CreateOrg
│ │ ├── user.go # CreateUser, BcryptCost
│ │ └── rollback.go # RollbackOrg
│ └── indexes/
│ └── indexes.go # EnsureCoreIndexes
├── server/ # replace => ../shared
├── sitesvc/ # replace => ../shared
└── agent/ # untouched
```
`go.work`:
```
go 1.26
use (
./shared
./server
./sitesvc
)
```
Each consumer's `go.mod` also carries an explicit replace:
```
require gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
```
Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
across modules. The `replace` directives make Docker builds work whether or not
`go.work` is present, and stop `go build` outside the workspace from silently
trying to resolve `shared` from the network.
`shared` depends only on `go.mongodb.org/mongo-driver/v2`,
`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import
gin, redis, guac or anything else from the control plane's tree — that is what
keeps sitesvc small.
### What moves
**`shared/models`** — the three documents written by more than one service:
| Type | From | Written by |
| ------------------------------------- | ------------------------------------ | ---------------------------------- |
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
`Settings` moves now rather than later because spec 3's admin service reads it,
and moving it later would mean a second round of import churn across both
services.
`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`,
and the control plane does not know the collection exists.
**`shared/provision`** — the rules, promoted from private helpers to a real API:
```go
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12
)
var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ }
func Slugify(name string) string
func BaseSlug(name string) (string, error) // validates length + reserved
func NextSlug(base string, attempt int) string
// CreateOrg resolves a free slug and inserts. The caller supplies the
// collection handle so shared does not own a Mongo connection.
func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)
func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error)
// RollbackOrg deletes an org only if it has no users. Refuses otherwise.
func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error
```
`shared.CreateOrg` becomes the single implementation. The control plane's
`services.CreateOrg` shrinks to a wrapper that calls it and then runs
`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know
about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not
seed, which is the behaviour it has today.
Note on the slug loop: it is count-then-insert and therefore racy. It is safe
only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling
`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must
not lose that. Document the reliance in a comment at the loop.
**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes
on `users.email` and `orgs.slug`. Both services call it at boot; creating an
existing index is a no-op. These indexes are a security property, not an
optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so
duplicates would break the OIDC cross-org guard), so the shared version is
**fatal on failure** for both callers.
Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`,
`EnsureWorkflowIndexes`) stay in the server and keep their current
fatal/warn behaviour.
### What is deleted
- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only
- `sitesvc/internal/provision/` — deleted entirely
- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into
`shared/provision`; pending-signup storage stays
### Docker and CI
Both Go Dockerfiles currently build with the module directory as context:
```dockerfile
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build ... ./cmd
```
A `replace => ../shared` cannot resolve from that context. Build contexts move
to the repo root:
```dockerfile
WORKDIR /src
COPY shared/go.mod shared/go.sum ./shared/
COPY server/go.mod server/go.sum ./server/
RUN cd server && go mod download
COPY shared/ ./shared/
COPY server/ ./server/
ARG VERSION=dev
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
```
The two-stage copy keeps the dependency-download layer cached, which is the
reason the current Dockerfiles are written the way they are.
`.gitea/workflows/server-deploy.yml` must set `context: .` and
`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The
`web` and `site` image builds are unaffected.
`agent-release.yml` is untouched. The agent is not in the workspace, has no
`replace`, and cross-compiles exactly as it does today.
### Error handling
No new error paths. `shared/provision` returns the same error strings the two
callers produce today so that API responses do not change. The one place to be
careful is wording: sitesvc says "organisation" and the control plane says
"organization". `shared` standardises on **"organisation"**; the control plane's
two error strings change spelling. This is user-visible in API error text and is
called out here so it is a decision rather than an accident.
## Testing
**No automated tests.** Decision taken 2026-07-24: the repo has no Go test suite
and one is not being started here. Verification is by compiler, `grep`, and
running both services end to end.
That places the whole weight on three manual checks, which the implementation
plan makes mandatory steps rather than suggestions:
1. **bson tag diff**`diff` the `bson:"…"` tags of each moved struct against
the originals. A changed tag orphans production data silently, and this is
the only thing that catches it.
2. **Slug behaviour walkthrough** — a throwaway `main` printing `Slugify`,
`BaseSlug` and `NextSlug` output for a fixed input table, compared against
expected output recorded in the plan.
3. **End-to-end agreement** — sign up through sitesvc against a scratch
database, open the verification link, then log into the control plane with
those credentials. This is the check that proves the two services still agree
about the documents they share. If it passes, the refactor worked.
Plus `grep` assertions that exactly one definition of `Slugify` and
`ReservedSlugs` survives repo-wide, and that no struct under `sitesvc/` carries
a `bson:"org_id"` tag.
## Verification before merge
Evidence required, not assertions:
1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`.
2. `go vet ./...` clean in all three.
3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .`
both succeed from the repo root.
4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and
`shared` imports — no local struct redefinitions.
5. End-to-end against a scratch database: sitesvc signup form → verification link
→ org and owner created → that owner logs into the control plane
successfully. This is the test that proves the two services still agree.
6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`.
## Rollout
Single release. `server` and `sitesvc` images must be deployed together — a skew
is harmless here (documents are unchanged) but there is no reason to split it.
No database migration. No downtime.
## Risks
| Risk | Mitigation |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
## Follow-on
Spec 0b (`instance-rename`) becomes a rename inside one module plus its
consumers, rather than a rename across three independent copies. That is the
whole reason this spec goes first.
@@ -1,498 +0,0 @@
# Cloud instance creation and account membership — design
Spec 6. Designed 2026-07-26. Depends on specs 0a, 0b, 1, 2 and 3, all shipped.
## The problem
`https://vantage.hostxtra.co.uk/start` provisions a control-plane instance the
moment a customer opens the verification email. It creates nothing on the admin
side: no `accounts` row, no `admin_instances` row, no licence. Every cloud
customer who signs up today therefore lands on an unlicensed instance that spec 2
degrades to read-only, and staff must attach it by hand afterwards.
The fix is not to bolt licence issuance onto the existing verification handler.
It is to separate the two things that flow has conflated — **having an account**
and **having an instance** — so that the account exists first and the instance is
something the customer asks for.
Once accounts are real, a second thing follows: an account has *people* in it,
and those people need access to the account's instances. That is what forces the
`users.email` change below, and it is the largest single item in this spec.
## The new flow
```
site/start form ──POST──▶ admin /auth/signup
account name, email, password
→ accounts row + unverified customer_users row (account_role owner)
→ verification email; nothing written to the control plane
verification link ──▶ admin /auth/verify
→ customer_users.verified_at set
→ customer signs in at vantage-hq.hostxtra.co.uk
HQ portal, "Create instance" ──POST──▶ admin /api/instances
→ control-plane instances + users (creator becomes owner)
→ admin_instances row + instance_members row
→ Free licence issued, injected, emailed as "your instance is ready"
HQ portal, "Invite" and "Add to instance"
→ more customer_users on the account
→ each grant projects a control-plane users row into that cloud instance
```
Signup itself needs almost no new code: `auth.HandleSignup` already creates an
account, an unverified `customer_users` row and a verification email, and was
written for self-hosted customers. It turns out to be exactly the account-first
signup cloud needs.
This supersedes the "Signup migration off sitesvc" section of spec 5
(`2026-07-24-paddle-billing-design.md`). That section moved the *existing*
signup-provisions-an-instance flow to admin unchanged; this changes its shape.
Spec 5's Paddle work is unaffected and layers on top: the £0 Free subscription
and the Paddle customer are created where this spec issues the Free licence.
Paddle is explicitly **out of scope here**. Accounts created by this spec have an
empty `PaddleCustomerID`, which spec 5's account model already permits.
## Phasing
Three phases, each shippable, in this order. The plan should not interleave them
— phase 1 changes an index that everything else then depends on.
1. **Identity** — drop the global email index, scope the two unscoped lookups,
add the `hq` fields to the user document, and remove admin's unscoped
control-plane login branch. No new UI, and nothing is projected yet.
2. **Instance creation and Free lifecycle**`POST /api/instances`, renewal,
notices, the reaper, the sitesvc cutover.
3. **Membership** — account roles, invitations, per-instance grants, password
propagation.
## Phase 1 — identity
### Dropping the global email index
`users.email` currently carries a unique index **across the whole control
plane**. `CLAUDE.md` names it a security property, and it is one today. It is
also what makes "an account's people belong to several instances" impossible:
one address can own exactly one user document anywhere.
It is replaced by a unique compound index on `(instance_id, email)`, which is the
constraint that was actually wanted: one address is one user *within an
instance*.
The global index is only load-bearing because two lookups are unscoped. Both are
scoped instead, and the scoped lookups are a strictly stronger guarantee than the
index was — an index prevents the ambiguity, whereas a scoped query cannot be
ambiguous in the first place.
| Caller | Today | After |
|---|---|---|
| `auth.HandleLocalLogin` | `GetUserByEmail(email)` | resolve the instance, then `GetUserInInstanceByEmail` |
| `auth.HandleOIDCCallback` | `GetUserByEmail(email)`, then a cross-instance guard | `GetUserInInstanceByEmail(instanceID, …)`; the guard is deleted as unreachable |
`services.GetUserByEmail` is **deleted**, not merely left unused. Leaving an
unscoped helper in place is how this bug comes back.
Resolving the instance for local login:
1. `InstanceFromHost` — always succeeds on cloud, where every instance has its
own subdomain.
2. Otherwise, if exactly one instance exists, use it. This is the self-hosted
case, which is single-instance by construction because a licence binds one
instance UUID.
3. Otherwise refuse with a message naming the cause, rather than guessing.
### The index migration
In `shared/indexes.EnsureCoreIndexes`, in this order:
1. Create the unique compound index on `(instance_id, email)`. Fatal on failure.
2. Drop `email_1` if present, ignoring `IndexNotFound` so it is idempotent.
Creating before dropping means a failure at step 2 leaves both indexes in place,
which is safe. A failure at step 1 leaves the old index alone, which is also
safe.
`EnsureCoreIndexes` is called at boot by server, sitesvc and admin, so **all
three images must ship together**. `server-deploy.yml` rebuilds every image on
every push to `main`, so this happens by default; the risk is only a partial
manual rollout on the host.
**This migration is one-way.** Once two users share an address across instances,
`email_1` cannot be recreated. Rolling the server back past this change would
leave unscoped lookups running against data that can now be ambiguous. The
rollback plan is forward-only: fix and redeploy.
`sitesvc.EmailTaken` also does an unscoped count over `users`. It disappears with
sitesvc's signup in phase 2.
`admin.HandleCloudLogin`'s control-plane branch does an unscoped
`FindOne({email})` too, and unlike the other two there is no instance in context
to scope it by — HQ login is not per-instance. **That branch is deleted.** Every
customer created by this spec has a `customer_users` row, which already wins in
the existing precedence. Legacy cloud customers are handled by staff, who already
attach their instances by hand per the spec README, and who gain
`POST /api/staff/accounts/:id/users` to create their HQ login.
### The control-plane user document
`shared/models.User` gains:
- `hq_user_id` — the `customer_users.user_id` this row was projected from, absent
on locally-created users.
- `auth_source: "hq"` as a third value alongside `local` and `oidc`.
An `hq`-sourced user is **managed in HQ, not in the instance**. The control plane
refuses to change its role, delete it, or change its password through
`/api/instance/users`, answering with "managed in Vantage HQ". `web/` renders
those rows read-only with the same label. Locally-created users are unaffected
and stay fully editable in the instance — a cloud instance can hold both kinds.
This gives one owner per fact. A role that is editable in two places is a role
with two answers.
## Phase 2 — instance creation
`POST /api/instances`, customer session, `account_role` owner or admin,
body `{ "name": "..." }`.
In order, each step undoing the previous on failure:
1. Refuse if the account already holds a non-cancelled Free instance — a
pre-check of the same rule `licensing.checkFreeLimit` enforces, so we never
create an instance we then cannot licence. `409`.
2. Read the caller's `customer_users` row for its bcrypt hash.
3. `provision.CreateInstance` — control-plane instance and slug.
4. `provision.CreateUserWithHash(…, RoleOwner, "hq")` with that hash and
`hq_user_id`. On failure, `provision.RollbackInstance`.
5. Insert `admin_instances`, then `instance_members` for the creator. On failure,
delete the control-plane user, then roll back the instance.
6. `licensing.Issue{Tier: free, Term: "monthly", Reason: ReasonNew, IssuedBy:
"self-serve"}`, then `inject.Deliver`.
7. Email the creator: instance URL, sign-in address, licence expiry date.
Steps 6 and 7 do **not** fail the request. A licence that was not issued is
recoverable — the instance exists, the customer can sign in, they see spec 2's
licence banner, and staff can issue by hand. Failing the whole creation and
rolling back an instance the customer can already see would be worse. This
matches the rule spec 5 states for the same pair of failures: both outcomes
resolve toward "the customer gets in".
### Admin's control-plane write boundary
`inject`'s package doc says plainly that a second write target into the control
plane "is a design change and not a refactor". This is that design change, and it
is made explicitly rather than by widening `inject`.
Provisioning and membership projection live in a **new package,
`admin/internal/cloudprov`**. `inject` is left untouched, still writing exactly
three licence fields on `instances`. `db` gains a `ControlDB() *mongo.Database`
accessor, because `shared/provision` takes a database rather than a collection.
`cloudprov` writes exactly three things: instance documents (create and roll
back), user documents (create, delete, update role and password hash), and
nothing else. `CLAUDE.md`'s description of the boundary is updated in the same
commit, because it currently claims admin's control-plane access is read-only
apart from three licence fields, and that stops being true here.
## Phase 2 — Free lifecycle
A Free licence runs for one month plus the existing three-day `GracePeriod`,
using the `"monthly"` term `licensing.Issue` already implements. No new term
value. One Free instance per account, unchanged.
### Renewal
`POST /api/instances/:id/renew`, through `ownedInstance`, account owner or admin.
- Tier must be Free. Paid tiers renew through billing, not here.
- Allowed once `now > expires_at - 7d`, and at any point after that up to
deletion — so the same button rescues a lapsed instance rather than needing a
second mechanism.
- Reissues Free with `Reason: ReasonRenewal`, injects, emails the new date.
Renewal is deliberately manual. It is the entire reclaim signal: an instance
nobody renews is an instance nobody is using.
### Status and notices
`admin_instances.status` gains `deleted`. A sweep in admin flips `active` to
`lapsed` when the current licence's `expires_at` passes, and the existing
15-minute reconciler — which already logs "no control-plane instance X" — flips
those to `deleted` and clears their `instance_members` rows instead of only
logging.
Four emails to the account's owners and admins, driven by `expires_at`:
| When | Says |
|---|---|
| 7 days before expiry | Renew, one click, here is the link |
| on expiry | Read-only now; deleted in 14 days unless renewed |
| 7 days before deletion | Deleted in 7 days |
| 1 day before deletion | Deleted tomorrow |
Each send is recorded on the `admin_instances` document, so a restart or a double
tick cannot re-send one. Renewal clears the record, so the next term starts the
sequence again.
## Phase 2 — deletion
Deletion is the only irreversible path in the system, so it is owned by the
service that knows what an instance is made of.
**The reaper runs in the control plane, not in admin.** Admin already injects
`license_tier` and `license_expiry` onto the instance document, so the server
drives off data it holds locally, and the list of collections carrying
`instance_id` stays in the codebase that defines them. Mirroring that list into
admin would be exactly the class of duplication `CLAUDE.md` already warns about
for slug rules and design tokens — except a divergence here deletes the wrong
rows or leaves orphans behind.
The sweep, in `server/internal/services`:
- Eligible when `license_tier == "free"` **and** `license_expiry` is present
**and** `license_expiry` is more than the configured window in the past.
- Purges the instance document, its users, and every `instance_id`-scoped
document across the collections listed in `CLAUDE.md`. Workflow run logs on
disk go with them.
- Fail-safe by construction. An instance whose licence issuance failed has no
`license_tier` and is never eligible. A paid instance is never eligible. An
instance admin has not reached yet keeps whatever expiry was last injected, and
admin's reconciler keeps that field current.
- Every purge writes an audit entry before deleting, and logs the instance ID,
slug and document counts.
- Admin's reconciler notices the instance has gone and cleans up its own
`admin_instances` status and `instance_members` rows.
### The kill switch
Gated on `FREE_INSTANCE_REAP_AFTER`, a duration. **Empty disables the sweep
entirely**, and empty is the default.
It is unset in `deploy/docker-compose.yml` and set to `336h` only in
`deploy/docker-compose.site.yml`, so a self-hosted deployment can never reap
anything — the same containment rule that keeps `LICENSE_SIGNING_KEY` in exactly
one service in exactly one compose file.
## Phase 3 — accounts, people and membership
### The model
```
Account
├── customer_users the people. account_role: owner | admin | member
└── admin_instances the deployments
└── instance_members which people are on which cloud instance
```
`customer_users` gains `account_role`. Existing rows backfill to `owner` — they
are all account creators today. Owners and admins may invite users, create
instances, and grant instance access; billing stays owner-only. The vocabulary
deliberately matches the control plane's own three roles rather than inventing a
second one.
`instance_members` is new: `{member_id, account_id, instance_id,
customer_user_id, role, control_user_id, created_at}`, unique on
`(instance_id, customer_user_id)`. `role` is the role the projected
control-plane user holds inside the instance.
### Grants project, they do not federate
Granting a user access to a cloud instance creates a real control-plane `users`
row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The
instance authenticates it exactly as it authenticates any other user, with no
runtime dependency on admin. Revoking deletes that row.
**Self-hosted instances are never projected into.** `POST /api/instances/:id/
members` refuses when `deployment != cloud`, with that as the message. For a
self-hosted instance the account's users exist to manage the licence, and the
instance's own users are managed locally in the customer's own deployment, which
we cannot see and have no business writing to.
Endpoints, all customer-session and all through `ownedInstance` where an instance
is named:
```
GET,POST /api/account/users invite; owner|admin
PUT /api/account/users/:id/role owner|admin; cannot demote the last owner
DELETE /api/account/users/:id owner|admin; revokes every grant first
PUT /api/account/password any user; propagates
GET,POST /api/instances/:id/members owner|admin
PUT /api/instances/:id/members/:uid/role
DELETE /api/instances/:id/members/:uid
```
Invitations reuse `auth.CreateCustomerUser`, which already does the
unverified-row-plus-verification-email dance and already deletes the row if the
email fails to send. A user cannot be granted an instance until verified.
Revoking the last **owner** of an instance is refused, mirroring the control
plane's own `ErrLastOwner`. The check counts control-plane owners for that
instance, so it also sees owners created locally inside the instance.
### Password propagation
The HQ password is the single source of truth for every `hq`-sourced row.
`PUT /api/account/password` rehashes at cost 12, updates `customer_users`, then
has `cloudprov` write the same hash to every control-plane user carrying that
`hq_user_id`. The instance refuses to change an `hq`-sourced user's password
locally, so there is no competing writer.
Propagation is best-effort and retried, on exactly the pattern `inject` already
proves: a failure is logged and flagged, and admin's 15-minute reconciler gains a
pass that compares each `hq`-sourced row's hash against its `customer_users`
source and repairs mismatches. The worst case is a stale password on one instance
for up to fifteen minutes, which is recoverable; failing the password change
because one of three instances was unreachable is not.
## Frontend
### `site/`
`components/InstanceForm.tsx` becomes `AccountForm.tsx`: account name, email,
password, honeypot. It posts to `NEXT_PUBLIC_ADMIN_API_URL/auth/signup` rather
than to sitesvc. The live `your-instance.vantage.hostxtra.co.uk` slug preview
goes — there is no instance yet at this point, and showing one would be a lie.
`app/start/page.tsx` copy changes from "Set up your instance" to creating an
account, and its "What happens next" panel gains the create-an-instance step
between confirming the email and adding a key.
`ADMIN_API_URL` gains a browser-reachable presence in the `site` image build, and
`site`'s origin must be listed in admin's `ADMIN_ORIGIN`. Both are new failure
modes with the same footgun `CLAUDE.md` already documents for `SITE_API_URL`.
`SITE_API_URL` still serves the contact form.
### `adminsite/`
- `(customer)/page.tsx` — the "No instances yet" panel gains a primary **Create a
free instance** action. Hidden once the account holds a Free instance, with the
reason stated rather than the button silently absent.
- `(customer)/instances/new/` — name field and a live slug preview of the
resulting `<slug>.vantage.hostxtra.co.uk`.
- `(customer)/instances/[id]/` — a members panel: who is on this instance, their
role, add and remove. Absent for self-hosted instances, replaced by a line
saying users are managed inside the install.
- `(customer)/users/` — the account's people, invitations, account roles.
- `(customer)/settings/` — change password, with a note that it applies to every
instance you belong to.
- `components/InstanceCard.tsx` — expiry date, a **Renew** action inside the
window, and a deletion countdown when lapsed. Per `CLAUDE.md`'s rule, licence
state never reads by colour alone; the countdown is a text label.
- `lib/api.ts` — the new calls, `"deleted"` on `InstanceStatus`, and an
`AccountRole` type.
### `web/`
`settings/instance` gains the read-only treatment for `hq`-sourced users: role
shown, controls disabled, labelled "managed in Vantage HQ" with a link to the
portal. Everything else is unchanged; spec 2's licence banner already covers a
lapsed instance.
## sitesvc
Signup, verify, `site_pending_signups`, `EmailTaken` and the provisioning calls
are deleted. sitesvc keeps the contact form only, and drops `APP_LOGIN_URL`.
The staged cutover from spec 5 applies unchanged, and matters for the same
reason: an in-flight verification link must not break.
1. Deploy admin. Its signup already exists; nothing to enable.
2. Point `site/start` at admin. Deploy `site`.
3. Wait for sitesvc's outstanding pending signups to expire — 24 hours — with its
verify endpoint still live. **Do not delete the collection until it is empty.**
4. Deploy sitesvc with signup and verify removed.
A signup that completes through the old path during step 3 produces an instance
with no account and no licence, exactly as today. Staff attach those by hand, the
same job the README already describes for existing cloud tenants.
## Configuration
| Service | Variable | Required | Notes |
|---|---|---|---|
| admin | `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template, used in the instance-ready email |
| server | `FREE_INSTANCE_REAP_AFTER` | no | duration past expiry before a Free instance is purged. **Empty disables the reaper**, and empty is the default. `336h` in `docker-compose.site.yml` only |
| site build | `ADMIN_API_URL` | yes | browser-reachable; must be in admin's `ADMIN_ORIGIN` |
| sitesvc | `APP_LOGIN_URL` | — | removed |
## Testing
Phase 1, identity:
1. The compound index exists and `email_1` is gone after one boot; a second boot
is a no-op.
2. Two users with the same address in different instances can both be created and
both sign in, each landing in their own instance.
3. Two users with the same address in one instance are refused by the index.
4. Local login on a cloud subdomain finds only that instance's user; the same
address on another instance is not reachable from this host.
5. Local login on a bare host with one instance works; with two it refuses with a
named cause rather than picking one.
6. OIDC provisions into the instance from the callback state, and an address
belonging to another instance no longer produces a cross-org error because it
is simply not found — it provisions a new member instead, which is correct.
7. `GetUserByEmail` no longer exists.
Phase 2, creation and lifecycle:
8. Signup writes nothing to `instances` or `users`; only the emailed link makes
the account usable.
9. Creating an instance produces an instance, an `hq`-sourced owner user, an
`admin_instances` row, an `instance_members` row, a Free licence, and an
injected `license_blob`.
10. The creator can sign in to the new instance with their HQ password.
11. A second Free instance on the same account is refused `409` and writes
nothing.
12. Owner-insert failure rolls the instance back, and rollback refuses an
instance that has users.
13. Licence issuance failure still leaves a signed-in-able instance and flags for
staff.
14. Renew outside the window is refused; inside it, it supersedes, injects and
moves `expires_at` forward by a month plus grace.
15. Renewing a lapsed instance restores it before the reaper takes it.
16. Each notice sends once across a restart.
Phase 2, the reaper — the part that must be got right:
17. With `FREE_INSTANCE_REAP_AFTER` empty, nothing is ever deleted.
18. An instance with no `license_tier` is never eligible, whatever its age.
19. A Professional instance past expiry is never eligible.
20. A Free instance one hour short of the window is not deleted; one hour past it
is.
21. A purge leaves no document carrying that `instance_id` in any collection, and
writes an audit entry first.
22. Purging is idempotent — a second run over a half-deleted instance completes
it rather than erroring.
Phase 3, membership:
23. An invited user cannot be granted an instance until verified.
24. A grant creates a control-plane user that can sign in to that instance with
the invitee's HQ password.
25. The same user can hold rows in two instances at once, with different roles.
26. Revoking deletes the control-plane row, and that user can no longer sign in
to that instance while keeping access to the others.
27. Revoking or demoting an instance's last owner is refused, including when that
owner was created locally inside the instance.
28. Granting against a self-hosted instance is refused and writes nothing to the
customer's deployment.
29. A `member` cannot invite, create instances, or grant access.
30. A password change propagates to every linked instance; with one instance's
write forced to fail, the reconciler repairs it within one pass.
31. An `hq`-sourced user's role, deletion and password are refused inside the
instance API, not merely hidden in `web/`.
## Risks
| Risk | Mitigation |
|---|---|
| Dropping `email_1` is one-way and weakens a documented security property | Scoped lookups ship in the same binary that drops the index; the unscoped helper is deleted so it cannot be reintroduced; the compound index restores the equivalent guarantee; rollback plan is forward-only and stated |
| A partial rollout leaves an old service recreating `email_1` | All three services call `EnsureCoreIndexes`; `server-deploy.yml` rebuilds every image per push; the host rollout command already updates all services together |
| Reaper deletes a live instance | Kill switch defaults off; eligibility needs an explicitly-Free tier and a present expiry; unset fields are never eligible; four warning emails precede it |
| Admin's widened control-plane write access grows further | Confined to `cloudprov`, which writes instances and users and nothing else; `inject` untouched; `CLAUDE.md` updated to say so |
| Password propagation leaves an instance stale | Reconciler pass compares and repairs; worst case is fifteen minutes; the instance refuses local changes so there is no competing writer |
| A projected user is edited in both places | `hq`-sourced rows are refused by the instance API, not merely hidden in the UI |
| Cutover breaks an in-flight verification link | sitesvc's verify stays live until its collection is empty |
@@ -1,455 +0,0 @@
# Metered Licensing — Design
**Status:** designed 2026-07-26. Supersedes parts of spec 5 (paddle-billing) and
the tier table in [`README.md`](README.md).
**Goal:** turn the licence from a snapshot of a fixed tier into a snapshot of
what one customer configured and paid for. Two deployments times three tiers,
servers metered per month, features opted into individually, all of it
self-service in Vantage HQ.
**Why now:** spec 5 is designed but not implemented — `admin/internal/paddle`
and `admin/internal/billing` do not exist. Its `Subscription` struct, its
`plans.paddle_price_ids` shape, its single-price checkout and its
`ApplySubscription` all assume one price per subscription, and a metered plan has
several. Folding this in now costs a revision of an unstarted plan; folding it in
later would cost a rewrite of shipped billing code.
---
## The pricing model
Two deployments, three tiers, six plans.
| | servers | monitors | secret groups | channels | audit history | console | SSO | support |
|---|---|---|---|---|---|---|---|---|
| **Free** | 3 | 3 | 1 | 1 | 30 days | — | — | community |
| **Professional** | 3 + N | ∞ | ∞ | ∞ | 365 days | opt-in | opt-in | email, 24/5 |
| **Enterprise** | 10 + N | ∞ | ∞ | ∞ | ∞ | opt-in | opt-in | email + call, 24/7 |
The allowances are identical in both deployments. What differs is the term:
| | monthly | annual |
|---|---|---|
| Cloud Free | — | yes, renewed from HQ |
| Cloud Professional | yes | yes |
| Cloud Enterprise | yes | yes |
| Self-Hosted Free | — | yes, renewed from HQ |
| Self-Hosted Professional | — | yes |
| Self-Hosted Enterprise | — | yes |
**Self-Hosted stays annual-only, for the reason already written into
`shared/license/license.go`:** an offline licence cannot be revoked, so the term
length *is* the revocation window. A self-hosted monthly licence would renew that
unrevokable window twelve times a year for no commercial gain. A resolved
self-hosted monthly price is therefore a configuration error and must fail loudly
rather than issue.
**Servers are the only metered dimension.** Everything above Free is unlimited
except audit history. This was a deliberate narrowing: an earlier draft sold
secret groups in blocks of five, and dropping it leaves one number for a customer
to understand and one line item on an invoice.
**Enterprise is self-service at a published price**, bought through the same
configurator as Professional. The 24/7 phone commitment is an operational promise
we make, not a technical gate we build.
**Support level is not enforced by anything.** It is carried for display, and
that is the whole of its job.
---
## What breaks, and must be fixed in the same change
Three invariants stop being true. Each is load-bearing today.
**`plans` is keyed on `tier` alone.** It becomes `(deployment, tier)` with a
unique index on the pair. `license.PlanFor(tier)` becomes
`PlanFor(deployment, tier)`.
**Free is cloud-only by construction.** The single comparison in
`licensing.Issue``plan.Deployment != inst.Deployment` — is what enforces it
today, because Free's only plan row says `cloud`. With a self-hosted Free row
that comparison stops meaning "Free is cloud-only" and starts meaning only "the
plan row matches the instance". The paragraph in `shared/license/plans.go`
claiming construction-level enforcement must go, because it is no longer true.
**`checkFreeLimit` counts Free instances per account.** It must count per account
*and deployment*, or a customer holding a cloud Free instance is refused a
self-hosted Free one with a message about a limit they have not reached.
---
## Data model
### `plans` — the tier definition
Loses `paddle_product_id` and `paddle_price_ids` entirely; those move to
`catalogue`. Safe to delete because nothing has ever written to them.
```
{deployment: "cloud", tier: "professional", name: "Professional",
base_limits: {max_servers: 3, max_monitors: -1, max_secret_groups: -1,
max_channels: -1, audit_retention_days: 365},
base_features: [], support_level: "email_24_5", active: true}
```
`base_limits` replaces `limits`: it is the allowance before anything is bought,
which is a different claim from the one the old field made. `base_features` is
what the tier includes without opting in — empty for all six plans today, because
console and SSO are both opt-in, but the field is what lets a future tier bundle
one.
### `catalogue` — every priceable component
The only place a Paddle price ID appears anywhere in the system.
```
{kind: "base", deployment: "cloud", tier: "professional",
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"},
production: {monthly: "pri_…", annual: "pri_…"}}}
{kind: "limit", deployment: "cloud", tier: "professional", limit_key: "max_servers",
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"}, production: {…}}}
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "console",
price_ids: {}}
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "oidc",
price_ids: {}}
```
Unique index on `(deployment, tier, kind, limit_key, feature_key)`.
- **`kind: "base"`** is the plan's own fee, always quantity 1.
- **`kind: "limit"`** raises a named limit by one per quantity. `limit_key` is a
field name in `license.Limits`, so adding metered channels later is a catalogue
row and no code. There is deliberately **no `block_size` field**: with
secret-group blocks dropped it would be `1` in every row that will ever exist.
- **`kind: "feature"`** is a feature key. **An empty `price_ids` means free to
toggle.** A price appearing later is a staff edit in the plans UI, not a
migration and not a deploy — which is the whole reason features are catalogue
rows rather than a list on the plan.
A self-hosted row simply has no `monthly` key. Nesting by environment before term
keeps promoting sandbox to production a configuration change, as spec 5 already
established.
### `entitlements` — one row per instance
The customer's configuration. Both the subscription and the licence are derived
from it; it is derived from nothing.
```
{instance_id: "uuid", account_id: "uuid",
deployment: "cloud", tier: "professional", term: "monthly",
desired: {servers: 10, features: ["console"]},
granted: {servers: 5, features: []},
resolved_limits: {max_servers: 5, max_monitors: -1, max_secret_groups: -1,
max_channels: -1, audit_retention_days: 365},
granted_at, updated_at, scheduled_change_at}
```
Unique index on `instance_id`.
**`desired` is what they asked for; `granted` is what a payment confirmed.** The
checkout and the subscription update are built from `desired`. A licence is only
ever signed from `granted`. An abandoned checkout therefore leaves a `desired`
that reached no licence, which is harmless, and HQ can say "pending change"
truthfully instead of guessing.
**`resolved_limits` is stored, not derived on read.** It is `plan.base_limits`
with `granted.servers` folded in, and it is what `Issue` snapshots. Storing it
keeps the fold in exactly one place; deriving it at every read would put the
arithmetic in the issuer, the portal and the staff console.
**Free gets a row at instance creation** with `desired == granted` and no
subscription. Every one of the six cases then reads the same shape, and licence
issuance has one path rather than a Free branch.
### `license.Limits` gains two fields
```go
type Limits struct {
MaxServers int `json:"max_servers"`
MaxMonitors int `json:"max_monitors"`
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
AuditRetentionDays int `json:"audit_retention_days"`
}
```
`MaxMonitors` behaves exactly like the existing counts. `AuditRetentionDays` is a
new kind of limit — a duration rather than a cap — and `Unlimited` means never
trim.
### `license.License` gains `SupportLevel string`
Display-only, exactly as `InstanceName` already is. It goes in the signed payload
rather than being fetched from HQ so that `/settings/license` can state the
support level on an air-gapped install, which is the one deployment most likely
to need to know who to call.
### `models` additions
`ReasonEntitlementChange = "entitlement_change"` joins the issuance reasons.
Reasons end up in support conversations, so a mid-term server addition must not
be filed as a renewal — a renewal resets `relink_count`, and adding a server is
not a new term.
---
## Resolution
Two folds, in one package (`admin/internal/catalogue`), so the arithmetic exists
once.
**To a licence.** `Resolve(plan, granted) → (license.Limits, []string)`:
start from `plan.base_limits`, and for each `kind: "limit"` row add the
configured quantity to `limit_key`. `granted.servers` is the *total* the customer
sees, so the quantity billed is `servers - plan.base_limits.max_servers` and the
resolved limit is `servers`. Features are `plan.base_features` plus
`granted.features`, deduplicated, filtered to keys the catalogue actually offers
for that `(deployment, tier)` — a stale feature key in a stored entitlement must
not survive into a signed payload.
**To Paddle line items.** `LineItems(env, deployment, tier, term, desired) → []Item`:
the base row at quantity 1, the server row at quantity
`desired.servers - base_limits.max_servers`, and one item per desired feature
that has a price ID in this environment and term. A feature with no price ID
produces no line item and is granted for free. A quantity of zero produces no
line item at all, so a Professional customer at exactly 3 servers has a
single-item subscription.
**Reverse resolution replaces spec 5's `ResolvePriceID`.** A metered subscription
has several prices, and only one of them identifies the plan. Given the full item
list from a webhook:
1. Find the item whose price ID matches a `kind: "base"` row. That row gives
`deployment`, `tier` and — by which term key matched — `term`.
2. Sum the quantities of items matching that plan's `kind: "limit"` rows.
3. Collect the feature keys of items matching its `kind: "feature"` rows.
4. Any item matching nothing is a configuration error: fail the event loudly so
it lands on the staff dashboard. Guessing a tier from a price we cannot map is
how a customer ends up with the wrong licence and no record of why.
Only the running `PADDLE_ENV`'s IDs are consulted, so a production process cannot
be talked into resolving a sandbox price. That property is spec 5's and survives
unchanged.
**Out-of-order delivery is still handled by construction.** Paddle sends the
complete item list on every subscription event, so a handler that reads the whole
list is still a function of current state rather than of a transition. Nothing
about metering weakens this.
---
## Issuance
`licensing.Issue` reads the entitlement row for the instance and snapshots
`resolved_limits` and `granted.features`. When no row exists it falls back to the
plan's base — which covers staff manual issuance and any instance predating the
backfill.
`Issue` stays the only signer, and it stays the thing that does not deliver.
**Upgrades preserve the expiry.** A mid-term server addition passes
`ExpiresAt` = the current licence's expiry, so the licence is reissued with a
larger cap and the same end date. It must not extend the term: the customer paid
a prorated amount for the rest of this period, not for a new one. Note that the
current expiry already includes `GracePeriod`, so nothing adds it again —
`ExpiresAt` overriding `Term` is exactly the existing contract.
**Reductions issue nothing.** They live in `desired` with `scheduled_change_at`
set until the renewal webhook promotes `desired` into `granted` and issues the
next term at the lower cap. The customer keeps what they paid for to the end of
the period, there is no refund to reason about, and no licence ever shortens —
which is the rule spec 5 states and this design does not touch.
---
## Changing a live subscription
`PUT /api/instances/:id/entitlement` writes `desired`, then calls Paddle:
- **An increase** updates the subscription items prorated immediately. The
resulting `subscription.updated` webhook promotes `granted` and reissues.
- **A decrease** schedules the item change for the next billing period and sets
`scheduled_change_at`. No licence action now.
This is admin's **first outbound Paddle call beyond the portal session**, and
spec 5 currently states it has none. That statement changes. The important part
does not: **the webhook remains the only thing that promotes `granted` or issues
a licence.** The endpoint writes `desired` and asks Paddle for a change; it never
grants anything itself. A customer whose card is declined on a prorated upgrade
gets no licence, which is correct, and admin needs no compensating logic to
achieve it.
A tier change (Professional to Enterprise) is the same call with a different base
price, and issues with `ReasonTierChange` as it already would.
---
## Control-plane enforcement
**Feature gating already exists and is already mounted.** `RequireFeature` in
`server/internal/api/licence.go` answers 403 `feature_unavailable`, and
`server/internal/api/handlers.go` already wraps `POST /api/console/connect`,
`GET /api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. Free's feature list
is empty, so a Free instance already cannot open the console. **No capability is
taken away from an existing tenant by this spec, and no customer email is owed.**
**One gap remains, and it is a single check.** `HandleOIDCStart` already tests
`Feature("oidc")` and redirects to `/login?error=oidc_unavailable`.
`HandleOIDCCallback` does not test it at all. A start that 403s is a dead end; an
ungated callback completes a sign-in, so the unguarded half is the half that
matters.
The callback cannot copy the start's instance resolution: the start reads
`InstanceFromHost(c)`, while the callback resolves the instance from the OAuth
state it consumes, and by then it holds `instanceID` directly. The check goes
after `ConsumeStateInstance` and before `providerForInstance`, so a licence that
lapsed mid-flow stops the exchange rather than completing it.
`web/` hides the Console button and the SSO card when the feature is absent, but
as everywhere else in this codebase the API is the boundary and the UI is the
courtesy.
**`CheckMonitorLimit`** joins the three existing checks in
`server/internal/services/licence_limits.go`, counting `monitors` for the
instance. Same shape: refuse a new one at the cap, never truncate what exists.
`LicenseUsage` reports monitors alongside the other counts.
**Audit retention is new work.** Nothing trims `audit_logs` today. A daily sweep
deletes entries older than the licence's `AuditRetentionDays` per instance;
`Unlimited` skips the instance entirely. It is modelled on the existing workflow
log retention sweep, and it is the one item in this design that deletes customer
data — so it must read the *current* licence's value each run rather than caching
it, and an instance whose licence has lapsed must not be swept on the expired
term's allowance.
**Degraded mode is unchanged.** Expiry still stops mutations and leaves monitors
executing, alerts firing and agents keyed. A feature gate is a mutation gate for
console and SSO, so it behaves the same way.
---
## HQ, the configurator
One screen, reached from an instance in `InstanceRecord` and from the
self-hosted purchase page.
```
Deployment ( ) Cloud (•) Self-Hosted ← fixed after creation
Tier ( ) Free (•) Professional ( ) Enterprise
Term (•) Annual ← monthly hidden for self-hosted
Servers [ 10 ] base 3 included, 7 extra
Features [x] Browser console
[ ] Single sign-on
─────────────────────────────────────────────
£B + 7 × £S per year
[ Continue to payment ]
```
It is one component in both places, driven by the catalogue rather than by
anything hardcoded — a feature that gains a price shows its price with no
frontend change, which is the point of the catalogue being data.
**Existing subscriptions show `desired` and `granted` when they differ:** "10
servers, dropping to 5 on 12 August". A pending reduction is a fact about the
account and belongs on the screen, not only in Paddle.
**Choosing Free skips payment entirely.** With no catalogue rows there is no
checkout to open, so the configurator's Continue button links a UUID and issues
directly. For cloud that is the shipped `POST /api/instances`, untouched. For
self-hosted Free it is the existing link flow with no subscription attached — a
new path, and the only place in the system where an instance is licensed without
either a payment or a staff action. It is bounded by the same one-Free-per-account
rule, now scoped per deployment.
**The staff plans editor** edits `plans` (allowances, support level, active) and
`catalogue` (price IDs per environment and term) as two tables. This replaces
spec 5's price-ID editor, which was built for a single map on the plan row.
Follows `adminsite/`'s existing shell without exception: `PageHeader` with its
record line, `PageFrame`'s main-plus-rail split, tokens only and no hex values,
light default. Price and server count read as text as well as position, since
state never reads by colour alone here.
---
## Migration
Admin has no migrations collection: `models.Backfill` runs every boot and is
idempotent by filtering on the absence of what it writes. This all goes there.
1. **Seed six plan rows** from `shared/license/plans.go`, `$setOnInsert` only, so
staff edits to allowances survive a redeploy — the existing `SeedPlans` rule.
2. **Re-key existing plan rows.** The three current rows are keyed by tier alone.
`free` and `professional` gain `deployment: "cloud"`. The row with tier
`self_hosted` becomes `deployment: "self_hosted", tier: "professional"`.
3. **Re-tier existing self-hosted instances and their entitlements.** Instances
holding `tier: "self_hosted"` become `tier: "professional"`; their deployment
already says so.
4. **`license.TierSelfHosted` is kept as a legacy constant** that no new licence
uses. Licences already issued carry `tier: "self_hosted"` in a signed payload
we cannot rewrite, and the server reads limits and features from the payload
rather than from the tier name — so they keep working untouched. This is
exactly what "the server never branches on tier name" was for.
5. **Backfill an entitlement row per instance** from its current licence:
`granted.servers` from `limits.max_servers` (`Unlimited` maps to the plan
base, since an unlimited licence bought no server units), `granted.features`
from the licence's features, `desired` equal to `granted`.
6. **Seed the catalogue** with sixteen rows — the four paid plans times a `base`,
a `limit: max_servers`, a `feature: console` and a `feature: oidc` — price IDs
empty. **The two Free plans get no catalogue rows at all**, which is what keeps
Free outside Paddle: there is nothing to price, so no checkout can be built. Empty price IDs mean checkout refuses until staff paste them, which is
the correct failure: a checkout that silently picks the wrong price is worse
than one that will not open.
Existing licences are not reissued. `MaxMonitors` and `AuditRetentionDays` are
absent from their payloads and decode as `0`, which would read as "no monitors,
trim everything". **Zero must therefore be treated as unset on decode** and
filled from the plan base — a licence signed before a field existed cannot be
allowed to mean the most restrictive possible value of it. This is the one
sharp edge in the whole migration and it is worth a comment at the decode site.
---
## Out of scope
- **Paid feature add-ons.** The model supports one — a `price_ids` entry on a
`kind: "feature"` row — but no feature has a price at launch.
- **Metered channels, monitors or secret groups.** A catalogue row away, and
deliberately not taken.
- **Usage-based billing.** Servers are a configured cap, not a measured count. We
never bill for what an instance ran; we bill for what it is allowed to run.
- **Refunds and credits.** Paddle's, and only Paddle's.
- **Enterprise contract terms, POs and invoicing.** Card only at launch.
- **Anything that revokes or shortens a licence.** Offline verification means
this is not that kind of system, and no part of this design changes it.
---
## Done when
- Six plan rows exist, keyed on `(deployment, tier)`, and a customer can buy any
of the four paid combinations from the configurator.
- A Professional cloud customer can go from 3 to 10 servers and see the new cap
in `web/` without waiting for a renewal.
- The same customer can reduce to 5 and see both the current cap and the date it
drops, with their licence untouched until then.
- Free self-hosted can be created, renewed from HQ, and lapses to read-only
without being reaped.
- Unticking Browser console removes it from the next issued licence, and
`POST /api/console/connect` answers 403 on an instance whose licence lacks it
(already true; the new part is that a customer controls the tick).
- `/auth/oidc/callback` answers 403 on an instance whose licence lacks `oidc`.
- A monitor beyond the cap is refused with a machine-readable 403.
- `audit_logs` older than the licence's retention are gone, and an unlimited
licence's are not.
- Every price ID in the running environment resolves to a plan, and a webhook
naming one that does not fails loudly onto the staff dashboard.
@@ -1,177 +0,0 @@
# Control plane (`web/`) — mobile responsive design
Date: 2026-07-27
Scope: `web/` only. `site/` and `adminsite/` are untouched.
## Problem
`web/` was built for a desktop console and has no mobile handling at all.
- `Sidebar` is a fixed `w-60 h-screen` aside rendered unconditionally by
`app/(app)/layout.tsx`. On a 390px phone it eats 62% of the width.
- Every page opens with `p-8` — 64px of horizontal padding on a screen that has
390px to give.
- Six list pages render 46 column tables. They scroll horizontally, so nothing
overflows the page, but reading a row means swiping.
- The workflow builder is a hard `grid-cols-[1fr_320px]` with `w-[340px]` nodes.
At 390px the inspector alone exceeds the viewport.
- Several grids are unprefixed (`grid-cols-3`, `grid-cols-2`, `grid-cols-4`) and
never collapse.
Next's App Router injects `width=device-width, initial-scale=1` by default, so
the breakpoints *do* fire. This is a layout problem, not a viewport one.
## Decisions
| Decision | Choice | Why |
| --- | --- | --- |
| Sidebar collapse breakpoint | `lg` (< 1024px) | Content is dense — tables plus `lg:grid-cols-3` side rails. Reclaiming 240px helps tablets as much as phones, and `lg` is already where the app's own two-and-three column layouts switch. |
| Table treatment on phones | Card stack below `sm` | Horizontal swiping to read a hostname's status is the single worst thing about the current app on a phone. |
| Workflow builder / console | Best-effort responsive | Usable, not redesigned. No blocking notice — a cramped console beats no console. |
| Verification | Static audit + `next build` + `next lint` | The app is auth-gated behind Mongo, Redis and the Go server; none run in this environment. |
## Design
### 1. The shell
A new client component `web/components/AppShell.tsx` owns the responsive chrome
so `app/(app)/layout.tsx` stays a server component:
```
AppShell (client, holds `open` state)
├── <aside class="hidden lg:flex"> ← permanent sidebar, unchanged look
├── mobile top bar (lg:hidden, sticky, h-14)
│ hamburger · Logo · "Vantage" · instance name
├── offcanvas (lg:hidden, fixed inset-0 z-50)
│ backdrop (bg-black/60) + w-72 panel, translate-x transition
└── <main class="flex-1 overflow-y-auto"> ← LicenseBanner + children
```
`Sidebar.tsx` splits into:
- `SidebarContent` — the nav list, user block and logout. **One copy**, rendered
by both the permanent aside and the offcanvas panel. It takes an optional
`onNavigate` callback so the offcanvas can close on link click.
- `Sidebar` — the permanent `hidden lg:flex` aside.
- `SidebarDrawer` — the offcanvas.
`navItems` and the `activeHref` reduction move to module scope so both
containers share them. The active-item accent bar, the instance name in the
header and the user/logout footer all appear in both, unchanged.
Offcanvas behaviour:
- Closes on route change (`usePathname` effect), on Escape, on backdrop click
and on any nav link click.
- Locks `document.body.style.overflow` while open, restores on close.
- `aria-expanded` / `aria-controls` on the hamburger; `role="dialog"` and
`aria-modal="true"` on the panel; `aria-label` on the button.
- Focus moves into the panel on open and returns to the hamburger on close.
- The panel is always mounted so the slide transition runs in both directions;
it carries `pointer-events-none invisible` when closed rather than being
unmounted.
The top bar is `sticky top-0 z-40` inside the scroll container so it stays
reachable on long pages.
### 2. Tables become card stacks without duplicating markup
The responsive mode lives in the primitives (`web/components/ui/Table.tsx`),
not in each page. Writing two parallel trees per page — a `<table>` for desktop
and a `<div>` stack for mobile — would double six pages of markup and drift
apart on the first edit.
`Td` gains an optional `label`. Below `sm` the table flips to block layout:
| Element | Added classes (below `sm`) |
| --- | --- |
| `Table` | `max-sm:block` |
| `Thead` | `max-sm:hidden` |
| `Tbody` | `max-sm:block max-sm:divide-y-0 max-sm:space-y-3 max-sm:p-3` |
| `Tr` | `max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3` |
| `Td` | `max-sm:flex max-sm:items-start max-sm:justify-between max-sm:gap-4 max-sm:px-0 max-sm:py-1.5` |
When `label` is present, `Td` renders it in a `sm:hidden` span using the exact
mono keyed-label idiom `Th` already uses — `font-mono text-[0.68rem] uppercase
tracking-[0.13em] text-text-secondary`. The key/value pairing on a phone is the
same visual device as the column head on a desktop, because it means the same
thing.
A `Td` with no `label` (the trailing action cell) renders its child alone,
right-aligned in the card.
Pages change only by adding `label="Hostname"` to their cells. Affected:
`servers`, `keys`, `monitors`, `secrets`, `secrets/[group]`, `workflows`,
`workflows/[id]/runs`, `audit`, `keys/[id]`, `servers/[id]` (two tables),
`monitors/[id]`, and `components/settings/MembersCard.tsx`.
### 3. Page padding and headers
- `p-8``p-4 sm:p-6 lg:p-8`, everywhere it opens a page or a page-level
error/loading state — 30 occurrences across 21 files.
- Title-plus-action header rows: `flex items-center justify-between`
`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between`. The
action button then sits under the title on a phone rather than squeezing it.
### 4. Modal becomes a bottom sheet under `sm`
`Modal.tsx`: `items-center``items-end sm:items-center`, wrapper `p-4`
`p-0 sm:p-4`, panel gets `rounded-b-none sm:rounded` and `max-h-[85dvh]`
(`dvh`, not `vh` — mobile browser chrome makes `vh` overshoot). Sheets are what
phones expect for a modal, and it costs four classes.
### 5. Workflow builder
Below `lg` the fixed-height two-column grid is dropped entirely: single column,
natural page flow, canvas scrolls with the page.
- `grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]`
`flex flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]`.
The viewport-height calculation is `lg:`-only, which matters because the
mobile top bar changes the arithmetic and `100vh` is wrong on mobile anyway.
- Node width `w-[340px]``w-full lg:w-[340px]`; the column wrapper
`w-[340px]``w-full max-w-[340px]`.
- Canvas padding `p-8``p-4 sm:p-6 lg:p-8`.
- The inspector `<aside>` becomes a collapsible bottom panel below `lg`: it
keeps its place in the flex column, gains a top border instead of a left one,
and is hidden until a step is selected (on a phone an empty "Select a step to
configure it" panel is noise).
- The builder's own header row wraps: the action cluster moves to a second line
under `sm`.
### 6. Remaining fixed layouts
| File | Change |
| --- | --- |
| `servers/[id]/page.tsx:164` | `grid-cols-3``grid-cols-2 sm:grid-cols-3` |
| `servers/[id]/page.tsx:495` | install one-liner `min-w-64``min-w-0` so it scrolls internally instead of widening the page |
| `secrets/page.tsx:53` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `monitors/MonitorForm.tsx:76` | `grid-cols-4``grid-cols-2 sm:grid-cols-4` |
| `monitors/MonitorForm.tsx:98,123,144` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `workflows/StepPickerModal.tsx:132,168` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `workflows/[id]/runs/[runId]/page.tsx:254` | matrix table wrapped in `overflow-x-auto`; it is a genuine matrix and stays scrollable |
| `workflows/[id]/runs/[runId]/page.tsx:343` | `p-8``p-4 sm:p-6 lg:p-8` |
| `servers/[id]/console/page.tsx:168` | `p-8``p-4 sm:p-6 lg:p-8`; header/toolbar rows wrap |
The run-detail matrix and the console canvas are the two places that keep
horizontal scrolling. Both are genuinely two-dimensional; stacking them would
destroy the information.
## Non-goals
- No changes to `site/` or `adminsite/`.
- No redesign of the console for touch input (no on-screen keyboard work).
- No new dependencies. Tailwind's `max-sm:` variant and `translate-x` are
enough; no headless-UI or animation library.
- No changes to any API, route or data shape. This is presentation only.
## Verification
1. **Audit** — after the edits, `grep` must return no unprefixed `p-8`,
no unprefixed `grid-cols-[2-9]`, and no `w-[3` fixed node widths outside a
`lg:` prefix in `web/app` and `web/components`.
2. `npx next lint` passes with no new warnings.
3. `npx next build` succeeds.
Screenshot verification is out of scope: the app is auth-gated behind Mongo,
Redis and the Go server, none of which run in this environment.
@@ -1,162 +0,0 @@
# Documentation site — design
**Date:** 2026-07-28
**Status:** approved
## Problem
Vantage has no user-facing documentation. Everything an operator needs — how to
install self-hosted, how to enrol an agent, what a workflow step is, how a
licence gets issued — lives either in `CLAUDE.md` (written for contributors, not
users) or in the code. The marketing site sells the product and the control
plane runs it; neither explains it.
## Solution
A fourth Next-adjacent frontend, `docsite/`, built with Docusaurus v3 and shipped
alongside the marketing site.
### Placement and deployment
- Lives at repo root as `docsite/`.
- Served at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing site's
host, routed by a separate Nginx Proxy Manager custom location rather than by
`site/`. It is a path, not a subdomain, deliberately: `*.vantage.hostxtra.co.uk`
is the per-tenant instance namespace and `APP_ROOT_LABEL` resolves an org from
the label before `vantage`, so a `docs.` label there would be read as a tenant
slug.
This makes `baseUrl: "/docs/"` load-bearing. An NPM custom location forwards
the **full** request path upstream — it does not strip the `/docs` prefix — so
the container serves the build from `/usr/share/nginx/html/docs`, not from the
document root. Prefix, asset URLs and upstream paths then agree with no
rewrite rule to keep in step. Getting this wrong is quiet: the HTML loads and
every stylesheet and script 404s.
- Added to **`deploy/docker-compose.site.yml` only**, published as `3005`.
`deploy/docker-compose.yml` (the self-hosted install) must never mention it,
exactly as it never mentions `site`, `sitesvc`, `admin` or `adminsite`.
- Added to `.gitea/workflows/server-deploy.yml` as a seventh image, rebuilding
on `^docsite/` only, build context `docsite/`.
### Runtime
Docusaurus emits a fully static site, so unlike `site/`, `web/` and
`adminsite/` there is no Node server at runtime. Two stages:
1. `node:26-alpine` builder — `npm ci && npm run build``/app/build`.
2. `nginx:alpine-slim` runner — copies `build/` to
`/usr/share/nginx/html/docs` (see the `/docs` prefix note above), plus a
small `nginx.conf` giving `try_files` a 404 fallback to Docusaurus's
`404.html` and long cache headers on `/docs/assets/`. A bare `/` request
redirects to `/docs/`, so hitting the container directly is not a blank 403.
`nginx:alpine-slim` is roughly 12MB against `caddy:alpine`'s ~50MB, and nothing
here needs Caddy's automatic TLS — the host proxy already terminates it.
Build args, baked at build time the same way `site/`'s are:
| Arg | Purpose |
| --- | --- |
| `DOCS_URL` | site `url`; defaults to `https://vantage.hostxtra.co.uk` |
| `DOCS_BASE_URL` | site `baseUrl`; defaults to `/docs/`. Must match the NPM location and the runner's copy target |
| `APP_URL` | navbar link to the control plane |
| `HQ_URL` | navbar link to the HQ portal |
### Theme
`docsite/src/css/custom.css` carries `site/app/globals.css`'s token blocks
**copied verbatim** — same names, same values — and maps Docusaurus's `--ifm-*`
variables onto them. Docusaurus stamps `data-theme="light|dark"` on `<html>`,
which is the same selector `site/`'s dark block already keys on, so the built-in
toggle works with no extra wiring. Light is the default, matching `site/` and
`adminsite/`.
This makes a **fifth** copy of the token block (`site/`, `adminsite/`, `web/`
dark-only, `shared/mail/templates/layout.html.tmpl` as literal hex, and now
`docsite/`). Nothing enforces the match; `CLAUDE.md`'s Frontend section is
updated to say so. No component in `docsite/` may carry a hex value.
Search is `@easyops-cn/docusaurus-search-local` — index built at compile time,
served from the same origin. No Algolia account, no external host, nothing to
key or rotate.
Docs-only mode: `routeBasePath: "/"`, blog disabled, no tutorial scaffolding.
## Content
Sidebar is authored explicitly in `sidebars.ts` rather than autogenerated, so
ordering is a decision rather than a filename accident.
### Getting Started
| Page | Covers |
| --- | --- |
| `what-is-vantage` | The control plane, the agent, what problem each solves |
| `cloud-vs-self-hosted` | The two deployments, what differs (licensing, HQ-managed users, reaping) |
| `self-hosted-install` | Prereqs (Docker, external MongoDB, DNS, TLS), `docker-compose.yml`, required env, `docker compose up -d` |
| `first-login` | `/setup` bootstrap, first org and owner |
| `first-server` | `POST /servers/new`, the install one-liner, Linux and Windows, watching it flip to `active` |
| `claim-free-licence` | Linking the install to an HQ account, `claim-free` |
`self-hosted-install` is the page the section exists for; it names every
required environment variable with its consequence-of-omission, in particular
`GRPC_HOST` (boot fails, no default is safe) and `KEY_ENCRYPTION_KEY`.
### Vantage (the application)
`servers` (agent install Linux/Windows, inventory, OS updates, agent
self-update) · `ssh-keys` (upload, generate-on-server, assign, revoke, what the
agent writes and when) · `workflows` (step library, default steps and why they
are read-only, the designer, running, live logs, `on_failure`, `output_env`,
workspaces, log retention) · `monitors` (the four check types, server vs agent
runner, retries, incidents, uptime rollups) · `notification-channels` (five
types, testing) · `secrets` (vault, `secret_refs` in steps, the ESO read path
and its bearer token) · `browser-console` (SSH/RDP/VNC, one-time tokens) ·
`audit-log` · `settings` (members and roles, OIDC per org, retention, ESO token,
licence).
### Vantage HQ (the portal)
`accounts-and-signup` (account-first signup, email verification, an account is
a team) · `people-and-roles` (owner/admin/member, invitations, accepting) ·
`cloud-instances` (create, overview, granting members and what a grant actually
is) · `self-hosted-instances` (purchase creates a placeholder, claim-link binds
the real UUID, relink) · `licensing-and-entitlements` (tiers, metered server
count, feature toggles, desired vs granted) · `billing` (Paddle as merchant of
record, checkout, the customer portal, changing configuration) · `free-tier`
(limits, the renewal window, reaping on cloud).
### Reference
`environment-variables` (server, sitesvc, admin, agent) · `rest-api` (the route
tables, grouped as in `CLAUDE.md`) · `grpc-api` (the eight RPCs, the command
stream) · `agent-config` (config.yaml, paths, permissions) ·
`ports-and-networking` (which ports, which direction, what needs to be
reachable) · `troubleshooting`.
### Operations
`upgrading` (pull and recreate) · `backups` (MongoDB is the durable state; Redis
is sessions only) · `agent-updates` · `ci-cd` (which image rebuilds when, and
the repo-variable gap that pushes no commit).
## Writing rules
- Every guide is task-shaped: numbered steps, real paths and commands taken from
the repository, never invented UI.
- Behaviour that is a hard refusal gets an admonition, not a paragraph: default
steps are read-only (409 `ErrDefaultStep`), `POST /license` answers 409
`cloud_managed` on cloud, HQ-sourced users cannot have their role changed
locally (409 `ErrHQManaged`).
- Where the UI enforces something, say that the API is the boundary and the UI
is the courtesy — the same phrasing the codebase uses.
- No screenshots in this pass. They rot faster than prose and there is no
capture pipeline.
## Out of scope
- Versioned documentation. One version, tracking `main`. Docusaurus versioning
can be switched on later without restructuring.
- Internationalisation.
- Screenshots and diagrams beyond what Mermaid renders inline.
- A docs search backed by an external service.
@@ -1,209 +0,0 @@
# Agent-relayed console proxy
Date: 2026-07-29
Status: approved, not yet implemented
## Problem
`consoleTunnel` builds guacamole parameters from `srv.IPAddress` and hands them
to guacd, which then dials the target itself. On a self-hosted deployment the
control plane and the managed servers share a network, so that works. On Vantage
Cloud they do not: guacd runs on the cloud host and the customer's server is on
an RFC1918 address behind their NAT. Every cloud console session to a private
address fails, for SSH, RDP and VNC alike.
Agents already hold an outbound gRPC connection to the control plane. The fix is
to carry the console's TCP bytes over that existing path rather than asking guacd
to route somewhere it cannot reach.
## Decisions
**Self-relay only.** The agent relays to its own host and nowhere else. It is
never told a hostname; the host is hardcoded to `127.0.0.1` on the agent side and
only the port comes from the server. A jump-host mode (reaching agentless devices
through a neighbouring agent) was rejected: it would give an agent the power to
dial arbitrary addresses on the customer's LAN, and the console today can only
target servers that run an agent anyway.
**A dedicated bidirectional RPC, one stream per TCP connection.** Multiplexing
console bytes onto the existing `CommandStream` was rejected — that stream
already carries control commands and workflow stdout, and an RDP framebuffer
would introduce head-of-line blocking against key sync and step output. A
separate stream also gets connection lifetime, flow control and close semantics
for free instead of needing a hand-rolled connection-ID demux.
**Always proxy, both deployments.** Direct dial is deleted rather than kept as a
self-hosted fast path or a fallback. One code path means one tested code path,
and the cloud path is the one no developer can reproduce locally. A
try-direct-then-fall-back design was rejected outright: it puts a timeout in
front of every private-network session and makes "which path did this session
use" unanswerable from the audit log.
The cost is that the console now requires a live agent, where a self-hosted
deployment could previously reach a server whose agent was down. In practice an
offline agent almost always means an offline host, and the failure is now an
immediate, explicit refusal instead of a hang.
## Architecture
Three parties rendezvous on a single `proxy_id`. Neither guacd nor the agent
changes which direction it dials: guacd still makes an outbound TCP connection,
the agent still only connects outbound to the control plane.
```
consoleTunnel (server)
1. proxy.Open(instance, server_id, port) -> proxy_id + ephemeral listener :N
2. push OpenProxyCmd{proxy_id, port} down the existing CommandStream
3. agent dials 127.0.0.1:port locally, then opens ProxyStream and sends
ProxyOpen{server_id, agent_token, proxy_id}
4. guacd dials PROXY_ADVERTISE_HOST:N (the params it was handed in step 1)
5. registry holds both halves -> io.Copy in both directions
6. either side EOFs -> close listener, close stream, drop the registry entry
```
Steps 3 and 4 race, so a registry entry has two slots and starts piping when the
second one arrives. Both waits share a single 10 second deadline; expiry closes
everything and frees the entry.
The agent dials locally *before* opening the stream, so a refused connection
arrives as an explicit `ProxyClose{reason}` rather than as a hang.
`BuildGuacParams` stops reading `srv.IPAddress` and takes the relay host and port
instead. `IPAddress` remains in use for display and for monitors.
## Wire protocol
Additive only; no existing message changes shape.
```protobuf
rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg);
message OpenProxyCmd { // ServerCommand oneof field 8
string proxy_id = 1;
uint32 port = 2;
}
message ProxyClientMsg {
oneof payload {
ProxyOpen open = 1; // first message only
bytes data = 2;
ProxyClose close = 3;
}
}
message ProxyOpen { string server_id = 1; string agent_token = 2; string proxy_id = 3; }
message ProxyServerMsg { oneof payload { bytes data = 1; ProxyClose close = 2; } }
message ProxyClose { string reason = 1; }
```
Two implementation facts about this repo shape the above. The `pb` packages are
**hand-written Go, not protoc output** — `vantage.proto` is documentation, and
both `server/internal/grpc/pb` and `agent/internal/grpc/pb` are edited by hand
and kept in sync manually. And the registered codec is JSON, so a `bytes` field
travels as a base64 string: roughly 33% overhead on relayed traffic. That is
accepted rather than fixed here, because introducing a second codec for one RPC
is a larger change than this feature warrants. Relay chunks are 32 KiB.
## Security
**The agent only ever dials `127.0.0.1`.** The port is the only field it takes
from the server; the host is hardcoded agent-side. A compromised control plane
cannot use an agent to reach anything else on the customer's network. This is the
strongest property in the design and the reason self-relay was chosen.
**`proxy_id` is 32 random bytes, single-use and scoped.** On `ProxyOpen` the
server checks three things together: the agent token hash matches that
`server_id`, the `proxy_id` exists in the registry, and the entry's `server_id`
and `instance_id` match the authenticated agent. Any mismatch closes the stream
without revealing which check failed.
**The listener is the exposed surface and is narrowed four ways.** It binds an
ephemeral port; it lives at most 10 seconds unclaimed; it accepts exactly one
connection and closes immediately afterwards; and the accepted connection's
remote address must resolve to a host named in `GUACD_ADDR`. Without that last
check, any other container on the Docker network could claim the session during
the window.
**Agent-offline is refused early.** `consoleConnect` checks
`srv.Status == "active"` and returns 409 `agent_offline`, rather than letting the
browser open a WebSocket that dies on a deadline.
**Audit.** `console.opened` gains the relay port and `proxy_id`. A relay that
expires or is refused writes `console.proxy_failed` with a reason, so a failed
console session stops being invisible.
Credentials are unchanged. Private keys and RDP passwords travel from the server
to guacd inside the guacamole handshake and never reach the agent. The SSH and
RDP sessions are negotiated end-to-end between guacd and the target daemon, so
the agent relays bytes it cannot read.
## Components
New, server:
| Unit | Responsibility |
| --- | --- |
| `server/internal/proxy/registry.go` | `Open`, `AttachAgent`, `AttachTCP`, expiry sweep. Pure state — no net, no gRPC, testable alone |
| `server/internal/proxy/session.go` | One relay: listener, deadline, the `io.Copy` pair, teardown-once |
| `server/internal/grpc/proxystream.go` | The `ProxyStream` handler: authenticate, then hand the stream to the registry. No relay logic of its own |
New, agent:
| Unit | Responsibility |
| --- | --- |
| `agent/internal/proxy/proxy.go` | `Open(ctx, client, proxyID, port)` — dial loopback, open the stream, pump bytes. No build tags; Linux and Windows share it |
Changed:
- `proto/vantage/v1/vantage.proto`, and both generated pb trees
- `server/internal/services/console.go``BuildGuacParams(srv, relayHost, relayPort, …)`
- `server/internal/api/console.go` — offline pre-check in `consoleConnect`; open the relay before the guacd handshake in `consoleTunnel` and close it in `OnDisconnect`
- `agent/internal/sync/sync.go` — handle `OpenProxyCmd`, one goroutine per proxy
- `deploy/docker-compose.yml`, `deploy/docker-compose.site.yml``PROXY_ADVERTISE_HOST=server`
Two new optional environment variables on the server: `PROXY_ADVERTISE_HOST`
(default `server`, the name guacd resolves the control plane by) and
`PROXY_LISTEN_HOST` (default `0.0.0.0`).
Nothing new is opened on the customer's firewall — the relay rides the agent's
existing outbound gRPC connection.
`docsite/docs/reference/ports-and-networking.md` and
`docsite/docs/vantage/browser-console.md` must say so, and must state the new
requirement that the agent be online.
A secondary benefit beyond cloud: a VNC or RDP service bound only to `127.0.0.1`
is now reachable, where a direct dial from guacd never could be.
## Failure modes
| Failure | Behaviour |
| --- | --- |
| Agent offline at connect | 409 `agent_offline` from `consoleConnect`, before any WebSocket is opened |
| Agent never opens the stream | 10s deadline; listener closed; `console.proxy_failed{reason:"agent_timeout"}`; WebSocket closed with a message the UI surfaces |
| Local dial refused (daemon down, wrong port) | `ProxyClose{reason}` relayed up as the same audit event, reason `dial_refused` |
| guacd never dials | Same deadline path, reason `guacd_timeout` |
| Bad token, unknown or foreign `proxy_id` | Stream closed with no detail leaked; `console.proxy_failed{reason:"rejected"}` |
| Agent process dies mid-session | Stream EOF, relay torn down, console shows a disconnect |
| CommandStream reconnects mid-session | No effect on live sessions — the relay is on its own stream. Only a new `OpenProxyCmd` needs the control stream |
Teardown is guarded by `sync.Once` on both sides: both `io.Copy` goroutines
finish, and whichever finishes second must not double-close.
## Testing
Written test-first.
- `server/internal/proxy/registry_test.go` — the two halves pair in either
order; expiry frees the entry; a second claim on a used `proxy_id` is
rejected; a mismatched `instance_id` is rejected. No network.
- `server/internal/proxy/session_test.go` — two `net.Pipe` halves; bytes flow
both ways; EOF in each direction tears down; double-close is safe.
- `server/internal/grpc/proxystream_test.go` — the authentication matrix: valid,
wrong token, unknown `proxy_id`, `proxy_id` belonging to another instance.
- `agent/internal/proxy` — a refused dial emits `ProxyClose`; the happy path
echoes bytes.
- End-to-end in `server`: a fake agent plus a `net.Listen` echo server, asserting
bytes traverse listener → registry → stream → echo and back. This is the test
that would have caught the original bug.
Manual verification, in this order: self-hosted SSH (proves no regression),
cloud SSH to a private-network host, cloud RDP to a Windows agent.
@@ -0,0 +1,307 @@
# Multiple auth providers
Date: 2026-08-03
## Problem
An instance can configure exactly one OIDC provider. `instance_oidc` holds one
document per instance, `/auth/oidc/start` takes no argument, and `/login`
renders an unconditional "Sign in with your instance's SSO" button whether or
not anything is configured behind it. Customers who federate with more than one
identity source cannot, and customers who federate with none are shown a button
that leads to an error.
## Goals
- N auth providers per instance, each independently enabled and named.
- Login page renders one button per enabled provider, and none when there are
none.
- Local email/password login can be turned off per instance.
- Presets for the common identity providers, so a customer supplies a tenant ID
rather than an issuer URL.
- Existing configured SSO keeps working across the upgrade with no customer
action.
## Non-goals
- SAML. Different protocol, metadata parsing and certificate handling; not in
this work.
- Per-provider role or group mapping. Provisioned users remain `member`, as
today.
- Provider-specific account linking. An email address is an email address; the
existing instance-scoped lookup stands.
## Data model
New collection `auth_providers`, one document per provider:
```go
type AuthProvider struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ProviderID string `bson:"provider_id" json:"provider_id"`
Name string `bson:"name" json:"name"`
Kind string `bson:"kind" json:"kind"` // "oidc" | "oauth2"
Preset string `bson:"preset" json:"preset"` // "" for custom
Issuer string `bson:"issuer" json:"issuer"`
ClientID string `bson:"client_id" json:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
Scopes []string `bson:"scopes" json:"scopes"`
Enabled bool `bson:"enabled" json:"enabled"`
CallbackNotice bool `bson:"callback_notice" json:"callback_notice"`
Order int `bson:"order" json:"order"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
```
`ProviderID` is a short random identifier, not the Mongo `_id`: it appears in
the callback URL a customer pastes into their IdP, and an `_id` there would
publish a database key.
Unique index on `(instance_id, provider_id)`. Index build is fatal on failure,
matching `EnsureAuthIndexes` — a duplicate `provider_id` within an instance
would make the callback ambiguous.
`ClientSecretEnc` is AES-256-GCM under `KEY_ENCRYPTION_KEY`, as
`instance_oidc.client_secret_enc` is today, and is never serialised.
### Presets
A Go table in `server/internal/auth/presets.go`, not database rows — adding one
is a commit, not a migration.
| Preset | Kind | Issuer | Input asked of the customer | Default scopes |
| ------- | -------- | ------------------------------------------------- | --------------------------- | ----------------------------- |
| `entra` | `oidc` | `https://login.microsoftonline.com/{tenant}/v2.0` | Directory (tenant) ID | `openid profile email` |
| `google`| `oidc` | `https://accounts.google.com` | none | `openid profile email` |
| `okta` | `oidc` | `https://{domain}/oauth2/default` | Okta org domain | `openid profile email` |
| `github`| `oauth2` | n/a | none | `read:user user:email` |
| `` (custom) | `oidc` | supplied verbatim | Issuer URL | `openid profile email` |
The issuer template is expanded server-side on save; the stored `Issuer` is
always the resolved URL, so nothing downstream has to know a preset existed.
### Settings
`settings.local_login_enabled bool`, defaulting true. Absent on existing
documents, and Go's zero value for `bool` is false, so the field is read through
a `*bool` and a nil pointer means enabled. A plain `bool` would silently
disable password login on every instance in the fleet at upgrade.
## Migration
`0005_auth_providers` — the next free number; `0004_instance_rename` is the
highest recorded today. For each document in `instance_oidc`, insert one
`auth_providers` document:
- `Name: "Single sign-on"`
- `Preset: ""`, `Kind: "oidc"`
- `Issuer`, `ClientID`, `Enabled` copied
- `ClientSecretEnc` copied **verbatim**, not decrypted and re-encrypted — a
migration that needs `KEY_ENCRYPTION_KEY` fails on an instance that has none
and strands the SSO configuration.
- `Scopes: ["openid", "profile", "email"]`, matching what `oidc.go` hardcodes
today.
- `ProviderID` freshly generated.
- `CallbackNotice: true` — this provider's redirect URI has changed and an
administrator has not yet acknowledged it. Set only by the migration; cleared
by the settings UI. New providers are created `false`.
`instance_oidc` is left in place and no longer read. Idempotent by skipping any
instance that already has an `auth_providers` document, so a re-run after a
partial failure completes rather than duplicating.
## Auth flow
Routes:
```
GET /auth/oidc/:providerId/start
GET /auth/oidc/:providerId/callback
```
The old unparameterised `/auth/oidc/start` and `/auth/oidc/callback` are
**removed**, not retained. See Upgrade impact below — this breaks configured SSO
until the customer updates their IdP, and that is accepted deliberately rather
than carried as a compatibility path.
The state token in Redis stores `{instance_id, provider_id}` rather than the
bare instance ID. The callback resolves its provider from the consumed state
and cross-checks it against `:providerId` in the path, refusing a mismatch —
the path alone is attacker-controlled, and the state is the half that was
issued by the start handler.
`providerForInstance` becomes `providerFor(ctx, c, instanceID, providerID)`.
The `go-oidc` provider cache keys on `provider_id`, not instance. Saving,
disabling or deleting a provider evicts that key.
`redirectURL(c, providerID)` returns the one per-provider shape, and returns the
same URL in the start and callback halves of a flow — an IdP rejects the token
exchange if they differ.
### OIDC providers
Unchanged from the current implementation: `AuthCodeURL` with the stored
scopes, exchange, `id_token` verified against the provider's key set with
`ClientID` as audience, `email` and `name` claims extracted.
### GitHub (`kind: "oauth2"`)
GitHub is OAuth2 and issues no `id_token`, so it takes a separate branch:
exchange the code, then `GET https://api.github.com/user/emails` with the access
token and take the address that is both `primary` and `verified`. An
unverified-only response is refused — an unverified address is not proof of
control, and accepting one would let anyone holding a GitHub account claim any
address in the instance. `name` comes from `GET https://api.github.com/user`.
Both branches converge on one function:
```go
func completeSSOLogin(c *gin.Context, instanceID, email, name string) error
```
which holds today's lookup-or-provision, session creation, `TouchLastLogin` and
cookie set, verbatim. Email is lower-cased before lookup, and the lookup stays
`GetUserInInstanceByEmail` — instance-scoped, as it is now.
### Licence gate
`services.GetLicenseState(instanceID).Feature("oidc")` continues to gate both
the start and the callback, for every provider kind, and is checked on the
callback against the instance named by the consumed state rather than the host.
Unchanged behaviour, applied to more providers.
## REST API
Unauthenticated:
```
GET /auth/providers
-> {"local_enabled": true,
"providers": [{"id": "...", "name": "...", "preset": "entra"}]}
```
Instance is resolved from the host, as `/auth/bootstrap-status` already does.
The response carries **no issuer, no client ID and no secret** — it is served to
anyone who can reach the login page.
Session-authed, `owner|admin`, under `/api`:
```
GET,POST /auth/providers
PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/test
```
`test` fetches the provider's discovery document (or, for GitHub, calls the API
with the stored credentials) and reports reachability. It does not sign anyone
in.
`GET,PUT /api/org/oidc` is removed along with the old auth routes. Its only
caller is `OIDCCard.tsx`, which this work replaces, and a compatibility shim
over a one-of-many model would have to invent which provider it means.
Every mutation writes an audit event, as every mutating path does.
### Lockout guards
Both refused with 409 and a distinct error code:
- `local_login_required` — disabling local login while zero providers are
enabled.
- `last_provider` — disabling or deleting the last enabled provider while local
login is off.
These are enforced in the service layer, not the handler, so the two endpoints
that can reach the condition cannot disagree.
## Frontend
### Settings
`web/components/settings/OIDCCard.tsx` becomes `AuthProvidersCard`, in the
Access group of `/settings` where the OIDC card already lives. It renders the
provider list with per-row enable toggle, edit, delete and drag ordering, an
Add flow that asks for the preset first and then only the fields that preset
needs, and the local-login toggle beneath the list. A guard violation surfaces
the 409's message rather than a generic failure.
Every provider row shows its **callback URL** with click-to-copy — that is the
value the customer pastes into their IdP, it now differs per provider, and after
the upgrade every migrated provider needs it re-pasted. A migrated provider
additionally carries a warning until an administrator dismisses it, naming the
change and the URL. Dismissal is per provider, stored on the document.
### Login page
`web/app/login/page.tsx` calls `/auth/providers` on mount alongside the existing
`bootstrapStatus` call, and renders on the result:
| `local_enabled` | providers | Rendered |
| --------------- | --------- | --------------------------------------------------- |
| true | none | Password form only. No divider, no buttons. |
| true | some | Password form, divider, one button per provider. |
| false | some | Buttons only. No form, no divider. |
| false | none | Password form (see below). |
The last row cannot be reached through the API — the guards above prevent it —
but a hand-edited database could produce it, and a login page that renders
nothing at all is unrecoverable without database access. It therefore falls back
to the password form.
The current unconditional SSO button and its "SSO must be enabled for this
instance by an administrator" note are both removed; the button now only exists
when it works.
Buttons are labelled with the provider's `Name` and carry the preset's icon
where there is one, a neutral key glyph otherwise. Presets never override the
name — a customer who calls their Entra provider "Staff" gets "Staff".
Errors keep the existing `/login?error=<code>` redirect convention.
## Testing
- Migration: an `instance_oidc` document produces one enabled provider with the
ciphertext byte-identical; a re-run inserts nothing further.
- `local_login_enabled` absent decodes as enabled.
- Guards: both 409 paths, and the enable/disable sequences that approach them
without crossing.
- Per-provider callback: two providers in one instance, each resolving to its
own configuration; a `provider_id` from another instance answers 404.
- A callback whose `:providerId` disagrees with the consumed state is refused,
and the state is consumed rather than left replayable.
- The removed routes (`/auth/oidc/start`, `/auth/oidc/callback`,
`/api/org/oidc`) answer 404.
- GitHub: primary+verified selected; verified-only-absent refused.
- `/auth/providers` response contains no issuer, client ID or secret.
## Upgrade impact
**This release breaks configured SSO until each customer updates their identity
provider.** The old `/auth/oidc/callback` is gone, migrated providers are
reachable only at `/auth/oidc/<providerId>/callback`, and an IdP still pointing
at the old URL fails the flow.
It is a deliberate trade: one callback shape rather than two, no
`legacy_callback` branch through `redirectURL`, and no permanently retained
route whose only purpose is a single past upgrade.
Mitigations, in order of who sees them first:
- The settings card shows the new callback URL per provider with click-to-copy,
and a migrated provider carries a dismissable warning naming the change.
- The failure is visible rather than silent: an IdP rejects the redirect URI
before Vantage is reached, so the customer sees their own provider's error.
- Local password login is unaffected, so no instance is locked out — an
administrator can always sign in to fix the URL. This is why
`local_login_enabled` defaults to true and why nothing in this migration
turns it off.
- Release notes and `docsite/docs/vantage/settings.md` state the required
action.
## Deployment notes
No new environment variables. No agent change. `KEY_ENCRYPTION_KEY` is already
required wherever OIDC was configured, and the migration does not add a
dependency on it.
@@ -0,0 +1,235 @@
# Server tags and scheduled workflows
Date: 2026-08-04
Two features, designed together because the second is worth much less without
the first. Tags make a target set describable; schedules make it recur. A
nightly job that patches "everything tagged `env:staging`" needs both halves,
and neither half is large on its own.
---
## Part A — Server tags
### Model
`models.Server` gains one field:
```go
Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
```
Keys and values are lowercase `[a-z0-9_-]`. Keys are capped at 32 characters,
values at 64, and a server holds at most 20 tags. Validation lives in the
service layer rather than the handler, so the tag endpoint, the server-create
path and anything added later cannot disagree about what a valid tag is.
There is **no `tags` collection.** A tag is a property of a server, not an
entity with a lifecycle: a registry would need reference counting to know when
a tag stopped existing, and garbage collection to act on it, which is work
bought for nothing. The list of known keys and values that the UI offers for
autocomplete is a distinct aggregation over `servers`, cached for 60 seconds —
the same treatment org lookups already get.
No reserved keys ship in this change. If inventory-derived tags (`os`, `arch`)
are added later they take a `sys:` key prefix, so a user tag written today can
never collide with a system tag invented tomorrow.
Index: `{instance_id: 1, "tags.$**": 1}` — a wildcard index over the tag
subdocument, because the queried key is chosen by the user at request time and
cannot be named in advance.
### API
```
PUT /api/servers/:id/tags # replace the whole map
GET /api/servers/tags # known keys and values, for pickers
GET /api/servers?tag=env:prod # repeatable; AND across keys
```
`PUT` replaces the entire map rather than patching one tag. A tag set is small
enough that sending all of it is free, and last-write-wins over a whole map is
easier to reason about than merge semantics between two people editing the same
server. The audit event records the map before and after.
`?tag=` is repeatable and ANDs: `?tag=env:prod&tag=role:web` matches servers
carrying both. A malformed value (no colon, unknown characters) is a 400 rather
than a silent empty result — a filter that matches nothing and a filter that is
nonsense look identical in a list, and only one of them is the user's fault.
### Targeting
`models.Workflow` gains `TargetTags map[string]string` beside the existing
`TargetServerIDs`. One function in `services` resolves them:
```go
ResolveTargets(ctx, instanceID string, ids []string, tags map[string]string) ([]Server, error)
```
- Result is the **distinct union** of the explicit IDs and the tag matches.
- Tag matching ANDs across keys.
- Offline servers are included. The dispatcher already answers 503 per server,
and a patch run that silently omits an unreachable machine is worse than one
that visibly fails on it.
- Empty IDs **and** empty tags returns `ErrNoTargets` (400). A workflow that
matches nothing must say so rather than report success over zero servers.
The resolved set is snapshotted into `WorkflowRun.ServerRuns` exactly as today.
History records what actually ran, not what the selector would match when the
run is later read back — the same reason `steps_snapshot` exists.
### Frontend
- **Server detail**: tag chips in the header with an inline editor. Keys
autocomplete from `GET /api/servers/tags`, values autocomplete per key.
- **`/servers`**: a filter bar that reads and writes the same `?tag=` query
params the API takes, so a filtered fleet view is a URL someone can send.
- **Workflow designer**: a target section holding both inputs, with a live
"runs on 14 servers" readout that lists them on hover. The union model costs
us the at-a-glance answer to "what will this touch"; this readout buys it
back, and it is the reason the union is acceptable.
---
## Part B — Scheduled workflows
### Model
```go
type Schedule struct {
Enabled bool `bson:"enabled" json:"enabled"`
Cron string `bson:"cron" json:"cron"` // 5-field
TZ string `bson:"tz" json:"tz"` // IANA name
}
type Skip struct {
Reason string `bson:"reason" json:"reason"` // "missed" | "already_running"
Due time.Time `bson:"due" json:"due"`
At time.Time `bson:"at" json:"at"`
}
```
On `Workflow`:
```go
Schedule *Schedule `bson:"schedule,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty"` // UTC, indexed
LastRunAt *time.Time `bson:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty"`
```
`next_run_at` is **persisted, not held in memory.** A leader handover between
computing the next occurrence and firing it would otherwise either lose the
occurrence or fire it twice. Coordination state has to live where every replica
can see it — the same argument that put `workflow_log_seq` in MongoDB.
Cron parsing uses `robfig/cron/v3`'s **parser only**`Parse` and
`Next(time)`. Its scheduler and goroutines are not used; the loop below is ours
and has to be, because it runs under the leader lock.
**Alpine ships no tzdata.** `server/Dockerfile` builds a slim image, so
`time.LoadLocation("Europe/London")` returns an error and every schedule
falls back to UTC — an hour wrong for half the year, in the direction nobody
notices until a maintenance window lands in business hours. `main` therefore
imports `_ "time/tzdata"`, embedding the database in the binary. Zone names are
also validated at save time, so an unknown zone is a 400 rather than a surprise
at 2am.
### Scheduler
A new `server/internal/workflowsched` package, started inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched`, `StartReaper`
and the sweepers. One role, one lock. It takes the same cancellable context and
returns the instant leadership is lost.
The loop ticks every 30 seconds:
1. `find({schedule.enabled: true, next_run_at: {$lte: now}})`.
2. **Claim atomically.** `findOneAndUpdate` matching the document *and* its
current `next_run_at`, setting the recomputed next occurrence. A process
that reaches the same document after another has claimed it matches nothing
and does nothing. The claim is what makes this correct; the leader lock only
makes it cheap.
3. **Grace check.** If `now - due > 1h`, record
`last_skipped{reason: "missed"}`, write an audit event, and do not run. A
job missed by ten minutes during a deploy should still run; one missed by
two days should not fire at lunchtime.
4. **Overlap check.** If a run for this workflow is still active, record
`last_skipped{reason: "already_running"}`, audit, and do not run. A patch
workflow must never run twice at once, and a silent skip is how a week goes
by before anyone notices nothing ran.
5. Otherwise start the run through the **same** `RunWorkflow` path a person
uses, with `TriggeredBy: "schedule"`.
Step 5 is the design. A scheduled run is an ordinary run with a different
trigger: no second dispatch path, no second snapshot format, and the run detail
page needs no changes to display one.
### API
```
PUT /api/workflows/:id/schedule # {enabled, cron, tz}
GET /api/workflows/:id/schedule/preview?cron=…&tz=… # next 3 occurrences
```
`PUT` validates the expression and the zone, then computes and stores
`next_run_at`. The preview endpoint exists so the browser and the scheduler
agree on what a cron string means — a client-side cron parser that disagrees
with the server by one field is a bug found in production, at night.
### Frontend
- **Workflow page**: a schedule card with preset buttons (hourly, nightly at
HH:MM, weekly on DAY at HH:MM) that write cron underneath, a raw cron field
for anything else, a timezone select, and the next three occurrences rendered
from the preview endpoint in mono.
- **Workflows list**: a schedule chip and the next run as relative time.
- **Skips are surfaced**, not just stored: a warning line reading
"Skipped Sun 02:00 — previous run still active". Recording a reason nobody
reads is the same as not recording one.
---
## Out of scope
**Notification on scheduled-run failure.** It needs the monitor channel
machinery pointed at workflow outcomes and its own answer to what counts as
failure — a non-zero exit on a step with `on_failure: continue` is not
obviously an alert. Visibility in this change is the run list and the recorded
skip reason. Excluded deliberately, not overlooked.
**Tag-scoped permissions.** Roles stay instance-wide. Tags describe servers;
they do not yet gate who may act on them.
**Inventory-derived tags.** Reserved via the `sys:` prefix, not implemented.
---
## Migration and compatibility
No migration is required. `Tags`, `TargetTags` and `Schedule` are all
`omitempty` and absent means what it meant before: no tags, no selector, no
schedule. Existing workflows keep their explicit server lists and behave
identically.
The wildcard tag index and the `next_run_at` index are declared by a new
`EnsureServerIndexes`, following the convention `EnsureSecretIndexes` and
`EnsureWorkflowIndexes` already set: it warns rather than aborting boot,
because a missing index degrades
tag filtering to a collection scan on a small collection rather than breaking
the fleet list.
## Testing
- `ResolveTargets`: union deduplicates; AND across tag keys; empty/empty
returns `ErrNoTargets`; offline servers are included.
- Tag validation: charset, length caps, tag count cap, malformed `?tag=` is a
400.
- Schedule validation: bad cron and unknown zone both 400; `next_run_at` is
computed in the stored zone, verified across a DST boundary.
- Scheduler claim: two concurrent claims of the same due workflow start exactly
one run.
- Grace window: due 10 minutes ago runs; due 2 hours ago records `missed`.
- Overlap: an active run yields `already_running` and no second run.
- Preview endpoint and the scheduler agree on the next occurrence for a table
of expressions, including a DST-crossing one.
-89
View File
@@ -1,89 +0,0 @@
# Vantage Licensing Programme — Spec Index
Build in this order. Specs 0a5 were designed 2026-07-24; spec 6 on 2026-07-26.
| # | Spec | Plan | Status |
|---|---|---|---|
| 0a | [shared-module](2026-07-24-shared-module-design.md) | [plan](../plans/2026-07-24-shared-module.md) | **shipped** |
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live |
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | **shipped** |
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-27-paddle-billing.md) | **shipped (code)** — client, webhooks, checkout, entitlement update and portal built and compiled against spec-7's catalogue/entitlements; signup-migration dropped (done by 6). Live sandbox catalog + end-to-end pass is the operator's step. Old [2026-07-26 plan](../plans/2026-07-26-paddle-billing.md) superseded. |
| 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start |
| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | [plan](../plans/2026-07-26-metered-licensing.md) | **shipped** — staff can configure and issue any of the six plans; no customer can buy one until 5 lands |
Specs 1 and 2 together give working licensing with licences cut by hand with
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
7 lands before 5. It re-keys `plans` on `(deployment, tier)`, moves every Paddle
price ID out of `plans` into a new `catalogue` collection, and adds the
`entitlements` collection that both a subscription and a licence are derived from
— all of which plan 5 builds on top of, so building 5 first would mean writing
its billing code twice.
## The shape
```
Account (admin only)
├── Instance 1 cloud vantage.hostxtra.co.uk/<slug> licence auto-injected
├── Instance 2 cloud licence auto-injected
└── Instance 3 self-hosted customer's own deployment licence pasted by hand
```
The control plane knows only **Instance**. Accounts exist solely in the admin
service, because a self-hosted instance has no row in the cloud database at all.
## Decisions that everything else follows from
**Licences are offline-verified signed blobs.** ECDSA P-384 with SHA-256 via
`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home
anywhere. This buys air-gapped self-hosting and means no Vantage instance ever
depends on the licensing service being up. It costs revocation: a licence is
valid until it expires whatever Paddle later says. Self Hosted is annual-only to
bound that window.
**Every licence is bound to one instance UUID.** Self-hosted customers link their
UUID before the licence is signed, so there is no unbound licence and no claim
protocol.
**Expiry degrades, it does not break.** Monitors keep executing, alerts keep
firing, agents keep their keys, in-flight workflow runs finish. Mutations stop.
Deletes and OS-update application stay open so a customer is never trapped
over-limit or unpatched.
**Tiers are data, not code.** The server reads `Limits` and `Features` and never
branches on tier name. Tier contents live in the admin `plans` table and are
snapshotted into each issued licence, so editing a plan never rewrites history —
the same rule as `workflow_runs.steps_snapshot`.
Spec 7 replaces the three-tier table below with two deployments times three
tiers, and makes the server count a metered quantity rather than a fixed
allowance. See [metered-licensing](2026-07-26-metered-licensing-design.md) for
the current grid. As shipped through spec 3, the table is:
| | Free | Professional | Self Hosted |
|---|---|---|---|
| deployment | cloud only | cloud | self-hosted |
| max servers | 3 | unlimited | unlimited |
| max secret groups | 1 | unlimited | unlimited |
| max channels | 1 | unlimited | unlimited |
| console | no | yes | yes |
| OIDC | no | yes | yes |
| term | monthly, £0 | monthly or annual | annual only |
Free is cloud-only by construction: it is only ever signed with
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
no server-side flag to edit. One Free instance per account.
**Spec 7 ends that construction-level guarantee** — there is a self-hosted Free
plan, so `plan.Deployment != inst.Deployment` no longer implies it, and the Free
limit becomes one per account *per deployment*.
**Existing cloud tenants are not grandfathered.** The migration that would have
done it was removed before plan 2 shipped, so every existing cloud instance is
read-only until it is licensed by hand through the admin service: attach it to an
account with `POST /api/staff/instances`, then `POST /api/staff/instances/:id/issue`.
That flow is verified in plan 3, so it works today via the API and is the first
job the admin UI is used for.
@@ -10,67 +10,54 @@ HQ portal.
## What a licence is
A signed file. It carries the instance UUID it belongs to, the tier, the server
A signed file. It carries the instance ID it belongs to, the tier, the server
allowance, feature toggles and an expiry. The control plane verifies the
signature locally checking a licence never contacts HQ, and a running instance
does not need HQ to be reachable.
signature locally.
Signing happens in exactly one place, in HQ. The control plane can only verify.
A running instance does not need HQ to be reachable.
## 1. Find your instance UUID
:::info One Free per account, per deployment.
The limit is enforced per account **and** deployment, so a Free cloud instance does not stop you claiming Free on a self-hosted install.
:::
In the control plane, go to **Settings → Licence**. The instance UUID is shown
there. It is the identity your licence binds to.
## 1. Find your instance ID
## 2. Link the install to your HQ account
In the control plane, go to **Settings → Licence**. The instance ID is shown there.
## 2. Create a free license
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
account, see [Accounts and signup](../hq/accounts-and-signup.md).
2. Choose **Link an instance**.
3. Paste the instance UUID and give it a name you will recognise.
2. Click on the **Buy A Plan** button.
3. Click on **Self Hosted** then click on the **Free** plan, then finally Paste the instance ID and give it a name you will recognise.
Linking claims the UUID for your account. A UUID already linked elsewhere is
refused with a conflict rather than silently moved.
You will then see the new instance on the **Overview** page.
## 3. Claim Free
## 3. Downloading the free license
With the instance linked, choose **Claim Free** on it. HQ issues a Free licence
bound to that UUID and hands it back.
With the instance created go to the **Overview** page and expand the new instance.
:::info One Free per account, per deployment
The limit is enforced per account **and** deployment, so a Free cloud instance
does not stop you claiming Free on a self-hosted install. Both the friendly
pre-check and the issuer apply the same rule deliberately, because a
pre-check stricter than the issuer would refuse something that would actually
have worked.
:::
Click on the **View Instance Settings** button. You can then click on the **Download License** or the **Copy to clipboard** button.
## 4. Install the licence
Download the licence from HQ and paste it in the control plane at
**Settings → Licence**.
The instance validates the signature, checks the UUID matches its own, and
The instance validates the signature, checks the ID matches its own, and
starts reporting the tier, allowance and expiry.
:::warning Cloud instances cannot paste a licence
On a cloud instance `POST /license` answers `409 cloud_managed`, and the UI
hides the form entirely. A cloud licence is written directly by HQ. This is not
a restriction the injection path has to work around it writes to the database,
not through the endpoint.
:::info Cloud instances do **not** require installing the license as this is done automatically.
:::
## Renewing
Free licences are renewable from HQ within a renewal window near expiry;
outside that window the renew call refuses. See [Free tier](../hq/free-tier.md).
Pasting a licence keeps working while the current one is expired that endpoint
is exempt from the licence check, because it is the way out of degraded mode.
outside that window you cannot renew early. See [Free tier](../hq/free-tier.md).
## Moving the install to new hardware
Rebuilding produces a new instance UUID, and a licence binds to a UUID. Use
Rebuilding produces a new instance ID, and a licence binds to a ID. Use
**Relink** in HQ to move the licence across. The number of relinks per term is
capped; the portal shows how many you have left.
+33 -36
View File
@@ -4,7 +4,7 @@ title: First login
sidebar_label: First login
---
A fresh install has no users and no organisation. The first visit creates both.
A fresh install has no users and no instance. The first visit creates both.
## 1. Bootstrap
@@ -13,48 +13,49 @@ Open the control plane in a browser. Because no user exists, you land on
Fill in:
| Field | Notes |
| ----------------- | ----------------------------------------------------------------- |
| Organisation name | Display name. Shown throughout the UI |
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
| Your name | |
| Email | Becomes your sign-in identity |
| Password | Stored bcrypt-hashed |
| Field | Notes |
| ------------- | ------------------------------------- |
| Instance name | Display name. Shown throughout the UI |
| Email | Becomes your sign-in identity |
| Password | Stored bcrypt-hashed |
Submitting creates the organisation and its **owner** you.
Submitting creates the instance and its **owner** you.
:::warning Bootstrap works exactly once
The endpoint is open only while the database has no users. As soon as the first
one exists, `/setup` redirects to the login page and the bootstrap endpoint
refuses. There is no second chance to create the first owner, so record the
credentials before you close the tab.
one exists, There is no second chance to create the first owner, so record the
credentials before you continue.
:::
## 2. Sign in
## 2. Copy the Instance ID
You are taken to `/login`. Sign in with the email and password you just set.
Once you have finished setup you will see the successfully created page.
Sessions are an opaque 32-byte token in the `km_session` cookie, with the body
held in Redis for 24 hours. Restarting Redis signs everyone out and loses
nothing else.
This will show the Instance ID. You will need this ID when creating a license in the HQ.
## 3. Look around
## 3. Sign in
You land on the fleet dashboard, which is empty. The sidebar is the whole
Click the continue to sign in button on the successful setup page.
You will be taken to `/login`. Sign in with the email and password you just set.
## 4. Look around
You land on the servers dashboard, which is empty. The sidebar is the whole
product:
| Section | What it does |
| --------- | ----------------------------------------- |
| Servers | The fleet enrol, inspect, console, update |
| Keys | SSH public keys and their assignments |
| Workflows | Compose and run scripted work |
| Steps | The reusable step library |
| Monitors | HTTP, TCP, ICMP and TLS checks |
| Secrets | The encrypted vault |
| Audit | Every mutating action |
| Settings | Members, SSO, alerts, retention, licence |
| Section | What it does |
| --------- | ------------------------------------------ |
| Servers | The server enrol, inspect, console, update |
| Keys | SSH public keys and their assignments |
| Workflows | Compose and run scripted work |
| Steps | The reusable step library |
| Monitors | HTTP, TCP, ICMP and TLS checks |
| Secrets | The encrypted vault |
| Audit | Every mutating action |
| Settings | Members, SSO, alerts, retention, licence |
## 4. Add the rest of your team
## 5. Add the rest of your team
Go to **Settings → Access**. Add members with a role:
@@ -66,10 +67,6 @@ Go to **Settings → Access**. Add members with a role:
Settings and organisation management require `owner` or `admin`.
If you would rather not manage passwords, configure OIDC instead see
[Settings](../vantage/settings.md#single-sign-on-oidc). OIDC is configured per
organisation, and the client secret is stored encrypted.
If you would rather not manage passwords, configure single sign-on instead: see [Settings](../vantage/settings.md#single-sign-on).
## Next
[Add your first server](./first-server.md).
You can add more than one identity provider; each gets its own button on the login page, and no buttons appear at all until at least one provider is configured.
+27 -41
View File
@@ -4,41 +4,39 @@ title: Add your first server
sidebar_label: Add your first server
---
Enrolling a machine means running one command on it. The control plane issues a
Enrolling a server means running one command on it. The control plane issues a
short-lived token, the install script fetches the agent and writes a config, and
the machine registers itself.
## 1. Create the enrolment
In the UI, go to **Servers → Add server**. That calls `POST /api/servers/new`,
which generates a server ID and a pre-registration token and hands back a ready
one-liner.
In the UI, go to **Servers → Add server** Then click the **Generate Install Command** button.
This generates a server ID and a pre-registration token
:::warning The token is single-use and lives one hour
It is the only credential in the flow, and it is spent the moment the agent
calls `Register`. If you paste it somewhere and come back tomorrow, create a new
enrolment instead nothing is lost by doing so.
It is the only credential in the flow, and it is spent the moment the agent registers.
:::
## 2. Run the one-liner
### Linux
Run the generated install script as root.
Here is an example of the install script:
```bash
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
```
Run it as root. The script:
What the script does:
1. Detects architecture `x86_64` and `aarch64` only; anything else exits.
2. Asks the Gitea API for the newest `agent/v*` release.
3. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**,
aborting on a mismatch.
4. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
5. Writes `/etc/vantage/config.yaml` (directory `0700`, file `0600`) containing
the server ID, the pre-registration token and the gRPC host.
6. Writes `/etc/systemd/system/vantage-agent.service` with `Restart=always`, and
runs `systemctl enable --now vantage-agent`.
2. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**, aborting on a mismatch.
3. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
4. Writes the config file at `/etc/vantage/config.yaml`
1. This contains the server ID, the pre-registration token and the gRPC host.
5. Writes the systemd service file `/etc/systemd/system/vantage-agent.service` and starts the agent.
### Windows
@@ -46,42 +44,30 @@ Run it as root. The script:
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
```
Run from an elevated PowerShell. The agent is registered as a service through
NSSM, with the config at `%ProgramData%\vantage\config.yaml`. There is also an
MSI built by CI if you would rather deploy that.
Run from an elevated PowerShell.
:::info Windows agents are second-class on purpose
They register, heartbeat, run workflow steps and report inventory. They do
**not** manage `authorized_keys` the key subsystem is Linux-only, and a
Windows agent stops after the heartbeat portion of the poll.
What the script does:
1. Creates the config at `%ProgramData%\vantage\config.yaml`.
1. This contains the server ID, the pre-registration token and the gRPC host.
2. Downloads the agent MSI from Gitea.
3. Installs the MSI and creates the Windows service.
4. Starts the agent.
:::info Windows agents do **not** manage `authorized_keys` as this is a Linux-only function.
:::
## 3. Watch it come up
The server appears immediately as `pending`. Within one poll interval 30
seconds it flips to `active`.
The server appears immediately as `pending`. Within one poll interval, 30 seconds it becomes `active`.
On the machine:
Check the systemd logs using the following commands:
```bash
systemctl status vantage-agent
journalctl -u vantage-agent -f
```
What happens on that first run:
```
1. Load /etc/vantage/config.yaml
2. pre_reg_token present → register → save agent_token, clear pre_reg_token
3. Reconnect with the permanent token
4. Start: command stream · hourly update check · inventory · monitors
5. Enter the key poll loop
```
After registration the config no longer contains the pre-registration token; it
contains a permanent agent token instead. The control plane stores only the
SHA-256 of that token, never the token itself.
## 4. Confirm it works
Open the server's detail page. Within a minute or two you should see:
@@ -104,7 +90,7 @@ Open the server's detail page. Within a minute or two you should see:
A server is marked `offline` when its last-seen time passes the threshold; that
sweep runs every two minutes, so allow for it before concluding anything.
## Next
## Next Steps
- [Assign an SSH key](../vantage/ssh-keys.md)
- [Run a workflow](../vantage/workflows.md)
+7 -27
View File
@@ -4,8 +4,7 @@ title: Accounts and signup
sidebar_label: Accounts and signup
---
Vantage HQ, at `vantage-hq.hostxtra.co.uk`, is where you manage the **account**
behind your instances: your team, your instances, their licences and billing.
[Vantage HQ](https://vantage-hq.hostxtra.co.uk) is where you manage the **account**, your team, your instances, their licences and billing.
## An account is a team, not a person
@@ -31,26 +30,15 @@ Signup is **account-first**. Creating an account creates the account and you;
it does not create a Vantage instance. Nothing exists in any control plane until
you later create or link one.
1. Go to the signup form.
1. Go to the [signup form](https://vantage.hostxtra.co.uk/start).
2. Enter your name, email and a password.
3. Check your email and click the verification link.
:::info Verify before you can sign in
An unverified account gets a distinct "check your email" message rather than a
generic authentication failure the address is already known to be yours, so
there is nothing to protect by being vague.
:::
Verification links are valid for **24 hours**. The token is 32 random bytes and
only its SHA-256 hash is stored, so a leaked database yields no working links.
If the verification email cannot be sent, the signup is rolled back rather than
left stranded retry rather than assuming a half-created account is in the way.
Verification links are valid for **24 hours**.
## Signing in
Email and password. The session is a cookie, separate from the control plane's:
signing in to HQ does not sign you in to an instance, and vice versa.
Use the Email and password used in the signup form to login to the HQ, signing in to HQ does not sign you in to an instance, and vice versa.
## What comes next
@@ -65,14 +53,6 @@ signing in to HQ does not sign you in to an instance, and vice versa.
Three destinations: **Overview**, **People**, **Billing**.
Settings lives in the account menu rather than the nav, because it is your
password rather than a place. The appearance toggle is there too.
Overview lists your instances. Each is one record, closed to a row and open to
its licence contents, members and actions. It opens by default when it is your
only instance or when it needs attention, and your manual choice is remembered.
There is deliberately no "your plan" card in the sidebar: tier, limits and
expiry belong to a **licence**, and a licence belongs to one instance. An
account with a Free cloud instance and a Professional self-hosted one has no
single plan to show.
- Overview lists your instances.
- People shows all the account members and their roles.
- Billing show the current subscriptions and subscription management.
+17 -50
View File
@@ -8,56 +8,29 @@ Paid plans are billed through **Paddle**, which is the merchant of record. Your
invoice, your card details and your tax handling are all Paddle's; HQ holds a
customer reference and nothing sensitive.
Billing is **owner-only**.
:::warning
The Billing page requires the **owner-only** account role.
:::
## Buying
## Buying A Plan
Buying a plan license can be found in Vantage HQ by clicking on the **Buy a Plan** button on the **Overview** page.
### Cloud
Open the instance, change its configuration to what you want, and check out.
Checkout runs in the browser.
On the **Buy A Plan** page you will need to select the **Deployment** to **Cloud** then chose your **Billing** cycle (Monthly or Annually).
Then select your desired **Plan** and configure the features.
Finally specify the **Instance Name** and click the **Continue to payment** button.
### Self-hosted
**Buy self-hosted**, then bind the purchase to your install's UUID. See
[Self-hosted instances](./self-hosted-instances.md).
On the **Buy A Plan** page you will need to select the **Deployment** to **Self-Hosted** then chose your **Billing** cycle (Monthly or Annually).
## What you are buying
Then select your desired **Plan** and configure the features.
A subscription's line items are the configuration: the plan base, the metered
server count above the base, and any per-instance features. Changing the
configuration changes the line items.
## Changing configuration
**Instance → Configuration**, adjust servers or features, and save.
- **Increases** take effect when the payment confirms.
- **Reductions** are scheduled for the end of the term. The portal shows the
date and the new value.
## The customer portal
**Billing → Manage** mints a Paddle customer-portal session where you can
update your payment method, see invoices and cancel.
## How a licence follows a payment
```mermaid
flowchart LR
C["Checkout / change"] --> P["Paddle"]
P -->|signed webhook| H["HQ"]
H --> G["Entitlement: desired → granted"]
G --> L["Licence signed from granted"]
```
The webhook is the **only** issuing path for paid plans. It is signature
verified, processed exactly once, and resolved from the subscription's _current_
line items so a webhook that arrives out of order still produces the right
answer rather than replaying a stale state.
A licence is signed from **granted** only. A checkout you abandon changes
nothing.
Finally specify the **Instance Name** and click the **Continue to payment** button.
## Cancelling and failed payments
@@ -66,18 +39,12 @@ Your licence runs to its grace-padded expiry and then lapses normally. There is
no mid-term cut-off.
For a cloud Free instance, lapsing eventually leads to deletion see
[Free tier](./free-tier.md). Paid instances are not reaped.
[Free tier](./free-tier.md). Paid instances are not deleted.
## Renewals
At renewal the subscription bills again and the licence is reissued for the new
term. It is also the only moment a scheduled **reduction** takes effect.
Self-hosted customers: download and paste the reissued licence. Cloud customers:
nothing to do.
## Free is not in Paddle at all
Free has no subscription, no £0 line item and no Paddle record. It has its own
renewal, in the portal. An account only acquires a Paddle customer reference
with its first paid purchase.
- Self-hosted customers: download and paste the reissued licence.
- Cloud customers: the license is automatically linked to the instance.
-5
View File
@@ -25,11 +25,6 @@ features on a paid plan.
The limit is enforced per account **and** deployment. A Free cloud instance does
not prevent a Free self-hosted one they are separate slots.
## Free is outside Paddle
There is no subscription, no £0 line item and no invoice. Your account acquires
a Paddle customer reference only with its first paid purchase.
## Renewing
Free licences have a term and must be renewed from the portal.
@@ -30,10 +30,10 @@ entitlement.
Two are per-instance toggles rather than tier bundles:
| Feature | What it enables |
| --------- | ------------------------------------------------------------------------- |
| `console` | The [browser console](../vantage/browser-console.md) |
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on-oidc) |
| Feature | What it enables |
| --------- | -------------------------------------------------------------------- |
| `console` | The [browser console](../vantage/browser-console.md) |
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on) |
No tier includes them by default; you enable them on the instances that need
them.
@@ -88,8 +88,3 @@ or let it be written for you (cloud).
When you exceed your server allowance, enrolling another one is refused. The
existing fleet is unaffected. Raise the allowance in the portal, or remove a
server you are not using.
## Legacy tiers
An older `self_hosted` tier is mapped forward to self-hosted Professional
wherever it appears. Nothing needs doing about it.
-3
View File
@@ -10,7 +10,6 @@ themselves on command.
## Checking the current version
Each server's detail page shows the version it reported at its last sync.
`GET /api/agent/latest-version` reports the newest release available.
## Updating from the UI
@@ -21,8 +20,6 @@ version. The agent then:
2. Verifies the SHA-256 against `checksums.txt`.
3. Stops itself, replaces the binary in place, and starts again.
`Restart=always` on the systemd unit is what makes the last step work.
The server briefly goes `offline` and comes back within a poll interval or two.
## Updating from the machine
+1 -8
View File
@@ -52,16 +52,9 @@ cp /opt/vantage/.env /secure-location/vantage.env
Treat it as a credential in its own right it holds the encryption key.
## Run logs
Workflow run logs live in the `./data` bind mount, not in the database. They are
swept on the retention schedule anyway, so most people do not back them up. If
you keep them for compliance, set retention to `0` (forever) and include the
directory.
## What a restore gives you
Everything: fleet, keys, assignments, workflows and their history, monitors and
Everything: server, keys, assignments, workflows and their history, monitors and
incidents, secrets, settings and the audit log.
What it does **not** do is reconcile the world. After a restore:
+2 -32
View File
@@ -37,31 +37,9 @@ tls: true
:::danger This file is the credential
`agent_token` is plaintext here and nowhere else the control plane holds only
its SHA-256. Anyone who can read this file can act as this agent. That is why
it is `0600` and the directory is `0700`.
its SHA-256. Anyone who can read this file can act as this agent.
:::
## Startup sequence
```
1. Load the config
2. pre_reg_token present → register → save agent_token,
clear pre_reg_token, reconnect
3. Start: command stream · hourly update check · inventory · monitors
4. Enter the key poll loop
```
## The poll loop
```
1. Ask the control plane for the desired key state, reporting the
agent version
2. Non-Linux hosts stop here Windows agents register and heartbeat only
3. Diff the desired keys against /root/.ssh/authorized_keys;
unchanged → write nothing
4. Changed → write a temp file, rename it over the real one, chmod 0600
```
## Service management
### Linux
@@ -77,21 +55,13 @@ journalctl -u vantage-agent -f
### Windows
A service registered through NSSM, or installed by the MSI that CI builds.
A service registered through NSSM, or installed by the MSI.
```powershell
Get-Service vantage-agent
Restart-Service vantage-agent
```
## Command-line flags
```
vantage-agent -generate-key
```
Generates a keypair locally. Normal operation takes no flags.
## Moving an agent to a new control plane
Change `server_url`, clear `agent_token`, set a fresh `pre_reg_token` from a new
+7 -2
View File
@@ -22,7 +22,9 @@ GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me /auth/oidc/start /auth/oidc/callback
GET /auth/me
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} — no issuer, client ID or secret
GET /auth/oidc/:providerId/start · /auth/oidc/:providerId/callback
GET /api/secrets/:group/values # bearer token (ESO)
```
@@ -114,7 +116,10 @@ POST /license (self-hosted only)
GET,POST /org/users
PUT /org/users/:id/role
DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
GET,POST /auth/providers (owner|admin)
PUT,DELETE /auth/providers/:id (owner|admin)
POST /auth/providers/:id/test · /auth/providers/:id/ack-notice (owner|admin)
GET /auth/presets (owner|admin)
```
## Notable refusals
+46
View File
@@ -26,6 +26,52 @@ The offline sweep runs every two minutes, so a machine that has just gone away
takes a little while to be marked as such. That delay is intentional a single
missed poll is not an outage.
## Tags
A tag is a `key:value` label you put on a server. Tags are how you say what a
machine **is** `env:prod`, `role:web`, `team:core-infra` so that you can find
it later, and so that a [workflow](./workflows.md) can target it without you
naming it by hand.
There is no tag library to manage first. A tag exists because a server carries
it, and it stops existing when the last server carrying it drops it.
### The rules
| Rule | Value |
| ---------- | ------------------------------------------------- |
| Characters | lowercase letters, digits, `-` and `_`, on both halves |
| Key length | up to 32 characters |
| Value length | up to 64 characters |
| Per server | up to 20 tags |
Neither half may be empty, and keys beginning `sys:` are reserved for tags
Vantage may derive from inventory later, so a tag you write today can never
collide with one invented for you tomorrow.
Anything outside those rules is refused with a message naming the rule, rather
than quietly saved in a shape you did not intend. Uppercase is not folded to
lowercase for you `Env` is a mistake, not a synonym for `env`.
### Editing a server's tags
On the server detail page, **Edit** beside the tag chips. Saving replaces the
whole set: what you see in the editor is exactly what the server will have.
There is no per-tag merge, so if two people edit the same server at once, the
last save wins outright rather than producing a blend of the two.
### Filtering the fleet
The **Servers** list has a picker per tag key in use. Choosing values from more
than one key narrows the list a server must match **all** of them, not any.
Untagged servers appear only when no filter is set.
:::tip A filtered fleet view is a link
The filter lives in the URL (`/servers?tag=env:prod&tag=role:web`). Copy the
address bar and you have sent someone the same view, not a description of how to
reproduce it.
:::
## The server detail page
### Keys
+46 -10
View File
@@ -16,6 +16,16 @@ this instance behaves" produced two half-pages and a nav entry nobody could
distinguish from Settings. The old path still redirects.
:::
:::danger Upgrading breaks existing single sign-on until you re-register the callback URL
Callback URLs are now per provider instead of one shared URL for the whole
instance. If you already had single sign-on configured, it was carried
forward automatically, but its callback URL changed and **sign-in through it
will fail until you copy the new callback URL from its settings card and
register it with your identity provider**. The migrated provider's card shows
a dismissable warning as a reminder. Password sign-in is not affected by this
change, so an administrator can always sign in locally to make the update.
:::
## Access
### Members
@@ -42,20 +52,46 @@ change would be overwritten by the next sync and would leave two writers for one
password hash. Manage them from [People and roles](../hq/people-and-roles.md).
:::
### Single sign-on (OIDC)
### Single sign-on
Configured per organisation:
Add as many identity providers as you need: one instance can have several at
once, each with its own name, its own button on the login page and its own
callback URL.
| Field | |
| ------------- | ---------------------------- |
| Issuer | Your provider's issuer URL |
| Client ID | |
| Client secret | Stored AES-256-GCM encrypted |
Pick a provider from the list of presets:
Sign-in then goes `/auth/oidc/start` → your provider → `/auth/oidc/callback`.
| Preset | You provide |
| ---------------------- | ------------------------------------------- |
| Microsoft Entra ID | Directory (tenant) ID |
| Google Workspace | nothing further, the issuer is fixed |
| Okta | Your Okta org domain |
| GitHub | Client ID and client secret only |
| Other (OpenID Connect) | The issuer URL of your identity provider |
Local and OIDC users coexist. Keep at least one local owner: if the provider is
misconfigured or unreachable, a local account is the way back in.
Every provider also needs a **Client ID** and **Client secret**; the secret is
stored AES-256-GCM encrypted and never shown again after you save it.
:::info GitHub requires a verified primary email
Vantage signs a person in by their email address. GitHub is asked for the
account's addresses and only accepts one that is **both** the account's
primary address **and** marked verified: an address GitHub has not confirmed
is not proof anyone controls it.
:::
#### Callback URL
Each provider gets its own callback URL, shown on its settings card with a
copy button. This is the address you register with the identity provider when
you set up the application on their side: each provider is registered
separately, even if you have several with the same identity provider.
#### Turning off password sign-in
You can disable local (email and password) sign-in once at least one provider
is enabled. Vantage refuses to save a change that would leave nobody able to
sign in, whether that change comes from the local login toggle or from
disabling the last enabled provider. Keep at least one option open until every
person who needs access can reach the new one.
## Monitoring
+95 -1
View File
@@ -76,7 +76,8 @@ the API is the boundary; the UI is the courtesy.
2. Add steps in order from the library.
3. Set inputs per step.
4. Set failure behaviour per step.
5. Choose target servers.
5. Choose targets: named servers, a tag selector, or both. See
[Targeting](#targeting).
### Failure behaviour
@@ -92,6 +93,48 @@ A workflow can override a step's script or its secret references without
touching the library entry. This is how you adapt a default step, and it is
scoped to that workflow.
## Targeting
A workflow names servers two ways, and it can use both at once:
- **Target servers** an explicit list you pick from the fleet.
- **Target tags** a `key:value` selector matched against
[server tags](./servers.md#tags). More than one key ANDs: a server must carry
every pair to match.
A run goes to the **union** of the two, with duplicates removed. A server that is
both named explicitly and matched by the selector runs once, not twice. This is
what lets a workflow say "every production web server, plus this one box I am
watching" without maintaining a list.
The designer shows the resolved count as you edit, so you can see how many
machines a change to the selector just added or removed before you save.
:::warning An empty selector matches nothing
Clearing the tag selector does not mean "all servers". A workflow with no named
servers and no tags matches nothing and is refused at run time rather than
reported as a success over zero machines.
The alternative reading, where an empty field means the whole fleet, turns a
cleared box into a fleet-wide run. That is not a mistake anyone should be able to
make by deleting text.
:::
Tags are read **at run time**, not when you save. Tag a new machine `env:prod`
and the next run of an `env:prod` workflow includes it, with nothing to update on
the workflow itself. The same is true in reverse: removing a tag removes the
machine from every workflow that selected on it.
### Offline servers are still targeted
A server matched by tag is dispatched to even if its agent is offline, and that
step fails visibly on that machine. Vantage does not quietly shrink your target
list to the machines that happened to be reachable a patch run that skipped
three servers and reported success is worse than one that failed on three and
said so.
Re-run the workflow once they are back, or fix the agent first.
## Running
**Run** snapshots the resolved steps into the run record and dispatches each step
@@ -104,6 +147,57 @@ A run shows the script that actually executed, not the current library version.
Targets run **in parallel**; steps within one server run **in order**.
## Schedules
A workflow can carry a schedule, and Vantage will start it the same way a person
would — the same dispatch, the same snapshot, the same run page. A scheduled run
is an ordinary run with `schedule` recorded as who triggered it.
Open a workflow, choose **Edit**, and tick **Run on a schedule**. The expression
is standard five-field cron:
```
minute hour day-of-month month day-of-week
```
The presets write cron underneath, so you can start from one and adjust:
| Preset | Cron |
| ------------------- | ----------- |
| Hourly | `0 * * * *` |
| Nightly, 02:00 | `0 2 * * *` |
| Weekly, Sun 02:00 | `0 2 * * 0` |
| Monthly, 1st 02:00 | `0 2 1 * *` |
There is no seconds field and no `@daily`-style shorthand. The next three
occurrences are shown as you type, and they are computed by the server rather
than the browser, so what you see is exactly what will fire.
### Timezones
A schedule stores an IANA timezone by name — `Europe/London`, not an offset.
That is what makes a 02:00 job stay at 02:00 across a daylight-saving change
instead of drifting an hour for half the year. An unknown zone is refused when
you save it, not at 2am.
### Overlaps are skipped, not queued
If a run of the same workflow is still going when the next occurrence comes
round, the occurrence is **skipped** and the reason recorded. It is not queued
behind the running one. A patch workflow that takes longer than its interval
should fall behind visibly rather than pile up.
### Missed occurrences
If the control plane was not running when an occurrence was due, it still fires
when the control plane comes back — but only within **one hour** of the due
time. Anything older is recorded as missed and dropped. A job missed by ten
minutes during an upgrade should still run; one missed by two days should not
suddenly fire at lunchtime.
Either kind of skip is shown on the workflow's schedule panel, with the time it
was due and why it did not run.
## Watching a run
Step stdout and stderr stream back as chunks, are appended to a log file on the
+13
View File
@@ -182,9 +182,22 @@ message ServerCommand {
RunStepCmd run_step = 6;
CleanupWorkspaceCmd cleanup_workspace = 7;
OpenProxyCmd open_proxy = 8;
PingCmd ping = 9;
}
}
// PingCmd is a liveness beat, carrying nothing and requiring no reply.
//
// It exists because gRPC keepalive cannot prove what the agent needs to know.
// Behind an L7 proxy the agent's HTTP/2 connection terminates at the proxy, so
// keepalive pings are answered by the proxy whether or not the server behind it
// is still there. A pod that dies leaves the agent blocked in Recv on a stream
// that will never produce another message and never error commands are
// dispatched into it and silently lost. Only traffic that originates at the
// server itself distinguishes a live stream from an orphaned one.
message PingCmd {
}
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
// directory once all steps on that server have finished.
message CleanupWorkspaceCmd {
+62 -9
View File
@@ -2,11 +2,21 @@ package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
// Embeds the IANA zone database in the binary. Load-bearing: server/Dockerfile
// builds on Alpine, which ships no zoneinfo, so without this
// time.LoadLocation("Europe/London") fails in production and every workflow
// schedule silently falls back to UTC.
_ "time/tzdata"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
@@ -14,6 +24,7 @@ import (
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
"github.com/gin-gonic/gin"
)
@@ -97,10 +108,21 @@ func runSchemaSetup() {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
// index must exist before anything inserts providers, or a concurrent
// re-run could double-insert before the index is there to refuse it.
if err := services.MigrateAuthProviders(); err != nil {
log.Fatalf("auth provider migration failed: %v", err)
}
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
if err := services.EnsureServerIndexes(); err != nil {
log.Printf("warning: failed to ensure server indexes: %v", err)
}
if err := services.EnsureSettingsIndexes(); err != nil {
log.Fatalf("failed to ensure settings indexes: %v", err)
}
@@ -141,13 +163,15 @@ func serve() {
}
log.Printf("message bus ready as node %s", bus.NodeID())
ctx := context.Background()
// Cancelled on SIGTERM/SIGINT. Everything below that takes a context — the
// housekeeping jobs, the leader lock — stops when the pod is asked to.
ctx, shutdown := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer shutdown()
go func() {
if err := grpcserver.StartGRPC(9090); err != nil {
log.Fatalf("gRPC server error: %v", err)
}
}()
stopGRPC, err := grpcserver.StartGRPC(9090)
if err != nil {
log.Fatalf("gRPC server error: %v", err)
}
// Everything below runs on exactly one replica at a time.
//
@@ -162,6 +186,10 @@ func serve() {
services.StartAuditSweeper(jobCtx)
services.StartReaper(jobCtx)
monitorsched.Start(jobCtx)
workflowsched.Start(jobCtx, workflowsched.Deps{
TriggerWorkflow: services.TriggerWorkflow,
LogEvent: services.LogEvent,
})
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
@@ -183,12 +211,37 @@ func serve() {
r.Use(corsMiddleware())
api.RegisterRoutes(r)
log.Println("REST server listening on :8080")
if err := r.Run(":8080"); err != nil {
log.Fatalf("REST server error: %v", err)
srv := &http.Server{Addr: ":8080", Handler: r}
go func() {
log.Println("REST server listening on :8080")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("REST server error: %v", err)
}
}()
<-ctx.Done()
log.Println("shutdown signal received")
// gRPC first, and this ordering is the point of the whole exercise. Stopping
// it runs each CommandStream handler's deferred release, which clears that
// agent's presence claim; until that happens another replica will keep
// dispatching commands to this process. Draining HTTP first would leave the
// claims held for the length of the drain.
stopGRPC()
drainCtx, cancelDrain := context.WithTimeout(context.Background(), httpDrainTimeout)
defer cancelDrain()
if err := srv.Shutdown(drainCtx); err != nil {
log.Printf("REST server shutdown: %v", err)
}
log.Println("shutdown complete")
}
// How long in-flight REST requests are given to finish. Console tunnels are
// long-lived WebSockets that will not end on their own, so this is a ceiling
// rather than a target; the relays behind them are already gone by this point.
const httpDrainTimeout = 10 * time.Second
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
+5 -2
View File
@@ -14,9 +14,13 @@ require (
google.golang.org/grpc v1.64.0
)
require github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
require (
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
)
require (
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -38,7 +42,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+2
View File
@@ -70,6 +70,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+220
View File
@@ -0,0 +1,220 @@
package api
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type authProviderView struct {
models.AuthProvider
ClientSecretSet bool `json:"client_secret_set"`
CallbackURL string `json:"callback_url"`
}
func viewOf(c *gin.Context, p models.AuthProvider) authProviderView {
return authProviderView{
AuthProvider: p,
ClientSecretSet: p.ClientSecretEnc != "",
CallbackURL: auth.CallbackURL(c, p.ProviderID),
}
}
func listAuthPresets(c *gin.Context) {
c.JSON(http.StatusOK, auth.Presets())
}
func listAuthProviders(c *gin.Context) {
providers, err := services.ListAuthProviders(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]authProviderView, 0, len(providers))
for _, p := range providers {
out = append(out, viewOf(c, p))
}
c.JSON(http.StatusOK, out)
}
func createAuthProvider(c *gin.Context) {
var body struct {
Name string `json:"name"`
Preset string `json:"preset"`
IssuerInput string `json:"issuer_input"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
issuer, err := auth.ExpandIssuer(body.Preset, body.IssuerInput)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
instanceID := auth.InstanceID(c)
p, err := services.CreateAuthProvider(&models.AuthProvider{
InstanceID: instanceID,
Name: body.Name,
Kind: auth.KindFor(body.Preset),
Preset: body.Preset,
Issuer: issuer,
ClientID: body.ClientID,
Scopes: auth.DefaultScopes(body.Preset),
Enabled: body.Enabled,
}, body.ClientSecret)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "auth_provider.create", actorFromCtx(c), "", "", p.Name)
c.JSON(http.StatusCreated, viewOf(c, *p))
}
func updateAuthProvider(c *gin.Context) {
var body struct {
Name *string `json:"name"`
IssuerInput *string `json:"issuer_input"`
ClientID *string `json:"client_id"`
ClientSecret *string `json:"client_secret"`
Enabled *bool `json:"enabled"`
Order *int `json:"order"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
instanceID := auth.InstanceID(c)
providerID := c.Param("id")
existing, err := services.GetAuthProvider(instanceID, providerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
if err := guardProviderChange(instanceID, existing, body.Enabled, false); err != nil {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "last_provider"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
in := services.AuthProviderUpdate{
Name: body.Name, ClientID: body.ClientID,
ClientSecret: body.ClientSecret, Enabled: body.Enabled, Order: body.Order,
}
if body.IssuerInput != nil {
issuer, err := auth.ExpandIssuer(existing.Preset, *body.IssuerInput)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
in.Issuer = &issuer
}
if err := services.UpdateAuthProvider(instanceID, providerID, in); err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Issuer, client ID or secret may have changed; the cached discovery
// document was built from the old ones.
auth.EvictProvider(providerID)
services.LogEvent(instanceID, "auth_provider.update", actorFromCtx(c), "", "", existing.Name)
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func deleteAuthProvider(c *gin.Context) {
instanceID := auth.InstanceID(c)
providerID := c.Param("id")
existing, err := services.GetAuthProvider(instanceID, providerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
if err := guardProviderChange(instanceID, existing, nil, true); err != nil {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "last_provider"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := services.DeleteAuthProvider(instanceID, providerID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auth.EvictProvider(providerID)
services.LogEvent(instanceID, "auth_provider.delete", actorFromCtx(c), "", "", existing.Name)
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// guardProviderChange asks whether the instance would still have a way in.
// Deleting and disabling reach the same condition, so they share one answer.
func guardProviderChange(instanceID string, existing *models.AuthProvider, enabled *bool, deleting bool) error {
losing := deleting || (enabled != nil && !*enabled)
if !losing || !existing.Enabled {
return nil
}
n, err := services.CountEnabledAuthProviders(instanceID)
if err != nil {
return err
}
return services.CheckLockout(services.IsLocalLoginEnabled(instanceID), n-1)
}
func ackAuthProviderNotice(c *gin.Context) {
instanceID := auth.InstanceID(c)
providerID := c.Param("id")
existing, err := services.GetAuthProvider(instanceID, providerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
if err := services.AckAuthProviderNotice(instanceID, providerID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "auth_provider.ack_notice", actorFromCtx(c), "", "", existing.Name)
c.JSON(http.StatusOK, gin.H{"acknowledged": true})
}
// testAuthProvider proves the configuration is reachable. It signs nobody in.
func testAuthProvider(c *gin.Context) {
instanceID := auth.InstanceID(c)
p, err := services.GetAuthProvider(instanceID, c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
if p.Kind == models.KindOAuth2 {
// GitHub has no discovery document. The only meaningful check without
// a user token is that credentials are present.
if p.ClientID == "" || p.ClientSecretEnc == "" {
c.JSON(http.StatusOK, gin.H{"ok": false, "message": "client ID and secret are required"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "credentials are configured"})
return
}
if _, err := oidc.NewProvider(c.Request.Context(), p.Issuer); err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "discovery document fetched"})
}
+63
View File
@@ -3,6 +3,7 @@ package api
import (
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
@@ -87,69 +88,109 @@ func queryIntDefault(r *http.Request, key string, def int) int {
return v
}
// consoleTunnel upgrades the browser's WebSocket and joins it to guacd.
//
// Every branch here logs. That is deliberate and worth keeping: this handler
// spans four hops (session store, agent dispatch, relay announcement, guacd),
// any of which can fail, and the client is told the same near-useless thing by
// most of them — a 500 that guacamole then reports as an *upstream* error,
// naming the wrong hop entirely. Without a line per branch the only evidence a
// failure leaves is a GIN status code, and with several replicas you cannot
// even tell which process produced it.
//
// Lines are prefixed with the session ID so one attempt can be followed across
// pods, and the pod's own hostname so it is obvious which one served it.
func consoleTunnel(c *gin.Context) {
host, _ := os.Hostname()
token := c.Query("token")
sessionID, err := services.VerifySessionToken(token)
if err != nil {
log.Printf("console[%s]: reject: invalid session token: %v", host, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// Bound to the session from here on, so every later line correlates.
tlog := func(format string, args ...any) {
log.Printf("console[%s %s]: "+format, append([]any{host, sessionID}, args...)...)
}
tlog("tunnel opened by %s", actorFromCtx(c))
instanceID := auth.InstanceID(c)
sess, err := services.GetConsoleSession(instanceID, sessionID)
if err != nil {
tlog("reject: console session not found: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if actor := actorFromCtx(c); actor != sess.User {
tlog("reject: session belongs to %s, not %s", sess.User, actor)
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
return
}
if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil {
tlog("reject: token already consumed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
if err != nil {
tlog("reject: server %s not found: %v", sess.ServerID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
tlog("server %s (%s), protocol %s", srv.ServerID, srv.Hostname, sess.Protocol)
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(auth.InstanceID(c), sess.KeyID)
if err != nil {
tlog("reject: key %s has no private material: %v", sess.KeyID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
return
}
passphrase, _ = services.GetPassphrase(sess.KeyID)
tlog("ssh key %s loaded (passphrase=%t)", sess.KeyID, passphrase != "")
}
var rdpUser, rdpPass string
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(instanceID, sessionID)
if err != nil {
tlog("reject: could not consume %s credentials: %v", sess.Protocol, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
return
}
tlog("%s credentials consumed (user=%t)", sess.Protocol, rdpUser != "")
}
targetPort, err := services.TargetPort(srv, sess.Protocol)
if err != nil {
tlog("reject: no target port for %s: %v", sess.Protocol, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tlog("opening relay to %s:%d", srv.ServerID, targetPort)
relay, err := services.OpenConsoleProxy(instanceID, srv.ServerID, targetPort)
if err != nil {
if errors.Is(err, services.ErrAgentOffline) {
tlog("reject: agent offline")
c.JSON(http.StatusConflict, gin.H{"error": "agent_offline"})
return
}
// The client is deliberately told nothing specific, so this is the only
// place the real reason exists — a failed dispatch and a relay that was
// never announced are the same generic 500 to the browser.
tlog("reject: open relay: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open relay"})
return
}
tlog("relay %s ready at %s:%d", relay.ProxyID, relay.Host, relay.Port)
// guac.WebsocketServer.ServeHTTP returns before installing its
// OnDisconnect handler when the connect callback errors, which is exactly
// the path every relay failure this proxy introduces takes (the agent
@@ -161,10 +202,14 @@ func consoleTunnel(c *gin.Context) {
defer func() {
relay.Close()
if reason := relay.Reason(); reason != "" {
tlog("relay %s ended: %s", relay.ProxyID, reason)
services.LogEvent(instanceID, "console.proxy_failed", actorFromCtx(c), srv.ServerID, "",
fmt.Sprintf("console relay failed: %s (proxy_id=%s, port=%d)", reason, relay.ProxyID, relay.Port))
} else {
tlog("relay %s closed cleanly", relay.ProxyID)
}
_ = services.EndConsoleSession(instanceID, sessionID)
tlog("tunnel finished")
}()
services.LogEvent(instanceID, "console.proxy_opened", actorFromCtx(c), srv.ServerID, "",
@@ -173,6 +218,7 @@ func consoleTunnel(c *gin.Context) {
gp, err := services.BuildGuacParams(sess.Protocol, sess.SSHUsername, privKey, passphrase,
rdpUser, rdpPass, relay.Host, relay.Port)
if err != nil {
tlog("reject: build guacd parameters: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -192,18 +238,33 @@ func consoleTunnel(c *gin.Context) {
config.OptimalScreenHeight = queryIntDefault(r, "height", 768)
config.OptimalResolution = queryIntDefault(r, "dpi", 96)
// Resolution is logged separately from the dial: a headless guacd
// Service returns pod addresses, and which one was picked is the
// difference between "guacd refused" and "we called the wrong guacd".
addr, err := net.ResolveTCPAddr("tcp", guacdAddr)
if err != nil {
tlog("guacd: resolve %s: %v", guacdAddr, err)
return nil, err
}
tlog("guacd: dialling %s (%s)", guacdAddr, addr.String())
conn, err := net.DialTCP("tcp", nil, addr)
if err != nil {
tlog("guacd: dial %s: %v", addr.String(), err)
return nil, err
}
// The handshake is where guacd connects onward to the relay, so a
// failure here is guacd reporting it could not reach %s:%d — the hop
// that has been hardest to see from either end.
stream := guac.NewStream(conn, guac.SocketTimeout)
if err := stream.Handshake(config); err != nil {
tlog("guacd: handshake for %s to relay %s:%d: %v",
gp.Protocol, relay.Host, relay.Port, err)
return nil, err
}
tlog("guacd: tunnel established (%s %dx%d)",
gp.Protocol, config.OptimalScreenWidth, config.OptimalScreenHeight)
return guac.NewSimpleTunnel(stream), nil
}
@@ -211,5 +272,7 @@ func consoleTunnel(c *gin.Context) {
// func above, not here: this only fires once a tunnel was actually
// established, and letting both paths log would double the audit event.
wsServer := guac.NewWebsocketServer(connect)
tlog("serving websocket")
wsServer.ServeHTTP(c.Writer, c.Request)
tlog("websocket returned")
}
+75 -10
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"net/http"
"os"
@@ -35,8 +36,9 @@ func RegisterRoutes(r *gin.Engine) {
r.POST("/auth/login", auth.HandleLocalLogin)
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
r.GET("/auth/oidc/start", auth.HandleOIDCStart)
r.GET("/auth/oidc/callback", auth.HandleOIDCCallback)
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback)
r.GET("/auth/providers", auth.HandleListPublicProviders)
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
@@ -49,6 +51,9 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
apiGroup.GET("/servers", listServers)
// Static segment, registered alongside /servers/:id exactly as
// /servers/new already is — gin resolves statics ahead of wildcards.
apiGroup.GET("/servers/tags", listKnownTags)
apiGroup.POST("/servers", createServer)
apiGroup.GET("/servers/new", newServer)
apiGroup.POST("/servers/new", newServer)
@@ -57,6 +62,7 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/servers/:id/generate-key", generateKey)
apiGroup.POST("/servers/:id/update-agent", updateAgent)
apiGroup.POST("/servers/:id/apply-updates", applyUpdates)
apiGroup.PUT("/servers/:id/tags", putServerTags)
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
@@ -100,14 +106,29 @@ func RegisterRoutes(r *gin.Engine) {
instance.POST("/users", createInstanceUser)
instance.PUT("/users/:id/role", updateInstanceUserRole)
instance.DELETE("/users/:id", deleteInstanceUser)
instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC)
instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC)
}
providers := apiGroup.Group("/auth/providers")
providers.Use(auth.RequireRole("owner", "admin"), RequireFeature("oidc"))
{
providers.GET("", listAuthProviders)
providers.POST("", createAuthProvider)
providers.PUT("/:id", updateAuthProvider)
providers.DELETE("/:id", deleteAuthProvider)
providers.POST("/:id/test", testAuthProvider)
providers.POST("/:id/ack-notice", ackAuthProviderNotice)
}
apiGroup.GET("/auth/presets", auth.RequireRole("owner", "admin"), listAuthPresets)
}
}
func listServers(c *gin.Context) {
servers, err := services.ListServers(auth.InstanceID(c))
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
servers, err := services.ListServersFiltered(auth.InstanceID(c), sel)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -115,6 +136,47 @@ func listServers(c *gin.Context) {
c.JSON(http.StatusOK, servers)
}
func listKnownTags(c *gin.Context) {
tags, err := services.KnownTags(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tags)
}
func putServerTags(c *gin.Context) {
var body struct {
Tags map[string]string `json:"tags"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
instanceID := auth.InstanceID(c)
serverID := c.Param("id")
before, err := services.GetServer(instanceID, serverID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.SetServerTags(instanceID, serverID, body.Tags); err != nil {
if errors.Is(err, services.ErrInvalidTag) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "server.tags_updated", actorFromCtx(c), serverID, "",
fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags))
c.JSON(http.StatusOK, gin.H{"tags": body.Tags})
}
func createServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
@@ -142,7 +204,6 @@ func newServer(c *gin.Context) {
}
services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
host := publicHostFromRequest(c)
installCmd := fmt.Sprintf(
@@ -415,7 +476,7 @@ if [ -z "$LATEST" ]; then
fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\
LATEST_ENCODED="${LATEST/\//%%2F}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
@@ -470,14 +531,18 @@ func getSettings(c *gin.Context) {
func saveSettings(c *gin.Context) {
var body struct {
Alerts models.AlertSettings `json:"alerts"`
Email models.EmailSettings `json:"email"`
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
LocalLoginEnabled *bool `json:"local_login_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -522,7 +587,7 @@ if [ -z "$LATEST" ]; then
fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\
LATEST_ENCODED="${LATEST/\//%%2F}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
-37
View File
@@ -118,40 +118,3 @@ func orgUserErrStatus(err error) int {
}
return http.StatusInternalServerError
}
func getInstanceOIDC(c *gin.Context) {
cfg, err := services.GetInstanceOIDC(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false})
return
}
c.JSON(http.StatusOK, gin.H{
"instance_id": cfg.InstanceID,
"issuer": cfg.Issuer,
"client_id": cfg.ClientID,
"enabled": cfg.Enabled,
"updated_at": cfg.UpdatedAt,
"client_secret_set": cfg.ClientSecretEnc != "",
})
}
func putInstanceOIDC(c *gin.Context) {
var body struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveInstanceOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auth.EvictOIDCProvider(auth.InstanceID(c))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
+56
View File
@@ -13,7 +13,9 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/mongo"
)
func registerWorkflowRoutes(g *gin.RouterGroup) {
@@ -34,6 +36,8 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
g.DELETE("/workflows/:id", deleteWorkflow)
g.POST("/workflows/:id/run", runWorkflow)
g.GET("/workflows/:id/runs", listWorkflowRuns)
g.PUT("/workflows/:id/schedule", putWorkflowSchedule)
g.GET("/workflows/:id/schedule/preview", previewWorkflowSchedule)
g.GET("/runs/:runId", getRun)
g.POST("/runs/:runId/cancel", cancelRun)
@@ -343,6 +347,10 @@ func deleteWorkflow(c *gin.Context) {
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
if err != nil {
if errors.Is(err, services.ErrNoTargets) {
c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
return
}
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
@@ -382,3 +390,51 @@ func cancelRun(c *gin.Context) {
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
func putWorkflowSchedule(c *gin.Context) {
var body models.Schedule
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
instanceID := auth.InstanceID(c)
next, err := services.SetSchedule(instanceID, c.Param("id"), &body)
if err != nil {
if errors.Is(err, workflowsched.ErrBadSchedule) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, mongo.ErrNoDocuments) {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next})
}
// previewWorkflowSchedule exists so the browser and the scheduler agree on
// what a cron string means. A client-side cron parser that disagrees with the
// server by one field is a bug found in production, at night.
func previewWorkflowSchedule(c *gin.Context) {
expr := c.Query("cron")
tz := c.Query("tz")
occurrences := make([]time.Time, 0, 3)
from := time.Now()
for i := 0; i < 3; i++ {
next, err := workflowsched.NextOccurrence(expr, tz, from)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
occurrences = append(occurrences, next)
from = next
}
c.JSON(http.StatusOK, gin.H{"occurrences": occurrences})
}
+98
View File
@@ -0,0 +1,98 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"golang.org/x/oauth2"
)
// githubAPIBase is a variable rather than a constant so tests can point it at
// an httptest server. Nothing in production reassigns it.
var githubAPIBase = "https://api.github.com"
const (
githubAuthURL = "https://github.com/login/oauth/authorize"
githubTokenURL = "https://github.com/login/oauth/access_token"
)
type githubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
// githubOAuthConfig builds the OAuth2 config for a GitHub provider. GitHub has
// no discovery document, so the endpoints are constants rather than fetched.
func githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: secret,
RedirectURL: redirectURL,
Scopes: p.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: githubAuthURL,
TokenURL: githubTokenURL,
},
}
}
// selectGitHubEmail requires an address that is both primary and verified.
//
// Verified alone is not enough: a non-primary address is one the person happens
// to have proved, not the one they present as themselves. Primary alone is far
// worse — an unverified address is not proof of control at all, and accepting
// one would let anyone with a GitHub account claim any address in the instance.
func selectGitHubEmail(emails []githubEmail) (string, error) {
for _, e := range emails {
if e.Primary && e.Verified && e.Email != "" {
return e.Email, nil
}
}
return "", errors.New("no primary verified email address on the GitHub account")
}
func githubGetJSON(ctx context.Context, client *http.Client, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New("github api returned " + resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// githubIdentity resolves the signed-in GitHub account to an email and a name.
//
// The name is best-effort: it is cosmetic, and a failing /user must not fail a
// sign-in whose identity is already established.
func githubIdentity(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (string, string, error) {
client := cfg.Client(ctx, token)
var emails []githubEmail
if err := githubGetJSON(ctx, client, githubAPIBase+"/user/emails", &emails); err != nil {
return "", "", err
}
email, err := selectGitHubEmail(emails)
if err != nil {
return "", "", err
}
var user struct {
Name string `json:"name"`
}
if err := githubGetJSON(ctx, client, githubAPIBase+"/user", &user); err != nil {
return email, "", nil
}
return email, user.Name, nil
}
+39
View File
@@ -65,6 +65,12 @@ func HandleLocalLogin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// The login page hides the form, but the page is a courtesy and the API is
// the boundary.
if !services.LocalLoginPermitted(instanceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
return
}
u, err := services.GetUserInInstanceByEmail(instanceID, body.Email)
if err != nil || !services.VerifyPassword(u, body.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
@@ -82,6 +88,39 @@ func HandleLocalLogin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// HandleListPublicProviders is unauthenticated: it is what the login page reads
// to decide what to draw. It carries no issuer, no client ID and no secret —
// only what a button needs, because anyone who can reach the login page can
// read this.
func HandleListPublicProviders(c *gin.Context) {
type publicProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Preset string `json:"preset"`
}
out := []publicProvider{}
instanceID, err := resolveLoginInstance(c)
if err != nil {
// An unresolvable instance is not an error the login page can act on:
// it still has to render a password form. Answer the safe shape.
c.JSON(http.StatusOK, gin.H{"local_enabled": true, "providers": out})
return
}
// A lapsed licence stops SSO, so a button that cannot work is not offered.
if services.GetLicenseState(instanceID).Feature("oidc") {
providers, err := services.ListEnabledAuthProviders(instanceID)
if err == nil {
for _, p := range providers {
out = append(out, publicProvider{ID: p.ProviderID, Name: p.Name, Preset: p.Preset})
}
}
}
c.JSON(http.StatusOK, gin.H{"local_enabled": services.LocalLoginPermitted(instanceID), "providers": out})
}
func HandleBootstrapStatus(c *gin.Context) {
var (
n int64
+126 -61
View File
@@ -19,52 +19,76 @@ var (
provCache = map[string]*oidc.Provider{}
)
func EvictOIDCProvider(instanceID string) {
// EvictProvider drops a cached discovery document. Keyed on provider, not
// instance: an instance now has several, and evicting all of them because one
// changed would re-fetch discovery for providers nobody touched.
func EvictProvider(providerID string) {
provMu.Lock()
delete(provCache, instanceID)
delete(provCache, providerID)
provMu.Unlock()
}
func redirectURL(c *gin.Context) string {
scheme := "https"
func requestScheme(c *gin.Context) string {
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
return "http"
}
return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host)
return "https"
}
func providerForInstance(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetInstanceOIDC(instanceID)
if err != nil || !cfg.Enabled {
return nil, nil, fmt.Errorf("inst SSO not configured")
}
secret, err := services.GetInstanceOIDCSecret(instanceID)
if err != nil {
return nil, nil, err
// CallbackURL must return the same string in the start and callback halves of
// one flow, or the identity provider rejects the token exchange.
func CallbackURL(c *gin.Context, providerID string) string {
return fmt.Sprintf("%s://%s/auth/oidc/%s/callback", requestScheme(c), c.Request.Host, providerID)
}
func oauthConfigFor(ctx context.Context, c *gin.Context, p *models.AuthProvider, secret string) (*oidc.Provider, *oauth2.Config, error) {
if p.Kind == models.KindOAuth2 {
return nil, githubOAuthConfig(p, secret, CallbackURL(c, p.ProviderID)), nil
}
provMu.Lock()
p := provCache[instanceID]
prov := provCache[p.ProviderID]
provMu.Unlock()
if p == nil {
p, err = oidc.NewProvider(ctx, cfg.Issuer)
if prov == nil {
var err error
prov, err = oidc.NewProvider(ctx, p.Issuer)
if err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("provider discovery failed: %w", err)
}
provMu.Lock()
provCache[instanceID] = p
provCache[p.ProviderID] = prov
provMu.Unlock()
}
return p, &oauth2.Config{
ClientID: cfg.ClientID, ClientSecret: secret,
RedirectURL: redirectURL(c), Endpoint: p.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
return prov, &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: secret,
RedirectURL: CallbackURL(c, p.ProviderID),
Endpoint: prov.Endpoint(),
Scopes: p.Scopes,
}, nil
}
func HandleOIDCStart(c *gin.Context) {
// loadProvider resolves a provider strictly within one instance. There is no
// unscoped lookup: a provider ID travels in a URL, and an unscoped one would
// let a request against one instance's host drive another instance's provider.
func loadProvider(instanceID, providerID string) (*models.AuthProvider, string, error) {
p, err := services.GetAuthProvider(instanceID, providerID)
if err != nil {
return nil, "", fmt.Errorf("unknown provider")
}
if !p.Enabled {
return nil, "", fmt.Errorf("provider is disabled")
}
secret, err := services.GetAuthProviderSecret(instanceID, providerID)
if err != nil {
return nil, "", err
}
return p, secret, nil
}
func HandleSSOStart(c *gin.Context) {
inst, ok := InstanceFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown instance host"})
c.Redirect(http.StatusFound, "/login?error=unknown_host")
return
}
// Losing the feature stops new SSO logins. It deliberately does not touch
@@ -73,29 +97,44 @@ func HandleOIDCStart(c *gin.Context) {
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
return
}
ctx := c.Request.Context()
_, oauthCfg, err := providerForInstance(ctx, c, inst.InstanceID)
providerID := c.Param("providerId")
p, secret, err := loadProvider(inst.InstanceID, providerID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
c.Redirect(http.StatusFound, "/login?error=provider_unavailable")
return
}
ctx := c.Request.Context()
_, oauthCfg, err := oauthConfigFor(ctx, c, p, secret)
if err != nil {
c.Redirect(http.StatusFound, "/login?error=provider_unreachable")
return
}
state, err := randomHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
c.Redirect(http.StatusFound, "/login?error=state_failed")
return
}
if err := SaveStateInstance(ctx, state, inst.InstanceID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
if err := saveState(ctx, state, oidcState{InstanceID: inst.InstanceID, ProviderID: p.ProviderID}); err != nil {
c.Redirect(http.StatusFound, "/login?error=state_failed")
return
}
c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state))
}
func HandleOIDCCallback(c *gin.Context) {
func HandleSSOCallback(c *gin.Context) {
ctx := c.Request.Context()
instanceID, ok := ConsumeStateInstance(ctx, c.Query("state"))
st, ok := consumeState(ctx, c.Query("state"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
c.Redirect(http.StatusFound, "/login?error=invalid_state")
return
}
// The path segment is attacker-controlled; the state was issued by the start
// handler. A mismatch means the two halves of this flow disagree about which
// provider is signing somebody in, and that is not a thing to resolve by
// picking one. The state has already been consumed, so this is not replayable.
if c.Param("providerId") != st.ProviderID {
c.Redirect(http.StatusFound, "/login?error=invalid_state")
return
}
@@ -107,41 +146,67 @@ func HandleOIDCCallback(c *gin.Context) {
// Resolved from the consumed state rather than from the host, because on
// this route the instance is whatever the state said and nobody is signed
// in yet.
if !services.GetLicenseState(instanceID).Feature("oidc") {
if !services.GetLicenseState(st.InstanceID).Feature("oidc") {
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
return
}
provider, oauthCfg, err := providerForInstance(ctx, c, instanceID)
p, secret, err := loadProvider(st.InstanceID, st.ProviderID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
c.Redirect(http.StatusFound, "/login?error=provider_unavailable")
return
}
provider, oauthCfg, err := oauthConfigFor(ctx, c, p, secret)
if err != nil {
c.Redirect(http.StatusFound, "/login?error=provider_unreachable")
return
}
token, err := oauthCfg.Exchange(ctx, c.Query("code"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"})
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
return
}
idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).Verify(ctx, rawIDToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"})
return
}
var claims struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"})
c.Redirect(http.StatusFound, "/login?error=exchange_failed")
return
}
email := strings.ToLower(claims.Email)
var email, name string
if p.Kind == models.KindOAuth2 {
email, name, err = githubIdentity(ctx, oauthCfg, token)
if err != nil {
c.Redirect(http.StatusFound, "/login?error=identity_failed")
return
}
} else {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
c.Redirect(http.StatusFound, "/login?error=missing_id_token")
return
}
idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).Verify(ctx, rawIDToken)
if err != nil {
c.Redirect(http.StatusFound, "/login?error=verification_failed")
return
}
var claims struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
c.Redirect(http.StatusFound, "/login?error=missing_email")
return
}
email, name = claims.Email, claims.Name
}
completeSSOLogin(c, st.InstanceID, email, name)
}
// completeSSOLogin is the tail both provider kinds share: resolve the user
// within the instance, provision on first sign-in, mint the session.
func completeSSOLogin(c *gin.Context, instanceID, email, name string) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
c.Redirect(http.StatusFound, "/login?error=missing_email")
return
}
// Scoped to the instance the callback state names, so an address that also
// exists in another instance is invisible here. That scoping replaces the
@@ -151,16 +216,16 @@ func HandleOIDCCallback(c *gin.Context) {
if err != nil {
u, err = services.CreateUser(instanceID, email, "", models.RoleMember, models.AuthOIDC)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
c.Redirect(http.StatusFound, "/login?error=provisioning_failed")
return
}
}
sessionID, err := SaveSession(ctx, &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: claims.Name,
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
c.Redirect(http.StatusFound, "/login?error=session_failed")
return
}
_ = services.TouchLastLogin(u.UserID)
+119
View File
@@ -0,0 +1,119 @@
package auth
import (
"fmt"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// Preset describes one well-known identity provider.
//
// This is a Go table rather than a collection on purpose: adding a preset is a
// commit and a review, not a row somebody typed into production.
type Preset struct {
ID string `json:"id"`
Label string `json:"label"` // shown in the add-provider picker
Kind string `json:"kind"` // models.KindOIDC | models.KindOAuth2
IssuerFormat string `json:"-"` // %s is replaced by InputValue; empty means no issuer
InputLabel string `json:"input_label"` // empty means the preset asks for nothing
InputHint string `json:"input_hint"`
Scopes []string `json:"-"`
}
var presets = []Preset{
{
ID: "entra",
Label: "Microsoft Entra ID",
Kind: models.KindOIDC,
IssuerFormat: "https://login.microsoftonline.com/%s/v2.0",
InputLabel: "Directory (tenant) ID",
InputHint: "Found in Entra under Overview. A UUID, not your domain name.",
Scopes: []string{"openid", "profile", "email"},
},
{
ID: "google",
Label: "Google Workspace",
Kind: models.KindOIDC,
IssuerFormat: "https://accounts.google.com",
Scopes: []string{"openid", "profile", "email"},
},
{
ID: "okta",
Label: "Okta",
Kind: models.KindOIDC,
IssuerFormat: "https://%s/oauth2/default",
InputLabel: "Okta org domain",
InputHint: "e.g. acme.okta.com — no scheme, no trailing slash.",
Scopes: []string{"openid", "profile", "email"},
},
{
ID: "github",
Label: "GitHub",
Kind: models.KindOAuth2,
Scopes: []string{"read:user", "user:email"},
},
{
ID: "",
Label: "Other (OpenID Connect)",
Kind: models.KindOIDC,
IssuerFormat: "%s",
InputLabel: "Issuer URL",
InputHint: "The discovery base, e.g. https://id.example.com/realms/main",
Scopes: []string{"openid", "profile", "email"},
},
}
// Presets returns the table for the settings UI to render a picker from.
func Presets() []Preset {
out := make([]Preset, len(presets))
copy(out, presets)
return out
}
func PresetByID(id string) (Preset, bool) {
for _, p := range presets {
if p.ID == id {
return p, true
}
}
return Preset{}, false
}
// ExpandIssuer turns what the customer typed into the issuer URL that gets
// stored. The stored value is always fully resolved, so nothing downstream has
// to know a preset was involved.
func ExpandIssuer(presetID, input string) (string, error) {
p, ok := PresetByID(presetID)
if !ok {
return "", fmt.Errorf("unknown provider preset %q", presetID)
}
if p.IssuerFormat == "" {
return "", nil // OAuth2 providers have no issuer
}
if !strings.Contains(p.IssuerFormat, "%s") {
return p.IssuerFormat, nil // fixed issuer, input ignored
}
input = strings.TrimSpace(strings.TrimSuffix(input, "/"))
if input == "" {
return "", fmt.Errorf("%s is required", p.InputLabel)
}
return fmt.Sprintf(p.IssuerFormat, input), nil
}
func DefaultScopes(presetID string) []string {
p, ok := PresetByID(presetID)
if !ok {
return []string{"openid", "profile", "email"}
}
out := make([]string, len(p.Scopes))
copy(out, p.Scopes)
return out
}
func KindFor(presetID string) string {
if p, ok := PresetByID(presetID); ok {
return p.Kind
}
return models.KindOIDC
}
-12
View File
@@ -89,15 +89,3 @@ func GetSession(ctx context.Context, id string) (*Session, error) {
func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
func SaveStateInstance(ctx context.Context, state, instanceID string) error {
return rdb.Set(ctx, statePrefix+state, instanceID, 10*time.Minute).Err()
}
func ConsumeStateInstance(ctx context.Context, state string) (string, bool) {
instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || instanceID == "" {
return "", false
}
return instanceID, true
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"encoding/json"
"time"
)
// oidcState is what a login flow parks in Redis between the start redirect and
// the callback. It carries the provider as well as the instance: the callback's
// :providerId path segment is attacker-controlled, and this is the half that
// was issued by the start handler.
type oidcState struct {
InstanceID string `json:"instance_id"`
ProviderID string `json:"provider_id"`
}
func encodeState(s oidcState) (string, error) {
b, err := json.Marshal(s)
if err != nil {
return "", err
}
return string(b), nil
}
func decodeState(raw string) (oidcState, bool) {
var s oidcState
if raw == "" {
return oidcState{}, false
}
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return oidcState{}, false
}
if s.InstanceID == "" || s.ProviderID == "" {
return oidcState{}, false
}
return s, true
}
func saveState(ctx context.Context, state string, s oidcState) error {
raw, err := encodeState(s)
if err != nil {
return err
}
return rdb.Set(ctx, statePrefix+state, raw, 10*time.Minute).Err()
}
// consumeState is GetDel: a state is single-use, so a replayed callback finds
// nothing and is refused.
func consumeState(ctx context.Context, state string) (oidcState, bool) {
raw, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil {
return oidcState{}, false
}
return decodeState(raw)
}
+84
View File
@@ -92,9 +92,23 @@ const (
// ProxyEndChannel carries a console relay's terminal reason back to the pod
// serving the WebSocket, which is the pod that has to write the audit event.
ProxyEndChannel = prefix + "proxyend:"
// ProxyAddrChannel carries the address of a console relay listener back to
// the pod serving the WebSocket.
//
// The listener cannot be bound in advance on any particular pod. An agent's
// ProxyStream is a separate HTTP/2 request from its CommandStream, and an
// L7 proxy (Traefik) balances requests, not connections — so it may land on
// any replica, not the one holding the command stream. The pod it does land
// on binds the listener and announces it here.
ProxyAddrChannel = prefix + "proxyaddr:"
// PresenceKey records which node holds an agent's command stream.
PresenceKey = prefix + "agent:"
// ProxyPendingKey authorises one not-yet-opened ProxyStream. It is the only
// state tying a proxy_id to the instance and server it was minted for, and
// it must be visible to every replica because any of them may receive the
// stream.
ProxyPendingKey = prefix + "proxypending:"
// leaderKey records the holder of a named singleton job.
leaderKey = prefix + "leader:"
)
@@ -190,6 +204,21 @@ func SetPresence(ctx context.Context, serverID string, ttl time.Duration) error
return rdb.Set(ctx, PresenceKey+serverID, nodeID, ttl).Err()
}
// RenewPresence extends serverID's claim, but only while this node still holds
// it, and reports whether it did.
//
// A blind SET here is wrong, not merely untidy. When an agent reconnects, its
// previous stream can stay half-open on another pod for the length of a
// keepalive cycle, and that pod goes on renewing. Two processes then overwrite
// each other's claim every renewal interval and the key names whichever wrote
// last rather than whichever holds the live stream. A superseded pod must lose
// quietly instead.
func RenewPresence(ctx context.Context, serverID string, ttl time.Duration) bool {
n, err := renewPresenceIfOwner.Run(ctx, rdb,
[]string{PresenceKey + serverID}, nodeID, int64(ttl/time.Millisecond)).Int64()
return err == nil && n == 1
}
// ClearPresence releases serverID, but only if this node still holds it. A
// blind DEL would let a pod whose stream had already been re-established
// elsewhere delete the new owner's claim on its way out.
@@ -212,6 +241,61 @@ func IsConnected(ctx context.Context, serverID string) bool {
return err == nil && n > 0
}
// SetPendingProxy records that proxyID has been minted for instanceID and
// serverID, for ttl. Written before the OpenProxyCmd is dispatched, so it is in
// place before any agent can act on it.
func SetPendingProxy(ctx context.Context, proxyID, instanceID, serverID string, ttl time.Duration) error {
b, err := json.Marshal(map[string]string{"instance_id": instanceID, "server_id": serverID})
if err != nil {
return err
}
return rdb.Set(ctx, ProxyPendingKey+proxyID, b, ttl).Err()
}
// ClaimPendingProxy consumes proxyID's pending record and returns the instance
// and server it was minted for. Get and delete are one Lua call rather than two
// round trips: single use is the whole security property, and two agents
// racing the same proxy_id must not both be served.
//
// A missing record is reported as "", "" rather than an error — an unknown
// proxy_id, an expired one and a second claim are all the same refusal.
func ClaimPendingProxy(ctx context.Context, proxyID string) (instanceID, serverID string) {
v, err := claimPending.Run(ctx, rdb, []string{ProxyPendingKey + proxyID}).Text()
if err != nil || v == "" {
return "", ""
}
var rec struct {
InstanceID string `json:"instance_id"`
ServerID string `json:"server_id"`
}
if err := json.Unmarshal([]byte(v), &rec); err != nil {
return "", ""
}
return rec.InstanceID, rec.ServerID
}
// ClearPendingProxy drops a pending record whose command never reached an
// agent, so a dead proxy_id is not left claimable for the rest of its TTL.
func ClearPendingProxy(ctx context.Context, proxyID string) {
_ = rdb.Del(ctx, ProxyPendingKey+proxyID).Err()
}
var claimPending = redis.NewScript(`
local v = redis.call("GET", KEYS[1])
if v then
redis.call("DEL", KEYS[1])
return v
end
return ""
`)
var renewPresenceIfOwner = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
`)
var releaseIfOwner = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
+6
View File
@@ -158,8 +158,14 @@ type ServerCommand struct {
RunStep *RunStepCmd `json:"run_step,omitempty"`
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
Ping *PingCmd `json:"ping,omitempty"`
}
// PingCmd is a server-originated liveness beat. It carries nothing and expects
// no reply: its arrival is the entire message. See the .proto for why gRPC
// keepalive is not sufficient on its own.
type PingCmd struct{}
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
@@ -1,39 +0,0 @@
package pb
import (
"encoding/json"
"testing"
)
func TestProxyClientMsgRoundTrip(t *testing.T) {
in := &ProxyClientMsg{Data: []byte{0x00, 0xff, 0x10}}
raw, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out ProxyClientMsg
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if string(out.Data) != string(in.Data) {
t.Fatalf("data mismatch: got %v want %v", out.Data, in.Data)
}
if out.Open != nil || out.Close != nil {
t.Fatalf("empty oneof fields should stay nil, got open=%v close=%v", out.Open, out.Close)
}
}
func TestOpenProxyCmdOnServerCommand(t *testing.T) {
cmd := &ServerCommand{CommandId: "c1", OpenProxy: &OpenProxyCmd{ProxyId: "p1", Port: 22}}
raw, err := json.Marshal(cmd)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out ServerCommand
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.OpenProxy == nil || out.OpenProxy.ProxyId != "p1" || out.OpenProxy.Port != 22 {
t.Fatalf("open_proxy did not round-trip: %+v", out.OpenProxy)
}
}
+11 -6
View File
@@ -31,7 +31,7 @@ func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error {
return status.Error(codes.PermissionDenied, "proxy session unavailable")
}
if err := serveProxy(proxy.Default, msg.Open, srv.InstanceID, stream); err != nil {
if err := serveProxy(msg.Open, srv.InstanceID, stream); err != nil {
// The reason is deliberately not returned to the agent: an unknown and a
// foreign proxy_id must be indistinguishable.
log.Printf("proxy %s (server %s): %v", msg.Open.ProxyId, msg.Open.ServerId, err)
@@ -40,12 +40,17 @@ func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error {
return nil
}
// serveProxy claims the pending session and relays it. Split out from the gRPC
// method so the authorisation matrix is testable without a real stream.
func serveProxy(reg *proxy.Registry, open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error {
entry, err := reg.Claim(instanceID, open.ServerId, open.ProxyId)
// serveProxy claims the pending session, binds this pod's relay listener for
// it, and relays. Split out from the gRPC method so the authorisation matrix is
// testable without a real stream.
//
// The listener is bound here, on whichever replica the stream reached, rather
// than in advance on the pod holding the agent's command stream — those are not
// the same pod, because an L7 proxy balances HTTP/2 requests independently.
func serveProxy(open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error {
sess, err := services.ClaimProxyStream(instanceID, open.ServerId, open.ProxyId)
if err != nil {
return err
}
return entry.Session.Serve(stream)
return sess.Serve(stream)
}
+91 -4
View File
@@ -212,11 +212,46 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
}
}()
// The heartbeat is what lets the agent tell a live stream from an orphaned
// one. gRPC keepalive cannot: behind an L7 proxy the agent's connection
// terminates at the proxy, which answers pings on its own behalf, so a dead
// pod leaves the agent blocked in Recv forever with commands vanishing into
// a stream nobody is serving. A message that originates here is the only
// thing that proves this process is still on the other end.
ping := time.NewTicker(pingInterval)
defer ping.Stop()
// Beats are counted and reported periodically rather than logged one by
// one: at one every 20s per agent, a fleet of any size would drown every
// other line in the log. What is worth a line of its own is the first beat
// (it tells the operator this stream's watchdog is now armed on the agent
// side) and any failure to send one.
var beats int
summary := time.NewTicker(pingSummaryInterval)
defer summary.Stop()
ctx := stream.Context()
for {
select {
case <-ctx.Done():
return nil
case <-summary.C:
log.Printf("agent %s command stream healthy, %d beats in the last %s",
srv.ServerID, beats, pingSummaryInterval)
beats = 0
case <-ping.C:
// A failed send is the point: it is how this side learns the stream
// is gone, which runs the deferred release and frees the agent's
// presence claim for whichever pod it reconnects to.
if err := stream.Send(&pb.ServerCommand{Ping: &pb.PingCmd{}}); err != nil {
log.Printf("agent %s command stream beat failed after %d beats: %v",
srv.ServerID, beats, err)
return err
}
beats++
if beats == 1 {
log.Printf("agent %s command stream beating every %s", srv.ServerID, pingInterval)
}
case cmd, ok := <-ch:
if !ok {
return nil
@@ -228,10 +263,29 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
}
}
func StartGRPC(port int) error {
// How often the server beats on an idle command stream. Comfortably under the
// agent's staleness threshold, so a single dropped beat does not cost a
// reconnect.
const pingInterval = 20 * time.Second
// How often an otherwise silent healthy stream says so. Long enough that a
// large fleet does not fill the log, short enough that "this pod is still
// serving that agent" is answerable from the log rather than by inference.
const pingSummaryInterval = 5 * time.Minute
// StartGRPC serves the agent API until stop is called.
//
// It returns a stop function rather than serving forever because an abrupt exit
// is not a neutral act here: every CommandStream handler holds an agent's
// presence claim, released by a deferred call that a killed process never runs.
// The claim then outlives its owner for the remainder of its 30s TTL, during
// which dispatch believes the agent is reachable, publishes to a channel with
// no subscriber, and fails as "agent offline" — a pod that has already exited
// still answering for an agent it can no longer reach.
func StartGRPC(port int) (stop func(), err error) {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return fmt.Errorf("failed to listen: %w", err)
return nil, fmt.Errorf("failed to listen: %w", err)
}
s := grpc.NewServer(
@@ -248,6 +302,39 @@ func StartGRPC(port int) error {
)
pb.RegisterVantageServer(s, &vantageServer{})
log.Printf("gRPC server listening on :%d", port)
return s.Serve(lis)
go func() {
log.Printf("gRPC server listening on :%d", port)
if err := s.Serve(lis); err != nil {
log.Fatalf("gRPC server error: %v", err)
}
}()
// GracefulStop sends GOAWAY and waits for the handlers to return, which is
// what runs those deferred releases and, on the agent's side, ends the
// stream with a clean error it reconnects from immediately rather than
// waiting out a TCP timeout.
//
// It is bounded: an idle CommandStream returns as soon as its context is
// cancelled, but a console relay mid-transfer would otherwise hold the
// process past the pod's grace period and earn a SIGKILL — which is the
// abrupt exit this exists to avoid.
return func() {
done := make(chan struct{})
go func() {
s.GracefulStop()
close(done)
}()
select {
case <-done:
log.Println("gRPC server stopped gracefully")
case <-time.After(grpcStopTimeout):
log.Printf("gRPC server did not stop within %s, forcing", grpcStopTimeout)
s.Stop()
}
}, nil
}
// How long GracefulStop is given before outstanding streams are cut. Comfortably
// inside the chart's termination grace period, so the forced stop below still
// leaves time for the HTTP server to drain.
const grpcStopTimeout = 10 * time.Second
+38
View File
@@ -0,0 +1,38 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// AuthProvider is one configured identity provider for one instance.
//
// ProviderID is a short random identifier rather than the Mongo _id: it appears
// in the callback URL a customer pastes into their identity provider, and an
// _id there would publish a database key.
type AuthProvider struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ProviderID string `bson:"provider_id" json:"provider_id"`
Name string `bson:"name" json:"name"`
Kind string `bson:"kind" json:"kind"` // "oidc" | "oauth2"
Preset string `bson:"preset" json:"preset"` // "" for custom
Issuer string `bson:"issuer" json:"issuer"`
ClientID string `bson:"client_id" json:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
Scopes []string `bson:"scopes" json:"scopes"`
Enabled bool `bson:"enabled" json:"enabled"`
// CallbackNotice marks a provider whose redirect URI changed at the upgrade
// to per-provider callbacks. Set only by migration 0005; cleared when an
// administrator acknowledges it in settings.
CallbackNotice bool `bson:"callback_notice" json:"callback_notice"`
Order int `bson:"order" json:"order"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
const (
KindOIDC = "oidc"
KindOAuth2 = "oauth2"
)
-17
View File
@@ -1,17 +0,0 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type InstanceOIDC struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Issuer string `bson:"issuer" json:"issuer"`
ClientID string `bson:"client_id" json:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
Enabled bool `bson:"enabled" json:"enabled"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
+21 -20
View File
@@ -44,24 +44,25 @@ type Inventory struct {
}
type Server struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
-1
View File
@@ -5,6 +5,5 @@ import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type (
Settings = shared.Settings
AlertSettings = shared.AlertSettings
EmailSettings = shared.EmailSettings
SecretsSettings = shared.SecretsSettings
)
+19
View File
@@ -44,13 +44,32 @@ type StepOverride struct {
SecretRefs []string `bson:"secret_refs,omitempty" json:"secret_refs,omitempty"`
}
type Schedule struct {
Enabled bool `bson:"enabled" json:"enabled"`
Cron string `bson:"cron" json:"cron"` // 5-field: minute hour dom month dow
TZ string `bson:"tz" json:"tz"` // IANA name, e.g. Europe/London
}
// Skip records why an occurrence did not run. Recording a reason nobody reads
// is the same as not recording one, so this is surfaced in the UI.
type Skip struct {
Reason string `bson:"reason" json:"reason"` // "missed" | "already_running"
Due time.Time `bson:"due" json:"due"`
At time.Time `bson:"at" json:"at"`
}
type Workflow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
Name string `bson:"name" json:"name"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"`
Steps []WorkflowStepRef `bson:"steps" json:"steps"`
Schedule *Schedule `bson:"schedule,omitempty" json:"schedule,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"`
LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
+15 -1
View File
@@ -7,6 +7,10 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// TypeServer marks an event whose subject is a managed server rather than a
// monitor check. MonitorName carries the hostname in that case.
const TypeServer = "server"
type Event struct {
MonitorName string
Type string
@@ -21,7 +25,17 @@ func (e Event) title() string {
if e.NewStatus == models.StatusDown {
verb = "is DOWN"
}
s := fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
var s string
if e.Type == TypeServer {
if e.NewStatus == models.StatusDown {
verb = "went offline"
} else {
verb = "is back online"
}
s = fmt.Sprintf("[Vantage] Server %s %s", e.MonitorName, verb)
} else {
s = fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
}
if e.Message != "" {
s += ": " + e.Message
}
+11 -54
View File
@@ -7,7 +7,6 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
"sync"
)
var (
@@ -25,56 +24,14 @@ func NewID() (string, error) {
return hex.EncodeToString(b), nil
}
type Entry struct {
ProxyID string
InstanceID string
ServerID string
Session *Session
}
type Registry struct {
mu sync.Mutex
entries map[string]*Entry
}
func NewRegistry() *Registry {
return &Registry{entries: make(map[string]*Entry)}
}
var Default = NewRegistry()
func (r *Registry) Add(e *Entry) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries[e.ProxyID] = e
}
// Claim removes and returns the entry. It is single-use: a second claim on the
// same proxy_id gets ErrNotFound. A claim whose instance or server does not
// match leaves the entry in place and gets ErrForbidden.
func (r *Registry) Claim(instanceID, serverID, proxyID string) (*Entry, error) {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[proxyID]
if !ok {
return nil, ErrNotFound
}
if e.InstanceID != instanceID || e.ServerID != serverID {
return nil, ErrForbidden
}
delete(r.entries, proxyID)
return e, nil
}
func (r *Registry) Remove(proxyID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.entries, proxyID)
}
func (r *Registry) Len() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.entries)
}
// There is deliberately no in-process registry of pending sessions here any
// more. One existed, keyed by proxy_id, on the assumption that the pod which
// bound a listener was the pod that would receive the matching ProxyStream.
// That assumption holds only for a single replica: a ProxyStream is its own
// HTTP/2 request and an L7 proxy routes it independently of the agent's
// command stream, so with N replicas the lookup missed (N-1)/N of the time and
// the console failed with "proxy session not found".
//
// The pending record lives in Redis instead (bus.SetPendingProxy /
// ClaimPendingProxy), and the listener is bound by whichever pod the stream
// actually reaches — see services.ClaimProxyStream.
+278
View File
@@ -0,0 +1,278 @@
package services
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrLockout is returned when a change would leave an instance with neither
// local password login nor an enabled provider — nobody could sign in, and no
// endpoint exists to undo it without database access.
var ErrLockout = errors.New("that would leave nobody able to sign in")
// ErrLastProvider is returned when a change would remove or disable the last
// enabled provider while local login is also off.
var ErrLastProvider = errors.New("cannot remove or disable the last sign-in method")
// ErrLocalLoginRequired is returned when disabling local login would leave no
// enabled provider to sign in with.
var ErrLocalLoginRequired = errors.New("local login is required until a provider is enabled")
const authProviderCol = "auth_providers"
func authProviderCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// CheckLockout is pure so the two endpoints that can reach this condition —
// saving settings and changing a provider — share one answer.
func CheckLockout(localEnabled bool, enabledProviders int) error {
if localEnabled || enabledProviders > 0 {
return nil
}
return ErrLockout
}
func ListAuthProviders(instanceID string) ([]models.AuthProvider, error) {
ctx, cancel := authProviderCtx()
defer cancel()
cur, err := db.Col(authProviderCol).Find(ctx,
bson.M{"instance_id": instanceID},
options.Find().SetSort(bson.D{{Key: "order", Value: 1}, {Key: "created_at", Value: 1}}))
if err != nil {
return nil, err
}
out := []models.AuthProvider{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func ListEnabledAuthProviders(instanceID string) ([]models.AuthProvider, error) {
all, err := ListAuthProviders(instanceID)
if err != nil {
return nil, err
}
out := []models.AuthProvider{}
for _, p := range all {
if p.Enabled {
out = append(out, p)
}
}
return out, nil
}
func CountEnabledAuthProviders(instanceID string) (int, error) {
enabled, err := ListEnabledAuthProviders(instanceID)
if err != nil {
return 0, err
}
return len(enabled), nil
}
// GetAuthProvider is scoped by instance. There is deliberately no lookup by
// provider_id alone: a provider ID travels in a URL, and an unscoped lookup
// would let one instance's callback resolve another instance's provider.
func GetAuthProvider(instanceID, providerID string) (*models.AuthProvider, error) {
ctx, cancel := authProviderCtx()
defer cancel()
var p models.AuthProvider
err := db.Col(authProviderCol).FindOne(ctx,
bson.M{"instance_id": instanceID, "provider_id": providerID}).Decode(&p)
if err != nil {
return nil, err
}
return &p, nil
}
func GetAuthProviderSecret(instanceID, providerID string) (string, error) {
p, err := GetAuthProvider(instanceID, providerID)
if err != nil {
return "", err
}
if p.ClientSecretEnc == "" {
return "", fmt.Errorf("provider %q has no client secret configured", p.Name)
}
return decryptString(p.ClientSecretEnc)
}
func CreateAuthProvider(p *models.AuthProvider, clientSecret string) (*models.AuthProvider, error) {
if strings.TrimSpace(p.Name) == "" {
return nil, errors.New("name is required")
}
if strings.TrimSpace(p.ClientID) == "" {
return nil, errors.New("client ID is required")
}
if clientSecret == "" {
return nil, errors.New("client secret is required")
}
enc, err := encryptString(clientSecret)
if err != nil {
return nil, err
}
id, err := randomProviderID()
if err != nil {
return nil, err
}
existing, err := ListAuthProviders(p.InstanceID)
if err != nil {
return nil, err
}
now := time.Now()
p.ProviderID = id
p.ClientSecretEnc = enc
p.CallbackNotice = false
p.Order = len(existing)
p.CreatedAt = now
p.UpdatedAt = now
ctx, cancel := authProviderCtx()
defer cancel()
if _, err := db.Col(authProviderCol).InsertOne(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// AuthProviderUpdate carries only what an edit may change. Pointer fields are
// "leave alone when nil", which is what lets an empty client secret mean "keep
// the stored one" rather than "erase it".
type AuthProviderUpdate struct {
Name *string
Issuer *string
ClientID *string
ClientSecret *string
Scopes *[]string
Enabled *bool
Order *int
}
func UpdateAuthProvider(instanceID, providerID string, in AuthProviderUpdate) error {
set := bson.M{"updated_at": time.Now()}
if in.Name != nil {
if strings.TrimSpace(*in.Name) == "" {
return errors.New("name is required")
}
set["name"] = *in.Name
}
if in.Issuer != nil {
set["issuer"] = *in.Issuer
}
if in.ClientID != nil {
set["client_id"] = *in.ClientID
}
if in.Scopes != nil {
set["scopes"] = *in.Scopes
}
if in.Order != nil {
set["order"] = *in.Order
}
if in.Enabled != nil {
set["enabled"] = *in.Enabled
}
// An empty secret means "keep what is stored". Only a non-empty one writes.
if in.ClientSecret != nil && *in.ClientSecret != "" {
enc, err := encryptString(*in.ClientSecret)
if err != nil {
return err
}
set["client_secret_enc"] = enc
}
ctx, cancel := authProviderCtx()
defer cancel()
res, err := db.Col(authProviderCol).UpdateOne(ctx,
bson.M{"instance_id": instanceID, "provider_id": providerID},
bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return mongo.ErrNoDocuments
}
return nil
}
func DeleteAuthProvider(instanceID, providerID string) error {
ctx, cancel := authProviderCtx()
defer cancel()
res, err := db.Col(authProviderCol).DeleteOne(ctx,
bson.M{"instance_id": instanceID, "provider_id": providerID})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return mongo.ErrNoDocuments
}
return nil
}
func AckAuthProviderNotice(instanceID, providerID string) error {
ctx, cancel := authProviderCtx()
defer cancel()
_, err := db.Col(authProviderCol).UpdateOne(ctx,
bson.M{"instance_id": instanceID, "provider_id": providerID},
bson.M{"$set": bson.M{"callback_notice": false}})
return err
}
// IsLocalLoginEnabled fails open. A settings read error must not lock an
// instance out of its own login page, and the safe direction here is the one
// that still asks for a password.
func IsLocalLoginEnabled(instanceID string) bool {
s, err := GetSettings(instanceID)
if err != nil {
return true
}
return shared.LocalLoginEnabled(s)
}
// LocalLoginPermitted answers whether password sign-in must be accepted for
// this instance, which is not the same question as whether an administrator
// turned it on. An instance whose only providers have become unusable — a
// lapsed licence, or every provider disabled — has to keep its password form,
// or nobody can sign in and there is no endpoint left to fix it with.
func LocalLoginPermitted(instanceID string) bool {
if IsLocalLoginEnabled(instanceID) {
return true
}
return CountUsableAuthProviders(instanceID) == 0
}
// CountUsableAuthProviders counts providers that could actually complete a
// sign-in right now: enabled, and permitted by the licence.
func CountUsableAuthProviders(instanceID string) int {
if !GetLicenseState(instanceID).Feature("oidc") {
return 0
}
n, err := CountEnabledAuthProviders(instanceID)
if err != nil {
return 0
}
return n
}
// randomProviderID is 8 bytes hex: short enough to read in a URL, wide enough
// that guessing one is not a way to enumerate an instance's providers.
func randomProviderID() (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+129 -55
View File
@@ -21,18 +21,30 @@ import (
// so this is fatal rather than a degraded mode.
var ErrAgentOffline = errors.New("agent is not connected")
// A console session spans two processes once there is more than one replica.
// A console session spans up to three processes once there is more than one
// replica, and no two of them can be assumed to be the same one:
//
// The browser's WebSocket lands on an arbitrary pod. The agent's ProxyStream
// lands on the pod holding that agent's command stream. The relay listener has
// to be on the latter — that is the only process that can match an incoming
// ProxyStream to a waiting listener — while guacd is dialled from the former.
// the browser's WebSocket lands on an arbitrary pod
// the agent's CommandStream lands on the pod holding presence for it
// the agent's ProxyStream lands on an arbitrary pod
//
// So the WebSocket's pod asks, over the bus, for a relay to be bound on the
// agent's pod, and gets back an address to hand to guacd. That address is the
// owner pod's own, which is why it must resolve to a single pod (POD_IP under
// Kubernetes) rather than to the Service, which would send guacd to a pod
// holding no listener roughly (n-1)/n of the time.
// That third line is the one that is easy to get wrong. A ProxyStream is a
// separate HTTP/2 request, and an L7 proxy (Traefik, which the chart's gRPC
// ingress uses) balances requests rather than connections — so it does not
// follow the command stream. Binding the relay listener on the command
// stream's pod therefore fails roughly (n-1)/n of the time with "proxy session
// not found": the stream arrives at a pod whose registry is empty.
//
// So the listener is bound by whichever pod receives the ProxyStream, at the
// moment it receives it, and that pod announces its own address on
// ProxyAddrChannel. The WebSocket's pod subscribes before dispatching and
// hands the announced address to guacd. The address is the announcing pod's
// own, which is why it must resolve to a single pod (POD_IP under Kubernetes)
// rather than to the Service.
//
// Authorisation cannot live in that pod's memory either, so a pending record
// in Redis (bus.SetPendingProxy) carries the instance and server a proxy_id
// was minted for, and is consumed atomically on first claim.
//
// Teardown needs no message of its own. When the browser goes away guac closes
// its connection to the relay, the relay sees the read end, and the session
@@ -46,6 +58,15 @@ var ErrAgentOffline = errors.New("agent is not connected")
// waited for longer.
const proxyEndGrace = 2 * time.Second
// How long a minted proxy_id stays claimable, and how long the WebSocket's pod
// waits for the relay's address to be announced. The TTL is the longer of the
// two on purpose: a record that expired while its own opener was still waiting
// would turn a slow agent into an unexplained refusal.
const (
proxyPendingTTL = 30 * time.Second
proxyAddrWait = 15 * time.Second
)
// ConsoleProxy is a relay as seen by the pod serving the WebSocket.
type ConsoleProxy struct {
ProxyID string
@@ -114,51 +135,63 @@ func guacdHosts(addr string) []string {
return ips
}
// localRelay is a listener bound by this process on behalf of a remote request.
type localRelay struct {
proxyID string
host string
port int
session *proxy.Session
// proxyAddr is what a relay's binding pod announces: the address guacd should
// dial to reach the listener it has just bound.
type proxyAddr struct {
Host string `json:"host"`
Port int `json:"port"`
}
// openLocalRelay binds a listener here and registers it, so the agent's
// ProxyStream — which will arrive at this process — can be matched to it.
// Called on the owner pod, from the dispatch handler.
func openLocalRelay(instanceID, serverID, proxyID string) (*localRelay, error) {
// ClaimProxyStream authorises an incoming ProxyStream, binds a relay listener
// for it on this pod, and announces the address to whichever pod is serving the
// browser's WebSocket. It is called from the gRPC handler, on whichever replica
// the stream happened to reach.
//
// instanceID and serverID are the *authenticated* identity of the calling
// agent; they must match the pending record or the claim is refused, so an
// agent cannot relay a console session minted for another server.
func ClaimProxyStream(instanceID, serverID, proxyID string) (*proxy.Session, error) {
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer cancel()
wantInstance, wantServer := bus.ClaimPendingProxy(ctx, proxyID)
if wantInstance == "" {
return nil, proxy.ErrNotFound
}
if wantInstance != instanceID || wantServer != serverID {
return nil, proxy.ErrForbidden
}
sess, err := proxy.NewSession(proxyListenHost(), guacdHosts(guacdAddr()))
if err != nil {
return nil, err
}
sess.OnEnd(func(reason string) {
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer cancel()
if _, err := bus.Publish(ctx, bus.ProxyEndChannel+proxyID, proxyEnd{Reason: reason}); err != nil {
endCtx, endCancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer endCancel()
if _, err := bus.Publish(endCtx, bus.ProxyEndChannel+proxyID, proxyEnd{Reason: reason}); err != nil {
log.Printf("proxy: publish end for %s: %v", proxyID, err)
}
})
proxy.Default.Add(&proxy.Entry{
ProxyID: proxyID,
InstanceID: instanceID,
ServerID: serverID,
Session: sess,
})
addr := proxyAddr{Host: proxyAdvertiseHost(), Port: sess.Port()}
return &localRelay{
proxyID: proxyID,
host: proxyAdvertiseHost(),
port: sess.Port(),
session: sess,
}, nil
}
// Logged on the success path, not just on failure. The address guacd is
// about to be sent to is chosen per pod (POD_IP), so when a console fails
// for some replicas and not others this line is the difference between
// seeing which one answered and inferring it from silence.
log.Printf("proxy %s (server %s): relay bound on %s:%d, node %s",
proxyID, serverID, addr.Host, addr.Port, bus.NodeID())
// abandon tears down a relay that was bound but whose command never reached the
// agent, so the listener does not sit out its rendezvous timeout for nothing.
func (r *localRelay) abandon() {
proxy.Default.Remove(r.proxyID)
r.session.Close("dispatch_failed")
if _, err := bus.Publish(ctx, bus.ProxyAddrChannel+proxyID, addr); err != nil {
// Nobody will ever dial this listener, so it is closed now rather than
// left to sit out its rendezvous timeout.
sess.Close("announce_failed")
return nil, fmt.Errorf("announce relay address: %w", err)
}
return sess, nil
}
// OpenConsoleProxy asks the pod holding serverID's stream to bind a relay and
@@ -173,44 +206,85 @@ func OpenConsoleProxy(instanceID, serverID string, targetPort int) (*ConsoleProx
return nil, fmt.Errorf("generate proxy id: %w", err)
}
// Subscribed before the relay is asked for: a relay that fails immediately
// (the agent never claims it, the dial is refused) publishes its reason at
// once, and that reason is the whole content of the audit event.
// Both subscriptions are established before the command is dispatched: a
// fast agent binds and announces its relay within milliseconds, and a relay
// that fails immediately publishes its reason just as quickly. Either
// arriving before the subscriber is in place would be lost.
cp := &ConsoleProxy{ProxyID: proxyID, serverID: serverID, ended: make(chan struct{})}
endCtx, endCancel := context.WithCancel(context.Background())
ends, unsub, err := bus.Subscribe(endCtx, bus.ProxyEndChannel+proxyID)
ends, unsubEnd, err := bus.Subscribe(endCtx, bus.ProxyEndChannel+proxyID)
if err != nil {
endCancel()
return nil, fmt.Errorf("subscribe relay end: %w", err)
}
addrs, unsubAddr, err := bus.Subscribe(endCtx, bus.ProxyAddrChannel+proxyID)
if err != nil {
endCancel()
unsubEnd()
return nil, fmt.Errorf("subscribe relay address: %w", err)
}
cp.stop = func() {
endCancel()
unsub()
unsubAddr()
unsubEnd()
}
go cp.watchEnd(ends)
ack, err := Dispatcher.send(CommandEnvelope{
// The pending record authorises the ProxyStream the agent is about to open,
// and is written before the command so it cannot lose the race with it.
pendCtx, pendCancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
if err := bus.SetPendingProxy(pendCtx, proxyID, instanceID, serverID, proxyPendingTTL); err != nil {
pendCancel()
cp.stop()
return nil, fmt.Errorf("register pending relay: %w", err)
}
pendCancel()
abandon := func() {
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
bus.ClearPendingProxy(ctx, proxyID)
cancel()
cp.stop()
}
if _, err := Dispatcher.send(CommandEnvelope{
ServerID: serverID,
Command: &pb.ServerCommand{
CommandId: proxyID,
OpenProxy: &pb.OpenProxyCmd{ProxyId: proxyID, Port: uint32(targetPort)},
},
Proxy: &ProxyRelayRequest{InstanceID: instanceID, ProxyID: proxyID},
})
if err != nil {
cp.stop()
}); err != nil {
abandon()
if errors.Is(err, ErrAgentNotConnected) {
return nil, ErrAgentOffline
}
return nil, err
}
if ack.ProxyHost == "" || ack.ProxyPort == 0 {
cp.stop()
// The command has reached the agent; the relay's address arrives only once
// the agent has actually opened its ProxyStream somewhere in the fleet.
var addr proxyAddr
select {
case b, ok := <-addrs:
if !ok {
abandon()
return nil, fmt.Errorf("relay address subscription closed")
}
if err := json.Unmarshal(b, &addr); err != nil {
abandon()
return nil, fmt.Errorf("undecodable relay address: %w", err)
}
case <-time.After(proxyAddrWait):
abandon()
return nil, fmt.Errorf("agent did not open a relay for %s", serverID)
}
if addr.Host == "" || addr.Port == 0 {
abandon()
return nil, fmt.Errorf("relay opened without an address")
}
cp.Host = ack.ProxyHost
cp.Port = ack.ProxyPort
cp.Host = addr.Host
cp.Port = addr.Port
return cp, nil
}
+5 -3
View File
@@ -30,9 +30,11 @@ func EnsureAuthIndexes() error {
return err
}
// instance_oidc is control-plane only, so its index stays here.
if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}},
// auth_providers is control-plane only, so its index stays here. The
// (instance_id, provider_id) pair is unique because a duplicate provider_id
// inside one instance would make the callback ambiguous.
if _, err := db.Col("auth_providers").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "provider_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
+51 -45
View File
@@ -38,11 +38,19 @@ const (
// CommandEnvelope is what actually crosses the bus. It is the command plus the
// small amount of context the owning pod needs to act on it locally.
type CommandEnvelope struct {
ServerID string `json:"server_id"`
Command *pb.ServerCommand `json:"command"`
ReplyTo string `json:"reply_to"`
Log *LogRequest `json:"log,omitempty"`
Proxy *ProxyRelayRequest `json:"proxy,omitempty"`
ServerID string `json:"server_id"`
Command *pb.ServerCommand `json:"command"`
ReplyTo string `json:"reply_to"`
Log *LogRequest `json:"log,omitempty"`
// Node names the pod this envelope is for: the presence holder at the time
// it was published. The command channel is a fan-out, so during a reconnect
// two pods can be subscribed for one agent — the pod with the live stream,
// and a pod whose stream is half-open and has not yet noticed. Both would
// receive the envelope, and the first to ack wins the request. If that is
// the stale one, the command is queued onto a dead stream and acked OK: the
// operator is told it worked and the agent never sees it.
Node string `json:"node,omitempty"`
}
// LogRequest asks the owner pod to open a step log before it dispatches.
@@ -56,25 +64,17 @@ type LogRequest struct {
Mask []string `json:"mask,omitempty"`
}
// ProxyRelayRequest asks the owner pod to bind a console relay listener and
// register it before dispatching OpenProxyCmd.
// CommandAck is the owner pod's answer. It reports only that the command
// reached the agent's stream.
//
// The listener has to live on the owner pod: the agent's ProxyStream arrives
// there, and only there can it be matched to a waiting listener. The pod
// serving the browser's WebSocket learns the address from the ack and hands
// that to guacd.
type ProxyRelayRequest struct {
InstanceID string `json:"instance_id"`
ProxyID string `json:"proxy_id"`
}
// CommandAck is the owner pod's answer.
// A console relay listener used to be bound here and its address returned in
// this ack. It no longer is: the agent's ProxyStream does not necessarily
// arrive at the pod holding its command stream, so the listener is bound by
// whichever pod receives that stream and announced on bus.ProxyAddrChannel.
type CommandAck struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Node string `json:"node,omitempty"`
ProxyHost string `json:"proxy_host,omitempty"`
ProxyPort int `json:"proxy_port,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Node string `json:"node,omitempty"`
}
type commandDispatcher struct{}
@@ -117,8 +117,13 @@ func (d *commandDispatcher) Serve(ctx context.Context, serverID string) (<-chan
case <-runCtx.Done():
return
case <-t.C:
if err := bus.SetPresence(runCtx, serverID, presenceTTL); err != nil {
log.Printf("dispatch: renew presence for %s: %v", serverID, err)
// Renew only while this pod still holds the claim. Losing it
// means a newer stream for the same agent was established
// elsewhere, and this one is a half-open leftover: it must stop
// renewing rather than overwrite the live owner every 10s.
if !bus.RenewPresence(runCtx, serverID, presenceTTL) {
log.Printf("dispatch: presence for %s is held elsewhere, stopping renewal", serverID)
return
}
}
}
@@ -161,6 +166,15 @@ func (d *commandDispatcher) handleEnvelope(ctx context.Context, raw []byte, out
return
}
// Not addressed to this pod: stay silent rather than ack. Answering would
// win the race against the pod that actually holds the agent's stream, and
// the caller would be told a command succeeded that was queued onto a
// stream nobody is reading. Silence lets the real owner answer, or lets the
// request time out as ErrNoResponder, which fails loudly and correctly.
if env.Node != "" && env.Node != bus.NodeID() {
return
}
ack := CommandAck{OK: true, Node: bus.NodeID()}
if env.Log != nil {
@@ -169,27 +183,10 @@ func (d *commandDispatcher) handleEnvelope(ctx context.Context, raw []byte, out
}
}
var relay *localRelay
if env.Proxy != nil {
r, err := openLocalRelay(env.Proxy.InstanceID, env.ServerID, env.Proxy.ProxyID)
if err != nil {
ack = CommandAck{OK: false, Error: err.Error(), Node: bus.NodeID()}
} else {
relay = r
ack.ProxyHost = r.host
ack.ProxyPort = r.port
}
}
if ack.OK {
select {
case out <- env.Command:
default:
ack = CommandAck{OK: false, Error: "command queue full", Node: bus.NodeID()}
if relay != nil {
relay.abandon()
}
}
select {
case out <- env.Command:
default:
ack = CommandAck{OK: false, Error: "command queue full", Node: bus.NodeID()}
}
if err := bus.Reply(ctx, env.ReplyTo, ack); err != nil {
@@ -221,6 +218,15 @@ func (d *commandDispatcher) send(env CommandEnvelope) (CommandAck, error) {
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer cancel()
// Resolved once, here, and carried in the envelope. Reading it at publish
// time rather than letting subscribers self-select is what makes a stale
// subscriber harmless: it will see an envelope addressed elsewhere and
// ignore it.
env.Node = bus.PresenceHolder(ctx, env.ServerID)
if env.Node == "" {
return CommandAck{}, fmt.Errorf("%w: %s", ErrAgentNotConnected, env.ServerID)
}
raw, err := bus.Request(ctx, bus.CommandChannel+env.ServerID, env.ReplyTo, env, dispatchAckTimeout)
if err != nil {
if errors.Is(err, bus.ErrNoResponder) {
-50
View File
@@ -1,50 +0,0 @@
package services
import (
"context"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func GetInstanceOIDC(instanceID string) (*models.InstanceOIDC, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.InstanceOIDC
err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetInstanceOIDCSecret(instanceID string) (string, error) {
o, err := GetInstanceOIDC(instanceID)
if err != nil {
return "", err
}
return decryptString(o.ClientSecretEnc)
}
func SaveInstanceOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{
"instance_id": instanceID, "issuer": issuer, "client_id": clientID,
"enabled": enabled, "updated_at": time.Now(),
}
if clientSecret != "" {
enc, err := encryptString(clientSecret)
if err != nil {
return err
}
set["client_secret_enc"] = enc
}
_, err := db.Col("instance_oidc").UpdateOne(ctx,
bson.M{"instance_id": instanceID}, bson.M{"$set": set},
options.UpdateOne().SetUpsert(true))
return err
}
@@ -0,0 +1,98 @@
package services
import (
"context"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
// legacyInstanceOIDC is the pre-0005 shape: one document per instance, in a
// collection this migration is the last thing to read.
type legacyInstanceOIDC struct {
InstanceID string `bson:"instance_id"`
Issuer string `bson:"issuer"`
ClientID string `bson:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc"`
Enabled bool `bson:"enabled"`
UpdatedAt time.Time `bson:"updated_at"`
}
// MigrateAuthProviders copies each instance_oidc document into auth_providers.
//
// The ciphertext is copied verbatim rather than decrypted and re-encrypted: a
// migration that needs KEY_ENCRYPTION_KEY fails on an instance that has none
// and strands the SSO configuration it was supposed to preserve.
//
// instance_oidc is left in place and no longer read. Nothing deletes it — a
// migration that drops the only copy of a client secret has no undo.
func MigrateAuthProviders() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
const marker = "0005_auth_providers"
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
cur, err := db.Col("instance_oidc").Find(ctx, bson.M{})
if err != nil {
return err
}
var legacy []legacyInstanceOIDC
if err := cur.All(ctx, &legacy); err != nil {
return err
}
migrated := 0
for _, l := range legacy {
if l.InstanceID == "" {
continue
}
// Idempotent by skipping an instance that already has a provider, so a
// re-run after a partial failure completes rather than duplicating.
n, err := db.Col(authProviderCol).CountDocuments(ctx, bson.M{"instance_id": l.InstanceID})
if err != nil {
return err
}
if n > 0 {
continue
}
providerID, err := randomProviderID()
if err != nil {
return err
}
now := time.Now()
p := models.AuthProvider{
InstanceID: l.InstanceID,
ProviderID: providerID,
Name: "Single sign-on",
Kind: models.KindOIDC,
Preset: "",
Issuer: l.Issuer,
ClientID: l.ClientID,
ClientSecretEnc: l.ClientSecretEnc,
Scopes: []string{"openid", "profile", "email"},
Enabled: l.Enabled,
// This provider's redirect URI has changed and nobody has been told
// yet. The settings card raises it until an administrator dismisses.
CallbackNotice: true,
Order: 0,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := db.Col(authProviderCol).InsertOne(ctx, p); err != nil {
return err
}
migrated++
}
if migrated > 0 {
log.Printf("0005: migrated %d OIDC configuration(s) to auth_providers", migrated)
}
_, err = db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
return err
}
@@ -39,6 +39,7 @@ var ScopedCollections = []string{
"notification_channels",
"console_sessions",
"audit_logs",
"auth_providers",
}
// collectionRenames maps the two collections whose names change. Ordered so the
+80 -5
View File
@@ -12,8 +12,10 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -327,11 +329,8 @@ func markOfflineForFilter(scope bson.M, instanceID string) error {
for _, s := range goingOffline {
LogEvent(s.InstanceID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
if settings != nil && settings.Email.Enabled {
go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress)
if settings != nil {
notifyServerOffline(s.InstanceID, settings.Alerts.OfflineChannelIDs, s)
}
}
@@ -340,3 +339,79 @@ func markOfflineForFilter(scope bson.M, instanceID string) error {
)
return err
}
// notifyServerOffline delivers an agent-offline alert over the instance's
// chosen notification channels — the same destinations monitors dispatch to,
// so a webhook or SMTP destination is configured and tested in exactly one
// place. No channels selected means the alert is audited but not sent.
func notifyServerOffline(instanceID string, channelIDs []string, s models.Server) {
if len(channelIDs) == 0 {
return
}
channels, err := GetChannels(instanceID, channelIDs)
if err != nil {
log.Printf("notify: load offline channels for %s: %v", instanceID, err)
return
}
ev := notify.Event{
MonitorName: s.Hostname,
Type: notify.TypeServer,
OldStatus: models.StatusUp,
NewStatus: models.StatusDown,
Message: fmt.Sprintf("agent has not checked in (%s)", s.IPAddress),
Time: time.Now(),
}
for _, ch := range channels {
if !ch.Enabled {
continue
}
go func(c models.NotificationChannel) {
if err := notify.Dispatch(c, ev); err != nil {
log.Printf("notify: offline dispatch to %s (%s): %v", c.Name, c.Type, err)
}
}(ch)
}
}
// ListServersFiltered is ListServers with an optional tag selector. An empty
// selector returns the whole fleet — unlike MatchesTags, where empty means
// "nothing", because here the caller is a list view whose default is
// "everything", not a run about to touch machines.
func ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := bson.M{"instance_id": instanceID}
for k, v := range sel {
filter["tags."+k] = v
}
cur, err := db.Col("servers").Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
servers := []models.Server{}
if err := cur.All(ctx, &servers); err != nil {
return nil, err
}
return servers, nil
}
// EnsureServerIndexes declares the wildcard index over the tag subdocument.
// It is wildcard because the queried key is chosen by the user at request time
// and cannot be named in advance.
//
// Non-fatal, following EnsureSecretIndexes: a missing index degrades tag
// filtering to a collection scan over a small collection, which is slower.
// A fatal error here would refuse to boot the fleet list over it.
func EnsureServerIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := db.Col("servers").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "tags.$**", Value: 1}},
})
return err
}
+22 -118
View File
@@ -1,19 +1,11 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
@@ -25,13 +17,8 @@ import (
var defaultSettings = models.Settings{
Alerts: models.AlertSettings{
Enabled: false,
WebhookURL: "",
OfflineThresholdMinutes: 5,
},
Email: models.EmailSettings{
SMTPPort: 587,
},
}
func EnsureSettingsIndexes() error {
@@ -132,21 +119,36 @@ func ResolveSecretsReadToken(token string) (string, bool) {
return s.InstanceID, true
}
func SaveSettings(instanceID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool) error {
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
if email.SMTPPort <= 0 {
email.SMTPPort = 587
if alerts.OfflineChannelIDs == nil {
alerts.OfflineChannelIDs = []string{}
}
set := bson.M{"alerts": alerts, "email": email}
// The guard lives here rather than in the handler so the settings path and
// the provider path cannot disagree about what a lockout is.
if localLoginEnabled != nil && !*localLoginEnabled {
n, err := CountEnabledAuthProviders(instanceID)
if err != nil {
return err
}
if err := CheckLockout(false, n); err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{"alerts": alerts}
if retentionDays != nil {
set["workflow_log_retention_days"] = *retentionDays
}
if localLoginEnabled != nil {
set["local_login_enabled"] = *localLoginEnabled
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{"instance_id": instanceID},
bson.M{"$set": set, "$setOnInsert": bson.M{"instance_id": instanceID}},
@@ -165,101 +167,3 @@ func GetWorkflowLogRetentionDays(instanceID string) (int, error) {
}
return *s.WorkflowLogRetentionDays, nil
}
func SendOfflineWebhook(webhookURL, hostname, serverID, ipAddress string) {
payload := map[string]any{
"event": "server.offline",
"hostname": hostname,
"server_id": serverID,
"ip_address": ipAddress,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"message": fmt.Sprintf("Server %s (%s) has gone offline", hostname, ipAddress),
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("webhook marshal error: %v", err)
return
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("webhook delivery error for %s: %v", hostname, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("webhook returned %d for %s", resp.StatusCode, hostname)
}
}
func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress string) {
if !cfg.Enabled || cfg.SMTPHost == "" || len(cfg.ToAddrs) == 0 {
return
}
subject := fmt.Sprintf("Vantage Alert: %s is offline", hostname)
bodyText := fmt.Sprintf(
"Server %s (%s) has gone offline.\r\n\r\nServer ID: %s\r\nTimestamp: %s\r\n",
hostname, ipAddress, serverID, time.Now().UTC().Format(time.RFC3339),
)
msg := []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.FromAddr,
strings.Join(cfg.ToAddrs, ", "),
subject,
bodyText,
))
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.SMTPHost)
}
var sendErr error
if cfg.UseTLS {
sendErr = sendMailTLS(addr, cfg.SMTPHost, auth, cfg.FromAddr, cfg.ToAddrs, msg)
} else {
sendErr = smtp.SendMail(addr, auth, cfg.FromAddr, cfg.ToAddrs, msg)
}
if sendErr != nil {
log.Printf("email alert error for %s: %v", hostname, sendErr)
}
}
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
+158
View File
@@ -0,0 +1,158 @@
package services
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrInvalidTag is returned for any tag the rules below reject. Handlers map
// it to 400 — a malformed tag is the caller's mistake, not a server fault.
var ErrInvalidTag = errors.New("invalid tag")
const (
maxTagKeyLen = 32
maxTagValueLen = 64
maxTagsPerHost = 20
// Reserved for tags the agent may derive from inventory later. Refusing
// it now means a user tag written today can never collide with a system
// tag invented tomorrow.
sysTagPrefix = "sys:"
)
func validTagRunes(s string) bool {
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case r == '-' || r == '_':
default:
return false
}
}
return true
}
// ValidateTags enforces the shape of a whole tag map. It lives in the service
// layer rather than a handler so that every write path — the tags endpoint,
// server create, anything added later — agrees on what a valid tag is.
func ValidateTags(tags map[string]string) error {
if len(tags) > maxTagsPerHost {
return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost)
}
for k, v := range tags {
if strings.HasPrefix(k, sysTagPrefix) {
return fmt.Errorf("%w: keys beginning %q are reserved", ErrInvalidTag, sysTagPrefix)
}
if k == "" || len(k) > maxTagKeyLen || !validTagRunes(k) {
return fmt.Errorf("%w: key %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagKeyLen)
}
if v == "" || len(v) > maxTagValueLen || !validTagRunes(v) {
return fmt.Errorf("%w: value for %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagValueLen)
}
}
return nil
}
// ParseTagFilters turns repeated ?tag=key:value query values into a map.
//
// A malformed filter is an error rather than a silently ignored value: a
// filter that matches nothing and a filter that is nonsense look identical in
// a list, and only one of them is the caller's fault.
func ParseTagFilters(raw []string) (map[string]string, error) {
out := make(map[string]string, len(raw))
for _, r := range raw {
k, v, found := strings.Cut(r, ":")
if !found {
return nil, fmt.Errorf("%w: filter %q must be key:value", ErrInvalidTag, r)
}
if strings.Contains(v, ":") {
return nil, fmt.Errorf("%w: filter %q has more than one colon", ErrInvalidTag, r)
}
out[k] = v
}
if err := ValidateTags(out); err != nil {
return nil, err
}
return out, nil
}
// SetServerTags replaces a server's whole tag map.
//
// Replace rather than patch: a tag set is small enough that sending all of it
// is free, and last-write-wins over a whole map is easier to reason about than
// merge semantics between two people editing the same server.
func SetServerTags(instanceID, serverID string, tags map[string]string) error {
if err := ValidateTags(tags); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": serverID, "instance_id": instanceID},
bson.M{"$set": bson.M{"tags": tags}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return mongo.ErrNoDocuments
}
return nil
}
// KnownTags returns every key in use in this instance with its distinct
// values, for the UI's pickers. This is an aggregation rather than a
// maintained registry: a tag is a property of a server, not an entity, and a
// registry would need reference counting to know when a tag stopped existing.
func KnownTags(instanceID string) (map[string][]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cur, err := db.Col("servers").Find(ctx,
bson.M{"instance_id": instanceID, "tags": bson.M{"$exists": true}},
options.Find().SetProjection(bson.M{"tags": 1}),
)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
seen := map[string]map[string]bool{}
for cur.Next(ctx) {
var s models.Server
if err := cur.Decode(&s); err != nil {
return nil, err
}
for k, v := range s.Tags {
if seen[k] == nil {
seen[k] = map[string]bool{}
}
seen[k][v] = true
}
}
if err := cur.Err(); err != nil {
return nil, err
}
out := make(map[string][]string, len(seen))
for k, vals := range seen {
list := make([]string, 0, len(vals))
for v := range vals {
list = append(list, v)
}
sort.Strings(list)
out[k] = list
}
return out, nil
}

Some files were not shown because too many files have changed in this diff Show More