Records why instance_members is an index and not the authority, why hqsync is not part of inject, and why an invitation carries no password. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
43 KiB
Vantage
A self-hosted, multi-tenant infrastructure control plane. It started as SSH key management and has grown into fleet management: SSH key assignment, workflow/script execution, service monitoring, a secrets vault, a browser console (SSH/RDP/VNC), and OS update management.
A central server (Go + Next.js + MongoDB + Redis) drives a lightweight Go agent installed on each managed server. Agents poll over gRPC and also hold a bidirectional command stream for push-style commands.
Architecture Overview
┌──────────────────────────────────────────────┐
│ Next.js 16 Frontend (web, :3000) │
│ servers · keys · workflows · monitors │
│ secrets · audit · console · settings │
└───────────────┬──────────────────────────────┘
│ REST + cookie session
┌───────────────▼──────────────────────────────┐
│ Go Backend (server) │
│ :8080 REST (gin) :9090 gRPC (agents) │
│ MongoDB (state) · Redis (sessions) │
│ monitor scheduler · workflow runner │
│ guacd tunnel proxy for browser console │
└───────────────┬──────────────────────────────┘
│ gRPC (TLS) — outbound from agent only
┌───────────────▼──────────────────────────────┐
│ Go Agent (per server, Linux + Windows) │
│ polls SyncKeys · CommandStream │
│ rewrites authorized_keys (Linux only) │
│ runs workflow steps · monitors · inventory │
└──────────────────────────────────────────────┘
Multi-tenancy: every domain document carries org_id, and every service query is scoped by it. Org is resolved from the session, and optionally cross-checked against the request host (<slug>.vantage.<tld>).
Repository Structure
vantage/
├── agent/
│ ├── cmd/main.go # flags: -generate-key
│ └── internal/
│ ├── checker/ # monitor check execution
│ ├── config/ # config.yaml load/save
│ ├── exec/ # workflow step execution
│ ├── grpc/ # client + generated pb
│ ├── inventory/ # CPU/mem/disk collection (linux/other)
│ ├── keys/ # authorized_keys read/diff/write
│ ├── monitors/ # agent-run monitor loop
│ ├── sync/ # poll loop + command stream
│ └── updates/ # OS package update check/apply
├── server/
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # REST handlers
│ ├── auth/ # local, OIDC, session, middleware, orghost
│ ├── checker/ # server-run monitor checks
│ ├── db/ # mongo connect + Col()
│ ├── grpc/ # gRPC server + generated pb
│ ├── models/ # MongoDB documents
│ ├── monitorsched/ # server-side monitor scheduler
│ ├── notify/ # smtp, http, templating, dispatch
│ └── services/ # business logic + migrations
├── web/ # the application UI (authenticated)
│ ├── app/(app)/ # authed routes
│ ├── app/login, app/setup # unauthed routes
│ ├── components/ # ui/, workflows/, monitors/, Sidebar
│ └── lib/ # api client, guac console, query client
├── site/ # public marketing site
│ ├── app/ # one directory per route
│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms
│ ├── assets/ # image sources, not served
│ └── Dockerfile # same shape as web/: standalone, node, 3000
├── sitesvc/ # public form: contact mail only
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # contact
│ ├── mail/ # SMTP
│ └── store/ # Mongo connect helper
├── admin/ # licensing authority: the only signer
│ ├── cmd/main.go # boot: two Mongo connections, reconciler, HTTP
│ ├── cmd/adminctl/ # staff-add; deliberately has no HTTP surface
│ └── internal/
│ ├── api/ # customer + staff handlers, route table
│ ├── auth/ # staff, HQ customer and cloud-owner sessions
│ ├── inject/ # licence write path into the control plane
│ ├── cloudprov/ # instance write path: creates instances + owners
│ ├── licensing/ # Issue, LinkInstance, Relink
│ ├── mail/ # verification and licence delivery
│ └── models/ # accounts, instances, licences, plans
├── adminsite/ # staff + customer console (vantage-hq)
│ ├── app/(customer)/ # overview, instance, link, billing
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
│ ├── components/ # InstanceCard, Ledger, Queue, EnvBadge
│ └── lib/ # api client, session guards, formatters
├── shared/ # imported by server, sitesvc and admin
│ ├── license/ # payload, sign, verify, trusted keys, plans
│ ├── models/ # Instance, User, Settings
│ └── cmd/lkctl/ # issue and inspect licences by hand
├── proto/vantage/v1/vantage.proto
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
├── deploy/ # docker-compose.yml, agent.service
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
Subsystems
SSH keys
Upload a public key, assign it per server, revoke softly. The agent diffs desired vs on-disk state and rewrites /root/.ssh/authorized_keys atomically. Keys can also be generated on a server by the agent; the private half can optionally be uploaded and is stored AES-256-GCM encrypted.
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.
Default steps are seeded per org at boot (SeedDefaultSteps). Logs are swept by retention (workflow_log_retention_days; nil = 30 days, 0 = forever).
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.
Notification channels
Per-org outbound destinations: webhook, smtp, discord, slack, telegram. Monitors reference channels by ID. Channels are testable from the UI.
Secrets vault
Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed two ways: referenced by workflow steps via secret_refs (injected as env at execution), and read by Kubernetes External Secrets Operator via GET /api/secrets/:group/values using a bearer token whose SHA-256 hash is stored in settings.
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.
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).
Agent self-update
UpdateAgentCmd carries a target version and Gitea base URL; the agent downloads and replaces itself.
Marketing site and sitesvc
site/ is a separate Next.js app built exactly like web/ — output: "standalone", run by Node in a node:26-alpine image, listening on 3000 and published as 3003. The contact form posts to sitesvc; account signup posts to admin (NEXT_PUBLIC_ADMIN_API_URL), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
adminsite/ is built the same way and published as 3004, served at vantage-hq.hostxtra.co.uk — deliberately outside *.vantage.hostxtra.co.uk, because that namespace is per-tenant instance subdomains and APP_ROOT_LABEL resolves an org from the label before vantage. It shares site/'s design tokens verbatim (see Frontend below) and, unlike web/, does not proxy through a Next rewrite: the browser calls admin directly, so ADMIN_API_URL must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain hostxtra.co.uk, which keeps admin_session's SameSite=Lax cookie in play.
ADMIN_ORIGIN must list every browser origin that calls admin — currently two: https://vantage-hq.hostxtra.co.uk for the console, and https://vantage.hostxtra.co.uk because the marketing site's /start form posts account signups to admin directly. It is comma-separated. A missing origin does not produce a 403: cors() simply omits the Access-Control-Allow-Origin header and still answers the preflight 204, so the browser blocks the request and admin logs nothing at all. Symptom is a CORS preflight failure on an endpoint that works fine under curl.
sitesvc/ (port 8082) now owns only the contact flow:
| Form | Endpoint | Effect |
|---|---|---|
| Contact | POST /api/contact |
Emails support@hostxtra.co.uk, Reply-To the sender. Nothing stored. |
Account signup lives in admin instead (POST /auth/signup, GET /auth/verify?token=…) — see Signup and verification below.
site, sitesvc and admin are deliberately excluded from the self-hosted deployment: deploy/docker-compose.yml mentions none of them, and they live in deploy/docker-compose.site.yml instead.
# self-hosted install — no marketing site, no sitesvc
docker compose up -d
# vantage.hostxtra.co.uk — control plane plus the public site
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
Signup and verification
Signup is account-first: it creates an HQ account and an unverified customer_user in admin's own database, nothing in the control plane. Only after a customer later creates a cloud instance from the portal (POST /api/instances, see Admin REST API) does an org, or rather an instance, come to exist — provisioned by cloudprov, with the owner's password hash copied from the HQ user rather than shared. site_pending_signups is gone; sitesvc no longer has a signup flow at all.
- The token is 32 random bytes; only its SHA-256 hash is stored, so a leaked database yields no working links.
- Links expire after 24 hours (
VerifyWindow). - An unverified sign-in gets a distinct "check your email" error rather than the generic auth failure, because the address is already known to be theirs.
- If sending the verification email fails, the freshly inserted
customer_user(and account, on first signup) is rolled back rather than left stranded holding the unique index on email. - Rate limited per client IP, plus a honeypot field.
An account is a team, not a person. customer_users.account_role is owner,
admin or member — the same three words as the control plane's roles, on
purpose. Owners and admins invite people, create instances and grant instance
access; billing is owner-only.
An invitation creates a customer_user with an empty password hash, which
cannot authenticate, and the invitee sets their own at /accept-invite. An
inviter-chosen password would be a shared credential to every instance that
person is later granted. GET /auth/verify therefore peeks before it consumes:
a token belonging to a passwordless row answers {"needs_password":true} and is
left unspent.
Shared provisioning
shared/provision (instance.go, slug.go, user.go) holds the slug rules, reserved names and instance/user creation logic that both server and admin/internal/cloudprov need, so there is no longer a second copy to drift: cloudprov.CreateInstance calls straight into it to create a control-plane instance and its owner from a customer request.
Grants project, they do not federate
Granting someone access to a cloud instance writes a real control-plane users
row through cloudprov, with auth_source: "hq" and hq_user_id set. The
instance authenticates it exactly as it authenticates anyone else, with no
runtime dependency on admin. Revoking deletes that row — the control plane has
no disabled state, and a row that exists is a row that can sign in.
instance_members in admin's database is only admin's index of those
projections; the control-plane row is the access. That is why a failed
instance_members insert unwinds the projection, and why the boot backfill can
rebuild the index from the control plane but never the other way round.
Self-hosted instances are never projected into. All three mutating member
endpoints refuse when deployment != cloud.
The HQ password is the single source of truth for every hq-sourced row.
PUT /api/account/password rehashes and has cloudprov copy the hash to every
projected row; propagation is best-effort, and admin/internal/hqsync compares
and repairs every 15 minutes. It is its own package rather than a pass inside
inject — inject writes three licence fields and nothing else, and that
narrowness is what makes admin's reach into the control plane reviewable.
The control plane refuses to change an hq-sourced user's role or delete it
(services.ErrHQManaged, 409). web/ shows those rows read-only with a link to
the portal, but the API is the boundary; the UI is a courtesy. There is no local
password-change endpoint at all, so there is no competing writer for the hash.
Auth and Orgs
- Bootstrap — first run has no users.
GET /auth/bootstrap-statusdrives/setup,POST /auth/bootstrapcreates 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. - Sessions — opaque 32-byte hex ID in the
km_sessioncookie, session body stored in Redis with a 24h TTL. - Roles —
owner,admin,member./api/settingsand/api/org/*require owner or admin. - Host/org guard —
APP_ROOT_LABEL(defaultvantage) 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.
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.
gRPC API
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
}
CommandStream is the only streaming RPC: the agent authenticates once with AgentReady, then the server pushes ServerCommands and the agent replies with CommandResult, StepResult, or StepOutputChunk.
ServerCommand variants: GenerateKeyCmd, DeleteKeyCmd, UpdateAgentCmd, ApplyUpdatesCmd, RunStepCmd, CleanupWorkspaceCmd.
Key-state polling stays on the 30s SyncKeys interval. Full message definitions live in proto/vantage/v1/vantage.proto.
REST API
Unauthenticated:
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 /api/secrets/:group/values # bearer token (ESO)
Session-authed under /api:
servers GET,POST /servers · GET,POST /servers/new · GET,DELETE /servers/:id
POST /servers/:id/{generate-key,update-agent,apply-updates}
keys GET,POST /keys · GET,DELETE /keys/:id · GET /keys/:id/private-key
POST /keys/:id/assign · DELETE /keys/:id/assign/:serverId
workflows GET,POST /steps · PUT,DELETE /steps/:id · GET /steps/:id/export
POST /steps/{import,seed-defaults,parse} · GET /steps/usage
GET,POST /workflows · GET,PUT,DELETE /workflows/:id
POST /workflows/:id/run · GET /workflows/:id/runs
GET /runs/:runId · POST /runs/:runId/cancel
GET /runs/:runId/servers/:serverId/logs[/stream]
monitors GET,POST /monitors · GET,PUT,DELETE /monitors/:id
GET /monitors/:id/{incidents,uptime}
channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:id/test
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)
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
Admin REST API (admin, :8083)
A separate service with its own session cookie (admin_session) and its own database. Unauthenticated:
GET /healthz
GET /auth/me # who am I; 401 drives the UI's redirects
POST /auth/staff/login /auth/login /auth/logout
POST /auth/signup # self-hosted only; honeypot + rate limited
GET /auth/verify?token=…
POST /auth/accept-invite # an invitee sets their own password
Customer-session (/api), every instance resolved through ownedInstance:
GET /account # account, instances, max_relinks
POST /instances # create a cloud instance (Free tier, capped at one per account)
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
POST /instances/link · /instances/:id/relink
GET /instances/:id/license · /instances/:id/license/download
GET /subscriptions
GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id
PUT /account/password # propagates to every projected user
GET,POST /instances/:id/members # cloud only
PUT /instances/:id/members/:uid/role · DELETE /instances/:id/members/:uid
Reading is open to any signed-in member; every mutation above except
/account/password (which is your own) sits behind RequireAccountRole(owner, admin). :uid is the customer_users.user_id, not the projected
control-plane user_id — the portal never has to know that one.
Staff-session (/api/staff):
GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID
GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state
POST /instances/:id/issue · /instances/:id/relink
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:tier
GET /health/injection
Customer endpoints answer 404, never 403, for another account's resource — a 403 confirms the resource exists. Route-group guards in adminsite/ mirror this, but the backend is the layer that matters.
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
Every document except migrations carries org_id. Struct definitions are the source of truth — see server/internal/models/.
Notes that are not obvious from the structs:
servers.agent_token_hashstores SHA-256 of the token, never plaintext.pre_reg_tokenis cleared afterRegister().statusispending→activeon register,offlinewhenlast_seenpasses the threshold (swept every 2 min).servers.inventoryholds the latest metrics snapshot with separatemetrics_at/static_attimestamps.keys.private_key_encandpassphrase_encare AES-256-GCM; the JSON form exposes onlyhas_private_key/has_passphrase.assignments.revoked_at: nullmeans active. Revocation is soft, preserving audit history.workflow_runs.steps_snapshotfreezes the resolved steps so editing the library never rewrites history.console_sessions.token_consumed_atis set atomically to enforce one-time use.users.auth_sourceislocal,oidcorhq. Anhquser was projected from a Vantage HQ account and carrieshq_user_id; HQ owns its role, password and existence.
Admin's own database is separate and holds accounts · admin_instances · licenses · subscriptions · plans · staff_users · customer_users · instance_members · admin_audit. 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.
Migrations
services.RunMigrations() runs at boot, recording markers in migrations:
0001_default_org_backfill0002_settings_org_backfill(must run before 0003 — 0003 can create adefaultorg, which pushes 0002 into its ambiguous multi-org branch)0003_missed_org_scopes
Index builders (EnsureAuthIndexes, EnsureSettingsIndexes) are fatal on failure; EnsureSecretIndexes and EnsureWorkflowIndexes only warn.
Agent Lifecycle
Config file
Linux /etc/vantage/config.yaml, Windows %ProgramData%\vantage\config.yaml. Directory 0700, file 0600.
server_url: "vantage.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<token>" # removed after first successful Register()
agent_token: "" # written by agent after Register()
poll_interval: 30s
tls: true
Startup
1. Load config
2. If pre_reg_token present → Register() → save agent_token, clear pre_reg_token, reconnect
3. Start goroutines: command stream · update check (hourly) · inventory · monitors
4. Enter SyncKeys poll loop (default 30s)
Poll loop
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
Install
Linux: systemd unit at /etc/systemd/system/vantage-agent.service, Restart=always, runs as root.
Windows: MSI built by CI (WiX), or installer/setup.ps1 registering the agent as a service via NSSM.
Server Registration Flow
- Add Server in the UI calls
POST /api/servers/new, which generates aserver_idand a pre-registration token (TTL 1 hour, single-use). - The UI shows a one-liner:
Windows gets the
curl -fsSL https://vantage.yourdomain.com/install | \ bash -s -- --server-id=<id> --token=<token>/install.ps1equivalent. - The script detects arch, downloads the agent from the Gitea release, verifies the SHA-256 checksum, writes the config, installs and starts the service.
- The server flips to
activeon first sync.
/install is served dynamically, injecting the latest agent version from the Gitea API.
Environment Variables (server)
| Name | Required | Notes |
|---|---|---|
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_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. |
GITEA_HOST |
yes | used to build install scripts and agent download URLs |
GUACD_ADDR |
no | default guacd:4822 |
APP_ROOT_LABEL |
no | default vantage; wrong value disables the host/session org guard |
VANTAGE_WORKFLOW_LOG_DIR |
no | where run logs are written |
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):
| Name | Required | Notes |
|---|---|---|
MONGO_URI |
yes | must point at the control plane's database. sitesvc no longer provisions orgs itself, but it still refuses to start (RequireMigratedDatabase) against a database that has not run migration 0004 (the orgs → instances rename), and it (re)declares the shared users.email / instances.slug indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes MONGO_DB separately. |
SMTP_HOST / SMTP_FROM |
yes | without them the contact form refuses (503) rather than silently dropping |
SMTP_TO |
no | default support@hostxtra.co.uk; contact enquiries only |
SMTP_PORT |
no | default 587; 465 uses implicit TLS |
SMTP_USERNAME / SMTP_PASSWORD |
no | auth skipped when username is empty |
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 |
deploy/docker-compose.yml runs four services: redis, guacd, server (8080 + 9090), web (3000). MongoDB is external. deploy/docker-compose.site.yml adds four more — site (3003), sitesvc (8082), admin (8083) and adminsite (3004) — and is only used on vantage.hostxtra.co.uk.
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.
Security
- gRPC over TLS; agents connect outbound only, no inbound firewall holes on managed servers.
- Per-server agent token stored as SHA-256 on the server, plaintext only in the agent's
0600config. - Pre-registration tokens are short-lived (1 hour) and single-use.
- AES-256-GCM at rest for private keys, key passphrases, vault secrets, OIDC client secrets, RDP/VNC credentials.
- Console session tokens are one-time; RDP credentials are consumed on tunnel open.
- ESO read token stored as a SHA-256 hash and rotatable.
- 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. authorized_keyswritten0600, owned by root. The agent runs as root because it must.- Every mutating API path writes an audit event.
Frontend
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in web/lib/guacamole-common.js.
There are three separate visual identities, and the split is deliberate:
| App | Ground | Accent | Themes |
|---|---|---|---|
web/ |
#0f1117 |
indigo #6366f1 |
dark only, locked |
site/ |
token-based | brand navy #0b2a58 / #5b9be8 |
light + dark |
adminsite/ |
the same tokens as site/ |
brand navy | light + dark, light default |
adminsite/app/globals.css holds site/app/globals.css's token blocks copied verbatim — same names, same values. Change them in both files in the same commit; nothing enforces the match automatically, the same shape of hazard as sitesvc's mirrored slug rules. Tailwind in adminsite/ maps var(--…) references only, so no component may carry a hex value. site/ names the semantic three --up/--pend/--down for monitor state; adminsite/ aliases them to valid/warn/expired for licence state — same colours.
adminsite/ defaults to light on purpose: web/ is locked to dark, and a staff member with both open should never mistake one for the other before clicking Reissue. In dark mode the shared accent lifts to #5b9be8, closer to web/'s indigo, so that distinction rests on the ground — do not make dark the default. Licence state never reads by colour alone: every pill carries a distinct shape and a text label.
| Route | Purpose |
|---|---|
/setup |
First-run bootstrap: create the first org and owner |
/login |
Local or OIDC sign-in |
/ |
Fleet dashboard |
/servers, /servers/new, /servers/[id] |
Fleet list, install one-liner, server detail (keys, inventory, updates) |
/servers/[id]/console |
Browser SSH/RDP/VNC session |
/keys, /keys/[id] |
Key library; assign and revoke per server |
/workflows, /workflows/[id], /workflows/[id]/runs[/runId] |
Compose, run, and follow live logs |
/steps |
Reusable step library |
/monitors, /monitors/new, /monitors/[id][/edit] |
Checks, uptime, incidents |
/secrets, /secrets/[group] |
Vault |
/audit |
Audit log |
/settings, /settings/org, /settings/notifications |
Alerts, members, OIDC, channels |
CI/CD — Gitea Actions
agent-release.yml — triggered by agent/v* tags
Builds linux/amd64, linux/arm64, windows/amd64, writes checksums.txt, creates a Gitea release. A second msi job on windows-2022 packages the WiX installer.
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-linux-amd64 ./cmd
server-deploy.yml — triggered on every push to main
Builds and pushes six images to the Gitea container registry: server, web, site, sitesvc, admin and adminsite.
Note that despite the name, this workflow does not deploy — it only builds and pushes. There is no SSH step and no path filter; every push to main rebuilds all three images. Rolling them out is a separate manual step on the host:
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
Tagging
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
git push origin main # server + web deploy
Secrets / variables
| Name | Type | Value |
|---|---|---|
RELEASE_TOKEN |
Secret | Gitea API token, write:release |
REGISTRY_USER |
Secret | Gitea username |
REGISTRY_PASSWORD |
Secret | Gitea token, write:packages |
GITEA_HOST |
Variable | gitea.hostxtra.co.uk |
DOCKER_HOST |
Variable | registry host used for image tags |
API_URL |
Variable | baked into the web image at build time |
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 |
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. |
Design Decisions
- gRPC for agent traffic — strong typing and cheap versioning; polling for state, one bidirectional stream for commands.
- Outbound-only agents — no inbound ports on managed servers, works behind NAT.
- Poll for keys, push for commands — a 30s key poll is fine, but running a workflow step should not wait up to 30s.
- Atomic
authorized_keysrewrite — temp file plusos.Rename(); a machine that dies mid-write keeps the old file. - Fingerprint diffing before write — no disk churn on unchanged state.
- Soft revocation —
revoked_atrather than deletes; preserves audit history. - Run snapshots — workflow runs freeze their resolved steps so editing a step never rewrites past runs.
- Monitors run in two places — server-side for external endpoints, agent-side for anything only reachable from inside the target network.
- Redis for sessions only — all durable state stays in MongoDB; losing Redis logs everyone out and nothing else.
- guacd for console — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
org_idon 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_keysmanagement. - 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.