# 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 (`.vantage.`). --- ## Repository Structure ``` vantage/ ├── 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/ # channel dispatch: http, discord, slack, telegram, smtp │ └── 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 ├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs ├── deploy/ # docker-compose.yml, Helm chart └── .gitea/workflows/ # server-deploy.yml, chart-release.yml ``` **Five repositories carry parts of Vantage that this one does not.** What is left here is the control plane and its UI, and nothing else. | Repository | What it holds | | ---------------- | ------------------------------------------------------------------------------------------------- | | `vantage-shared` | the private Go module below — `mail`, `license`, `models`, `provision`, `backup`, `grpc/pb`, … | | `vantage-admin` | Vantage HQ: the licensing authority (`server/`, was `admin/`) and its console (`web/`, was `adminsite/`) | | `vantage-site` | the marketing site (`web/`, was `site/`) and its contact-form service (`server/`, was `sitesvc/`) | | `vantage-docs` | the user documentation, at the repository root (was `docsite/`) | | `vantage-agent` | the agent, at the repository root (was `agent/`), and the Windows `installer/` | | `vantage-ctl` | `vantagectl`, the backup and restore CLI, at the repository root (was `vantagectl/`) | **None of the three is a build dependency of anything here**, and nothing here is a dependency of them. `vantage-site` and `vantage-docs` are wholly independent — the contact-form service stores nothing and reads no database, so the split cost nothing. `vantage-admin` is the only one with a live coupling, and there is still no import in either direction, deliberately (see "Grants project, they do not federate"). It reaches this codebase two ways at runtime, both by writing directly into the control plane's MongoDB: `inject` for three licence fields and `cloudprov` for instances and their owners. The parts of that contract this repository must honour are documented where they bite — `users.auth_source == "hq"` and `services.ErrHQManaged`, `POST /license` answering 409 `cloud_managed`, and `FREE_INSTANCE_REAP_AFTER` needing to match admin's value. The rest lives in that repository's own CLAUDE.md. **`shared/` is not in this repository either.** It is the private module `gitea.hostxtra.co.uk/vantage/vantage-shared`, and it holds `mail/` (the one email system: transport plus templates), `license/` (payload, sign, verify, trusted keys, plans), `models/` (Instance, User, Settings), `provision/`, `backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and `cmd/lkctl/`, and `proto/vantage/v1/vantage.proto`, which documents `grpc/pb` and moved there to sit beside it. **One** module here depends on it — `server` — pinning a version in its own `go.mod`, as do `vantage-admin`, `vantage-site`, `vantage-agent` and `vantage-ctl`. It was a directory in this repository until it was extracted with its history; the `replace ../shared` directives and the `./shared` entry in `go.work` are gone with it. **A version pin is now the coupling, and that is the point.** While it was a directory, every service in a given commit built against exactly one `shared/`, and a change there rebuilt several images at once whether or not they were ready for it. Now a service moves when somebody bumps its pin, which is a commit under that service's own directory — so the existing per-directory rebuild triggers already cover it, and there is no longer any way to ship a service against a `shared/` it was never built against. The cost is the obvious one: a fix in `vantage-shared` is live nowhere until each consumer's pin is bumped, and nothing in this repository will remind you. Every Go build now needs a credential for it — `GOPRIVATE=gitea.hostxtra.co.uk/*` plus a netrc. CI writes one per job from `REGISTRY_USER` + `RELEASE_TOKEN` (**that token needs read access to the `vantage` org, not only `mrhid6`**), and the four Go Dockerfiles take it as a **BuildKit secret** rather than a build arg, because an arg survives in the builder layer's history and this one is a Gitea token. Locally, either a netrc or `git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`. **`server` still builds from the repository root**, and only because its runtime stage copies `default_steps/`. It is the only image this repository builds from a context wider than one directory. `go.work` survives with a single `use ./server` entry. That looks pointless and is not: without it, a `go.work` further up the developer's filesystem is picked up instead and the build fails with `directory prefix . does not contain modules listed in go.work`. --- ## 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 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 `