Compare commits

...
116 Commits
Author SHA1 Message Date
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
mrhid6 6dced22499 fix: Fixed agent collect packages
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 21s
Agent Release / build (push) Successful in 58s
Agent Release / msi (push) Successful in 1m34s
2026-08-06 16:20:34 +01:00
mrhid6 5cee53dc5f feat: Better button description
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 2m23s
2026-08-06 15:51:18 +01:00
mrhid6 81248bb159 style: bring the vulnerabilities page onto the house page shape
Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.

The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
2026-08-06 15:49:39 +01:00
mrhid6 6354d54de8 feat: added addon price to pricing page 2026-08-06 15:48:20 +01:00
mrhid6 da6d64f95c fix: give the scratch server image a /tmp for the vulnerability database
The runtime stage is FROM scratch, which has no /tmp, so vulnsched died at
startup with "temp dir: stat /tmp: no such file or directory" and no scan
ever ran. Nothing in the server wrote to a temporary directory before the
trivy-db puller, which is why this only appeared now.

scratch cannot mkdir its own, so the directory is staged in the builder at
1777 and copied in. Also corrects CLAUDE.md, which described this image as
Alpine; the time/tzdata import it justifies is if anything more load-bearing
on scratch.
2026-08-06 15:46:30 +01:00
mrhid6 9ba3d4a61f feat: Vulnerability Scanning feature on license page
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m20s
2026-08-06 15:40:13 +01:00
mrhid6 eee236a072 fix: tidy server go.mod for the vulnerability database dependencies
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Canceled after 2m11s
trivy-db, oras-go, the three version comparators and robfig/cron were
recorded as indirect with an incomplete go.sum, which builds locally
against a warm module cache but fails in CI with "updates to go.mod
needed". trivy-db pulls testify into the build graph, and its hashes
were missing entirely.
2026-08-06 15:18:57 +01:00
mrhid6 9df89e2db4 fix: surface vuln_scanning across licence, staff and pricing UI
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Canceled after 59s
The catalogue row alone was not enough; the feature was invisible in
three places and mislabelled in a fourth.

PlanConfigurator rendered every key that was not "console" as "Single
sign-on", so the staff checkbox granting vulnerability scanning was
labelled single sign-on. Feature wording was duplicated between the staff
configurator and the purchase form and the copies had drifted, so it now
lives in adminsite/lib/features.ts and both read from it.

The customer licence panel showed raw keys; it now labels them.

Pricing gains a comparison row. The add-on block with a monthly price is
deliberately NOT added: that is a pricing decision, and the Paddle price
IDs for the new catalogue rows have to be pasted in before it can be sold
anyway.
2026-08-06 15:10:26 +01:00
mrhid6 f60c509b47 feat: vuln_scanning entitlement and documentation
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m11s
Agent Release / build (push) Successful in 1m0s
Agent Release / msi (push) Successful in 2m18s
Adds license.FeatureVulnScanning as the one name for the feature and a
catalogue row per deployment/tier, following console and oidc: features
are opt-in per customer, so no plan bundles it.

Documents the subsystem in CLAUDE.md, including that ScopedCollections is
the canonical registry instance deletion derives from — there is no
separate deletion list, which the plan had wrong.
2026-08-06 14:44:11 +01:00
mrhid6 84dfcfeac7 feat: vulnerability findings UI
Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.

The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.

Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
2026-08-06 14:40:37 +01:00
mrhid6 5dda3b5c4a feat: vulnerability scanning pipeline, matcher, scheduler and API
Completes tasks 10-15 and fixes what was outstanding:

- vulndb.Pull implemented with oras-go, streaming the ~50MB layer and
  staging both files before replacing either, so a failed pull leaves the
  previous database intact rather than a half-written one.
- db.go: Vulnerability.Severity is a string, not trivy Severity, so the
  int conversion did not compile. Severity now resolves vendor (highest
  when vendors disagree) then NVD then unknown, and CVSS is read too.
- findings.go: added sweepFixedFindings plus the fleet query, severity
  counts, rescan flag and accept/unaccept the API needs.
- vulnrules.go: added rule CRUD and the digest builder. ResolveTargets
  returns []models.Server, not []string, so filterByServers was wrong.
- api/vulnerabilities.go was an empty file while handlers.go registered
  twelve routes against it; written, grouped by CVE.
- shared/mail: added the missing sender. The templates were orphaned and
  the HTML one was a copy of the text one, defining "subject" (which
  html/template would escape) and emitting no markup. render.go parses
  every template in init(), so a bad one panics server, admin and sitesvc
  at boot — go build never runs init(), which is why nothing complained.
- notify: digests dispatch through their own path so SMTP gets the digest
  template rather than arriving dressed as a monitor alert.
2026-08-06 14:33:46 +01:00
mrhid6 db64320bd8 feat: agent reports installed packages on the hourly loop
SyncKeys now returns the whole response so the poll can carry
CollectPackages; a separate RPC for one boolean would be a message every
30 seconds for a value that changes when a licence does.

The flag is an atomic: the 30s poll writes it, the hourly package loop
reads it, and they are different goroutines.
2026-08-06 13:21:13 +01:00
mrhid6 583f60771c feat: store agent package reports and serve the collect flag
VulnScanningEnabled reads GetLicenseState(...).Feature("vuln_scanning")
and requires an active licence, never switching on tier. ReportPackages
re-checks it server-side: the agent flag is the optimisation, this is
the boundary.
2026-08-06 13:19:39 +01:00
mrhid6 a92c3190c2 feat: ReportPackages wire types with hash short-circuit
The pb packages are hand-written, not protoc-generated, and the wire
codec is JSON (encoding.RegisterCodec(JSONCodec{})). Field numbers in
the .proto are documentation; JSON field names are the contract. Both pb
packages edited by hand to match.

SyncResponse.collect_packages is omitempty and absent decodes as false,
so an older server leaves agents collecting nothing rather than
collecting without a licence.
2026-08-06 13:17:44 +01:00
mrhid6 3a6d24fe0e feat: models and indexes for package inventory and CVE findings
Adds server_packages, vuln_findings and vuln_alert_rules to
ScopedCollections rather than to a separate deletion list. purgeInstance
derives its collection list from that registry, so instance deletion
follows automatically and there is no second copy to drift.
2026-08-06 11:59:00 +01:00
mrhid6 c277ecff44 feat: agent collects installed packages per package manager 2026-08-06 11:56:41 +01:00
mrhid6 bd690c94c3 feat: agent parses /etc/os-release for distro identification 2026-08-06 11:55:45 +01:00
mrhid6 a22fdf197e feat: map OS family and version to trivy-db advisory buckets 2026-08-06 11:55:01 +01:00
mrhid6 bd24b03cac feat: version comparators for distro package ordering 2026-08-06 11:54:13 +01:00
mrhid6 3afc4ab012 docs: workload registry plan; remove tests from both plans
Both plans now verify by build, vet and manual checks written into the
tasks. Spec verification sections updated to match so they no longer
describe tests that will not be written.
2026-08-06 11:27:37 +01:00
mrhid6 d1ac3e98ce docs: design for the workload registry
Agents enumerate Docker containers, compose stacks and systemd services;
start/stop/restart and bounded log snapshots from the UI.

Sub-project B, Linux only. Live log following stays in the console.
2026-08-06 11:11:26 +01:00
mrhid6 5bba54f3e5 fix: Fixed style layout on workflow run page 2026-08-06 10:50:31 +01:00
mrhid6 fe7bc300e2 docs: implementation plan for package inventory and CVE findings
17 tasks, TDD where the logic is pure. Corrects two spec claims:
the server reads features via License.HasFeature rather than admin's
entitlement directly, and shared/mail/render_test.go does not exist.
2026-08-06 10:49:10 +01:00
mrhid6 00c03c365d docs: design for package inventory and CVE findings
Agents report installed packages; the control plane matches them against
trivy-db and raises findings that link to the existing ApplyUpdatesCmd
patching path.

Scoped to sub-project A, Linux only. Container registry, image scanning
and compliance baselines are separate specs.
2026-08-06 10:33:54 +01:00
mrhid6 dc8dd3dd58 fix: Fixed step descriptions
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 35s
2026-08-04 17:45:31 +01:00
mrhid6 85a8865892 feat: restyle the steps table and add 22 default steps
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 2m46s
2026-08-04 17:34:18 +01:00
mrhid6 50a9ac5fdc fix: count tag-matched servers in the workflows list 2026-08-04 17:28:01 +01:00
mrhid6 3388d2f895 fix: Fixed padding on add step button
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 41s
2026-08-04 17:24:40 +01:00
mrhid6 3a77fc2abd feat: edit target servers and tags together in the workflow modal 2026-08-04 17:21:12 +01:00
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
mrhid6 e6fe463216 feat: Updated for api ingress routes
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 4m26s
2026-07-31 11:21:15 +01:00
mrhid6 8528f14ed7 feat: Added ingress to chart
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m24s
2026-07-31 10:49:20 +01:00
mrhid6 df1d9658f5 fix: Chart build
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m20s
2026-07-31 10:41:41 +01:00
mrhid6 9f9b384481 fix: Fixed chart version
Chart Release / chart (push) Failing after 13s
2026-07-31 10:37:04 +01:00
mrhid6 165114471f fix: Fixes to running on kubernetes
Chart Release / chart (push) Failing after 13s
Server Deploy / deploy (push) Successful in 6m35s
2026-07-31 10:34:10 +01:00
mrhid6 de78688093 feat: authenticate the server's Redis connection
InitRedis now takes a username and password, read from REDIS_USERNAME and
REDIS_PASSWORD, matching what admin has always done. Both empty keeps an
unauthenticated Redis working; a password with an empty username is what a
legacy requirepass instance needs, since go-redis then sends AUTH with one
argument instead of two.

This is what lets a Kubernetes install point at a managed Redis instead of
the bundled one.
2026-07-31 09:36:31 +01:00
mrhid6 bbf9f72fd3 feat: Docker and helm charts
Server Deploy / deploy (push) Successful in 5m26s
Agent Release / build (push) Successful in 10m45s
Agent Release / msi (push) Successful in 1m31s
2026-07-31 09:28:54 +01:00
mrhid6 978b665aa6 fix: stop local relay teardown from logging a spurious proxy_failed reason
Session.Close now closing its own accepted conn (from the prior fix wave)
made net.ErrClosed on the guacd-side reader indistinguishable from a real
remote failure, so a normal browser-tab close could race the handler's
defer and intermittently log console.proxy_failed on a healthy session.
Add a closing flag, set before Close's sync.Once body actually tears
anything down, that setReason respects -- a deliberate local teardown can
no longer produce or race in a failure reason, while Close's own explicit
reason argument still wins normally.
2026-07-31 09:25:27 +01:00
mrhid6 1fe608f531 fix: bound and complete console relay teardown, restore proxy_failed audit
- Arm the unclaimed-relay watchdog in NewSession rather than Serve, so an
  agent that never opens its ProxyStream is bounded to 10s and reports
  reason "agent_timeout", per the design spec's failure-mode table.
- Session.Close now also closes the accepted net.Conn (stored via setConn),
  so ConsoleProxy.Close() is an unconditional kill of the whole relay chain
  instead of only closing an already-idle listener.
- Emit console.proxy_failed and end the console session from a defer in
  consoleTunnel guarded on relay.Reason(), since guac's OnDisconnect never
  runs when the connect callback errors -- which is the path every relay
  failure this feature introduces takes. Update the two docsite
  troubleshooting rows to match what the audit event can now actually show.
