# 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/ ├── 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/ # 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 ├── 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 │ └── 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/ # admin's boot-time shared/mail Sender │ └── 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/ # AppBar, PageHeader, PageFrame, InstanceRecord │ └── lib/ # api client, session guards, formatters ├── docsite/ # user documentation (Docusaurus, static) │ ├── docs/ # getting-started, vantage, hq, reference, operations │ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-* │ ├── sidebars.ts # authored by hand, not autogenerated │ └── nginx.conf # serves the build under /docs ├── shared/ # imported by server, sitesvc and admin │ ├── mail/ # the one email system: transport + tmpl templates │ ├── 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 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 `