diff --git a/claude.md b/claude.md index 9e637d5..9651849 100644 --- a/claude.md +++ b/claude.md @@ -1,34 +1,38 @@ # Vantage -A self-hosted SSH key management system. A central server (Go + Next.js + MongoDB) manages public key assignments across servers. A lightweight Go agent runs on each managed server, polls the central server via gRPC, and atomically rewrites `/root/.ssh/authorized_keys` to match the desired state. +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 Frontend │ -│ - Upload/manage keys │ -│ - Add servers (install script) │ -│ - Assign/revoke per server │ -└────────────┬────────────────────┘ - │ REST -┌────────────▼────────────────────┐ -│ Go Backend │ -│ - REST API for frontend │ -│ - gRPC server for agents │ -│ - MongoDB │ -└────────────┬────────────────────┘ - │ gRPC (TLS) -┌────────────▼────────────────────┐ -│ Go Agent (per server) │ -│ - Polls every 30s │ -│ - Rewrites authorized_keys │ -│ - Can generate SSH keypairs │ -└─────────────────────────────────┘ +┌──────────────────────────────────────────────┐ +│ 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 @@ -36,264 +40,290 @@ A self-hosted SSH key management system. A central server (Go + Next.js + MongoD ``` vantage/ ├── agent/ -│ ├── cmd/main.go +│ ├── cmd/main.go # flags: -generate-key │ └── internal/ -│ ├── config/ -│ ├── grpc/ -│ ├── keys/ -│ └── sync/ +│ ├── 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 for Next.js -│ ├── grpc/ # gRPC server implementation -│ ├── models/ # MongoDB models -│ └── services/ -│ ├── keys.go -│ ├── servers.go -│ └── sync.go # builds desired state per server +│ ├── 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/ -│ ├── app/ -│ └── components/ -├── proto/ -│ └── vantage/v1/vantage.proto -├── deploy/ -│ ├── docker-compose.yml -│ └── agent.service -└── .gitea/ - └── workflows/ - ├── agent-release.yml - └── server-deploy.yml +│ ├── app/(app)/ # authed routes +│ ├── app/login, app/setup # unauthed routes +│ ├── components/ # ui/, workflows/, monitors/, Sidebar +│ └── lib/ # api client, guac console, query client +├── 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. + +--- + +## Auth and Orgs + +- **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`. +- **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 `.vantage.` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s. + +Unique indexes on user email and org slug are a **security property**, not an optimisation: `GetUserByEmail` does an unscoped `FindOne`, so duplicates would let the OIDC cross-org guard compare against an arbitrary user. Same for duplicate settings docs and duplicate ESO token hashes. + +--- + ## gRPC API ```protobuf -syntax = "proto3"; -package vantage.v1; - service Vantage { - rpc Register(RegisterRequest) returns (RegisterResponse); - rpc SyncKeys(SyncRequest) returns (SyncResponse); - rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse); -} - -message RegisterRequest { - string server_id = 1; - string pre_reg_token = 2; - string hostname = 3; - string ip_address = 4; - string os_info = 5; -} -message RegisterResponse { - string agent_token = 1; -} - -message SyncRequest { - string server_id = 1; - string agent_token = 2; -} -message SyncResponse { - repeated string public_keys = 1; // full authorized_keys lines -} - -message UploadKeyRequest { - string server_id = 1; - string agent_token = 2; - string public_key = 3; - string label = 4; -} -message UploadKeyResponse { - string key_id = 1; + 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); } ``` -No streaming — polling only. Poll interval: **30 seconds**. +`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`. + +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) +``` --- ## MongoDB Collections -### `servers` +`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` -```json -{ - "_id": "ObjectId", - "server_id": "uuid", - "hostname": "proxmox-node-1", - "ip_address": "10.10.10.5", - "os_info": "Ubuntu 24.04", - "pre_reg_token": "abc123", - "pre_reg_expires": "ISODate", - "agent_token_hash": "sha256...", - "status": "pending|active|offline", - "last_seen": "ISODate", - "created_at": "ISODate" -} -``` +Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`. -- `pre_reg_token` is cleared after the agent successfully calls `Register()` -- `agent_token_hash` stores SHA-256 of the token — never plaintext -- `status` transitions: `pending` → `active` on first `Register()`, `offline` if last_seen exceeds threshold +Notes that are not obvious from the structs: -### `keys` +- `servers.agent_token_hash` stores SHA-256 of the token, never plaintext. `pre_reg_token` is cleared after `Register()`. `status` is `pending` → `active` on register, `offline` when `last_seen` passes the threshold (swept every 2 min). +- `servers.inventory` holds the latest metrics snapshot with separate `metrics_at` / `static_at` timestamps. +- `keys.private_key_enc` and `passphrase_enc` are AES-256-GCM; the JSON form exposes only `has_private_key` / `has_passphrase`. +- `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. -```json -{ - "_id": "ObjectId", - "key_id": "uuid", - "label": "dom-macbook", - "public_key": "ssh-ed25519 AAAA...", - "fingerprint": "SHA256:...", - "source": "uploaded|generated", - "generated_by_server_id": "uuid", - "created_at": "ISODate" -} -``` +### Migrations -### `assignments` +`services.RunMigrations()` runs at boot, recording markers in `migrations`: -```json -{ - "_id": "ObjectId", - "key_id": "uuid", - "server_id": "uuid", - "assigned_at": "ISODate", - "revoked_at": "ISODate | null" -} -``` +- `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` -- `revoked_at: null` = key is active on that server -- Revocation is soft — set `revoked_at`, agent picks it up on next poll +Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn. --- ## Agent Lifecycle -### Config file — `/etc/vantage/config.yaml` +### Config file + +Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. Directory `0700`, file `0600`. ```yaml server_url: "vantage.yourdomain.com:9090" server_id: "" -pre_reg_token: "" # removed after first successful Register() -agent_token: "" # written by agent after Register() +pre_reg_token: "" # removed after first successful Register() +agent_token: "" # written by agent after Register() poll_interval: 30s tls: true ``` -Config file permissions: `0600`. Config directory: `0700`. - -### Startup flow +### Startup ``` 1. Load config -2. If pre_reg_token present: - → call Register(server_id, pre_reg_token, hostname, ip, os_info) - → save returned agent_token to config - → delete pre_reg_token from config -3. Enter poll loop +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 (every 30s) +### Poll loop ``` -1. Call SyncKeys(server_id, agent_token) -2. Receive []public_keys -3. Compute fingerprints of current /root/.ssh/authorized_keys -4. If state unchanged → skip write -5. If changed: - → write to /root/.ssh/authorized_keys.tmp - → os.Rename() to /root/.ssh/authorized_keys (atomic) - → chmod 0600 +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 ``` -### Key generation (on demand) +### Install -- Triggered by a flag or API call from the server -- Runs `ssh-keygen` via `exec.Command` -- Uploads public key via `UploadGeneratedKey()` -- Private key stays local on the machine - -### Systemd unit — `/etc/systemd/system/vantage-agent.service` - -```ini -[Unit] -Description=Vantage Agent -After=network.target - -[Service] -ExecStart=/usr/local/bin/vantage-agent -Restart=always -RestartSec=10 -User=root - -[Install] -WantedBy=multi-user.target -``` +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 -1. Click **Add Server** in the UI -2. Backend generates a short-lived pre-registration token (TTL: 1 hour) and a `server_id` -3. UI displays a one-liner install command with copy button: - ```bash - curl -fsSL https://vantage.yourdomain.com/install | \ - bash -s -- --server-id= --token= - ``` -4. Install script: - - Detects arch (`amd64` / `arm64`) - - Downloads agent binary from Gitea release - - Verifies SHA-256 checksum - - Writes `/etc/vantage/config.yaml` - - Installs and starts systemd unit -5. On first `SyncKeys` call, server marks status as `active` +1. **Add Server** in the UI calls `POST /api/servers/new`, which generates a `server_id` and a pre-registration token (TTL 1 hour, single-use). +2. The UI shows a one-liner: + ```bash + curl -fsSL https://vantage.yourdomain.com/install | \ + bash -s -- --server-id= --token= + ``` + Windows gets the `/install.ps1` equivalent. +3. The script detects arch, downloads the agent from the Gitea release, verifies the SHA-256 checksum, writes the config, installs and starts the service. +4. The server flips to `active` on first sync. -The backend serves `/install` dynamically, injecting the latest agent version by querying the Gitea API for the most recent `agent/v*` release tag. +`/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 | + +`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. --- ## Security -- gRPC over TLS (Let's Encrypt or self-signed with cert pinning on the agent) -- Agent authenticates with a per-server token stored at `/etc/vantage/config.yaml` (`0600`) -- Server stores `SHA-256(agent_token)` — never the plaintext token -- Private keys generated by agents are encrypted at rest in MongoDB (AES-256) -- `authorized_keys` written as `0600`, owned by root -- Pre-registration tokens are short-lived (1 hour) and single-use -- Agent runs as `root` (required for `/root/.ssh/authorized_keys` writes) +- 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 `0600` config. +- 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 user email, org slug, settings org, and ESO token hash are load-bearing for tenant isolation. +- `authorized_keys` written `0600`, owned by root. The agent runs as root because it must. +- Every mutating API path writes an audit event. --- -## Frontend Routes +## Frontend -| Route | Purpose | -| --------------- | -------------------------------------------------------------------- | -| `/servers` | List all servers, online/offline status badge, last seen timestamp | -| `/servers/new` | Displays the one-liner install script with copy button | -| `/servers/[id]` | Keys installed on this server, trigger key generation, remove server | -| `/keys` | All keys — label, fingerprint, source, assigned count | -| `/keys/[id]` | Assign key to servers, revoke per server | +Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`. + +| 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 — `.gitea/workflows/agent-release.yml` +### `agent-release.yml` — triggered by `agent/v*` tags -Triggered by a `agent/v*` tag. Cross-compiles for `linux/amd64` and `linux/arm64`, creates a Gitea release with binaries and checksums. - -```yaml -on: - push: - tags: - - "agent/v*" -``` - -Build command: +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. ```bash GOOS=linux GOARCH=amd64 go build \ @@ -301,51 +331,47 @@ GOOS=linux GOARCH=amd64 go build \ -o dist/vantage-agent-linux-amd64 ./cmd ``` -Release assets: +### `server-deploy.yml` — triggered on push to `main` -- `vantage-agent-linux-amd64` -- `vantage-agent-linux-arm64` -- `checksums.txt` - -### Server deploy — `.gitea/workflows/server-deploy.yml` - -Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH: +Builds and pushes the `server` and `web` images to the Gitea container registry, then deploys over SSH: ```bash cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans ``` -### Tagging convention +### Tagging ```bash -# Release a new agent version -git tag agent/v1.0.0 && git push origin agent/v1.0.0 - -# Server + web deploy automatically on push to main -git push origin main +git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release +git push origin main # server + web deploy ``` -### Required Gitea secrets / variables +### Secrets / variables -| Name | Type | Value | -| ------------------- | -------- | ------------------------------------------ | -| `RELEASE_TOKEN` | Secret | Gitea API token with `write:release` scope | -| `REGISTRY_USER` | Secret | Gitea username | -| `REGISTRY_PASSWORD` | Secret | Gitea token with `write:packages` scope | -| `DEPLOY_HOST` | Secret | IP/hostname of the server VM | -| `DEPLOY_USER` | Secret | SSH user for deploy | -| `DEPLOY_SSH_KEY` | Secret | Private key for deploy SSH | -| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` | +| Name | Type | Value | +| --- | --- | --- | +| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` | +| `REGISTRY_USER` | Secret | Gitea username | +| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` | +| `DEPLOY_HOST` | Secret | server VM host | +| `DEPLOY_USER` | Secret | SSH user for deploy | +| `DEPLOY_SSH_KEY` | Secret | deploy private key | +| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` | --- ## Design Decisions -- **gRPC over REST for agent communication** — strong typing, easy versioning, bi-directional streaming available later if push-based updates are needed -- **Poll-only, no streaming** — 30s interval is sufficient for a homelab; simplifies agent implementation -- **Outbound-only agent connections** — no inbound firewall holes required on managed servers -- **Atomic `authorized_keys` rewrite** — write to `.tmp` then `os.Rename()` prevents partial writes -- **Fingerprint diffing before write** — avoids unnecessary disk writes on unchanged state -- **Soft revocation** — `revoked_at` timestamp rather than hard deletes; preserves audit history -- **root only** — manages `/root/.ssh/authorized_keys` only; no per-user key management -- **Gitea releases for agent binaries** — slots into existing act_runner CI pipeline; install script queries Gitea API for latest version at serve time +- **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_keys` rewrite** — temp file plus `os.Rename()`; a machine that dies mid-write keeps the old file. +- **Fingerprint diffing before write** — no disk churn on unchanged state. +- **Soft revocation** — `revoked_at` rather 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_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.