2026-07-31 09:21:07 +01:00
mrhid6 1e1546cb60 docs: document the agent-relayed console proxy
Every console session now rides the agent's outbound gRPC connection
instead of a direct guacd-to-target dial, so it works for servers
behind NAT and now requires a live agent (409 agent_offline
otherwise). Documents PROXY_ADVERTISE_HOST / PROXY_LISTEN_HOST and
corrects reachability claims across the docsite and CLAUDE.md.
2026-07-31 09:10:05 +01:00
mrhid6 119d8694d1 feat: Reap admin free instance license 2026-07-30 14:42:06 +01:00
mrhid6 8d43c689f5 feat: Reap admin free instance 2026-07-30 14:31:43 +01:00
mrhid6 05f10ed3c9 feat: record relay proxy_id and port in console audit events 2026-07-29 13:10:58 +01:00
mrhid6 c0bec3737b feat: route every console session through the agent relay 2026-07-29 13:07:30 +01:00
mrhid6 59d147fe4d feat: handle OpenProxyCmd in the agent command stream 2026-07-29 13:03:02 +01:00
mrhid6 9e38a01e3d feat: add agent-side console relay 2026-07-29 12:59:24 +01:00
mrhid6 20a302f84a feat: add OpenConsoleProxy service facade 2026-07-29 12:55:36 +01:00
mrhid6 ba2e263d00 fix: collapse ProxyStream auth failures into one indistinguishable response 2026-07-29 12:52:53 +01:00
mrhid6 a000703199 feat: add ProxyStream handler with scoped single-use auth 2026-07-29 12:50:20 +01:00
mrhid6 8fcda63742 fix: avoid closing proxy relay listener before validating remote source 2026-07-29 12:47:35 +01:00
mrhid6 3363ac9dad feat: add console proxy session relay 2026-07-29 12:43:04 +01:00
mrhid6 a7e338b171 feat: add console proxy session registry 2026-07-29 12:40:22 +01:00
mrhid6 bc79daab48 feat: add ProxyStream wire types for agent-relayed console 2026-07-29 12:37:37 +01:00
mrhid6 d3d8dba3ff docs: Implementation plan for agent-relayed console proxy 2026-07-29 12:26:16 +01:00
mrhid6 6d047e25ab docs: Design for agent-relayed console proxy 2026-07-29 12:16:45 +01:00
mrhid6 ed4c39650c feat: Removed hq signup page
Server Deploy / deploy (push) Successful in 1m44s
2026-07-29 10:42:51 +01:00
mrhid6 7b8fa4a8a0 feat: Updated hq login page
Server Deploy / deploy (push) Successful in 40s
2026-07-29 10:35:07 +01:00
mrhid6 8a02c35ec9 docs: Updated docs
Server Deploy / deploy (push) Successful in 55s
2026-07-28 16:54:39 +01:00
242 changed files with 25795 additions and 35347 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Grep",
"hooks": [
{
"type": "command",
"command": "C:/Python314/Scripts/graphify.EXE hook-guard search"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "C:/Python314/Scripts/graphify.EXE hook-guard read"
}
]
}
]
}
}
+1
View File
@@ -0,0 +1 @@
graphify-out/graph.json merge=graphify
+221
View File
@@ -0,0 +1,221 @@
name: Chart Release
on:
# Every push that touches the chart is validated. Publishing is separate and
# deliberate: a chart version is immutable in the registry once pushed, so
# it must come from a tag someone chose, not from whatever landed on main.
# No `paths` filter on push, deliberately. A paths filter applies to tag
# pushes too, so tagging a commit that happened not to touch the chart
# would skip the publish entirely — a release that silently does nothing.
# Validation is seconds of helm rendering; running it on every push to main
# is cheaper than that failure mode.
push:
branches:
- main
tags:
- "chart/v*"
pull_request:
paths:
- "deploy/chart/**"
workflow_dispatch:
env:
CHART_DIR: deploy/chart/vantage
HELM_VERSION: v3.16.3
jobs:
chart:
runs-on: ubuntu-docker
container: alpine:3.21
steps:
# git for actions/checkout, curl for both the Helm download and the
# registry upload, tar because the Helm tarball is not self-extracting.
- name: Setup
run: apk add --no-cache bash curl git tar nodejs npm
- name: Install Helm
run: |
set -eu
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
| tar -xz -C /tmp linux-amd64/helm
mv /tmp/linux-amd64/helm /usr/local/bin/helm
helm version --short
- name: Checkout
uses: actions/checkout@v4
- name: Lint
run: helm lint "$CHART_DIR"
# Rendering is the real test. `helm lint` accepts a chart whose
# templates fail to execute, and every guard in this chart is a
# template `fail` that only fires during rendering.
- name: Render default values
run: helm template test "$CHART_DIR" > /dev/null
- name: Render a multi-replica install
run: |
helm template test "$CHART_DIR" \
--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" \
--set redis.enabled=false \
--set redis.addr=redis.example.com:6379 \
--set mongo.enabled=false \
--set server.env.mongoUri=mongodb://mongo.example.com:27017/vantage > /dev/null
- name: Render with the Traefik ingress
run: |
helm template test "$CHART_DIR" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.host=agents.example.com \
--set ingress.tls.certResolver=letsencrypt \
--set server.env.grpcHost=agents.example.com:443 > /dev/null
# The shape the cloud deployment actually uses: a wildcard tenant
# namespace, /api and /auth routed at the edge, and no apex — that
# belongs to the marketing site, which this chart does not deploy.
- name: Render a wildcard host with edge-routed API paths
run: |
helm template test "$CHART_DIR" \
--set ingress.enabled=true \
--set 'ingress.web.host=*.vantage.example.com' \
--set ingress.api.enabled=true \
--set ingress.grpc.host=agents.example.com \
--set server.env.grpcHost=agents.example.com:443 \
--set ingress.tls.secretName=vantage-tls \
--set ingress.tls.grpcSecretName=agents-tls > /dev/null
# The guards are load-bearing, so their absence is a regression the
# same way a broken render is. Each of these must fail.
- name: Check the guards still refuse bad values
run: |
set -eu
refuses() {
desc="$1"; shift
if helm template test "$CHART_DIR" "$@" > /dev/null 2>&1; then
echo "GUARD MISSING: $desc was accepted"
exit 1
fi
echo "ok: refused $desc"
}
refuses "mongo disabled with an in-chart URI" \
--set mongo.enabled=false
refuses "redis disabled with no external address" \
--set redis.enabled=false
refuses "multiple replicas on a ReadWriteOnce volume" \
--set server.replicaCount=2 --set server.persistence.enabled=true
refuses "ingress with no web host" \
--set ingress.enabled=true
refuses "edge-routed API with an empty path list" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.enabled=false \
--set ingress.api.enabled=true \
--set 'ingress.api.paths=null'
refuses "gRPC ingress with no host" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set server.env.grpcHost=agents.example.com:443
refuses "gRPC ingress while grpcHost is still in-cluster" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.host=agents.example.com
- name: Read the chart version
id: chart
run: |
set -eu
VERSION="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "chart version is $VERSION"
# Chart.yaml is the source of truth for the version; the tag only
# says "publish this one". A mismatch is a mistake worth stopping
# for — the alternative is stamping the tag over Chart.yaml, which
# leaves the repository disagreeing with what was published.
- name: Check the tag matches Chart.yaml
if: startsWith(github.ref, 'refs/tags/chart/v')
run: |
set -eu
TAG_VERSION="${GITHUB_REF_NAME#chart/v}"
CHART_VERSION="${{ steps.chart.outputs.version }}"
if [ "$TAG_VERSION" != "$CHART_VERSION" ]; then
echo "tag chart/v$TAG_VERSION does not match Chart.yaml version $CHART_VERSION"
echo "bump version: in $CHART_DIR/Chart.yaml, or retag."
exit 1
fi
- name: Package
run: |
set -eu
mkdir -p dist
helm package "$CHART_DIR" --destination dist
ls -l dist
- name: Publish to the Gitea chart registry
if: startsWith(github.ref, 'refs/tags/chart/v')
env:
# github.server_url is this Gitea instance, so the registry
# host needs no variable of its own and cannot drift from it.
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts
# The same pair server-deploy.yml uses for `docker login`.
# RELEASE_TOKEN, not REGISTRY_PASSWORD: the latter is named in
# the docs but set by no workflow, and an unset secret becomes
# an empty password, which Gitea reports as "Failed to
# authenticate user" rather than as a missing credential.
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.RELEASE_TOKEN }}
CHART_VERSION: ${{ steps.chart.outputs.version }}
run: |
set -eu
PKG="dist/vantage-${CHART_VERSION}.tgz"
test -f "$PKG"
# Checked explicitly, because the failure it prevents is a
# 401 that looks like a permissions problem on the token that
# was never sent.
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
echo "REGISTRY_USER or RELEASE_TOKEN is not set on this repository."
echo "RELEASE_TOKEN needs the write:package scope to publish a chart."
exit 1
fi
echo "publishing to ${REGISTRY} as ${REGISTRY_USER}"
# --fail-with-body so an HTTP error is a failed step with the
# server's explanation, rather than a green run that published
# nothing. A repeated version is rejected by the registry;
# that is the intended behaviour, not something to retry past.
curl --fail-with-body -sS \
--user "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
-X POST \
--upload-file "$PKG" \
"$REGISTRY"
echo "published vantage ${CHART_VERSION}"
echo " helm repo add vantage ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm"
echo " helm install vantage vantage/vantage --version ${CHART_VERSION}"
+1
View File
@@ -150,6 +150,7 @@ jobs:
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
--build-arg NEXT_PUBLIC_SITE_URL="${{ vars.SITE_URL }}" \
-t "$IMAGE" \
-f adminsite/Dockerfile adminsite/
docker push "$IMAGE"
+4 -1
View File
@@ -3,6 +3,7 @@ dist
build
.env
.env.bck
.env.live
docs/*
!docs/superpowers/
.superpowers
@@ -11,4 +12,6 @@ installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
*.tsbuildinfo
*.tsbuildinfo
graphify-out
docker-compose.live.yml
+312 -12
View File
@@ -120,10 +120,90 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
### Workflows
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to MongoDB (`workflow_log_lines`, one document per line); the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
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`
runs on `scratch`, 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.
**Both halves of the selector are edited in `EditWorkflowModal`** — the named
servers in a `DualListBox`, the tag rows directly beneath it — and saved
together by one `updateWorkflow`. The designer's Targets panel is **read-only**:
it reports the count and the tags and links to Edit. Splitting the two halves
across two screens meant a workflow's reach was decided in two places with no
one view showing both.
`web/lib/targets.ts` **duplicates the 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. It is a shared module rather than inline in a component because
the logic had already been written twice, and the second copy — the workflows
list — counted `target_server_ids` alone, so a **tag-only workflow reported zero
targets** while running fine.
The server picker is a hand-built two-pane list, not `<select multiple>`: a
native multi-select paints its selected rows with the platform highlight colour,
which cannot be restyled across browsers and lands outside the token palette on
a dark ground.
### 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.
@@ -138,12 +218,148 @@ Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed tw
### Browser console
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` upgrades to a WebSocket and proxies to **guacd** (Apache Guacamole daemon) using `github.com/wwt/guac`. SSH connections authenticate with a stored private key; RDP/VNC credentials are encrypted, single-use, and consumed when the tunnel opens.
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel`
upgrades to a WebSocket and proxies to **guacd** using `github.com/wwt/guac`.
guacd never dials the managed server. The server binds a single-use ephemeral
listener, pushes `OpenProxyCmd` down the agent's command stream, and the agent
opens a `ProxyStream` and relays the connection from its own **`127.0.0.1`** —
the host is hardcoded agent-side, so the control plane can name only a port.
This is what makes the console work on Vantage Cloud, where the customer's
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
daemon, so the agent relays bytes it cannot read.
### Running more than one server replica
An agent's `CommandStream` terminates on exactly **one** server process. Every
piece of coordination below exists because of that single fact: with several
replicas, the process asked to do something to an agent is almost never the
process holding that agent's stream.
`server/internal/bus` is the Redis message bus that closes the gap. It adds no
infrastructure — Redis was already required for sessions — and it is **not
optional on a single-replica deployment**: dispatch takes the bus path always,
so the code running in production is the code running everywhere, rather than a
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. 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 | **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,
with a `workflow_log_seq` counter document per run/server). Two pods write the
same log concurrently — the run's pod emits markers, the agent's pod emits
output — so ordering only means anything if both draw sequence numbers from the
same counter. `StepRun.log_offset` is that sequence number now, not a byte
offset. Writes are batched (128 lines or 250ms) and capped: 8 KB per line,
200k lines per server-run, after which one final `[vantage] log truncated`
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
purge the same Free instance. `monitorsched`, `StartReaper`, `StartLogSweeper`,
`StartAuditSweeper` and the offline sweep therefore all run inside one
`RunAsLeader("housekeeping", …)` — one role, one lock. Each takes a context
cancelled the instant leadership is lost, and must return when it is.
Redis rather than a Kubernetes `Lease` so Compose takes the identical path: one
implementation to reason about, not two.
Two deployment requirements come with `replicaCount > 1`: every replica must
share **one** Redis (a per-pod Redis partitions the bus and every agent looks
offline to two thirds of the fleet), and `POD_IP` must be set — the chart does
it from the downward API — because `PROXY_ADVERTISE_HOST` names the Service, and
a Service cannot address the one pod holding a console listener.
### Inventory and OS updates
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
### Package inventory and CVE findings
Agents report their installed packages hourly; the control plane matches them
against distribution security feeds and raises findings that link to the
existing `ApplyUpdatesCmd` patching path. Gated by the `vuln_scanning` licence
feature, **checked at collection rather than display** — an ungated instance
stores no inventory, and storage is the expensive half.
**Matching uses distribution feeds, never NVD version ranges.** Distributions
backport security fixes without changing the upstream version: Ubuntu's
`openssl 3.0.2-0ubuntu1.15` is patched against CVE-2023-0286 while NVD still
calls 3.0.2 vulnerable. Matching on NVD would report a fully patched fleet as
critical, and once the first report is mostly wrong nobody reads the second.
`trivy-db` is those feeds pre-merged; `server/internal/vulndb` pulls it as an
OCI artifact to an ephemeral directory. Version comparison is bought from
`go-deb-version`/`go-rpm-version`/`go-apk-version` because dpkg epochs, `~`
sorting before the empty string, and `rpmvercmp` are each a silent false
negative waiting to happen.
**Only the leader matches.** `ReportPackages` upserts the list and sets
`scan_pending`; it does not scan. `vulnsched` runs inside the existing
`bus.RunAsLeader("housekeeping", …)` and does the matching, because otherwise
every replica needs the ~50MB database resident and a database refresh has N
replicas rescanning the same fleet and sending N digests. The tick is also the
digest's batch boundary, which is what makes "one message, not five hundred"
structural rather than a debounce someone maintains.
Findings are **never deleted when a package is patched** — the state moves to
`fixed`, so "what did we remediate last quarter" stays answerable. Acceptance
requires a reason and an expiry, and reopens automatically: permanent dismissal
is where risk goes to be forgotten. An unsupported distribution reports
`status: unsupported`, never "0 findings"; claiming clean when the truth is
unknown is the same lie as a silently stale database, which is why
`vulndb_meta.pulled_at` is on screen rather than only in a log.
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
no such file or directory`, logged once at boot while every other subsystem
runs normally, so the only symptom is a fleet that never reports a finding.
Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
scheduler off entirely.
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
@@ -271,7 +487,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.
@@ -304,7 +521,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`.
@@ -315,11 +543,13 @@ Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions
Unauthenticated:
```
GET /healthz /readyz # liveness / readiness probes
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)
```
@@ -342,12 +572,19 @@ channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:i
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
console POST /console/connect · GET /console/tunnel (websocket)
vulns GET /vulnerabilities · GET /vulnerabilities/summary
POST /vulnerabilities/rescan (owner|admin)
POST,DELETE /vulnerabilities/:id/accept (owner|admin)
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
GET /packages/search?name=
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
audit GET /audit
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.
@@ -421,7 +658,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` · `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` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
@@ -433,7 +670,13 @@ 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.
- `server_packages` holds a server's whole package set in **one** document, not one per package. The hash already established that something changed, so a report is a single atomic upsert with no delta logic to get wrong; ~2000 packages is ~150KB, well inside the 16MB limit. `scan_pending` lives on the document rather than in memory so a leader handover cannot lose it.
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable".
- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`.
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
@@ -446,6 +689,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.
@@ -514,11 +758,19 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
| `MONGO_DB` | no | default `vantage` |
| `REDIS_USERNAME` | no | Redis 6+ ACL user. Leave empty for a legacy `requirepass` instance — go-redis then sends AUTH with one argument instead of two |
| `REDIS_PASSWORD` | no | empty for an unauthenticated Redis |
| `REDIS_ADDR` | no | default `localhost:6379` |
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
| `GUACD_ADDR` | no | default `guacd:4822` |
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit |
| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale |
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
**sitesvc** (`deploy/docker-compose.site.yml` only):
@@ -533,11 +785,33 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
### Ingress (Helm, Traefik)
`ingress.enabled` publishes **two** hostnames, because the two audiences arrive over different protocols:
| Values | Route |
| -------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `ingress.web.host` (+ `web.extraHosts`) | browsers → `web:3000` |
| `ingress.api.paths` (when `api.enabled`) | `/api`, `/auth``<release>-server:8080`, bypassing the Next proxy |
| `ingress.grpc.host` | agents → a dedicated `<release>-server-grpc` Service on 9090, annotated `serversscheme: h2c` |
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.api.enabled` routes `/api` and `/auth` straight to the server.** Both arrangements work — without it `web` proxies those prefixes onward itself (`web/next.config.ts`) — but edge routing is one hop shorter and matches what the Nginx Proxy Manager in front of the Docker deployment already does, so leaving it off makes the request path a different shape on Kubernetes than in production. It stays **off by default** because it only helps where the server is reachable on the same host and certificate as `web`; turning it on blindly moves the whole API onto a route that may not be provisioned. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
**`server.env.grpcHost` is not derived from `ingress.grpc.host`, and the chart refuses to render if they disagree.** Agents dial whatever `grpcHost` says, and it is baked into every install one-liner; left pointing at the in-cluster Service while agents arrive through the ingress, every install succeeds and every agent then fails to connect, with nothing in the control plane explaining why. Guessing at the port (443? 9090?) would be worse than stopping.
TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) **or** `certResolver` (Traefik ACME). Setting neither while `tls.enabled` produces a TLS router with no certificate, so Traefik serves its self-signed default — which looks valid and is trusted by nothing. NOTES.txt warns on install rather than the chart failing, since it is a real if unusual choice behind another terminator.
---
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk.
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only.
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`. `server` now reads the same three, so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it, so in Docker those credentials remain admin's alone.
---
@@ -651,10 +925,24 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags
Two jobs' worth of work in one, split by trigger. Any push or PR touching `deploy/chart/` lints the chart and renders it four ways: defaults, a multi-replica install, external Redis and MongoDB, and a set of values that **must be refused**. That last one is the point — every safety rail in this chart is a template `fail`, and `helm lint` happily accepts a chart whose templates never execute, so only rendering proves they still fire.
Publishing runs only on a `chart/v*` tag, to the Gitea Helm registry at `/api/packages/<owner>/helm/api/charts`. **`Chart.yaml` is the source of truth for the version**; the tag only selects which one to publish, and a tag that disagrees with `Chart.yaml` fails rather than stamping over it — the alternative leaves the repository disagreeing with what shipped. A version already in the registry is rejected by Gitea, which is intended: published chart versions are immutable.
The registry host comes from `github.server_url`, so it cannot drift from the instance the workflow is running on. It authenticates with `REGISTRY_USER` + **`RELEASE_TOKEN`** — the pair `server-deploy.yml` actually uses for `docker login`. `REGISTRY_PASSWORD` is listed in the secrets table below but set by no workflow; passing an unset secret yields an empty password and Gitea answers `401 Failed to authenticate user`, which reads like a scope problem on a token that was never sent. The publish step therefore checks both are non-empty before it calls curl. `RELEASE_TOKEN` needs `write:package` in addition to `write:release`.
```bash
helm repo add vantage https://gitea.hostxtra.co.uk/api/packages/mrhid6/helm
helm install vantage vantage/vantage --version 0.1.0
```
### Tagging
```bash
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package
git push origin main # server + web deploy
```
@@ -662,13 +950,14 @@ git push origin main # server + web deploy
| Name | Type | Value |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart). **This is the only token any workflow authenticates with**`docker login` and the chart publish both pair it with `REGISTRY_USER` |
| `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected |
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
@@ -697,4 +986,15 @@ git push origin main # server + web deploy
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+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"
+10
View File
@@ -77,6 +77,16 @@ func Run(ctx context.Context) error {
}
}
if lic.ExpiresAt.Add(reapAfter).Before(now) {
if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
}
if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
}
}
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
if due == "" {
continue
+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
}
+3 -1
View File
@@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool {
return false
}
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
// server limit, and one row per feature key.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
@@ -84,6 +85,7 @@ func SeedCatalogue(ctx context.Context) error {
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
}
for _, r := range rows {
filter := bson.M{
+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")
+5
View File
@@ -26,6 +26,11 @@ ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
# Marketing site origin. Signup lives there (/start), not here; empty renders no
# link at all rather than one that 404s.
ARG NEXT_PUBLIC_SITE_URL=
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build
FROM node:26-alpine AS runner
@@ -12,6 +12,7 @@ import { StatePill } from "@/components/StatePill";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
import { featureLabel } from "@/lib/features";
export default function InstancePage() {
const id = String(useParams().id);
@@ -104,7 +105,11 @@ export default function InstancePage() {
},
{
label: "Features",
value: lic.features.join(", ") || "none",
// Labelled, not raw keys: this is
// the customer's own licence, and
// "vuln_scanning" is not a name
// anyone bought.
value: lic.features.map(featureLabel).join(", ") || "none",
},
]}
/>
@@ -6,25 +6,16 @@ import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
import { featureDesc, featureLabel } from "@/lib/features";
/* Tiers in the order a customer reads them, cheapest first. */
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Human labels for feature keys. The catalogue names them by key; this is the
* one place the customer-facing wording lives. */
const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
};
const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
};
function featureLabel(key: string) {
return FEATURE_LABEL[key] ?? key;
}
/* Feature wording lives in lib/features.ts, shared with the staff
* configurator. It was duplicated here and there, and the two copies had
* already drifted. */
interface Choice {
tier: Tier;
@@ -294,7 +285,7 @@ export function PurchaseForm() {
{featureKeys.map((key) => {
const st = featureStateFor(plan, rows, options.env, choice.term, key);
return (
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
{st === "included" ? (
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
) : st === "absent" ? (
+1 -1
View File
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
/*
* The masthead deliberately does NOT live here. It belongs to the authenticated
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
* layouts, so /login, /verify and /accept-invite stop rendering a bar
* whose navigation and account menu they cannot use.
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
+65 -35
View File
@@ -2,12 +2,13 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
@@ -44,46 +45,75 @@ export default function LoginPage() {
return (
<Main>
<h1 className="text-3xl">Sign in</h1>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input
type="checkbox"
checked={staff}
onChange={(e) => setStaff(e.target.checked)}
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
<div className="mb-7 flex flex-col items-center gap-2 text-center">
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">
HQ
</span>
</span>
<h1 className="text-[1.16rem]">Sign in</h1>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Licences · instances · billing
</p>
</div>
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
<form onSubmit={submit} className="grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
I work at Vantage
</label>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={busy}>
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input
type="checkbox"
checked={staff}
onChange={(e) => setStaff(e.target.checked)}
className="accent-[var(--accent)]"
/>
I work at Vantage
</label>
<Button type="submit" disabled={busy} className="w-full justify-center">
{busy ? "Signing in…" : "Sign in"}
</Button>
<Link href="/signup" className="text-[0.82rem] text-accent underline">
Create an account for a self-hosted licence
</Link>
</div>
</form>
</form>
{SITE_URL && (
<>
<div className="my-5 h-px bg-rule-soft" />
{/* Signup lives on the marketing site's /start, not here. */}
<p className="text-center text-[0.82rem] text-ink-3">
No account?{" "}
<a href={`${SITE_URL}/start`} className="text-accent underline">
Create one
</a>
</p>
</>
)}
</div>
</Main>
);
}
function Main({ children }: { children: React.ReactNode }) {
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{children}
</main>
);
}
-92
View File
@@ -1,92 +0,0 @@
"use client";
import { useState } from "react";
import { ApiError, NotConnected, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export default function SignupPage() {
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setState("busy");
setError(null);
try {
await api.signup(form);
setState("sent");
} catch (err) {
setState("idle");
setError(
err instanceof NotConnected
? "The licensing service is not reachable from this page."
: err instanceof ApiError
? err.message
: "Could not create the account. Try again.",
);
}
}
return (
<main className="mx-auto max-w-rail px-5 py-12">
{state === "sent" ? (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Check your email</h1>
<p className="text-ink-2">
We sent a link to {form.email}. Open it to finish setting up your account
it expires in 24 hours. Nothing is created until you do.
</p>
</div>
) : (
<>
<h1 className="text-3xl">Create an account</h1>
<p className="mt-2 max-w-xl text-ink-2">
For self-hosted licences. If you run on our cloud, sign in with the same
details you use for your Vantage instance.
</p>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Organisation"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<Field
label="Email"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
<Field
label="Password"
type="password"
required
minLength={12}
hint="At least 12 characters."
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
error={error ?? undefined}
/>
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
className="absolute left-[-9999px] h-0 w-0"
/>
<Button type="submit" disabled={state === "busy"}>
{state === "busy" ? "Creating…" : "Create account"}
</Button>
</form>
</>
)}
</main>
);
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
import { featureLabel } from "@/lib/features";
export interface PlanChoice {
tier: Tier;
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
})
}
/>
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
<span>{featureLabel(key)}</span>
<span className="text-[0.72rem] text-ink-3">
{priced ? "paid add-on" : "included"}
</span>
-2
View File
@@ -296,8 +296,6 @@ export const api = {
staffLogin: (email: string, password: string) =>
post<Session>("/auth/staff/login", { email, password }),
logout: () => post<{ ok: boolean }>("/auth/logout"),
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
post<{ pending: boolean }>("/auth/signup", payload),
verify: (token: string) =>
req<{ verified: boolean; needs_password?: boolean }>(
`/auth/verify?token=${encodeURIComponent(token)}`,
+27
View File
@@ -0,0 +1,27 @@
/* Human wording for licence feature keys.
*
* One place, because there were two and they disagreed: the staff configurator
* rendered every key that was not "console" as "Single sign-on", so adding a
* third feature silently mislabelled the checkbox that grants it. A map with a
* fallback degrades to the raw key, which is ugly but never wrong.
*
* Keys must match shared/license/license.go. */
export const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
vuln_scanning: "Vulnerability scanning",
};
export const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
vuln_scanning: "Package inventory matched against distribution security advisories",
};
export function featureLabel(key: string): string {
return FEATURE_LABEL[key] ?? key;
}
export function featureDesc(key: string): string {
return FEATURE_DESC[key] ?? "";
}
-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)
}
}
}
+24 -2
View File
@@ -80,7 +80,11 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
return resp.AgentToken, nil
}
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
// SyncKeys returns the whole response rather than just the keys: the poll now
// also carries CollectPackages, and a second RPC purely to learn one boolean
// would be a message every 30 seconds for a value that changes at most when a
// licence does.
func (c *Client) SyncKeys(serverID, agentToken, version string) (*pb.SyncResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -92,7 +96,21 @@ func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error
if err != nil {
return nil, err
}
return resp.PublicKeys, nil
return resp, nil
}
// ReportPackages sends a package report and returns whether the server wants
// the full list. Given a longer deadline than the other unary calls because the
// full body is ~150KB on a slow link.
func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := c.client.ReportPackages(ctx, req)
if err != nil {
return false, err
}
return resp.NeedFull, nil
}
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
@@ -151,3 +169,7 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
return c.client.CommandStream(ctx)
}
func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) {
return c.client.ProxyStream(ctx)
}
+136
View File
@@ -30,6 +30,45 @@ type SyncRequest struct {
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
// CollectPackages tells the agent whether this instance's licence grants
// vulnerability scanning. Absent decodes as false, which is the safe
// direction: an older server leaves agents collecting nothing.
CollectPackages bool `json:"collect_packages,omitempty"`
}
type OSRelease struct {
Family string `json:"family"`
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
// versions for the same CVE, so a scan without it is guesswork.
VersionId string `json:"version_id"`
Arch string `json:"arch,omitempty"`
}
type InstalledPackage struct {
Name string `json:"name"`
Version string `json:"version"`
Epoch int32 `json:"epoch,omitempty"`
Arch string `json:"arch,omitempty"`
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
// against "openssl" covers libssl3, openssl and libssl-dev.
SourceName string `json:"source_name,omitempty"`
}
// ReportPackagesRequest carries a server's installed package set.
//
// The agent calls twice at most: first with Packages empty, offering only the
// hash. If the server already holds it, NeedFull is false and the ~150KB body
// is never sent.
type ReportPackagesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
Os OSRelease `json:"os"`
Packages []InstalledPackage `json:"packages,omitempty"`
}
type ReportPackagesResponse struct {
NeedFull bool `json:"need_full"`
}
type UploadKeyRequest struct {
@@ -131,6 +170,32 @@ type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
type OpenProxyCmd struct {
ProxyId string `json:"proxy_id"`
Port uint32 `json:"port"`
}
type ProxyOpen struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
ProxyId string `json:"proxy_id"`
}
type ProxyClose struct {
Reason string `json:"reason,omitempty"`
}
type ProxyClientMsg struct {
Open *ProxyOpen `json:"open,omitempty"`
Data []byte `json:"data,omitempty"`
Close *ProxyClose `json:"close,omitempty"`
}
type ProxyServerMsg struct {
Data []byte `json:"data,omitempty"`
Close *ProxyClose `json:"close,omitempty"`
}
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
@@ -139,8 +204,15 @@ type ServerCommand struct {
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
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 {
@@ -254,15 +326,62 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
return m, nil
}
type Vantage_ProxyStreamServer interface {
Send(*ProxyServerMsg) error
Recv() (*ProxyClientMsg, error)
grpc.ServerStream
}
type vantageProxyStreamServer struct {
grpc.ServerStream
}
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
return s.ServerStream.SendMsg(m)
}
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
m := new(ProxyClientMsg)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type Vantage_ProxyStreamClient interface {
Send(*ProxyClientMsg) error
Recv() (*ProxyServerMsg, error)
CloseSend() error
grpc.ClientStream
}
type vantageProxyStreamClient struct {
grpc.ClientStream
}
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
m := new(ProxyServerMsg)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
}
type UnimplementedVantageServer struct{}
@@ -317,6 +436,14 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
return out, nil
}
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
out := new(ReportPackagesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
out := new(InventoryReportResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
@@ -349,3 +476,12 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
}
return &vantageCommandStreamClient{stream}, nil
}
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
if err != nil {
return nil, err
}
return &vantageProxyStreamClient{stream}, nil
}
+63
View File
@@ -0,0 +1,63 @@
package packages
import (
"bufio"
"errors"
"io"
"os"
"runtime"
"strings"
)
// OSRelease identifies the distribution well enough to select an advisory
// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different
// fixed versions for the same CVE.
type OSRelease struct {
Family string
VersionID string
Arch string
}
// ParseOSRelease reads the os-release format: KEY=value, one per line, with
// values optionally quoted, and # comments.
//
// The quote stripping handles both ID=ubuntu and ID="rocky", which real
// distributions both emit.
func ParseOSRelease(r io.Reader) (OSRelease, error) {
out := OSRelease{Arch: runtime.GOARCH}
sc := bufio.NewScanner(r)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
val = strings.Trim(strings.TrimSpace(val), `"'`)
switch strings.TrimSpace(key) {
case "ID":
out.Family = strings.ToLower(val)
case "VERSION_ID":
out.VersionID = val
}
}
if err := sc.Err(); err != nil {
return OSRelease{}, err
}
if out.Family == "" {
return OSRelease{}, errors.New("os-release has no ID")
}
return out, nil
}
// DetectOS reads /etc/os-release.
func DetectOS() (OSRelease, error) {
f, err := os.Open("/etc/os-release")
if err != nil {
return OSRelease{}, err
}
defer f.Close()
return ParseOSRelease(f)
}
+73
View File
@@ -0,0 +1,73 @@
package packages
import (
"context"
"fmt"
"os/exec"
"runtime"
"time"
)
const collectTimeout = 2 * time.Minute
// Collect enumerates installed packages. Linux only: Windows agents are
// second-class by design, and vulnerability scanning there needs a different
// source, a different collector and a different matcher, all out of scope.
//
// The format strings below are raw string literals on purpose. The "\t" and
// "\n" reach dpkg-query and rpm as two characters each, and those tools do the
// interpreting themselves — Go must not consume the escapes first.
func Collect() (OSRelease, []Package, error) {
if runtime.GOOS != "linux" {
return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
}
osrel, err := DetectOS()
if err != nil {
return OSRelease{}, nil, fmt.Errorf("detect os: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
defer cancel()
switch {
case have("dpkg-query"):
out, err := run(ctx, "dpkg-query", "-W", "-f",
`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
if err != nil {
return osrel, nil, err
}
return osrel, ParseDpkg(out), nil
case have("rpm"):
out, err := run(ctx, "rpm", "-qa", "--qf",
`%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`)
if err != nil {
return osrel, nil, err
}
return osrel, ParseRPM(out), nil
case have("apk"):
out, err := run(ctx, "apk", "info", "-v")
if err != nil {
return osrel, nil, err
}
return osrel, ParseAPK(out), nil
default:
return osrel, nil, fmt.Errorf("no supported package manager found")
}
}
func have(bin string) bool {
_, err := exec.LookPath(bin)
return err == nil
}
func run(ctx context.Context, name string, args ...string) (string, error) {
out, err := exec.CommandContext(ctx, name, args...).Output()
if err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
return string(out), nil
}
+146
View File
@@ -0,0 +1,146 @@
package packages
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
)
// Package is one installed package as the distribution reports it. Version is
// the distribution's own version string, verbatim — never normalised, because
// the advisory feeds are keyed on exactly this form.
type Package struct {
Name string
Version string
Epoch int
Arch string
SourceName string
}
// ParseDpkg reads tab-separated output of
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
//
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
// alone finds one of the three.
func ParseDpkg(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 3 {
continue
}
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
if len(f) > 3 && f[3] != "" {
p.SourceName = f[3]
} else {
p.SourceName = p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}
// ParseRPM reads tab-separated output of
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
func ParseRPM(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 4 {
continue
}
epoch := 0
// rpm prints "(none)" rather than omitting the field when a package has
// no epoch. That must become 0, not fail the line.
if f[1] != "" && f[1] != "(none)" {
if n, err := strconv.Atoi(f[1]); err == nil {
epoch = n
}
}
p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
if len(f) > 4 {
p.SourceName = srcRPMName(f[4])
}
if p.SourceName == "" {
p.SourceName = p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}
// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
// the trailing ".src.rpm" and then the version and release segments, which are
// the last two hyphen-separated fields.
func srcRPMName(s string) string {
s = strings.TrimSuffix(s, ".src.rpm")
parts := strings.Split(s, "-")
if len(parts) <= 2 {
return s
}
return strings.Join(parts[:len(parts)-2], "-")
}
// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line.
// Alpine has no separate source package, so SourceName mirrors Name.
func ParseAPK(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, version := splitAPK(line)
if name == "" {
continue
}
pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
}
return pkgs
}
// splitAPK finds the version boundary from the RIGHT. The version is always the
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable
// where scanning from the left is not: package names legitimately contain
// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be
// found by looking for the first digit.
func splitAPK(s string) (name, version string) {
last := strings.LastIndex(s, "-")
if last <= 0 {
return "", ""
}
prev := strings.LastIndex(s[:last], "-")
if prev <= 0 {
return "", ""
}
return s[:prev], s[prev+1:]
}
// Hash fingerprints a package set so an unchanged set never has to be sent.
//
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
// and an ordering-sensitive hash would resend the full ~150KB list every hour
// for no reason — a cost visible only as traffic.
func Hash(pkgs []Package) string {
lines := make([]string, 0, len(pkgs))
for _, p := range pkgs {
lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
}
sort.Strings(lines)
h := sha256.New()
for _, l := range lines {
h.Write([]byte(l))
h.Write([]byte("\n"))
}
return hex.EncodeToString(h.Sum(nil))
}
+114
View File
@@ -0,0 +1,114 @@
// Package agentproxy relays a single TCP connection between a local service and
// the control plane, so a control plane that cannot route to this host's network
// can still open a console session.
//
// The dial host is hardcoded to loopback. The control plane supplies only a
// port, and nothing in this package can be made to dial anywhere else.
package agentproxy
import (
"errors"
"fmt"
"io"
"net"
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
const (
loopbackHost = "127.0.0.1"
chunkSize = 32 * 1024
dialTimeout = 10 * time.Second
)
// Stream is the agent's half of a ProxyStream.
type Stream interface {
Send(*pb.ProxyClientMsg) error
Recv() (*pb.ProxyServerMsg, error)
CloseSend() error
}
// Open dials the local port, announces itself on the stream, and relays until
// either side ends. A refused dial is reported as an explicit close so the
// operator sees a reason rather than a hang.
func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error {
conn, dialErr := net.DialTimeout("tcp",
net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout)
if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{
ServerId: serverID,
AgentToken: agentToken,
ProxyId: proxyID,
}}); err != nil {
if conn != nil {
_ = conn.Close()
}
return fmt.Errorf("send open: %w", err)
}
if dialErr != nil {
_ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{
Reason: "dial_refused: " + dialErr.Error(),
}})
_ = stream.CloseSend()
return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr)
}
defer conn.Close()
return relay(conn, stream)
}
func relay(conn net.Conn, stream Stream) error {
errCh := make(chan error, 2)
// local service -> control plane
go func() {
buf := make([]byte, chunkSize)
for {
n, err := conn.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil {
errCh <- sendErr
return
}
}
if err != nil {
errCh <- err
return
}
}
}()
// control plane -> local service
go func() {
for {
msg, err := stream.Recv()
if err != nil {
errCh <- err
return
}
if msg.Close != nil {
errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason)
return
}
if len(msg.Data) > 0 {
if _, err := conn.Write(msg.Data); err != nil {
errCh <- err
return
}
}
}
}()
err := <-errCh
_ = conn.Close()
_ = stream.CloseSend()
if errors.Is(err, io.EOF) {
return nil
}
return err
}
+125
View File
@@ -0,0 +1,125 @@
package agentsync
import (
"context"
"log"
"runtime"
"sync"
"sync/atomic"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
)
// collectPackagesFlag is written by the 30s key poll and read by the hourly
// package loop — two different goroutines, hence the atomic.
//
// It defaults to false, so an agent that has not yet completed a poll, or is
// talking to a server too old to send the field, collects nothing. Off is the
// safe default: collecting without a licence costs the customer storage they
// are not paying for.
var collectPackagesFlag atomic.Bool
// firstPoll closes once a SyncKeys response has set the flag above.
//
// Without it the boot-time package report loses a race it can only lose: the
// hourly loop starts before the first poll, reads a flag that is still false by
// construction, and skips — so a freshly installed agent reports no packages for
// an hour and the server shows nothing to scan.
// How long the boot package report waits for that first poll. Two poll
// intervals plus slack: long enough to cover one failed attempt, short enough
// that a dead control plane does not hold the OS-update report hostage.
const firstPollWait = 90 * time.Second
var (
firstPoll = make(chan struct{})
firstPollOnce sync.Once
)
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
// bounded because this loop also reports OS updates, which do not depend on the
// flag at all — a control plane that cannot be polled must not silence those too.
func waitFirstPoll(ctx context.Context, limit time.Duration) {
t := time.NewTimer(limit)
defer t.Stop()
select {
case <-firstPoll:
case <-t.C:
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
case <-ctx.Done():
}
}
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
// reportPackages offers a hash of the installed package set and sends the full
// list only if the server does not already hold it.
//
// It runs on the same hourly cadence as the update check because a package set
// changes on roughly the same schedule, and reusing that loop means one timer
// rather than two.
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
if runtime.GOOS != "linux" {
return
}
if !collectPackagesEnabled() {
return
}
osrel, pkgs, err := packages.Collect()
if err != nil {
log.Printf("package collection error: %v", err)
return
}
pbOS := pb.OSRelease{
Family: osrel.Family,
VersionId: osrel.VersionID,
Arch: osrel.Arch,
}
hash := packages.Hash(pkgs)
// The offer: hash only, no body. On an unchanged host this is the whole
// exchange, which is the point of the handshake.
needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Hash: hash,
Os: pbOS,
})
if err != nil {
log.Printf("ReportPackages offer error: %v", err)
return
}
if !needFull {
return
}
pbPkgs := make([]pb.InstalledPackage, len(pkgs))
for i, p := range pkgs {
pbPkgs[i] = pb.InstalledPackage{
Name: p.Name,
Version: p.Version,
Epoch: int32(p.Epoch),
Arch: p.Arch,
SourceName: p.SourceName,
}
}
if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Hash: hash,
Os: pbOS,
Packages: pbPkgs,
}); err != nil {
log.Printf("ReportPackages full error: %v", err)
return
}
log.Printf("reported %d installed packages", len(pkgs))
}
+200 -18
View File
@@ -24,6 +24,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
)
@@ -91,11 +92,19 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
}
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
resp, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
if err != nil {
return fmt.Errorf("SyncKeys: %w", err)
}
// Stored atomically: the hourly package loop reads this from another
// goroutine. Absent on the wire decodes as false, so an older server leaves
// collection off rather than on.
collectPackagesFlag.Store(resp.CollectPackages)
markFirstPoll()
desired := resp.PublicKeys
if runtime.GOOS != "linux" {
return nil
}
@@ -117,9 +126,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 {
@@ -128,22 +164,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
}
}
}
}
@@ -154,7 +213,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)
}
@@ -167,7 +232,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 {
@@ -176,11 +241,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)
@@ -197,6 +344,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
if cmd.CleanupWorkspace != nil {
go handleCleanupWorkspace(cmd)
}
if cmd.OpenProxy != nil {
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
}
if cmd.RunStep != nil {
go func(rc *pb.RunStepCmd, cid string) {
emit := func(seq uint64, data []byte) {
@@ -253,8 +403,17 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
return
}
log.Printf("reported %d available OS updates", len(pkgs))
// Same hourly cadence, same connection. A package set changes on
// roughly the schedule available updates do, so this needs no timer of
// its own.
reportPackages(client, cfg)
}
// The boot round only: after this the flag has long been set, and every
// later tick is an hour past a poll that runs every 30s.
waitFirstPoll(ctx, firstPollWait)
doCheck()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -326,6 +485,29 @@ func handleCleanupWorkspace(cmd *pb.ServerCommand) {
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
}
// handleOpenProxy relays one console connection. It uses its own gRPC
// connection so console traffic never shares a stream with commands, key sync
// or workflow output.
func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err)
return
}
defer client.Close()
stream, err := client.ProxyStream(ctx)
if err != nil {
log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err)
return
}
log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port)
if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil {
log.Printf("proxy %s: %v", cmd.ProxyId, err)
}
}
func handleDeleteKey(cmd *pb.ServerCommand) {
label := cmd.DeleteKey.Label
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
@@ -0,0 +1,12 @@
{
"kind": "vantage.step/v1",
"name": "Apply Package Updates",
"description": "Apply all pending OS package updates. Supports apt, dnf, yum, zypper, apk and pacman.",
"interpreter": "bash",
"script": "set -u\nif command -v apt-get >/dev/null 2>&1; then\n export DEBIAN_FRONTEND=noninteractive\n apt-get update -qq && apt-get -y -qq upgrade\nelif command -v dnf >/dev/null 2>&1; then\n dnf -y upgrade\nelif command -v yum >/dev/null 2>&1; then\n yum -y update\nelif command -v zypper >/dev/null 2>&1; then\n zypper --non-interactive update\nelif command -v apk >/dev/null 2>&1; then\n apk update && apk upgrade\nelif command -v pacman >/dev/null 2>&1; then\n pacman -Syu --noconfirm\nelse\n echo \"no supported package manager found\"\n exit 1\nfi\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"package update failed\"\n exit 1\nfi\necho \"packages up to date\"\n# Debian and Ubuntu drop this file when a new kernel or libc needs a restart.\n# Reported rather than acted on: rebooting a fleet is a decision, not a detail.\nif [ -f /var/run/reboot-required ]; then\n echo \"REBOOT_REQUIRED=true\" >> $WORKFLOW_ENV\n echo \"a reboot is required to finish applying updates\"\nelse\n echo \"REBOOT_REQUIRED=false\" >> $WORKFLOW_ENV\nfi",
"declared_outputs": [
"REBOOT_REQUIRED"
],
"declared_inputs": [],
"secret_refs": []
}
+23
View File
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Check Port Is Listening",
"description": "Fail unless something is listening on a TCP port.",
"interpreter": "bash",
"script": "set -u\nhost=\"${host:-127.0.0.1}\"\nif command -v nc >/dev/null 2>&1; then\n nc -z -w 5 \"$host\" \"$port\" >/dev/null 2>&1\n ok=$?\nelse\n # bash builds /dev/tcp in, so this needs nothing installed.\n timeout 5 bash -c \"cat < /dev/null > /dev/tcp/$host/$port\" >/dev/null 2>&1\n ok=$?\nfi\nif [ $ok -ne 0 ]; then\n echo \"PORT_OPEN=false\" >> $WORKFLOW_ENV\n echo \"nothing listening on $host:$port\"\n exit 1\nfi\necho \"PORT_OPEN=true\" >> $WORKFLOW_ENV\necho \"$host:$port is open\"",
"declared_outputs": [
"PORT_OPEN"
],
"declared_inputs": [
{
"name": "host",
"default": "127.0.0.1",
"description": "host to test"
},
{
"name": "port",
"default": "",
"description": "TCP port to test"
}
],
"secret_refs": []
}
+23
View File
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Copy File/Directory",
"description": "Copy a file or directory, preserving mode, ownership and timestamps.",
"interpreter": "bash",
"script": "set -u\nif [ ! -e \"$source\" ]; then\n echo \"source $source does not exist\"\n exit 1\nfi\ncp -a \"$source\" \"$destination\" || { echo \"failed to copy $source to $destination\"; exit 1; }\necho \"copied $source to $destination\"\necho \"DEST_PATH=$destination\" >> $WORKFLOW_ENV",
"declared_outputs": [
"DEST_PATH"
],
"declared_inputs": [
{
"name": "source",
"default": "",
"description": "path to copy from"
},
{
"name": "destination",
"default": "",
"description": "path to copy to"
}
],
"secret_refs": []
}
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Create Directory",
"description": "Create a directory, including any missing parents.",
"interpreter": "bash",
"script": "set -u\nmkdir -p \"$path\" || { echo \"failed to create $path\"; exit 1; }\nif [ -n \"${mode:-}\" ]; then\n chmod \"$mode\" \"$path\" || { echo \"failed to set mode $mode on $path\"; exit 1; }\nfi\necho \"created $path\"\necho \"DIR_PATH=$path\" >> $WORKFLOW_ENV",
"declared_outputs": [
"DIR_PATH"
],
"declared_inputs": [
{
"name": "path",
"default": "",
"description": "directory to create"
},
{
"name": "mode",
"default": "",
"description": "optional octal mode, e.g. 0750"
}
],
"secret_refs": []
}
+16
View File
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Delete File/Directory",
"description": "Delete a path. Refuses the root filesystem and an empty value.",
"interpreter": "bash",
"script": "set -u\n# A step that runs as root on every server in a selector has to refuse the\n# one input that would wipe the fleet. An unset variable expands to empty,\n# so the empty case is the accident this actually guards against.\ncase \"$path\" in\n \"\"|\"/\"|\"/.\"|\"/..\")\n echo \"refusing to delete '$path'\"\n exit 1\n ;;\nesac\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist, nothing to do\"\n exit 0\nfi\nrm -rf \"$path\" || { echo \"failed to delete $path\"; exit 1; }\necho \"deleted $path\"",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "",
"description": "path to delete"
}
],
"secret_refs": []
}
+24
View File
@@ -0,0 +1,24 @@
{
"kind": "vantage.step/v1",
"name": "Disk Usage Report",
"description": "Report usage for a mount point and fail past a threshold.",
"interpreter": "bash",
"script": "set -u\nmount=\"${mountPoint:-/}\"\nlimit=\"${maxPercent:-90}\"\ndf -h \"$mount\"\nused=$(df --output=pcent \"$mount\" | tail -1 | tr -dc \"0-9\")\navail=$(df -h --output=avail \"$mount\" | tail -1 | tr -d \" \")\necho \"DISK_USED_PERCENT=$used\" >> $WORKFLOW_ENV\necho \"DISK_AVAILABLE=$avail\" >> $WORKFLOW_ENV\nif [ \"$used\" -ge \"$limit\" ]; then\n echo \"$mount is ${used}% full, at or over the ${limit}% limit\"\n exit 1\nfi\necho \"$mount is ${used}% full, ${avail} available\"",
"declared_outputs": [
"DISK_USED_PERCENT",
"DISK_AVAILABLE"
],
"declared_inputs": [
{
"name": "mountPoint",
"default": "/",
"description": "mount point to measure"
},
{
"name": "maxPercent",
"default": "90",
"description": "fail at or above this percentage"
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Docker Compose Pull and Up",
"description": "Pull the latest images for a compose project and recreate its containers.",
"interpreter": "bash",
"script": "set -u\ncd \"$projectDir\" || { echo \"no such directory: $projectDir\"; exit 1; }\nif docker compose version >/dev/null 2>&1; then\n dc=\"docker compose\"\nelif command -v docker-compose >/dev/null 2>&1; then\n dc=\"docker-compose\"\nelse\n echo \"docker compose is not installed\"\n exit 1\nfi\n$dc pull || { echo \"pull failed\"; exit 1; }\n$dc up -d --remove-orphans || { echo \"up failed\"; exit 1; }\n$dc ps",
"declared_outputs": [],
"declared_inputs": [
{
"name": "projectDir",
"default": "",
"description": "directory holding docker-compose.yml"
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Download File (Linux)",
"description": "Download a file over HTTP to a local path",
"interpreter": "bash",
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": ["FILE_PATH"],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": ""
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Enable Linux Service",
"description": "Enable a systemd unit so it starts on boot.",
"interpreter": "bash",
"script": "set -u\necho \"enabling service $serviceName\"\nsystemctl enable \"$serviceName\" || { echo \"failed to enable $serviceName\"; exit 1; }\necho \"$serviceName enabled\"",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": "systemd unit to enable"
}
],
"secret_refs": []
}
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Extract Archive",
"description": "Extract a tar, tar.gz, tar.bz2, tar.xz or zip archive into a directory.",
"interpreter": "bash",
"script": "set -u\ndest=\"${destination:-.}\"\nif [ ! -f \"$archive\" ]; then\n echo \"archive $archive does not exist\"\n exit 1\nfi\nmkdir -p \"$dest\"\ncase \"$archive\" in\n *.tar.gz|*.tgz) tar -xzf \"$archive\" -C \"$dest\" ;;\n *.tar.bz2|*.tbz2) tar -xjf \"$archive\" -C \"$dest\" ;;\n *.tar.xz|*.txz) tar -xJf \"$archive\" -C \"$dest\" ;;\n *.tar) tar -xf \"$archive\" -C \"$dest\" ;;\n *.zip)\n command -v unzip >/dev/null 2>&1 || { echo \"unzip is not installed\"; exit 1; }\n unzip -oq \"$archive\" -d \"$dest\"\n ;;\n *)\n echo \"unsupported archive type: $archive\"\n exit 1\n ;;\nesac\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to extract $archive\"\n exit 1\nfi\necho \"extracted $archive into $dest\"\necho \"EXTRACT_DIR=$dest\" >> $WORKFLOW_ENV",
"declared_outputs": [
"EXTRACT_DIR"
],
"declared_inputs": [
{
"name": "archive",
"default": "",
"description": "archive file to extract"
},
{
"name": "destination",
"default": ".",
"description": "directory to extract into"
}
],
"secret_refs": []
}
+9 -11
View File
@@ -1,12 +1,10 @@
{
"kind": "vantage.step/v1",
"name": "Get Host Name",
"description": "",
"interpreter": "bash",
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": [
"HOSTNAME"
],
"declared_inputs": [],
"secret_refs": []
}
"kind": "vantage.step/v1",
"name": "Get Host Name",
"description": "Gets the agents hostname",
"interpreter": "bash",
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": ["HOSTNAME"],
"declared_inputs": [],
"secret_refs": []
}
@@ -0,0 +1,33 @@
{
"kind": "vantage.step/v1",
"name": "HTTP Health Check",
"description": "Request a URL and fail unless it answers with the expected status.",
"interpreter": "bash",
"script": "set -u\nexpected=\"${expectedStatus:-200}\"\nattempts=\"${retries:-3}\"\ndelay=\"${retryDelay:-5}\"\nstatus=\"\"\ni=1\n# Retries live in the script rather than in on_failure: a service coming up\n# after a restart wants a few seconds, not a whole step re-dispatched.\nwhile [ \"$i\" -le \"$attempts\" ]; do\n status=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$url\" || echo \"000\")\n echo \"attempt $i: $url returned $status\"\n if [ \"$status\" = \"$expected\" ]; then\n break\n fi\n i=$(( i + 1 ))\n if [ \"$i\" -le \"$attempts\" ]; then sleep \"$delay\"; fi\ndone\necho \"HTTP_STATUS=$status\" >> $WORKFLOW_ENV\nif [ \"$status\" != \"$expected\" ]; then\n echo \"$url returned $status, expected $expected\"\n exit 1\nfi\necho \"$url is healthy\"",
"declared_outputs": [
"HTTP_STATUS"
],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": "URL to request"
},
{
"name": "expectedStatus",
"default": "200",
"description": "HTTP status that counts as healthy"
},
{
"name": "retries",
"default": "3",
"description": "how many attempts before failing"
},
{
"name": "retryDelay",
"default": "5",
"description": "seconds between attempts"
}
],
"secret_refs": []
}
+15 -15
View File
@@ -1,16 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "List Directory",
"description": "",
"interpreter": "bash",
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "./",
"description": ""
}
],
"secret_refs": []
}
"kind": "vantage.step/v1",
"name": "List Directory",
"description": "Lists the files in the specified path",
"interpreter": "bash",
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "./",
"description": ""
}
],
"secret_refs": []
}
+13
View File
@@ -0,0 +1,13 @@
{
"kind": "vantage.step/v1",
"name": "Memory Usage Report",
"description": "Report memory usage as a percentage of total.",
"interpreter": "bash",
"script": "set -u\nfree -h\ntotal=$(free -m | awk \"/^Mem:/ {print \\$2}\")\nused=$(free -m | awk \"/^Mem:/ {print \\$3}\")\npct=$(( used * 100 / total ))\necho \"MEM_USED_PERCENT=$pct\" >> $WORKFLOW_ENV\necho \"MEM_USED_MB=$used\" >> $WORKFLOW_ENV\necho \"memory ${pct}% used (${used}MB of ${total}MB)\"",
"declared_outputs": [
"MEM_USED_PERCENT",
"MEM_USED_MB"
],
"declared_inputs": [],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Reboot Server",
"description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.",
"interpreter": "bash",
"script": "set -u\ndelay=\"${delayMinutes:-1}\"\n# Scheduled rather than immediate on purpose: `shutdown -r now` kills the\n# agent before it can report, and the run records a failure on a server\n# that did exactly what it was told.\necho \"rebooting in $delay minute(s)\"\nshutdown -r \"+$delay\" \"Reboot requested by Vantage\" || { echo \"failed to schedule a reboot\"; exit 1; }",
"declared_outputs": [],
"declared_inputs": [
{
"name": "delayMinutes",
"default": "1",
"description": "minutes to wait before rebooting"
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Restart Linux Service",
"description": "Restart a systemd unit and fail if it does not come back up.",
"interpreter": "bash",
"script": "set -u\necho \"restarting service $serviceName\"\nsystemctl restart \"$serviceName\" || { echo \"failed to restart $serviceName\"; exit 1; }\nsystemctl is-active --quiet \"$serviceName\" || {\n echo \"$serviceName did not come back up\"\n systemctl status \"$serviceName\" --no-pager --lines=20 || true\n exit 1\n}\necho \"$serviceName is active\"",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": "systemd unit to restart, e.g. nginx"
}
],
"secret_refs": []
}
@@ -0,0 +1,19 @@
{
"kind": "vantage.step/v1",
"name": "Linux Service Status",
"description": "Report whether a systemd unit is active and enabled. Does not fail on a stopped unit.",
"interpreter": "bash",
"script": "set -u\nstate=$(systemctl is-active \"$serviceName\" 2>/dev/null || true)\nenabled=$(systemctl is-enabled \"$serviceName\" 2>/dev/null || true)\necho \"$serviceName: state=$state enabled=$enabled\"\necho \"SERVICE_STATE=$state\" >> $WORKFLOW_ENV\necho \"SERVICE_ENABLED=$enabled\" >> $WORKFLOW_ENV",
"declared_outputs": [
"SERVICE_STATE",
"SERVICE_ENABLED"
],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": "systemd unit to inspect"
}
],
"secret_refs": []
}
@@ -0,0 +1,31 @@
{
"kind": "vantage.step/v1",
"name": "Set Permissions and Ownership",
"description": "Set the mode and optionally the owner of a path.",
"interpreter": "bash",
"script": "set -u\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\nrecurse=\"\"\nif [ \"${recursive:-false}\" = \"true\" ]; then\n recurse=\"-R\"\nfi\nif [ -n \"${mode:-}\" ]; then\n chmod $recurse \"$mode\" \"$path\" || { echo \"failed to set mode\"; exit 1; }\n echo \"set mode $mode on $path\"\nfi\nif [ -n \"${owner:-}\" ]; then\n chown $recurse \"$owner\" \"$path\" || { echo \"failed to set owner\"; exit 1; }\n echo \"set owner $owner on $path\"\nfi",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "",
"description": "path to change"
},
{
"name": "mode",
"default": "",
"description": "octal mode, e.g. 0640"
},
{
"name": "owner",
"default": "",
"description": "owner, e.g. root:root"
},
{
"name": "recursive",
"default": "false",
"description": "true to apply recursively"
}
],
"secret_refs": []
}
@@ -1,7 +1,7 @@
{
"kind": "vantage.step/v1",
"name": "Start Linux Service",
"description": "",
"description": "Start a linux systemd service",
"interpreter": "bash",
"script": "echo \"starting service $serviceName\"\nsystemctl start $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to start service\"\n exit 1\nfi",
"declared_outputs": [],
+15 -15
View File
@@ -1,16 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Stop Linux Service",
"description": "",
"interpreter": "bash",
"script": "echo \"stopping service $serviceName\"\nsystemctl stop $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to stop service\"\n exit 1\nfi",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": ""
}
],
"secret_refs": []
}
"kind": "vantage.step/v1",
"name": "Stop Linux Service",
"description": "Stops a linux systemd service",
"interpreter": "bash",
"script": "echo \"stopping service $serviceName\"\nsystemctl stop $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to stop service\"\n exit 1\nfi",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": ""
}
],
"secret_refs": []
}
+21
View File
@@ -0,0 +1,21 @@
{
"kind": "vantage.step/v1",
"name": "Tail Log File",
"description": "Print the last N lines of a file, for reading a log after a deployment step.",
"interpreter": "bash",
"script": "set -u\nlines=\"${lines:-50}\"\nif [ ! -f \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\necho \"last $lines lines of $path:\"\ntail -n \"$lines\" \"$path\"",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "",
"description": "log file to read"
},
{
"name": "lines",
"default": "50",
"description": "how many lines to print"
}
],
"secret_refs": []
}
+29
View File
@@ -0,0 +1,29 @@
{
"kind": "vantage.step/v1",
"name": "TLS Certificate Expiry",
"description": "Report days remaining on a TLS certificate and fail under a threshold.",
"interpreter": "bash",
"script": "set -u\nport=\"${port:-443}\"\nmin=\"${minDays:-14}\"\ncommand -v openssl >/dev/null 2>&1 || { echo \"openssl is not installed\"; exit 1; }\n# -servername sends SNI, without which a shared host returns the wrong\n# certificate and the expiry reported here belongs to someone else.\nend=$(echo | openssl s_client -servername \"$host\" -connect \"$host:$port\" 2>/dev/null \\\n | openssl x509 -noout -enddate | cut -d= -f2)\nif [ -z \"$end\" ]; then\n echo \"could not read a certificate from $host:$port\"\n exit 1\nfi\nendEpoch=$(date -d \"$end\" +%s)\nnowEpoch=$(date +%s)\ndays=$(( (endEpoch - nowEpoch) / 86400 ))\necho \"CERT_DAYS_REMAINING=$days\" >> $WORKFLOW_ENV\necho \"CERT_EXPIRES=$end\" >> $WORKFLOW_ENV\necho \"$host:$port expires in $days days ($end)\"\nif [ \"$days\" -lt \"$min\" ]; then\n echo \"fewer than $min days remaining\"\n exit 1\nfi",
"declared_outputs": [
"CERT_DAYS_REMAINING",
"CERT_EXPIRES"
],
"declared_inputs": [
{
"name": "host",
"default": "",
"description": "hostname to check"
},
{
"name": "port",
"default": "443",
"description": "TLS port"
},
{
"name": "minDays",
"default": "14",
"description": "fail below this many days remaining"
}
],
"secret_refs": []
}
-18
View File
@@ -1,18 +0,0 @@
{
"kind": "vantage.step/v1",
"name": "WGET",
"description": "",
"interpreter": "bash",
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": [
"FILE_PATH"
],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": ""
}
],
"secret_refs": []
}
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Download File (Windows)",
"description": "Download a file over HTTP to a local path.",
"interpreter": "powershell",
"script": "$ErrorActionPreference = \"Stop\"\n$url = $env:url\n$dest = if ($env:destination) { $env:destination } else { Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) }\nWrite-Output \"downloading $url\"\ntry {\n # -UseBasicParsing keeps this working on Server Core, where the IE\n # engine Invoke-WebRequest otherwise reaches for is not installed.\n Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing\n} catch {\n Write-Output \"failed to download: $_\"\n exit 1\n}\nWrite-Output \"saved to $dest\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"FILE_PATH=$dest\"",
"declared_outputs": [
"FILE_PATH"
],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": "URL to download"
},
{
"name": "destination",
"default": "",
"description": "where to save it; defaults to a file in TEMP"
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Reboot Windows Server",
"description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.",
"interpreter": "powershell",
"script": "$ErrorActionPreference = \"Stop\"\n$delay = if ($env:delaySeconds) { [int]$env:delaySeconds } else { 60 }\nWrite-Output \"rebooting in $delay second(s)\"\n& shutdown.exe /r /t $delay /c \"Reboot requested by Vantage\"\nif ($LASTEXITCODE -ne 0) {\n Write-Output \"failed to schedule a reboot\"\n exit 1\n}",
"declared_outputs": [],
"declared_inputs": [
{
"name": "delaySeconds",
"default": "60",
"description": "seconds to wait before rebooting"
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Restart Windows Service",
"description": "Restart a Windows service and fail if it does not come back up.",
"interpreter": "powershell",
"script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\nWrite-Output \"restarting service $name\"\ntry {\n Restart-Service -Name $name -Force\n} catch {\n Write-Output \"failed to restart ${name}: $_\"\n exit 1\n}\n$svc = Get-Service -Name $name\nif ($svc.Status -ne \"Running\") {\n Write-Output \"$name is $($svc.Status), not Running\"\n exit 1\n}\nWrite-Output \"$name is running\"",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": "Windows service name to restart"
}
],
"secret_refs": []
}
@@ -0,0 +1,24 @@
{
"kind": "vantage.step/v1",
"name": "Windows Disk Report",
"description": "Report free space on a drive and fail past a usage threshold.",
"interpreter": "powershell",
"script": "$ErrorActionPreference = \"Stop\"\n$letter = if ($env:driveLetter) { $env:driveLetter } else { \"C\" }\n$limit = if ($env:maxPercent) { [int]$env:maxPercent } else { 90 }\n$d = Get-PSDrive -Name $letter -ErrorAction SilentlyContinue\nif ($null -eq $d) {\n Write-Output \"drive $letter not found\"\n exit 1\n}\n$total = $d.Used + $d.Free\n$pct = [math]::Round(($d.Used / $total) * 100)\n$freeGb = [math]::Round($d.Free / 1GB, 1)\nWrite-Output \"${letter}: is $pct% full, $freeGb GB free\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_USED_PERCENT=$pct\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_FREE_GB=$freeGb\"\nif ($pct -ge $limit) {\n Write-Output \"at or over the $limit% limit\"\n exit 1\n}",
"declared_outputs": [
"DISK_USED_PERCENT",
"DISK_FREE_GB"
],
"declared_inputs": [
{
"name": "driveLetter",
"default": "C",
"description": "drive letter, without a colon"
},
{
"name": "maxPercent",
"default": "90",
"description": "fail at or above this percentage"
}
],
"secret_refs": []
}
@@ -0,0 +1,19 @@
{
"kind": "vantage.step/v1",
"name": "Windows Service Status",
"description": "Report a Windows service's status and start type. Does not fail on a stopped service.",
"interpreter": "powershell",
"script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\n$svc = Get-Service -Name $name -ErrorAction SilentlyContinue\nif ($null -eq $svc) {\n Write-Output \"$name is not installed\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=missing\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=none\"\n exit 0\n}\nWrite-Output \"${name}: $($svc.Status), start type $($svc.StartType)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=$($svc.Status)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=$($svc.StartType)\"",
"declared_outputs": [
"SERVICE_STATE",
"SERVICE_START_TYPE"
],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": "Windows service name to inspect"
}
],
"secret_refs": []
}
-23
View File
@@ -1,23 +0,0 @@
[Unit]
Description=Vantage Agent
Documentation=https://github.com/your-org/vantage
After=network.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=vantage-agent
# Security hardening
NoNewPrivileges=true
ProtectSystem=false
ProtectHome=false
[Install]
WantedBy=multi-user.target
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: vantage
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
type: application
version: 1.0.7
appVersion: "1.0.7"
+72
View File
@@ -0,0 +1,72 @@
Vantage has been deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".
Services created:
{{- if .Values.redis.enabled }}
- {{ .Release.Name }}-redis (ClusterIP {{ .Values.redis.port }})
{{- else }}
- Redis: not deployed, using external {{ .Values.redis.addr }}
{{- end }}
{{- if .Values.mongo.enabled }}
- {{ .Release.Name }}-mongo (ClusterIP {{ .Values.mongo.port }})
{{- else }}
- MongoDB: not deployed, using the external server.env.mongoUri
{{- end }}
- {{ .Release.Name }}-guacd ({{ .Values.guacd.service.type }} {{ .Values.guacd.service.port }})
- {{ .Release.Name }}-server ({{ .Values.server.service.type }} http:{{ .Values.server.service.httpPort }} grpc:{{ .Values.server.service.grpcPort }})
- {{ .Release.Name }}-web ({{ .Values.web.service.type }} {{ .Values.web.service.port }})
Scaling (server.replicaCount / web.replicaCount):
- Both scale. Pin the image tags first — replicas on different builds serve
mismatched web asset hashes, and mixed server versions share one bus.
- server replicas route agent commands, step results and console relays to
each other over Redis, so every replica must use the SAME Redis. Workflow
logs are in MongoDB, not on a volume.
- Background work (monitor scheduler, Free reaper, log and audit retention,
the offline sweep) runs on one replica at a time under a Redis leader lock.
- server.persistence must be off to scale past one replica on a ReadWriteOnce
volume. Nothing writes to it any more.
{{- if gt (int .Values.server.replicaCount) 1 }}
- Console relays are reached by pod IP; guacd must be able to dial pod IPs
directly (it can, inside the cluster network).
{{- end }}
{{- if .Values.server.migrationJob.enabled }}
- Migrations run in the {{ .Release.Name }}-migrate Job before each upgrade;
the pods skip them. Its logs are kept: kubectl logs job/{{ .Release.Name }}-migrate
{{- end }}
{{- if .Values.ingress.enabled }}
Ingress (Traefik):
- Browsers: https://{{ .Values.ingress.web.host }}
{{- range .Values.ingress.web.extraHosts }}
https://{{ . }}
{{- end }}
{{- if .Values.ingress.api.enabled }}
{{ join ", " .Values.ingress.api.paths }} go straight to the server; everything else to web.
{{- else }}
Everything goes to web, which proxies /api and /auth onward. Set
ingress.api.enabled=true to route them at the edge instead.
{{- end }}
{{- if .Values.ingress.grpc.enabled }}
- Agents: {{ .Values.ingress.grpc.host }} (gRPC, h2c behind TLS)
Agents dial server.env.grpcHost, currently {{ tpl .Values.server.env.grpcHost . }}.
Point DNS for both hostnames at the Traefik load balancer.
{{- if not .Values.ingress.tls.enabled }}
- WARNING: ingress.tls.enabled is false. Agent tokens and session cookies
would cross the network in clear.
{{- else if and (not .Values.ingress.tls.certResolver) (not .Values.ingress.tls.secretName) }}
- WARNING: TLS is on but neither ingress.tls.secretName nor
ingress.tls.certResolver is set, so Traefik will serve its self-signed
default certificate, which no agent and no browser will trust.
{{- end }}
{{- end }}
{{- end }}
By default the server/web/guacd services are ClusterIP only (no host port publishing,
unlike the original docker-compose file). To expose them externally, set
server.service.type / web.service.type / guacd.service.type to NodePort or LoadBalancer,
or add an Ingress on top of the -web and -server services.
Quick access via port-forward, e.g.:
kubectl port-forward svc/{{ .Release.Name }}-web {{ .Values.web.service.port }}:{{ .Values.web.service.port }}
kubectl port-forward svc/{{ .Release.Name }}-server {{ .Values.server.service.httpPort }}:{{ .Values.server.service.httpPort }}
@@ -0,0 +1,85 @@
{{/*
Common name helpers
*/}}
{{- define "vantage.fullname" -}}
{{ .Release.Name }}
{{- end -}}
{{- define "vantage.labels" -}}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{/*
vantage.server.env renders the server container's environment.
It lives here because two workloads need it identically: the Deployment and the
pre-upgrade migration Job. A Job that connected to a different database than the
pods it migrates for would be worse than no Job at all, so there is one copy and
both read it.
*/}}
{{- define "vantage.server.env" -}}
- name: MONGO_URI
{{- $mongoUri := tpl .Values.server.env.mongoUri . }}
{{- if and (not .Values.mongo.enabled) (contains (printf "%s-mongo" .Release.Name) $mongoUri) }}
{{- fail "mongo.enabled is false, so server.env.mongoUri must point at an external MongoDB rather than the in-chart one" }}
{{- end }}
value: {{ $mongoUri | quote }}
- name: REDIS_ADDR
{{- if .Values.redis.enabled }}
value: "{{ .Release.Name }}-redis:{{ .Values.redis.port }}"
{{- else }}
{{- if not .Values.redis.addr }}
{{- fail "redis.enabled is false, so redis.addr must be set to an external Redis host:port" }}
{{- end }}
value: {{ .Values.redis.addr | quote }}
{{- end }}
{{- if .Values.redis.auth.existingSecret }}
- name: REDIS_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Values.redis.auth.existingSecret }}
key: {{ .Values.redis.auth.usernameKey }}
optional: true
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.auth.existingSecret }}
key: {{ .Values.redis.auth.passwordKey }}
{{- else }}
{{- if .Values.redis.auth.username }}
- name: REDIS_USERNAME
value: {{ .Values.redis.auth.username | quote }}
{{- end }}
{{- if .Values.redis.auth.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.auth.password | quote }}
{{- end }}
{{- end }}
- name: GRPC_HOST
value: {{ .Values.server.env.grpcHost | quote }}
- name: GRPC_PORT
value: {{ .Values.server.service.grpcPort | quote }}
- name: HTTP_PORT
value: {{ .Values.server.service.httpPort | quote }}
- name: KEY_ENCRYPTION_KEY
value: {{ .Values.server.env.keyEncryptionKey | quote }}
- name: GUACD_ADDR
value: "{{ .Release.Name }}-guacd:{{ .Values.guacd.service.port }}"
- name: APP_ROOT_LABEL
value: {{ .Values.server.env.appRootLabel | quote }}
- name: PROXY_ADVERTISE_HOST
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
- name: PROXY_LISTEN_HOST
value: {{ .Values.server.env.proxyListenHost | quote }}
{{- 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:
fieldPath: status.podIP
{{- end -}}
+47
View File
@@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-guacd
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: guacd
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: guacd
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: guacd
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: guacd
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
ports:
- containerPort: 4822
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-guacd
labels:
{{- include "vantage.labels" . | nindent 4 }}
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
ports:
- port: {{ .Values.guacd.service.port }}
targetPort: 4822
+173
View File
@@ -0,0 +1,173 @@
{{- if .Values.ingress.enabled }}
{{/*
Two hostnames, because the two audiences arrive over different protocols.
Browsers reach the web host. What answers there depends on the path: with
ingress.api.enabled, /api and /auth go straight to the server and everything
else goes to `web`. Without it, everything goes to `web`, which proxies those
prefixes onward itself (web/next.config.ts).
Both work. Routing at the edge is one hop shorter and is what the Nginx Proxy
Manager deployment in front of the Docker install already does, so leaving it
off changes the shape of the request path between the two deployments. It is
still off by default, because turning it on where `web` is the only thing with
a public certificate would strand /api behind a route nobody can reach.
The web host is normally a wildcard — `*.vantage.example.com` — because that is
the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
label. Kubernetes wildcard hosts match exactly one label, so this does not match
the apex, and on the deployment this chart was written for it must not: the apex
is the marketing site, a separate application (see site/ and
docker-compose.site.yml). extraHosts exists for a genuine second name, not for
reclaiming the apex.
Agents reach the server's gRPC port, which is plain h2c — the server holds no
certificates of its own, TLS has always been terminated by whatever sits in
front. Traefik will not speak h2c to a backend unless told to, and it is told
per Service, which is why the gRPC route gets a Service of its own below rather
than reusing the two-port one. Annotating the shared Service would force h2c on
its HTTP port too.
*/}}
{{- $tls := .Values.ingress.tls }}
{{- $webHost := required "ingress.enabled requires ingress.web.host" .Values.ingress.web.host }}
{{- $hosts := prepend .Values.ingress.web.extraHosts $webHost }}
{{- $apiPaths := .Values.ingress.api.paths }}
{{- if and .Values.ingress.api.enabled (not $apiPaths) }}
{{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }}
{{- end }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-web
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: web
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: {{ .Values.ingress.entrypoint | quote }}
{{- if and $tls.enabled $tls.certResolver }}
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: {{ $tls.certResolver | quote }}
{{- else if $tls.enabled }}
traefik.ingress.kubernetes.io/router.tls: "true"
{{- end }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if and $tls.enabled $tls.secretName }}
tls:
- hosts:
{{- range $hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ $tls.secretName }}
{{- end }}
rules:
{{- range $host := $hosts }}
- host: {{ $host | quote }}
http:
paths:
{{- /*
The API paths come first and, more importantly, are longer. Traefik
derives router priority from rule length, so Host(x) &&
PathPrefix(/api) outranks Host(x) && PathPrefix(/) without anyone
having to assign priorities by hand. Order within the list is for
the reader; the length is what decides.
*/}}
{{- if $.Values.ingress.api.enabled }}
{{- range $apiPaths }}
- path: {{ . | quote }}
pathType: Prefix
backend:
service:
name: {{ $.Release.Name }}-server
port:
number: {{ $.Values.server.service.httpPort }}
{{- end }}
{{- end }}
- path: /
pathType: Prefix
backend:
service:
name: {{ $.Release.Name }}-web
port:
number: {{ $.Values.web.service.port }}
{{- end }}
{{- if .Values.ingress.grpc.enabled }}
{{- $grpcHost := required "ingress.grpc.enabled requires ingress.grpc.host" .Values.ingress.grpc.host }}
{{/*
GRPC_HOST is what an agent is told to dial, and it is baked into every install
one-liner. Left pointing at the in-cluster Service while agents are expected to
arrive through the ingress, every install would succeed and every agent would
fail to connect — with nothing in the control plane saying why.
*/}}
{{- $grpcEnv := tpl .Values.server.env.grpcHost . }}
{{- if contains (printf "%s-server" .Release.Name) $grpcEnv }}
{{- fail (printf "ingress.grpc.enabled routes agents through %s, but server.env.grpcHost is still the in-cluster address %q. Agents dial the value of grpcHost, so set it to the public gRPC address (for example %q)." $grpcHost $grpcEnv (printf "%s:443" $grpcHost)) }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-server-grpc
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: server
annotations:
# The server speaks h2c: it terminates no TLS itself. Without this Traefik
# dials the backend as HTTP/1.1 and every agent handshake fails.
traefik.ingress.kubernetes.io/service.serversscheme: h2c
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
ports:
- name: grpc
port: {{ .Values.server.service.grpcPort }}
targetPort: {{ .Values.server.service.grpcPort }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-grpc
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: server
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: {{ .Values.ingress.entrypoint | quote }}
{{- if and $tls.enabled $tls.certResolver }}
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: {{ $tls.certResolver | quote }}
{{- else if $tls.enabled }}
traefik.ingress.kubernetes.io/router.tls: "true"
{{- end }}
{{- with .Values.ingress.grpc.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if and $tls.enabled $tls.grpcSecretName }}
tls:
- hosts:
- {{ $grpcHost | quote }}
secretName: {{ $tls.grpcSecretName }}
{{- end }}
rules:
- host: {{ $grpcHost | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-server-grpc
port:
number: {{ .Values.server.service.grpcPort }}
{{- end }}
{{- end }}
@@ -0,0 +1,68 @@
{{- if .Values.server.migrationJob.enabled }}
{{/*
Schema setup, lifted out of the serving pods.
Every server process used to run migrations, index builders and default-step
seeding at boot. With one replica that is fine. With two it is not: 0004 renames
the orgs collection to instances, and a sibling reading it mid-rename is a
corruption, not a retry.
A Helm hook Job runs it once, before any pod of the new version starts. The
Deployment then sets VANTAGE_SKIP_MIGRATIONS, which is what makes the Job's
existence load-bearing rather than decorative — if you disable the Job, the
pods go back to migrating themselves and you must go back to one replica.
hook-weight orders this after the dependency waits; before-hook-creation deletes
the previous Job so a repeat upgrade is not blocked by an immutable object. The
Job is deliberately NOT deleted on success: its logs are the record of what the
upgrade did to the database.
*/}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-migrate
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: {{ .Values.server.migrationJob.backoffLimit }}
# A migration that has not finished in this long is stuck, and a stuck
# migration should fail the upgrade rather than hold it open forever.
activeDeadlineSeconds: {{ .Values.server.migrationJob.activeDeadlineSeconds }}
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: migrate
spec:
restartPolicy: Never
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
{{- end }}
{{- if .Values.mongo.enabled }}
# Only Mongo. The Job never opens Redis, and waiting on a Redis this
# chart may not even deploy would block an upgrade for no reason.
initContainers:
- name: wait-for-mongo
image: busybox:1.36
command:
- sh
- -c
- |
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
echo "waiting for mongo..."; sleep 2;
done
{{- end }}
containers:
- name: migrate
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
env:
{{- include "vantage.server.env" . | nindent 12 }}
- name: VANTAGE_MIGRATE_ONLY
value: "true"
{{- end }}
+92
View File
@@ -0,0 +1,92 @@
{{- if .Values.mongo.enabled }}
{{- if .Values.mongo.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-mongo-data
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: mongo
spec:
accessModes:
- {{ .Values.mongo.persistence.accessMode }}
{{- if .Values.mongo.persistence.storageClass }}
storageClassName: {{ .Values.mongo.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.mongo.persistence.size }}
---
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-mongo
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: mongo
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: mongo
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: mongo
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: mongo
image: "{{ .Values.mongo.image.repository }}:{{ .Values.mongo.image.tag }}"
ports:
- containerPort: {{ .Values.mongo.port }}
volumeMounts:
- name: mongo-data
mountPath: /data/db
livenessProbe:
exec:
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
exec:
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
volumes:
- name: mongo-data
{{- if .Values.mongo.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Release.Name }}-mongo-data
{{- else }}
emptyDir: {}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-mongo
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: mongo
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: mongo
ports:
- port: {{ .Values.mongo.port }}
targetPort: {{ .Values.mongo.port }}
{{- end }}
+92
View File
@@ -0,0 +1,92 @@
{{- if .Values.redis.enabled }}
{{- if .Values.redis.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-redis-data
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
accessModes:
- {{ .Values.redis.persistence.accessMode }}
{{- if .Values.redis.persistence.storageClass }}
storageClassName: {{ .Values.redis.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.redis.persistence.size }}
---
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-redis
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
ports:
- containerPort: {{ .Values.redis.port }}
volumeMounts:
- name: redis-data
mountPath: /data
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
volumes:
- name: redis-data
{{- if .Values.redis.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Release.Name }}-redis-data
{{- else }}
emptyDir: {}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-redis
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
ports:
- port: {{ .Values.redis.port }}
targetPort: {{ .Values.redis.port }}
{{- end }}
+181
View File
@@ -0,0 +1,181 @@
{{- if and .Values.server.persistence.enabled (not .Values.server.persistence.useHostPath) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-server-data
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
accessModes:
- {{ .Values.server.persistence.accessMode }}
{{- if .Values.server.persistence.storageClass }}
storageClassName: {{ .Values.server.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.server.persistence.size }}
---
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-server
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
{{- $replicas := int .Values.server.replicaCount }}
replicas: {{ $replicas }}
{{- if and .Values.server.persistence.enabled (eq .Values.server.persistence.accessMode "ReadWriteOnce") }}
# A ReadWriteOnce volume cannot be mounted by a second pod at all, and cannot
# be handed to a new pod while the old one still holds it. Persistence is off
# by default now that nothing writes to it; if it is on, replicas are capped
# at one and updates go through Recreate.
{{- if gt $replicas 1 }}
{{- fail "server.persistence.enabled with a ReadWriteOnce volume cannot be combined with server.replicaCount > 1. Nothing in the server writes to that volume any more (workflow logs live in MongoDB); set server.persistence.enabled=false, or use a ReadWriteMany accessMode if you are keeping it for another reason." }}
{{- end }}
strategy:
type: Recreate
{{- end }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
template:
metadata:
labels:
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 }}
{{- end }}
{{- if or .Values.redis.enabled .Values.mongo.enabled }}
# Wait for the dependencies this chart deploys to be reachable,
# approximating compose's `depends_on: condition: service_healthy`. An
# external Redis or Mongo is assumed to be up already — waiting on one
# would only turn someone else's outage into a stuck pod.
initContainers:
{{- if .Values.redis.enabled }}
- name: wait-for-redis
image: busybox:1.36
command:
- sh
- -c
- |
until nc -z {{ .Release.Name }}-redis {{ .Values.redis.port }}; do
echo "waiting for redis..."; sleep 2;
done
{{- end }}
{{- if .Values.mongo.enabled }}
- name: wait-for-mongo
image: busybox:1.36
command:
- sh
- -c
- |
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
echo "waiting for mongo..."; sleep 2;
done
{{- end }}
{{- end }}
containers:
- name: server
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
ports:
- containerPort: {{ .Values.server.service.httpPort }}
- containerPort: {{ .Values.server.service.grpcPort }}
env:
{{- include "vantage.server.env" . | nindent 12 }}
{{- if .Values.server.migrationJob.enabled }}
# Schema setup ran in the pre-upgrade Job. Pods that repeated it
# would race each other, and the rename migration is not a race
# that tolerates a loser.
- name: VANTAGE_SKIP_MIGRATIONS
value: "true"
{{- end }}
# Liveness never touches Mongo or Redis: restarting every pod cannot
# fix a database outage, and each restart drops every agent command
# stream and console session it was carrying. Readiness does check
# both, so a pod that cannot serve leaves the Service and stays up.
startupProbe:
httpGet:
path: /healthz
port: {{ .Values.server.service.httpPort }}
periodSeconds: 5
# Generous: without the migration Job this pod runs every migration
# before it listens, and the rename has a ten-minute budget.
failureThreshold: 150
livenessProbe:
httpGet:
path: /healthz
port: {{ .Values.server.service.httpPort }}
periodSeconds: 20
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: {{ .Values.server.service.httpPort }}
periodSeconds: 10
failureThreshold: 3
{{- if .Values.server.persistence.enabled }}
# Nothing in the server writes here any more — workflow logs moved to
# MongoDB so that every replica can read and write them. The mount
# remains only so an operator upgrading from a file-log release can
# still reach the old files before turning persistence off.
volumeMounts:
- name: server-data
mountPath: /data
volumes:
- name: server-data
persistentVolumeClaim:
claimName: {{ .Release.Name }}-server-data
{{- end }}
---
{{- if gt (int .Values.server.replicaCount) 1 }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ .Release.Name }}-server
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
# Agents reconnect on their own, but a drain that took every replica at once
# would disconnect every agent in the fleet simultaneously and stall every
# workflow run in flight.
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
---
{{- end }}
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-server
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
type: {{ .Values.server.service.type }}
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
ports:
- name: http
port: {{ .Values.server.service.httpPort }}
targetPort: {{ .Values.server.service.httpPort }}
- name: grpc
port: {{ .Values.server.service.grpcPort }}
targetPort: {{ .Values.server.service.grpcPort }}
+80
View File
@@ -0,0 +1,80 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-web
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
# web holds no per-process state: sessions live in Redis and every request is
# proxied to the server. It is the one component here that scales freely.
replicas: {{ .Values.web.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: web
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: web
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
{{- end }}
initContainers:
- name: wait-for-server
image: busybox:1.36
command:
- sh
- -c
- |
until nc -z {{ .Release.Name }}-server {{ .Values.server.service.httpPort }}; do
echo "waiting for server..."; sleep 2;
done
containers:
- name: web
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
ports:
- containerPort: {{ .Values.web.service.port }}
env:
- name: API_URL
value: {{ tpl .Values.web.env.apiUrl . | quote }}
# /healthz is served by this Next process; /api is rewritten to the
# server, so a probe there would report the backend's health and keep
# passing while this pod was wedged.
startupProbe:
httpGet:
path: /healthz
port: {{ .Values.web.service.port }}
periodSeconds: 3
failureThreshold: 20
livenessProbe:
httpGet:
path: /healthz
port: {{ .Values.web.service.port }}
periodSeconds: 20
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: {{ .Values.web.service.port }}
periodSeconds: 10
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-web
labels:
{{- include "vantage.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
type: {{ .Values.web.service.type }}
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: web
ports:
- port: {{ .Values.web.service.port }}
targetPort: {{ .Values.web.service.port }}
+111
View File
@@ -0,0 +1,111 @@
# Default values for the vantage chart.
redis:
enabled: true
addr: ""
image:
repository: redis
tag: "8"
persistence:
enabled: true
size: 1Gi
storageClass: ""
accessMode: ReadWriteOnce
port: 6379
auth:
username: ""
password: ""
existingSecret: ""
usernameKey: username
passwordKey: password
mongo:
enabled: true
image:
repository: mongo
tag: "7"
persistence:
enabled: true
size: 5Gi
storageClass: ""
accessMode: ReadWriteOnce
port: 27017
guacd:
image:
repository: docker.io/guacamole/guacd
tag: "1.6.0"
service:
type: ClusterIP
port: 4822
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
activeDeadlineSeconds: 900
image:
repository: gitea.hostxtra.co.uk/mrhid6/vantage/server
tag: latest
service:
type: ClusterIP
httpPort: 8080
grpcPort: 9090
env:
mongoUri: "mongodb://{{ .Release.Name }}-mongo:27017/vantage"
grpcHost: "{{ .Release.Name }}-server:9090"
keyEncryptionKey: ""
appRootLabel: vantage
proxyAdvertiseHost: "{{ .Release.Name }}-server"
proxyListenHost: "0.0.0.0"
persistence:
enabled: false
size: 1Gi
storageClass: ""
accessMode: ReadWriteOnce
hostPath: /data
web:
replicaCount: 1
image:
repository: gitea.hostxtra.co.uk/mrhid6/vantage/web
tag: latest
service:
type: ClusterIP
port: 3000
env:
apiUrl: "http://{{ .Release.Name }}-server:8080"
ingress:
enabled: false
className: traefik
entrypoint: websecure
annotations: {}
web:
host: ""
extraHosts: []
api:
enabled: false
paths:
- /api
- /auth
- /update
- /install
- /update.ps1
- /install.ps1
grpc:
enabled: true
host: ""
annotations: {}
tls:
enabled: true
secretName: ""
grpcSecretName: ""
certResolver: ""
imagePullSecrets: []
@@ -5,7 +5,7 @@
# host:port agents dial for gRPC. No default; boot fails without it.
# Must be reachable from managed servers. Use the public host, port 9090.
GRPC_HOST=192.168.1.250:9090
GRPC_HOST=vantage.yourdomain.com:9090
# 64-char hex (32 bytes) for AES-256-GCM. Required for private keys,
# secrets, OIDC secrets, RDP/VNC credentials.
@@ -18,4 +18,3 @@ KEY_ENCRYPTION_KEY=
MONGO_URI=mongodb://mongo:27017/vantage
# Where workflow run logs are written inside the server container.
# VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
@@ -45,9 +45,8 @@ services:
GRPC_PORT: "9090"
HTTP_PORT: "8080"
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
GUACD_ADDR: guacd:4822
APP_ROOT_LABEL: vantage
PROXY_ADVERTISE_HOST: server
depends_on:
redis:
condition: service_healthy
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
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 |

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