diff --git a/claude.md b/claude.md index 6d9b69c..fb7e163 100644 --- a/claude.md +++ b/claude.md @@ -92,41 +92,50 @@ vantage/ ## 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. + +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 `3001`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint. `sitesvc/` (port `8082`) owns both flows end to end: -| Form | Endpoint | Effect | -| --- | --- | --- | -| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. | -| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. | -| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to `APP_LOGIN_URL`. | +| Form | Endpoint | Effect | +| ------------------- | ------------------------- | ----------------------------------------------------------------------- | +| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. | +| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. | +| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to `APP_LOGIN_URL`. | All three 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. @@ -134,7 +143,7 @@ All three are deliberately **excluded from the self-hosted deployment**: `deploy # self-hosted install — no marketing site, no sitesvc docker compose up -d -# vantage.sh — control plane plus the public site +# vantage.hostxtra.co.uk — control plane plus the public site docker compose -f docker-compose.yml -f docker-compose.site.yml up -d ``` @@ -163,7 +172,7 @@ sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at - **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`. +- **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. @@ -274,8 +283,8 @@ Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. D ```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 ``` @@ -309,11 +318,11 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a 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. + ```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. @@ -323,33 +332,33 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a ## 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 | +| 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 | **sitesvc** (`deploy/docker-compose.site.yml` only): -| Name | Required | Notes | -| --- | --- | --- | -| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. | -| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it | -| `APP_LOGIN_URL` | no | where a verified owner is sent to sign in; without it they get a plain confirmation page | -| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (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 | +| Name | Required | Notes | +| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. | +| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it | +| `APP_LOGIN_URL` | no | where a verified owner is sent to sign in; without it they get a plain confirmation page | +| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (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 the public marketing site on `3001` and is only used on vantage.sh. +`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds the public marketing site on `3001` and is only used on vantage.hostxtra.co.uk. --- @@ -371,20 +380,20 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a 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 | +| 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 | --- @@ -420,16 +429,16 @@ 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 | sitesvc base URL, baked into the `site` image (contact form) | -| `SITE_CONTACT_EMAIL` | Variable | optional; mailto fallback address | +| 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 | --- diff --git a/deploy/docker-compose.site.yml b/deploy/docker-compose.site.yml index e27afd9..34f5190 100644 --- a/deploy/docker-compose.site.yml +++ b/deploy/docker-compose.site.yml @@ -1,12 +1,3 @@ -# Public marketing site and its backend. Deliberately kept out of -# docker-compose.yml so a self-hosted install never runs either of them: -# -# self-hosted: docker compose up -d -# vantage.sh: docker compose -f docker-compose.yml -f docker-compose.site.yml up -d -# -# sitesvc owns both public forms end to end. It shares MongoDB with the control -# plane — that is how a new tenant becomes visible to the app — but shares no -# code and no process with it. The control plane has no public signup endpoint. services: site: image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest diff --git a/site/Dockerfile b/site/Dockerfile index d94004d..ac80821 100644 --- a/site/Dockerfile +++ b/site/Dockerfile @@ -15,8 +15,11 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # Baked in at build time: NEXT_PUBLIC_* values are inlined into the client -# bundle. SITE_API points at sitesvc, which serves both forms. Leave it empty -# and contact falls back to mailto while signup reports it is unavailable. +# bundle. SITE_API is the browser-reachable URL of sitesvc, which serves both +# forms. It is effectively required: leave it empty and both forms report they +# are not connected rather than submitting anywhere. Must be an origin the +# browser can reach (not the internal sitesvc:8082) and be listed in sitesvc's +# SITE_ORIGIN for CORS. ARG NEXT_PUBLIC_SITE_API="" ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API diff --git a/site/app/contact/page.tsx b/site/app/contact/page.tsx index b80f043..b50a32e 100644 --- a/site/app/contact/page.tsx +++ b/site/app/contact/page.tsx @@ -2,61 +2,59 @@ import type { Metadata } from "next"; import { ContactForm } from "@/components/ContactForm"; export const metadata: Metadata = { - title: "Contact", - description: "Sales questions, self-hosted licensing, security disclosures and bug reports.", + title: "Contact", + description: "Sales questions, self-hosted licensing, security disclosures and bug reports.", }; const CHANNELS = [ - { - title: "Support", - body: "Everything else, including anything urgent.", - link: "support@hostxtra.co.uk", - href: "mailto:support@hostxtra.co.uk", - }, - { - title: "Security disclosure", - body: "Encrypted reports, acknowledged within 72 hours.", - link: "support@hostxtra.co.uk", - href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure", - }, - { - title: "Bugs and feature requests", - body: "Public tracker, read by the people who write the code.", - link: "git.vantage.sh/vantage", - href: "https://git.vantage.sh/vantage", - }, - { - title: "Status", - body: "Control plane uptime and incident history.", - link: "status.vantage.sh", - href: "https://status.vantage.sh", - }, + { + title: "Support", + body: "Everything else, including anything urgent.", + link: "support@hostxtra.co.uk", + href: "mailto:support@hostxtra.co.uk", + }, + { + title: "Security disclosure", + body: "Encrypted reports, acknowledged within 72 hours.", + link: "support@hostxtra.co.uk", + href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure", + }, + { + title: "Bugs and feature requests", + body: "Public tracker, read by the people who write the code.", + link: "git.vantage.hostxtra.co.uk/vantage", + href: "https://git.vantage.hostxtra.co.uk/vantage", + }, + { + title: "Status", + body: "Control plane uptime and incident history.", + link: "status.vantage.hostxtra.co.uk", + href: "https://status.vantage.hostxtra.co.uk", + }, ]; export default function ContactPage() { - return ( -
- Contact -

- Tell us what your fleet looks like. -

+ return ( +
+ Contact +

Tell us what your fleet looks like.

-
-
- -
+
+
+ +
-
-

Pick the right door and you will get a faster answer.

- {CHANNELS.map((channel) => ( -
-

{channel.title}

-

{channel.body}

- {channel.link} +
+

Pick the right door and you will get a faster answer.

+ {CHANNELS.map((channel) => ( +
+

{channel.title}

+

{channel.body}

+ {channel.link} +
+ ))} +
- ))} -
-
-
- ); +
+ ); } diff --git a/site/app/layout.tsx b/site/app/layout.tsx index 5c6df26..3094dd5 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -5,37 +5,35 @@ import { Nav } from "@/components/Nav"; import { ThemeScript } from "@/components/ThemeScript"; export const metadata: Metadata = { - metadataBase: new URL("https://vantage.sh"), - title: { - default: "Vantage — one control plane for the whole fleet", - template: "%s — Vantage", - }, - description: - "Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.", - openGraph: { - type: "website", - siteName: "Vantage", - title: "Vantage — one control plane for the whole fleet", - description: - "Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.", - }, - icons: { icon: "/images/vantage_logo.svg" }, + metadataBase: new URL("https://vantage.hostxtra.co.uk"), + title: { + default: "Vantage — one control plane for the whole fleet", + template: "%s — Vantage", + }, + description: "Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.", + openGraph: { + type: "website", + siteName: "Vantage", + title: "Vantage — one control plane for the whole fleet", + description: "Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.", + }, + icons: { icon: "/images/vantage_logo.svg" }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - Skip to content - -