refactor: move the public host out to vantage-site and vantage-docs

site/ and sitesvc/ become web/ and server/ in vantage-site; docsite/
becomes the root of vantage-docs. Their images move with them, to
vantage/vantage-site/{web,server} and vantage/vantage-docs.

Nothing here imported any of them, and sitesvc turned out to read no
database at all, so both cuts are clean. docker-compose.site.yml is
deleted rather than emptied: every service it held now ships with the
repository that builds it, and deploy/docker/docker-compose.yml is once
again exactly a self-hosted install.

Corrects four comments that named sitesvc for work it no longer does.
This commit is contained in:
2026-09-08 08:41:11 +00:00
parent 872699c38c
commit f9dec9b230
98 changed files with 135 additions and 34383 deletions
+7 -38
View File
@@ -6,8 +6,8 @@ on:
- main
# Manual runs rebuild everything: there is no "before" commit to diff
# against, which the change detection below treats as "build it all". That
# is also the escape hatch for a repo VARIABLE change — editing API_URL or
# ADMIN_ENV pushes no commit, so nothing would rebuild on its own.
# is also the escape hatch for a repo VARIABLE change — editing HQ_URL
# pushes no commit, so nothing would rebuild on its own.
workflow_dispatch:
jobs:
@@ -85,14 +85,13 @@ jobs:
# in the same push should not depend on the pin being bumped
# in that same push to trigger a rebuild.
flag server '^(server/|proto/|default_steps/|go\.work)'
flag sitesvc '^(sitesvc/|go\.work)'
# The three Next images and the docs site use their own
# directory as the build context, so nothing outside it can
# affect them.
# web/ uses its own directory as the build context, so
# nothing outside it can affect it. It is the only front end
# left here: the marketing site and the docs went to
# vantage-site and vantage-docs, the HQ console to
# vantage-admin.
flag web '^web/'
flag site '^site/'
flag docsite '^docsite/'
# vantage-shared is private, so every Go build below needs a
# credential for it. A netrc is written once here rather than a
@@ -156,33 +155,3 @@ jobs:
-f web/Dockerfile web/
docker push "$IMAGE"
- name: Build and push site image
if: steps.changed.outputs.site == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
docker build \
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
-t "$IMAGE" \
-f site/Dockerfile site/
docker push "$IMAGE"
- name: Build and push sitesvc image
if: steps.changed.outputs.sitesvc == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
docker build --secret id=netrc,src="$HOME/.netrc" \
-t "$IMAGE" -f sitesvc/Dockerfile sitesvc/
docker push "$IMAGE"
- name: Build and push docsite image
if: steps.changed.outputs.docsite == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/docsite:latest"
# DOCS_BASE_URL must match the proxy location that routes to
# this container and the directory the image serves from.
docker build \
-t "$IMAGE" \
-f docsite/Dockerfile docsite/
docker push "$IMAGE"
+120 -105
View File
@@ -68,36 +68,26 @@ vantage/
│ ├── 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
├── 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
├── 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
```
**Two repositories carry parts of Vantage that this one does not.**
**Three repositories carry parts of Vantage that this one does not.**
| 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-admin` is **not** a build dependency of anything here — there is no
import in either direction, deliberately (see "Grants project, they do not
**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
@@ -111,9 +101,9 @@ The rest lives in that repository's own CLAUDE.md.
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/`. Four modules here depend on it — `server`, `agent`, `sitesvc`,
`vantagectl` — each pinning a version in its own `go.mod`, as does
`vantage-admin`. It was a
`cmd/lkctl/`. Three modules here depend on it — `server`, `agent` and
`vantagectl` — each pinning a version in its own `go.mod`, as do
`vantage-admin` and `vantage-site`. 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.
@@ -136,9 +126,9 @@ 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/`.
**Two build contexts shrank as a result.** `sitesvc` and `vantagectl` build from
their own directory now; only `server` still builds from the repository root,
and only because its runtime stage copies `default_steps/`.
**`vantagectl` builds from its own directory as a result.** `server` still
builds from the repository root, and only because its runtime stage copies
`default_steps/`.
---
@@ -611,7 +601,7 @@ inserted in front. The same setting also decides the address recorded in
`vantagectl` is a standalone Go module (`vantagectl/`), not a subcommand of
`server`. It needs its own module rather than living inside `server`'s for the
same reason `admin` and `sitesvc` already do: `server` imports the rest of
same reason `admin` and `sitesvc` did before they left: `server` imports the rest of
`server`'s dependency graph, and `spf13/cobra` has no business in a process
that also terminates gRPC streams and serves the REST API. More to the point,
`vantagectl` has to run when the control plane **does not** — a backup or
@@ -760,46 +750,47 @@ reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
reference page has to work on an air-gapped install with no outbound access at
all — the same requirement licence verification already meets.
### Marketing site and sitesvc
### The public host
Every Paddle variable — `PADDLE_CLIENT_TOKEN`, `PADDLE_ENV`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` — now belongs to `vantage-admin` and is set there. None is read by anything in this repository.
`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.
The HQ console is no longer built here at all — it is `web/` in
`vantage-admin`, published as `3004` and 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`.
`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 `docsite` 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. So is Vantage HQ, which is now excluded by construction — it is a different repository with its own compose fragment.
### Documentation site
`docsite/` is the user-facing documentation — Docusaurus 3 in docs-only mode (`routeBasePath: "/"`, no blog), one version tracking `main`, search indexed at build time by `@easyops-cn/docusaurus-search-local` so nothing external is keyed or called. It documents the **product**, not the codebase: this file remains the contributor's map, and the two are allowed to differ in altitude but not in fact. Five sections — Getting started, Vantage, Vantage HQ, Reference, Operations — with `sidebars.ts` authored by hand so ordering is a decision rather than a filename accident.
Unlike the three Next apps it builds to static files, so its runtime stage is `nginx:alpine-slim` rather than Node, and it listens on `80`. See the compose note below for the `/docs` prefix, which is the one thing about it that is easy to get wrong.
**vantage.hostxtra.co.uk is not served by this repository.** The marketing site
and its contact-form service are `vantage-site`; the documentation at `/docs` is
`vantage-docs`; the HQ console at `vantage-hq.hostxtra.co.uk` is
`vantage-admin`. Each carries its own compose fragment, and the host composes
them on top of this one:
```bash
# self-hosted install — no marketing site, no sitesvc
docker compose up -d
# self-hosted install — the control plane and nothing else
docker compose -f deploy/docker/docker-compose.yml 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
# vantage.hostxtra.co.uk — every repository's fragment together
docker compose \
-f vantage/deploy/docker/docker-compose.yml \
-f vantage-site/deploy/docker-compose.yml \
-f vantage-docs/deploy/docker-compose.yml \
-f vantage-admin/deploy/docker-compose.yml \
up -d
```
`docker-compose.site.yml` is gone from this repository: every service it held
now lives with the repository that builds it. The self-hosted exclusion used to
be a rule about which file a service went in; it is the repository boundary now.
**The reverse proxy in front is shared and belongs to none of them.** On
vantage.hostxtra.co.uk that is an Nginx Proxy Manager, and its routing spans
repositories: `/docs` to `vantage-docs` — a location that must sort **above**
the catch-all — and everything else on that host to `vantage-site`. A
self-hosted install needs its own; see the compose note below for what it must
route.
One coupling survives the split and is easy to miss: the marketing site's
`/start` form posts account signups **straight to `vantage-admin`**, not to
anything here. The control plane is not touched until the customer later creates
a cloud instance from the portal.
### 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.
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; the contact-form service has no signup flow at
all, and lives in another repository besides.
- 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`).
@@ -822,16 +813,17 @@ left unspent.
### Email
`shared/mail` is the only email system. It owns the SMTP conversation, the RFC
5322 envelope and the look of every message; `server`, `admin` and `sitesvc`
each import it and none of them builds a subject line, a MIME part or a colour.
5322 envelope and the look of every message; the control plane, `vantage-admin`
and `vantage-site` each import it and none of them builds a subject line, a MIME
part or a colour.
Before this existed the transport was copied three times, and the copies had
already diverged once — the 465-implicit-TLS fix landed in one of them while
the others silently delivered nothing.
`Sender` is a value, not a singleton: `server/internal/notify` builds one per
notification channel from the channel document in Mongo, while `sitesvc` builds
one at boot and `admin` holds one in `admin/internal/mail.Default`, alongside
its other boot-time singletons. Callers only ever see typed methods —
notification channel from the channel document in Mongo, while `vantage-site`'s
service builds one at boot and `vantage-admin` holds one in its own
`internal/mail.Default`, alongside its other boot-time singletons. Callers only ever see typed methods —
`SendVerification`, `SendExpiring`, `SendMonitorAlert`, `SendEnquiry` and the
rest, grouped by owner into `account.go`, `licence.go`, `billing.go`,
`monitor.go` and `contact.go`.
@@ -1169,19 +1161,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit |
| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale |
| `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 |
| `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` on vantage.hostxtra.co.uk only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
### Ingress (Helm, Traefik)
@@ -1193,7 +1173,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `ingress.api.paths` (when `api.enabled`) | `/api`, `/auth` → `<release>-server:8080`, bypassing the Next proxy |
| `ingress.grpc.host` | agents → a dedicated `<release>-server-grpc` Service on 9090, annotated `serversscheme: h2c` |
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site, which lives in `vantage-site` and which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
@@ -1207,11 +1187,9 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all.
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds three more — `site` (3003), `sitesvc` (8082) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk. **Vantage HQ is a third file**, `deploy/docker-compose.yml` in the `vantage-admin` repository, which adds `admin` (8083) and `adminsite` (3004); the host composes all three together.
`deploy/docker/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. **That is the whole of a self-hosted install**, and it is now the only compose file here. vantage.hostxtra.co.uk adds three fragments from three other repositories — `vantage-site` (`site` 3003, `sitesvc` 8082), `vantage-docs` (`docsite` 3005) and `vantage-admin` (`admin` 8083, `adminsite` 3004) — composed together as shown under "The public host".
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
`LICENSE_SIGNING_KEY` appears in **no compose file in this repository**, and must never be added to one: admin is the only signer, and it now lives in `vantage-admin` along with its own compose fragment. Neither `docker-compose.yml` nor `docker-compose.site.yml` should ever mention `admin` or `adminsite` again — the separation used to be a rule someone had to remember, and is now the repository boundary. `server` reads `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD` so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it.
`LICENSE_SIGNING_KEY` appears in **no compose file in this repository**, and must never be added to one: admin is the only signer, and it now lives in `vantage-admin` along with its own compose fragment. `docker-compose.yml` should never mention `admin` or `adminsite` again — the separation used to be a rule someone had to remember, and is the repository boundary now. `server` reads `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD` so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it.
---
@@ -1235,28 +1213,60 @@ Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client
All four apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
| App | Ground | Accent | Themes |
| ------------ | -------------------------- | -------------------------------- | --------------------------- |
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `adminsite/`\* | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| App | Ground | Accent | Themes |
| --------------- | -------------------------- | -------------------------------- | --------------------------- |
| `web/` (here) | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `vantage-site` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `vantage-admin` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| `vantage-docs` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
\* `adminsite/` is `web/` in the **`vantage-admin` repository** now, and is listed here because the palette is one system across all four apps regardless of which repository they sit in.
Only the first row is in this repository. The other three are listed because
the palette is one system across all four front ends regardless of which
repository they sit in.
`docsite/src/css/custom.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values — and so does `vantage-admin/web/app/globals.css`. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically** — and one of those four is now in another repository, so "the same commit" is no longer even possible. That makes this worse than it was, not better: the drift window is however long it takes to push twice.
`vantage-site`'s `web/app/globals.css` is the **origin**: it is the only one
carrying both light and dark values in full, and the other three copy its token
blocks **verbatim** — same names, same values. `web/` here holds the same tokens
but only the **dark** values, since it does not switch. **Nothing enforces the
match, and the four now sit in four repositories, so "change them in the same
commit" is not merely unenforced but impossible.** The drift window is however
long it takes to push four times. Treat a token change as an announcement
rather than a refactor.
`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token.
`vantage-docs` is the one place the tokens are not consumed through Tailwind:
everything below its token block maps Docusaurus's `--ifm-*` variables onto
them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector
the dark block keys on, so the built-in toggle needed no wiring. The rule holds
all the same — no rule in that file outside the token blocks carries a hex. Its
one concession is a favicon, which must, for the same reason the email layout
must: a browser tab cannot read a token.
There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex.
Tailwind in all of them maps `var(--…)` references only, so **no component in
any of them may carry a hex value**. The names differ per app on purpose,
because each has its own subject: `vantage-site` calls the semantic three
`--up`/`--pend`/`--down` for monitor state, `vantage-admin` aliases them to
`valid`/`warn`/`expired` for licence state, and `web/` here to
`success`/`warning`/`danger`. Same colours, honest names on each side.
Tailwind in all of them maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
`web/` is locked to dark and the HQ console defaults to **light**, and that
pairing is the point: an operator with both open should never mistake one for
the other before clicking Reissue. Now that both are drawn from the same palette
the distinction rests **entirely** on the ground, so do not make dark the HQ
console's default and do not give `web/` a light theme. State never reads by
colour alone in either: every pill carries a distinct shape and a text label.
`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `<alpha-value>` only compiles against channels. Keep the hex comments — they are what lets the three token blocks still be diffed by eye. `web/` also adds three tokens site/ has no use for: `--accent-hover` and `--down-hover` (site/ brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface.
There is a **fifth** copy, and it is the one people forget:
`shared/mail/templates/layout.html.tmpl` in `vantage-shared` carries `web/`'s
dark values as literal hex. Email clients support neither `var()` nor a reliable
`prefers-color-scheme`, so the token indirection is simply not available there —
an email is read before the recipient clicks through to the control plane, and
the two should not look like different products.
`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `<alpha-value>` only compiles against channels. Keep the hex comments — they are what lets the four token blocks still be diffed by eye, which matters more now that they cannot be diffed by `git`. `web/` also adds three tokens the marketing site has no use for: `--accent-hover` and `--down-hover` (it brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface.
`web/` is locked to dark and the HQ console defaults to **light**, and that pairing is the point: an operator with both open should never mistake one for the other before clicking Reissue. Now that both are drawn from the same palette the distinction rests **entirely** on the ground, so do not make dark the HQ console's default and do not give web/ a light theme. State never reads by colour alone in either: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either.
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to the shared 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
The HQ console's own shell, its `/staff/pricing` page and the catalogue coverage
ledger are documented in `vantage-admin`. They are still built from these
@@ -1314,13 +1324,17 @@ GOOS=linux GOARCH=amd64 go build \
### `server-deploy.yml` — triggered on every push to `main`
Builds and pushes five images to the Gitea container registry: `server`, `web`, `site`, `sitesvc` and `docsite`. **`admin` and `adminsite` are no longer among them** — they are built by `vantage-admin`'s own workflow, as `vantage/vantage-admin/server` and `vantage/vantage-admin/web`. **`vantagectl` is deliberately not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
Builds and pushes **two** images to the Gitea container registry: `server` and `web`. Everything else that used to be built here now belongs to the repository that owns it — `vantage-site`, `vantage-docs` and `vantage-admin` each publish their own. **`vantagectl` is also not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
```bash
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
# self-hosted
cd /opt/vantage && docker compose -f deploy/docker/docker-compose.yml pull && \
docker compose -f deploy/docker/docker-compose.yml up -d --remove-orphans
# vantage.hostxtra.co.uk — all four repositories' fragments, see "The public host"
```
**Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly:
@@ -1328,8 +1342,7 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
| Image | Rebuilds when |
| ---------------------------- | -------------------------------- |
| `server` | `server/`, `proto/`, `go.work` |
| `sitesvc` | `sitesvc/`, `go.work` |
| `web` · `site` · `docsite` | their own directory only |
| `web` | `web/` only |
**No path in this table names `shared/` any more**, and no fan-out rule replaces
it: `vantage-shared` is an external module pinned per service, so a service
@@ -1384,14 +1397,16 @@ git push origin main # server + web deploy
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. |
| `SITE_URL` | Variable | browser URL of the marketing site. Also set in `vantage-admin`, where it is baked into the console so `/login` can point at `/start`. **Signup has no page in the console at all** — one signup form, on `site/`. |
| `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 the `site` image — `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. `vantage-admin` sets a variable of the same name for its own console; they must agree. |
| `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. |
| `DOCS_URL` | Variable | site `url` baked into `docsite`; `https://vantage.hostxtra.co.uk`. Empty falls back to that default rather than breaking the build. |
| `DOCS_BASE_URL` | Variable | `/docs/`. Must match the NPM location and the directory the image serves from — all three, or the HTML loads and every asset 404s. |
| `APP_URL` | Variable | control-plane link in `docsite`'s navbar. |
`SITE_URL`, `SITE_API_URL`, `SITE_CONTACT_EMAIL`, `ADMIN_API_URL`, `ADMIN_ENV`,
`DOCS_URL`, `DOCS_BASE_URL`, `APP_URL` and every `PADDLE_*` name are set on the
repository that bakes them in — `vantage-site`, `vantage-docs` or
`vantage-admin` — and none of them is read by anything here. Two are set in
**two** repositories and must agree: `ADMIN_API_URL` (`vantage-site` bakes it
into the marketing site's signup form, `vantage-admin` into its own console) and
`SITE_URL`.
---
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -15,8 +15,8 @@ The web host is normally a wildcard — `*.vantage.example.com` — because that
the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
label. Kubernetes wildcard hosts match exactly one label, so this does not match
the apex, and on the deployment this chart was written for it must not: the apex
is the marketing site, a separate application (see site/ and
docker-compose.site.yml). extraHosts exists for a genuine second name, not for
is the marketing site, a separate application in the vantage-site repository.
extraHosts exists for a genuine second name, not for
reclaiming the apex.
Agents reach the server's gRPC port, which is plain h2c — the server holds no
-46
View File
@@ -1,46 +0,0 @@
services:
site:
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
restart: unless-stopped
ports:
- 3003:3000
depends_on:
- sitesvc
sitesvc:
image: gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc:latest
restart: unless-stopped
ports:
- 8082:8082
environment:
PORT: "8082"
PUBLIC_URL: ${PUBLIC_URL:-}
SITE_ORIGIN: ${SITE_ORIGIN:-}
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
SMTP_TO: ${SMTP_TO:-support@hostxtra.co.uk}
# admin and adminsite are NOT here. They live in the vantage-admin
# repository, which carries its own fragment; the host composes all three
# files together:
#
# docker compose \
# -f vantage/deploy/docker/docker-compose.yml \
# -f vantage/deploy/docker/docker-compose.site.yml \
# -f vantage-admin/deploy/docker-compose.yml \
# up -d
#
# LICENSE_SIGNING_KEY went with them, and must never appear in this file:
# admin is the only signer and the control plane must not hold the key.
# Static docs, served by nginx at vantage.hostxtra.co.uk/docs through its own
# proxy location. That location must sort ABOVE the catch-all forwarding to
# site:3003, or Next serves its own 404 for /docs. The container serves from
# /usr/share/nginx/html/docs because the proxy forwards the full path.
docsite:
image: gitea.hostxtra.co.uk/mrhid6/vantage/docsite:latest
restart: unless-stopped
ports:
- 3005:80
networks: {}
-5
View File
@@ -1,5 +0,0 @@
node_modules
build
.docusaurus
.git
.gitignore
-4
View File
@@ -1,4 +0,0 @@
node_modules
build
.docusaurus
.cache-loader
-40
View File
@@ -1,40 +0,0 @@
# Build stage
FROM node:26-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
# Baked in at build time. DOCS_BASE_URL must agree with three things at once:
# the Nginx Proxy Manager location that routes to this container, the directory
# the runtime stage serves from below, and this value. When they disagree the
# HTML loads and every stylesheet and script 404s.
ARG DOCS_URL="https://vantage.hostxtra.co.uk"
ARG DOCS_BASE_URL="/docs/"
ARG APP_URL="https://vantage.hostxtra.co.uk"
ARG HQ_URL="https://vantage-hq.hostxtra.co.uk"
ENV DOCS_URL=$DOCS_URL
ENV DOCS_BASE_URL=$DOCS_BASE_URL
ENV APP_URL=$APP_URL
ENV HQ_URL=$HQ_URL
RUN npm run build
# Runtime stage
#
# Docusaurus emits a fully static site, so unlike web/ and site/
# there is no Node server at runtime. alpine-slim is roughly a quarter the size
# of caddy:alpine, and nothing here needs automatic TLS — the host proxy
# terminates it.
FROM nginx:alpine-slim AS runner
# NPM forwards the FULL request path upstream; it does not strip the /docs
# prefix. Serving from a matching subdirectory means prefix, asset URLs and
# upstream paths agree with no rewrite rule to keep in step.
COPY --from=builder /app/build /usr/share/nginx/html/docs
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
@@ -1,76 +0,0 @@
---
id: claim-free-licence
title: Claim a Free licence
sidebar_label: Claim a Free licence
---
A self-hosted install stays read-only until you give it a licence. You claim a
Free one from Vantage HQ, and it takes a couple of minutes.
:::warning An unlicensed install is read-only
You can sign in and look around, but adding servers, keys, workflows and
everything else is refused until a licence is installed. Do this before
[adding your first server](./first-server.md).
:::
## What a licence is
A signed file that names your instance, its tier, how many servers you may
manage, which features are enabled and when it expires. Your install checks the
signature itself, so it never has to reach Vantage HQ to work.
:::info One Free licence per account, per deployment
A Free cloud instance does not use up your Free self-hosted one. They are
separate.
:::
## 1. Find your instance ID
Open the **Licence** page from the sidebar of your install. The instance ID is
shown at the top, and it is the value Vantage HQ asks for.
## 2. Create the instance in Vantage HQ
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
account yet, see [Accounts and signup](../hq/accounts-and-signup.md).
2. On **Overview**, choose **License my own install**. Once you already have an
instance, the same page offers **Buy a plan** instead.
3. Choose **Self-hosted**, then the **Free** plan.
4. Paste your instance ID, give the instance a name you will recognise, and
click **Create licence**.
The new instance now appears on the **Overview** page.
## 3. Download the licence
Expand the new instance on **Overview** and click **View Instance Settings**.
From there, use **Download licence** or **Copy to clipboard**.
## 4. Install the licence
Back in your install, open the **Licence** page, paste the licence and save.
Your instance confirms the licence was issued to it, then shows your tier,
server allowance and expiry date.
:::info Cloud instances need none of this
A cloud instance is licensed automatically when it is created. These steps are
for self-hosted installs only.
:::
## Renewing
Free licences run for a year. The renew button appears in Vantage HQ seven days
before expiry and stays available after it, so a lapsed instance can still be
rescued. See [Free tier](../hq/free-tier.md).
## Moving the install to new hardware
A rebuilt install gets a new instance ID, and a licence only works for the ID it
was issued to. Use **Relink** in Vantage HQ to move the licence across. You get
three relinks per term, and the portal shows how many are left.
## Next
- [Add your first server](./first-server.md)
- [Licensing and entitlements](../hq/licensing-and-entitlements.md)
- [Buying a paid self-hosted licence](../hq/self-hosted-instances.md)
@@ -1,65 +0,0 @@
---
id: cloud-vs-self-hosted
title: Cloud or self-hosted
sidebar_label: Cloud or self-hosted
---
Vantage runs in two ways. It is the same software; what differs is who runs it,
and how licensing and user accounts work.
## At a glance
| | Cloud | Self-hosted |
| --------------------- | ------------------------------------------- | ------------------------------ |
| Who runs it | We do | You do |
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
| Database and backups | Ours | Yours |
| Licence | Installed for you | You paste it in |
| Team members | Granted from Vantage HQ | Created in the instance itself |
| Free tier | Yes, one per account | Yes, one per account |
| Expired Free instance | Eventually deleted, after warning emails | Never deleted |
## Cloud
You create an instance from the Vantage HQ portal and it is ready seconds later,
already licensed. People you grant access to get a real account inside that
instance, but Vantage HQ owns their password and role. See
[People and roles](../hq/people-and-roles.md).
Your instance keeps working whether or not Vantage HQ is reachable. Signing in
and managing servers never depend on it.
Cloud instances on the Free tier are deleted some time after their licence
expires, with warning emails first. See [Free tier](../hq/free-tier.md).
## Self-hosted
You run Vantage with Docker Compose on your own infrastructure. It needs no
connection to us at runtime, because a licence is a signed file your install
checks for itself.
Two ways to get one:
1. **Free.** Link the install to a Vantage HQ account and claim it. See
[Claim a Free licence](./claim-free-licence.md).
2. **Paid.** Buy from Vantage HQ, then paste your install's instance ID to have
the licence issued. See
[Self-hosted instances](../hq/self-hosted-instances.md).
People who sign in to a self-hosted install are created in the install itself,
either with a password or through single sign-on. Vantage HQ cannot add them for
you.
## Which should you pick
Pick cloud if you want it running today and would rather not run a database.
Pick self-hosted if your policy requires the control plane inside your own
network, or the servers you manage cannot reach the internet.
Moving between the two means migrating your data, and a new licence, since a
licence is tied to one instance.
## Next
- [Self-hosted install](./self-hosted-install.md)
- [Accounts and signup](../hq/accounts-and-signup.md), if you are going cloud
@@ -1,87 +0,0 @@
---
id: first-login
title: First login
sidebar_label: First login
---
A fresh install has no users and no instance. The first visit creates both.
## 1. Create the first account
Open your Vantage address in a browser. Because no user exists yet, you land on
the setup page.
Fill in:
| Field | Notes |
| ---------------- | --------------------------------------------------------- |
| Instance name | Also used to derive your instance's own subdomain |
| Owner email | Becomes your sign-in identity |
| Password | At least 8 characters |
| Confirm password | Must match |
**Setup Instance** creates the instance and makes you its **owner**.
:::warning Your instance gets its own address
If you installed on a name like `vantage.example.com`, an instance called Acme
signs in at `acme.vantage.example.com`, and each instance keeps its own sign-in.
Make sure DNS and your reverse proxy cover that subdomain, or use a wildcard.
:::
:::warning Setup runs exactly once
It is only available while the database has no users. Once yours exists, the
page closes for good, so record the email and password before you continue.
:::
## 2. Copy the instance ID
The confirmation page, headed **Instance created**, shows your instance ID and
your sign-in address. You need that ID to claim a licence in Vantage HQ, and it
is the reference support works from. You can find it again later on the
**Licence** page in the sidebar.
## 3. Sign in
Click **Go to sign in**, then sign in with the email and password you just set.
## 4. Look around
You land on the servers page, which is empty. The sidebar is the whole product:
| Section | What it does |
| --------------- | ------------------------------------------------ |
| Servers | Your fleet: enrol, inspect, console, update |
| Monitors | HTTP, TCP, ping and certificate checks |
| Vulnerabilities | Known security issues in installed packages |
| Workloads | Containers and services running on your servers |
| SSH Keys | Public keys and which servers they are on |
| Secrets | The encrypted vault |
| Workflows | Compose and run scripted work |
| Steps | The reusable step library |
| Audit Log | A record of everything that changed |
| Licence | Your tier, allowance and expiry |
| Settings | People, sign-in, alerts and integrations |
**Licence** and **Settings** are shown only to owners and admins.
## 5. Install your licence
Until a licence is installed, the instance is read-only: you can look, but you
cannot add servers or anything else. Continue with
[Claim a Free licence](./claim-free-licence.md).
## 6. Add the rest of your team
Go to **Settings → Access** and add people with a role:
| Role | Can |
| -------- | ---------------------------------------------------- |
| `owner` | Everything |
| `admin` | Everything except owner-only settings |
| `member` | Day-to-day work: servers, keys, workflows, monitors |
Changing settings, and adding or removing people, needs `owner` or `admin`.
If you would rather not manage passwords, you can use single sign-on instead,
which is available on paid plans. See
[Settings](../vantage/settings.md#single-sign-on).
@@ -1,105 +0,0 @@
---
id: first-server
title: Add your first server
sidebar_label: Add your first server
---
Enrolling a server means running one command on it. Vantage issues a short-lived
token, the install script fetches the agent and writes a config file, and the
machine registers itself.
:::info You need a licence first
An unlicensed install is read-only, so **Add server** will be refused until a
licence is in place. If you have not done that yet, start with
[Claim a Free licence](./claim-free-licence.md).
:::
## 1. Create the enrolment
Go to **Servers → Add server**, then click **Generate install command**. Vantage
creates a server record and an enrolment token for it.
:::warning The token is single-use and lasts one hour
It is the only credential in the flow, and it is spent the moment the agent
registers. If it expires, generate a new command rather than reusing the old one.
:::
## 2. Run the one-liner
### Linux
Run the generated command as root. It looks like this:
```bash
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
```
The script:
1. Checks the architecture. Only `x86_64` and `aarch64` are supported.
2. Downloads the agent and verifies its SHA-256 checksum, stopping on a mismatch.
3. Installs the agent to `/usr/local/bin/vantage-agent`.
4. Writes `/etc/vantage/config.yaml` with the server ID, the enrolment token and
the address the agent connects to.
5. Installs and starts the `vantage-agent` systemd service.
### Windows
Run this from an elevated PowerShell prompt:
```powershell
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
```
It writes the config to `%ProgramData%\vantage\config.yaml`, installs the agent
as a Windows service and starts it.
:::info Windows servers do not get SSH key management
Windows agents register, heartbeat, report inventory, run workflow steps,
serve the browser console, check and apply OS updates, and report workloads
(services and containers). Managing `authorized_keys` is a Linux-only
feature, and so is package inventory and CVE scanning — the vulnerability
feeds this project uses carry no Windows data.
:::
## 3. Watch it come up
The server appears as `pending` straight away, and becomes `active` within about
30 seconds.
On Linux you can watch the agent itself:
```bash
systemctl status vantage-agent
journalctl -u vantage-agent -f
```
## 4. Confirm it works
Open the server's page. Within a minute or two you should see:
- Status `active`, with a recent last-seen time.
- Inventory: CPU, memory, swap, partitions and kernel. Metrics refresh every 30
seconds, and the fuller snapshot every 15 minutes.
- Any pending OS updates, which the agent checks for hourly.
## If it does not appear
| Symptom | What to check |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| Script stops at "Unsupported architecture" | The machine is not 64-bit x86 or ARM |
| "Checksum mismatch!" | An interrupted download, or a proxy rewriting the response. Run it again |
| "Could not determine latest agent version" | The machine cannot reach the release host, or `GITEA_HOST` is not set on your control plane |
| Service runs, server stays `pending` | The machine cannot reach the agent port. Test it from that machine, not from the control plane |
| Registers, then goes `offline` | A firewall or proxy allows the first connection but drops the long-lived one |
| "Server limit reached" | Your licence allowance is full. Raise it in Vantage HQ, or remove a server you no longer manage |
A server is marked `offline` once it has not been seen for a while, and that
check runs every couple of minutes, so give it a moment before concluding
anything.
## Next steps
- [Assign an SSH key](../vantage/ssh-keys.md)
- [Run a workflow](../vantage/workflows.md)
- [Watch something with a monitor](../vantage/monitors.md)
@@ -1,154 +0,0 @@
---
id: self-hosted-install
title: Install Vantage (self-hosted)
sidebar_label: Self-hosted install
---
This puts the control plane on a host you own. Budget about fifteen minutes.
## Before you start
You need:
- A Linux host with **Docker** and the **Compose plugin**.
- A DNS name pointing at that host. People use it for the web UI, and your
agents use it too.
- A reverse proxy in front of Vantage that terminates TLS. It needs to handle
both the web UI and the agent port, `9090`, which speaks HTTP/2.
- Those two ports reachable: the web port from wherever your people are, and
`9090` from every machine you intend to manage.
- Outbound access from the control plane, and from each managed machine, to
`gitea.hostxtra.co.uk`, which serves the agent downloads.
The stack brings MongoDB, Redis and the console daemon with it, so there is no
database to provide.
## 1. Get the Compose file
```bash
mkdir -p /opt/vantage && cd /opt/vantage
curl -fsSLO https://gitea.hostxtra.co.uk/mrhid6/vantage/raw/branch/main/deploy/docker/docker-compose.yml
```
## 2. Write the environment file
Create `/opt/vantage/.env`:
```bash
# The host:port your agents connect to. This is not the web URL;
# this port speaks gRPC.
GRPC_HOST=vantage.example.com:9090
# 32 bytes as 64 hex characters. Generate it with the command below.
KEY_ENCRYPTION_KEY=
# The host serving agent downloads.
GITEA_HOST=gitea.hostxtra.co.uk
```
Generate the encryption key:
```bash
openssl rand -hex 32
```
Then make sure the `server` service passes `GITEA_HOST` through, by adding this
line to its `environment:` block in `docker-compose.yml`:
```yaml
GITEA_HOST: ${GITEA_HOST}
```
Without it, the install command you hand to a new server cannot work out which
agent to download.
:::danger Keep the encryption key safe
`KEY_ENCRYPTION_KEY` encrypts SSH private keys, vault secrets, single sign-on
client secrets and console credentials. If you lose it, all of those become
unreadable and there is no way to recover them. Back it up somewhere other than
the server it protects, and do not change it once the install is in use.
:::
:::warning `GRPC_HOST` has no default
The server will not start without it. There is deliberately no fallback to your
web address, because that port does not speak the protocol agents use, and the
mistake would only show up later as every agent failing to connect.
:::
## 3. Start the stack
```bash
docker compose up -d
docker compose ps
```
Five services start: `mongo`, `redis`, `guacd`, `server` and `web`.
Check the server got through startup:
```bash
docker compose logs -f server
```
On first boot it prepares the database and loads the built-in workflow step
library. If it stops during that, it will say why, and it is meant to stop
rather than run in a half-prepared state.
## 4. Put a proxy in front
Terminate TLS at your reverse proxy and route **one hostname to two backends**:
| Path | Backend |
| -------------------------------------------------------------------------------- | ------------- |
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
| everything else | `web:3000` |
Both rules are required. The web app forwards nothing to the API, so a proxy
that sends the whole hostname to `web:3000` serves the interface and answers
`404` to every request it makes — starting with the login form.
Agents connect to port `9090`. Vantage does not terminate TLS itself, so put
that port behind your proxy too, with a certificate valid for the name in
`GRPC_HOST`. The proxy must speak HTTP/2 through to Vantage. Many do not do so
by default, and the symptom is agents that register once and then stop
responding.
For a private network where TLS is not required, you can instead set
`tls: false` in each [agent's config](../reference/agent-config.md) and let
agents reach the port directly.
## 5. First sign-in
Open your hostname in a browser. With no users in the database yet, you are sent
to the setup page.
Continue with [First login](./first-login.md).
## Verifying the install
| Check | Expected |
| ---------------------------------------------- | ------------------------------- |
| `docker compose ps` | five services `running` |
| `curl -s localhost:8080/auth/bootstrap-status` | JSON saying bootstrap is needed |
| `nc -z your-host 9090` | open |
| `docker compose logs server` | no fatal errors |
## Common install problems
**The server exits immediately.** Almost always a missing `GRPC_HOST`. The log
names it.
**Agents register but never go active.** They reached port `9090` once but
cannot hold the connection, or your proxy is not passing HTTP/2 through. Test
from the managed machine, not from the control plane host.
**Secrets pages show an error.** `KEY_ENCRYPTION_KEY` is empty or is not 64 hex
characters.
More in [Troubleshooting](../reference/troubleshooting.md).
## What is not included
The marketing site, the Vantage HQ portal and this documentation site are hosted
by us. A self-hosted install runs none of them, and it never holds the key that
signs licences.
@@ -1,56 +0,0 @@
---
id: what-is-vantage
title: What is Vantage
sidebar_label: What is Vantage
---
Vantage manages a fleet of servers from one place. It began as SSH key
management and grew outwards: key assignment, scripted workflows, service
monitoring, a secrets vault, a browser console and OS update management.
## The pieces
```mermaid
flowchart TD
W["Web UI<br/>servers · keys · workflows · monitors<br/>secrets · audit · console · settings"]
S["Vantage server<br/>the control plane"]
A["Agent<br/>one per managed server<br/>Linux and Windows"]
W -->|you sign in here| S
S -->|sends work| A
A -.->|connects outbound| S
```
**The server** is the control plane. It holds all your data and makes all the
decisions.
**The agent** is a single small program running on each managed server. It asks
the control plane what it should be doing, and holds an open connection so
Vantage can send it work without waiting.
**The web UI** is what you use. Everything it can do goes through the same API
that enforces your permissions, so nothing is possible in the UI that would not
be permitted elsewhere.
## How agents connect
The agent always connects **outbound**. There is no listener on a managed
server, no port to open and no NAT to work around. If the machine can reach your
Vantage address, it can be managed.
That is why you tell Vantage its own agent address (`GRPC_HOST`) when you install
it: the agent has to be given an address it can reach, and Vantage cannot guess
one for you.
## Keeping things current
| What | How it works |
| ------------------------- | ------------------------------------------------------------------------- |
| SSH keys on a server | The agent checks every 30 seconds and only writes when something changed |
| Workflow steps, updates | Sent to the agent straight away, so clicking Run does not wait for a check |
| Inventory | Reported every 30 seconds, with a fuller snapshot every 15 minutes |
| Pending OS updates | Checked hourly |
## Next
- [Cloud or self-hosted](./cloud-vs-self-hosted.md), to pick which one you want
- [Self-hosted install](./self-hosted-install.md), to stand it up
-63
View File
@@ -1,63 +0,0 @@
---
id: accounts-and-signup
title: Accounts and signup
sidebar_label: Accounts and signup
---
[Vantage HQ](https://vantage-hq.hostxtra.co.uk) is where you manage the **account**, your team, your instances, their licences and billing.
## An account is a team, not a person
One account holds many people and many instances. Everyone in it has an account
role:
| Role | Can |
| -------- | ------------------------------------------------------ |
| `owner` | Everything, including billing |
| `admin` | Invite people, create instances, grant instance access |
| `member` | Read what the account holds |
Reading is open to any signed-in member. Every mutation except changing your own
password requires `owner` or `admin`. Billing is owner-only.
These are the same three words your instances use, but they are separate things.
Your account role controls what you can do in the portal. Your role inside an
instance controls what you can do there.
## Signing up
Signup is **account-first**. Creating an account creates the account and you;
it does not create a Vantage instance. Nothing exists in any control plane until
you later create or link one.
1. Go to the [signup form](https://vantage.hostxtra.co.uk/start).
2. Enter your name, email and a password.
3. Check your email and click the verification link.
Verification links are valid for **24 hours**.
## Signing in
Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk) with the email and
password from the signup form.
HQ and your Vantage instances have separate sessions: signing in to HQ does not
sign you in to an instance, and signing in to an instance does not sign you in
to HQ.
## What comes next
| You want | Go to |
| ------------------------------ | ------------------------------------------------------------- |
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
| To licence an install you run | [Self-hosted instances](./self-hosted-instances.md) |
| To add colleagues | [People and roles](./people-and-roles.md) |
| To understand tiers and limits | [Licensing and entitlements](./licensing-and-entitlements.md) |
## The portal layout
Three destinations: **Overview**, **People**, **Billing**.
- Overview lists your instances.
- People shows all the account members and their roles.
- Billing shows your current subscriptions and lets you manage them.
-56
View File
@@ -1,56 +0,0 @@
---
id: billing
title: Billing
sidebar_label: Billing
---
Paid plans are billed through **Paddle**, which is the merchant of record. Your
invoice, your card details and your tax handling are all Paddle's; HQ holds a
customer reference and nothing sensitive.
:::warning
The Billing page requires the **owner-only** account role.
:::
## Buying a plan
In Vantage HQ, click **Buy a plan** on the **Overview** page.
### Cloud
1. Set **Deployment** to **Cloud**.
2. Choose a **Billing** cycle, monthly or annual.
3. Choose a **Plan** and configure its features and server allowance.
4. Enter an **Instance name** and click **Continue to payment**.
The instance is created and licensed as soon as payment confirms.
### Self-hosted
Install your control plane first — a licence binds to its instance ID.
1. Set **Deployment** to **Self-hosted**.
2. Choose a **Plan** and configure its features and server allowance.
3. Under **Your install**, paste the instance ID from your install's **Licence**
page. An ID you already hold is upgraded in place.
4. Enter an **Instance name** and click **Continue to payment**.
The licence is issued as soon as payment confirms; download it and paste it into
your install. See [Self-hosted instances](./self-hosted-instances.md).
## Cancelling and failed payments
Cancelling, or a payment going past due, takes **no immediate licence action**.
Your licence runs to its grace-padded expiry and then lapses normally. There is
no mid-term cut-off.
For a cloud Free instance, lapsing eventually leads to deletion. See
[Free tier](./free-tier.md). Paid instances are not deleted.
## Renewals
At renewal the subscription bills again and the licence is reissued for the new
term. It is also the only moment a scheduled **reduction** takes effect.
- Self-hosted customers: download and paste the reissued licence.
- Cloud customers: nothing to do. The licence is written to the instance for you.
-78
View File
@@ -1,78 +0,0 @@
---
id: cloud-instances
title: Cloud instances
sidebar_label: Cloud instances
---
A cloud instance is a Vantage control plane we run for you, reachable at
`<your-slug>.vantage.hostxtra.co.uk`.
## Creating one
1. On **Overview**, choose **Create a cloud instance**. Once you already have an
instance, the same page offers **Buy a plan** instead.
2. Choose **Cloud**, then the plan you want.
3. Give the instance a name and confirm.
The instance is provisioned with you as its owner, and a Free licence is issued
immediately. Your Vantage HQ password gets you into it, though the two are kept
in step rather than shared. See [People and roles](./people-and-roles.md).
### Slugs
Your instance's address comes from the name you choose, lowercased, with some
names reserved. Pick something short that you can say on a phone call.
:::warning One Free instance per account, per deployment
Creating a second Free cloud instance is refused. If you want another, it needs
a paid plan or free up the first.
:::
## Using it
Sign in at your instance's hostname with the email and password you use for HQ.
It is a normal Vantage install from that point: see
[Getting started](../getting-started/first-login.md) and the
[Vantage](../vantage/servers.md) section.
The instance does not depend on HQ at runtime. HQ being unreachable does not
affect anyone signing in or any agent syncing.
## The instance record
Each instance on Overview is one record. Closed, it is a row. Open, it shows:
- **Licence contents**: tier, server allowance, features and expiry.
- **Members**: who has access, and with what role.
- **Actions**: grant access, change configuration, renew.
## Members
Granting access writes a real user into the instance. Covered fully in
[People and roles](./people-and-roles.md).
## Changing what it can do
Server allowance and per-instance features (browser console, single sign-on) are
part of the instance's **entitlement**, the configuration your licence is cut
from. Changing it goes through billing. See
[Licensing and entitlements](./licensing-and-entitlements.md) and
[Billing](./billing.md).
## Renaming
The display name is free to change. The slug is part of your hostname, so ask
support if you need that changed.
## What happens if the licence lapses
A cloud instance whose Free licence expires enters degraded mode, then, after a
grace period, the instance and all its data are deleted. Warning emails go out
first. See [Free tier](./free-tier.md).
Paid instances do not get reaped by that mechanism. A cancelled subscription
runs to its grace-padded expiry and then lapses.
## Deleting
Ask support.
-67
View File
@@ -1,67 +0,0 @@
---
id: free-tier
title: Free tier
sidebar_label: Free tier
---
Free is a permanent tier. It is available whether we host Vantage for you or you
host it yourself.
## What you get
| | Free |
| --------------------- | --------- |
| Servers | 3 |
| Monitors | 3 |
| Secret groups | 1 |
| Notification channels | 1 |
| Audit retention | 30 days |
| Support | Community |
Browser console and single sign-on are not included; they are per-instance
features on a paid plan.
## One per account, per deployment
The limit is enforced per account **and** deployment. A Free cloud instance does
not prevent a Free self-hosted one; they are separate slots.
## Renewing
Free licences run for a year and are renewed from the portal.
- The renew button appears **7 days before expiry**.
- It stays available **after** expiry, right up until a lapsed cloud instance is
deleted, so the same button rescues one.
- Renewing outside that window is refused, and the message names the date it
opens.
:::tip Put the expiry in a calendar
Warning emails go to the account address. If nobody watches that inbox, a Free
cloud instance can lapse and eventually be deleted without anyone noticing.
:::
## What happens when it lapses
**Self-hosted:** the instance goes into degraded mode after the grace period and
stays that way. Nothing is deleted, ever.
**Cloud:** the instance goes into degraded mode, and after a further period the
instance **and all its data are deleted**. Warning emails are sent first, naming
the date.
:::danger Deletion is permanent
There is no restore. If a cloud Free instance is approaching that date and you
want to keep it, renew it, or move it to a paid plan.
:::
## Moving off Free
Change the instance's configuration to a paid tier and check out. Your data
stays where it is: a tier change reissues a licence, it does not rebuild
anything.
## Relinks
Free instances get the same allowance as paid ones: three relinks per term. If
you genuinely need more, ask support.
@@ -1,101 +0,0 @@
---
id: licensing-and-entitlements
title: Licensing and entitlements
sidebar_label: Licensing and entitlements
---
A **licence** is a signed statement of what one instance may do. An
**entitlement** is the configuration a licence is cut from.
## Tiers
Three tiers, in both deployments. The allowances are identical across cloud and
self-hosted; what differs is the term on offer, not what you get.
| | Free | Professional | Enterprise |
| --------------------- | --------- | ------------ | --------------------- |
| Servers (base) | 3 | 5 | 10 |
| Monitors | 3 | unlimited | unlimited |
| Secret groups | 1 | unlimited | unlimited |
| Notification channels | 1 | unlimited | unlimited |
| Audit retention | 30 days | 365 days | unlimited |
| Support | Community | Email, 24×5 | Email and phone, 24×7 |
The server count is **metered**: the base allowance comes with the tier, and you
buy additional servers on top. That is why Professional shows a real number
rather than "unlimited": the number you actually have is the one in your
entitlement.
## Features
Four features are enabled per instance rather than bundled into a tier:
| Feature | What it enables |
| ---------------------- | ------------------------------------------------------------------------------- |
| Browser console | The [browser console](../vantage/browser-console.md) |
| Single sign-on | [Sign-in through your identity provider](../vantage/settings.md#single-sign-on) |
| Vulnerability scanning | [Package vulnerability scanning](../vantage/vulnerabilities.md) |
| Status pages | [Public status pages](../vantage/status-pages.md) |
No tier includes them by default; you enable them on the instances that need
them.
## Entitlements: desired and granted
Each instance has one entitlement row holding two configurations:
| | Meaning |
| ----------- | ---------------------------- |
| **Desired** | What you last asked for |
| **Granted** | What a payment has confirmed |
Checkout is built from **desired**. A licence is only ever signed from
**granted**. An abandoned checkout therefore leaves a desired that reached
nothing and changed nothing.
### Increases and reductions
An increase takes effect when payment confirms, and the entitlement is promoted
desired → granted.
A **reduction** is scheduled rather than immediate: you keep what you paid for
until the end of the term, and the portal shows the date it drops. The collapse
happens at renewal.
## What a licence carries
Your instance ID, whether it is cloud or self-hosted, the tier, your limits,
which features are enabled, and when it expires. All of it is signed.
Two properties follow from that:
- **A licence works for one instance only.** Moving it to a rebuilt install
takes a [relink](./self-hosted-instances.md#relinking).
- **A licence is a snapshot.** Changing a plan later does not rewrite a licence
already issued.
Verification is local. Your instance does not call HQ to check a licence, and
signing happens only in HQ.
## Expiry, grace and degraded mode
Expiry is padded with a few days' grace. Past that, an instance goes into
**degraded mode**, which means:
- It keeps running, and all of your data stays exactly where it is.
- You can still sign in and read everything.
- Adding or changing anything is refused.
- Deleting things still works, so you can get back under a reduced allowance.
- Applying OS updates still works, because security patching is never blocked.
A brand-new self-hosted install behaves the same way until you install its first
licence.
The way out is a current licence: renew or purchase, then paste it
(self-hosted) or let it be written for you (cloud).
## Server limits in practice
When you exceed your server allowance, enrolling another one is refused. The
existing fleet is unaffected. Raise the allowance in the portal, or remove a
server you are not using.
-91
View File
@@ -1,91 +0,0 @@
---
id: people-and-roles
title: People and roles
sidebar_label: People and roles
---
Two separate things live here: who is in your **account**, and who has access to
each **instance**.
## Account members
**People** lists everyone in the account.
| Role | Can |
| -------- | ----------------------------------------------- |
| `owner` | Everything, including billing |
| `admin` | Invite, create instances, grant instance access |
| `member` | Read |
Owners and admins invite; billing is owner-only.
### Inviting someone
1. **People → Invite**.
2. Enter their email and pick a role.
3. They receive a link and set their own password at `/accept-invite`.
:::info Why you cannot set their password
An invited person cannot sign in at all until they set their own password. If
you chose it for them, it would be a shared password to every instance they are
later given access to.
Their invitation link stays valid until they use it to set that password.
:::
### Removing someone
Removing them from the account removes their portal access. See below for what
happens to their instance access.
## Instance access
Granting access to a **cloud** instance creates a real account inside that
instance, marked as managed by Vantage HQ.
```mermaid
flowchart LR
P["Person in your Vantage HQ account"] -->|you grant access| U["Account inside the instance"]
U --> I["They sign in at the instance,<br/>like anyone else"]
```
They then sign in at the instance itself, and that keeps working whether or not
Vantage HQ is reachable. Revoking removes the account outright, so access ends
immediately.
### Granting
On an instance record, **Members → Add**, choose an account member and an
instance role (`owner`, `admin`, `member`).
One person gets one account per instance, so granting twice is refused rather
than quietly creating a second.
### Roles inside an instance
Independent of the account role. Someone can be an account `member` and an
instance `owner`, or the reverse.
### Revoking
Removes their access immediately, and ends any session they have open.
:::warning Self-hosted instances cannot be granted from HQ
Vantage HQ cannot add or remove people in a self-hosted install. Manage them in
the install itself, at **Settings → Access**.
:::
## Passwords
One Vantage HQ password covers you and every cloud instance you have been given
access to. Change it in the portal and it changes everywhere, within about 15
minutes at worst if an instance is briefly unreachable.
Those people cannot change that password inside an instance, so there is only
ever one place it is set.
:::warning HQ-managed users are read-only in the instance
Changing the role of, or removing, someone managed by Vantage HQ has to be done
from the portal. Inside the instance those rows are read-only, with a link back
here.
:::
-71
View File
@@ -1,71 +0,0 @@
---
id: self-hosted-instances
title: Self-hosted instances
sidebar_label: Self-hosted instances
---
A self-hosted instance is your install, licensed through HQ. HQ never touches
it: it issues a signed file that your install verifies locally.
## Free
Install first, then link and claim. Step by step in
[Claim a Free licence](../getting-started/claim-free-licence.md).
## Paid
**Install first.** A licence is issued to one instance, so your control plane
has to exist and report an instance ID before you can buy for it — the same
precondition Free has.
```mermaid
flowchart LR
A["Install Vantage<br/>find its instance ID"] --> B["Buy in Vantage HQ<br/>paste that ID"]
B --> C["Payment confirms"]
C --> D["Licence issued<br/>for that instance"]
```
1. [Install Vantage](../getting-started/self-hosted-install.md) and find its
instance ID on the **Licence** page.
2. On **Overview**, choose **Buy a plan**. Pick **Self-hosted**, then your tier
and configuration.
3. Under **Your install**, paste the instance ID. Enter an **Instance name** and
click **Continue to payment**.
4. The licence is issued as soon as payment confirms. Download it and paste it
into your install.
## Upgrading an instance you already have
Paste the same instance ID you already hold — an install on Free moves to the
paid plan in place, keeping its ID and its history. An ID belonging to another
account is refused.
For a Free licence, see
[Claim a Free licence](../getting-started/claim-free-licence.md).
## Relinking
Rebuilding the host gives you a new instance ID, which your old licence does not
match. **Relink** moves the licence across and reissues it.
The number of relinks per term is capped, and the portal shows how many you have
left. If you have used them all for a genuine reason, ask support.
## Installing the licence
Paste it on the **Licence** page in your install. It confirms the licence was
issued to that instance before applying it.
Pasting works even while your current licence has expired, because that is how
you get out of degraded mode.
## Keeping it current
Your install never downloads a licence by itself. Whenever one is reissued, on
renewal, on a configuration change or after a relink, download it from Vantage
HQ and paste it in.
:::warning Nothing reminds your install
The control plane knows only what its licence says. Expiry emails come from HQ,
to the account's address. Make sure someone reads them.
:::
-39
View File
@@ -1,39 +0,0 @@
---
id: index
title: Vantage documentation
sidebar_label: Overview
slug: /
---
# Vantage documentation
Vantage manages a fleet of servers from one place: SSH keys, scripted workflows,
service monitoring, a secrets vault, browser consoles and OS updates. Run it
yourself, or let us run it for you.
A central server drives a lightweight agent installed on each managed machine.
The agent connects **outbound only**, so managed servers need no inbound
firewall holes.
## Where to start
| If you want to | Read |
| --------------------------------------- | --------------------------------------------------------------- |
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
| Decide who should run it | [Cloud or self-hosted](./getting-started/cloud-vs-self-hosted.md) |
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
| Licence a self-hosted install | [Claim a Free licence](./getting-started/claim-free-licence.md) |
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
| Look something up | [Reference](./reference/environment-variables.md) |
## The two products
**Vantage** is the control plane, the thing you sign in to in order to manage
servers. It runs either on your own infrastructure or as a cloud instance we
run for you.
**Vantage HQ** is the portal at `vantage-hq.hostxtra.co.uk` where you manage the
account behind those instances: who is on your team, which instances exist, what
licence each one holds and how it is billed. HQ never manages your servers, and
a Vantage instance never depends on HQ being reachable in order to run.
-72
View File
@@ -1,72 +0,0 @@
---
id: agent-updates
title: Agent updates
sidebar_label: Agent updates
---
Agents are versioned and released independently of the control plane, and update
themselves on command.
## Checking the current version
Each server's detail page shows the version it reported at its last sync.
## Updating from the UI
Open a server and choose **Update agent**. The agent then:
1. Downloads the binary for its platform from the release.
2. Verifies the SHA-256 against `checksums.txt`.
3. Stops itself, replaces the binary in place, and starts again.
The server briefly goes `offline` and comes back within a poll interval or two.
## Updating from the machine
There is a dynamic update script, the counterpart to the install one:
```bash
curl -fsSL https://vantage.example.com/update | bash
```
```powershell
irm https://vantage.example.com/update.ps1 | iex
```
It does the same download, checksum and replace, then restarts the service. Use
this when Vantage cannot reach the agent to push the update, but you can still
reach the machine.
## Rolling out across a fleet
There is no built-in bulk update. Two reasonable approaches:
- Update from each server's page, a few at a time.
- Build a [workflow](../vantage/workflows.md) whose step runs the update script,
and target it at the machines you want. That gives you ordering, failure
handling and a log.
:::tip Update a canary first
An agent that fails to start after replacing itself needs hands on that machine.
Do one, confirm it returns to `active`, then do the rest.
:::
## Version compatibility
An agent older than your control plane is supported. An agent newer than it is
not, so upgrade the control plane first.
Agents report their version on every poll, so a fleet running mixed
versions is visible in the server list rather than something you have to go
looking for.
## If an update fails
| Symptom | Cause |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| "Checksum mismatch" | Interrupted download, or a proxy rewriting the body. Retry |
| Downloads nothing | The machine cannot reach `gitea.hostxtra.co.uk` |
| Service will not start afterwards | Wrong architecture binary, or the file was replaced while a different service manager held it. Reinstall with the install one-liner |
Reinstalling is always safe: the config file is left alone, so the agent comes
back with the same identity and token.
@@ -1,233 +0,0 @@
---
id: backup-and-restore
title: Backup and restore
sidebar_label: Backup and restore
---
`vantagectl` is a separate command-line tool that backs up and restores the
MongoDB database behind a Vantage control plane. It talks to MongoDB directly,
never to the Vantage API, so it works against a control plane that is down,
half-migrated, or gone — exactly the situation a backup tool has to survive.
For the store-level overview — what holds what, and why the database alone is
not a backup — see [Backups](./backups.md). This page covers the tool.
:::danger The key comes first
Vantage encrypts SSH private keys, key passphrases, vault secrets, SSO client
secrets and console credentials with `KEY_ENCRYPTION_KEY`. **It is not in your
backup, and it is not recoverable.** A database restored without it is
permanently unreadable — not degraded, not partially readable, unreadable.
Store it wherever you store the credentials you could not rebuild: a password
manager, a secrets vault outside this control plane, a piece of paper in a
safe. Anywhere but next to the archive.
:::
## What a backup holds
Every collection in the database, the index definitions each one needs to be
useful again, and a SHA-256 **fingerprint** of `KEY_ENCRYPTION_KEY` — never the
key itself. The fingerprint is what lets a later `restore` or `verify` tell you
that the key you are holding is the wrong one, before it writes a database
nobody can read.
## What it does not hold
- **Redis sessions.** Everyone signs in again after a restore, which is already
true whenever Redis itself restarts.
- **The vulnerability database.** It is re-pulled automatically on next boot.
- **Agent state on managed servers.** Nothing needs re-enrolling: agents
reconnect on their own, because `servers.agent_token_hash` — the thing an
agent authenticates with — is itself in the backup.
:::note Pin the version
The image is published on each `vantagectl/v*` release and tagged with that
version; `:latest` also moves. Pin a version in anything scheduled. A restore
is easier to reason about when you can say which build produced the archive and
which one read it back.
:::
## Taking a backup
The loose binary:
```bash
export MONGO_URI=mongodb://localhost:27017
export MONGO_DB=vantage
export KEY_ENCRYPTION_KEY=<your 64-char hex key>
vantagectl backup --out /backups
```
The container:
```bash
docker run --rm \
-e MONGO_URI=mongodb://mongo:27017 \
-e MONGO_DB=vantage \
-e KEY_ENCRYPTION_KEY=<your 64-char hex key> \
-v /backups:/backups \
gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:0.1.0 backup --out /backups
```
Kubernetes, as a scheduled `CronJob` the Helm chart can render for you:
```yaml
backup:
enabled: true
schedule: "0 2 * * *"
image: "gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:0.1.0"
pvcName: "vantage-backups"
```
`backup.enabled` defaults to `false`, and the chart refuses to render if it is
turned on without both `backup.image` and `backup.pvcName` — a backup needs a
known image and somewhere durable to land, and guessing at either is worse than
refusing to start. `backup.exclude` names collections to leave out (recorded in
the archive's manifest, so an archive never claims to be complete when it is
not), and `backup.successfulJobsHistoryLimit` / `backup.failedJobsHistoryLimit`
/ `backup.resources` behave exactly as they do on any other `CronJob`.
`backup` refuses to run without `KEY_ENCRYPTION_KEY` set in the environment,
unless you pass `--allow-no-key` — for a deployment that genuinely stores no
encrypted data. Everywhere else, treat the refusal as the tool doing its job.
## Where to put the archive
`--out -` streams the tarball to stdout instead of writing a file, and every
line of progress output goes to stderr — so piping the archive into something
else is always safe, nothing progress-related lands in the stream.
Into `restic`:
```bash
vantagectl backup --out - | restic backup --stdin --stdin-filename vantage.tar.gz
```
Into S3:
```bash
vantagectl backup --out - | aws s3 cp - s3://my-backups/vantage-$(date +%F).tar.gz
```
An archive is as sensitive as a raw database dump — it carries every SSH key
assignment, every secret group, every session-adjacent setting, in a form the
right `KEY_ENCRYPTION_KEY` can decrypt. Whatever you pipe it into should
encrypt it at rest; `vantagectl` itself does not.
## Checking a backup is real
```bash
vantagectl verify /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \
--mongo-uri mongodb://localhost:27017 --db vantage
```
Each line of output answers a different question:
- **`Archive`** — every member's checksum still matches; the tarball has not
been truncated or corrupted.
- **`Archive key`** / **`Your key`** — the fingerprint stored in the archive
next to the fingerprint of the `KEY_ENCRYPTION_KEY` in your environment.
- **`Key match`** — whether those two fingerprints agree.
- **`Live probe`** — given `--mongo-uri`, `verify` goes one step further and
decrypts a real ciphertext value from that database with the key you hold.
A fingerprint match proves two archives agree about a key; only the probe
proves the key in your hand actually reads the data.
`verify` exits non-zero the moment anything above is wrong, which is what makes
it worth putting on a schedule — a backup job that "succeeded" last night is
not the same claim as a backup that will actually restore.
## Looking inside an archive
`inspect` prints an archive's manifest and touches no database at all — no
`--mongo-uri`, no key. It is what to run against an archive of unknown origin,
before deciding whether it is the one you want:
```bash
vantagectl inspect /backups/vantage-backup-vantage-20260907T020000Z.tar.gz
```
It reports when the archive was taken and on which host, the Vantage and
MongoDB versions behind it, the database it came from, the key fingerprint (or
that it carries none), every collection with its document count and size, and
anything `--exclude` left out. Opening the archive verifies every member's
checksum on the way, so a corrupt archive fails here too.
Reach for `verify` instead when the question is whether the key you hold opens
it; reach for `inspect` when the question is what it is.
## Restoring
`restore` expects the target database to be empty. Pointed at one that already
holds data, it refuses outright: there are no merge semantics, because merging
two control planes reconciles nothing and upserting old data over new would
resurrect revoked keys and deleted users.
```bash
vantagectl restore /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \
--mongo-uri mongodb://localhost:27017 --db vantage_restore
```
To overwrite a database that is not empty, add `--force`, which drops each
collection named in the archive before loading it. `--force` always needs a
second assurance, in one of two forms:
- `--confirm-db NAME`, naming the target exactly. A mismatch is refused. This
works everywhere — on a terminal and in a Kubernetes Job, a CI step or a cron
entry alike — and is the form to script.
- Nothing, on a terminal: `--force` alone prompts you to type the target
database's name back, a deliberate pause before something destructive.
Without a terminal and without `--confirm-db`, `--force` is refused: there is
nobody there to prompt. Naming the database in the command itself means a
copy-pasted invocation carries its intended target with it and cannot destroy a
different one by accident.
`--force` drops only the collections the archive carries. Anything else already
in the target is left alone and named in a warning, so an archive taken with
`--exclude workflow_log_lines` restored over a live database tells you the old
log lines are still there, joined to freshly restored runs. Dropping them
instead would delete data you never asked to delete.
`restore` also refuses when the archive's key fingerprint does not match the
`KEY_ENCRYPTION_KEY` in your environment — see "When the key is wrong" below.
## The restore drill
An untested backup is a hypothesis, not a backup. Rehearse the whole path,
monthly:
1. Restore last night's archive into a scratch database:
```bash
vantagectl restore /backups/vantage-backup-vantage-<date>.tar.gz \
--mongo-uri mongodb://localhost:27017 --db vantage_drill
```
2. Run `verify` against the result to confirm the data that landed is actually
readable with your current key:
```bash
vantagectl verify /backups/vantage-backup-vantage-<date>.tar.gz \
--mongo-uri mongodb://localhost:27017 --db vantage_drill
```
3. Drop the scratch database. It served its purpose.
The failure this catches is not "the archive is corrupt" — `verify` alone
catches that. It is "the archive is fine but nobody can actually stand a
control plane back up from it," which only a real restore proves.
## When the key is wrong
If `restore` finds the archive's key fingerprint does not match the
`KEY_ENCRYPTION_KEY` you are running with, it stops. Passing
`--ignore-key-mismatch` proceeds anyway, but says plainly which collections
will come back with ciphertext nobody can read:
- `keys` — SSH private keys and passphrases
- `secrets` — the vault
- `auth_providers` — OIDC/SSO client secrets
- `console_sessions` — RDP/VNC credentials
There is no way to recover that ciphertext afterwards. If you have reached
this point, the right key was lost along with the chance to read those rows —
the fix is to re-enter each of them by hand (re-upload SSH keys, re-save vault
secrets, reconfigure SSO), not to keep searching for a way to decrypt what is
already in the database.
-105
View File
@@ -1,105 +0,0 @@
---
id: backups
title: Backups
sidebar_label: Backups
---
Two things matter: **MongoDB** and **`KEY_ENCRYPTION_KEY`**. A backup missing
either one restores to something unusable.
## What holds what
| Store | Contents | Back up |
| -------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| MongoDB | Everything durable: servers, keys, assignments, workflows, runs and their logs, monitors, incidents, secrets, settings, audit | **Yes** |
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
:::danger The database alone is not a backup
Private keys, vault secrets, OIDC client secrets and console credentials are
encrypted with `KEY_ENCRYPTION_KEY`, which lives in your environment file and
nowhere in the database. Restore the database without it and every one of those
values is permanently unreadable.
Store the key somewhere other than the server it protects.
:::
:::info Use `vantagectl`
[**Backup and restore**](./backup-and-restore.md) is the supported way to take
and restore a backup. It writes an archive that carries a fingerprint of
`KEY_ENCRYPTION_KEY` — never the key — so a restore taken with the wrong key
**refuses** rather than silently producing a database whose secrets nobody can
read. It also checksums every archive member before writing anything, and
refuses to restore into a database that already holds data. A plain
`mongodump` does none of that: it records nothing about which key the data was
encrypted under, so a restore from one succeeds even when the key is wrong and
the failure only shows up later, as unreadable secrets.
The rest of this page, past the table above, describes the `mongodump` /
`mongorestore` fallback for an operator who does not have `vantagectl`
available. Prefer the linked page.
:::
## Backing up MongoDB (fallback, without `vantagectl`)
With the bundled Mongo container:
```bash
docker compose exec -T mongo mongodump --archive --gzip --db vantage \
> /backups/vantage-$(date +%F).archive.gz
```
:::warning
This archive records nothing about which `KEY_ENCRYPTION_KEY` it was taken
under. Restoring it with the wrong key produces a database that looks intact
and is not — every secret in it is silently unreadable until something tries
to decrypt one.
:::
Restoring:
```bash
docker compose exec -T mongo mongorestore --archive --gzip --drop \
< /backups/vantage-2026-07-28.archive.gz
```
`--drop` replaces existing collections. Stop the `server` container first, so
nothing writes during the restore.
## Backing up the environment file
```bash
cp /opt/vantage/.env /secure-location/vantage.env
```
Treat it as a credential in its own right, since it holds the encryption key.
## What a restore gives you
Everything: server, keys, assignments, workflows and their history, monitors and
incidents, secrets, settings and the audit log.
What it does **not** do is reconcile the world. After a restore:
- Agents reconnect with their existing tokens, since the token hashes are in the
database.
- If the restore is older than an enrolment, that server's token hash is missing
and the agent will fail to authenticate. Re-enrol it.
- The next agent poll rewrites `authorized_keys` to match the restored desired
state, which may remove keys added since the backup.
## A workable schedule
| What | When |
| ----------------- | ----------------------------------------------------- |
| Backup | Nightly, retained per your policy |
| Environment file | On change, held in a password manager or secret store |
| Restore rehearsal | Occasionally, into a throwaway host |
Rehearse a restore now and again. It is the step most often skipped, and the one
that finds the problems. See [Backup and restore](./backup-and-restore.md) for
the drill, and for `verify`, which checks a backup is real without a restore.
## Cloud instances
We back these up. You do not need to.
-80
View File
@@ -1,80 +0,0 @@
---
id: upgrading
title: Upgrading
sidebar_label: Upgrading
---
Upgrading the control plane is a pull and a recreate. Agents are versioned and
upgraded separately. See [Agent updates](./agent-updates.md).
:::info Cloud instances upgrade themselves
This page is for self-hosted installs. If your instance is hosted by us, there
is nothing here for you to do.
:::
## Upgrade
```bash
cd /opt/vantage
docker compose pull
docker compose up -d --remove-orphans
```
`--remove-orphans` clears containers for services that no longer exist in the
Compose file, which is what leaves a stale container running after a service is
renamed or removed.
## What happens on boot
1. The database is brought up to date. Each change runs once.
2. The built-in workflow steps are reinstalled, which is why those steps cannot
be edited.
If Vantage cannot complete either safely, it stops rather than run half-prepared.
Watch it:
```bash
docker compose logs -f server
```
## Before you upgrade
- **Back up MongoDB.** See [Backups](./backups.md). Migrations are one-way.
- **Read the release notes** for anything about migrations or environment
variables.
- **Check your `.env`** still supplies everything required. A newly required
variable stops the boot rather than defaulting to something unsafe.
## Single sign-on after an upgrade
Each identity provider now has its own callback URL. If you configured single
sign-on on an older version it was carried over, but its callback URL changed,
and sign-in through it fails until you copy the new one from its card in
**Settings** and register it with your identity provider. The card shows a
reminder until you dismiss it.
Password sign-in is unaffected, so you can always sign in locally to fix this.
## Downgrading
There is no automatic downgrade. Migrations do not roll back, so returning to an
older image means restoring the database backup taken before the upgrade. This
is the reason the backup is not optional.
## Zero-downtime
The stack is not designed for it. `docker compose up -d` recreates the server
container, which is a short interruption:
- Agents reconnect on their own.
- Workflow runs in progress lose their command stream. Steps already dispatched
finish on the agent, but their results have nowhere to go. **Do not upgrade
during a run.**
- Sessions survive, because they live in Redis rather than in the server.
## After upgrading
- Confirm every service is `running`.
- Confirm servers return to `active` within a couple of poll intervals.
- Open a secret group, to confirm `KEY_ENCRYPTION_KEY` came through.
-81
View File
@@ -1,81 +0,0 @@
---
id: agent-config
title: Agent configuration
sidebar_label: Agent config
---
The agent reads no environment variables. Everything is in one YAML file.
## Location
| Platform | Path |
| -------- | ----------------------------------- |
| Linux | `/etc/vantage/config.yaml` |
| Windows | `%ProgramData%\vantage\config.yaml` |
Directory `0700`, file `0600`. The install script sets both.
## Contents
```yaml
server_url: "vantage.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<token>" # cleared once the agent has registered
agent_token: "" # written by the agent when it registers
poll_interval: 30s
tls: true
```
| Field | Meaning |
| --------------- | --------------------------------------------------------------------- |
| `server_url` | The `host:port` the agent connects to. Comes from your `GRPC_HOST` |
| `server_id` | The identity issued when the enrolment was created |
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
| `agent_token` | The permanent credential, written by the agent itself |
| `poll_interval` | How often the agent polls for key state. Default `30s` |
| `tls` | Whether to use TLS. Leave `true` |
:::danger This file is the credential
`agent_token` exists in full only in this file. Anyone who can read it can act
as this agent.
:::
## Service management
### Linux
Unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, running
as root.
```bash
systemctl status vantage-agent
systemctl restart vantage-agent
journalctl -u vantage-agent -f
```
### Windows
A service registered through NSSM, or installed by the MSI.
```powershell
Get-Service vantage-agent
Restart-Service vantage-agent
```
## Moving an agent to a new control plane
Change `server_url`, clear `agent_token`, set a fresh `pre_reg_token` from a new
enrolment, and restart. The old control plane still holds a server record that
will go `offline`; delete it there.
## Uninstalling
```bash
systemctl disable --now vantage-agent
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
rm -rf /etc/vantage
systemctl daemon-reload
```
Keys already written to `authorized_keys` remain on disk, because the agent is
no longer running to remove them. Revoke first if that matters.
-110
View File
@@ -1,110 +0,0 @@
---
id: api-tokens
title: API tokens
sidebar_label: API tokens
---
A session cookie is fine for a browser. A script, a CI job or a cron task
needs something it can hold onto instead — an API token.
## Creating one
**API Keys**, in the Access group of the sidebar. The page is reachable at
every role: any member may create and revoke their own keys, and owner and
admin additionally see every key in the instance. Give it a name, a role
(owner, admin or member) and one or more scopes, and optionally an expiry. The value is shown
once, in full, immediately after creation:
```
vt_8f2c1a9e4b6d0735a1c8e29f4b0d6e17...
```
That is the only time you will see it. Vantage stores a hash of the token,
never the value itself, so if you lose it there is no support ticket that gets
it back — create a new token and revoke the old one.
## Scopes
A token can reach only what its scopes name. There are eight resources, each
with a `:read` and a `:write` scope, and holding `:write` on a resource also
satisfies a `:read` requirement for it — you do not need to tick both.
| Resource | Covers |
| ----------- | --------------------------------------------------- |
| `servers` | Fleet list, server detail, agent commands, tags |
| `keys` | SSH key library and assignment |
| `secrets` | The vault |
| `workflows` | Steps, workflows, runs and their logs |
| `monitors` | Monitors, incidents, uptime and notification channels |
| `vulns` | Vulnerability findings, packages and scan rules |
| `workloads` | Containers and systemd units, including control actions and logs |
| `settings` | Instance settings, members, single sign-on, licence, and token management itself |
A token created with only `servers:read` can list and inspect servers but
cannot run a workflow against them, touch a key, or read a secret — each of
those needs its own scope.
## A token never outranks its owner
A token's role can be at most the role of the person who created it, and its
effective role is **recomputed on every request** as the lower of the two —
not fixed at creation. Demote the person from owner to member and every token
they hold drops to member from that request onward. Remove the person and
every token they hold stops working immediately: a token has no existence
independent of its owner.
## Expiry
An expiry is optional on a token you create. An instance can set a
**maximum key lifetime** (Settings → Integrations) that caps how far out a new
token's expiry may be set; when that cap is in place, a token with no expiry
at all is refused, so there is no way to route around the policy by leaving
the field blank.
Changing the maximum lifetime only affects tokens created afterwards. It does
not shorten, extend or invalidate a token that already exists.
## Using a token
Send it as a bearer token:
```bash
curl -H "Authorization: Bearer vt_…" https://acme.vantage.example.com/api/servers
```
Everything else about the [REST API](./rest-api.md) applies the same way it
does to a session — JSON errors, audit logging, licence gating on writes —
except that authority comes from the token's role and scopes rather than a
signed-in person's role.
## Rate limit
A token is limited to **600 requests per minute**. Going over it gets a `429`
with a `Retry-After` header naming how many seconds to wait. Cookie sessions
are not subject to this limit; it exists so a runaway script cannot take an
instance down, not as a general throttle.
## Rotating a token
1. Create the replacement token first, with the scopes and role you need.
2. Deploy it wherever the old one was used, and confirm it works.
3. Revoke the old one.
Doing it in that order means there is no gap where the credential in use has
already been deleted.
## The full reference
This page covers the token model. Every route, request and response shape is
in the generated OpenAPI reference, served by **your own instance** at
`/api/docs` — not this documentation site, since the routes and their shapes
are specific to your install. The raw document is at `/api/openapi.json`.
:::danger Not the External Secrets token
The bearer token read by `GET /api/secrets/:group/values` for the Kubernetes
External Secrets Operator is a **separate credential** — a single instance-wide
value, rotated from Settings, that reaches only that one endpoint. It is not an
API token and an API token cannot be used in its place: the two are checked by
different code, and neither substitutes for the other. See
[Secrets](../vantage/secrets.md#kubernetes-external-secrets-operator).
:::
@@ -1,50 +0,0 @@
---
id: environment-variables
title: Environment variables
sidebar_label: Environment variables
---
Everything the control plane reads from the environment, and what happens when
it is absent.
## Server
| Name | Required | Default | Notes |
| -------------------------- | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GRPC_HOST` | **yes** | | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
| `REDIS_ADDR` | no | `localhost:6379` | Where sessions are held. If you run more than one copy of Vantage, they must all point at the same Redis |
| `REDIS_USERNAME` | no | | Redis 6+ ACL user. Leave empty against a legacy `requirepass` instance, which authenticates with the password alone |
| `REDIS_PASSWORD` | no | | Leave empty for an unauthenticated Redis. Both of these exist so an install can use a managed Redis rather than the bundled one |
| `KEY_ENCRYPTION_KEY` | yes in practice | | 64 hex characters (32 bytes) for AES-256-GCM. Required for private keys, vault secrets, OIDC client secrets and console credentials |
| `GITEA_HOST` | yes in practice | `gitea.example.com` | Host serving agent releases; used to build the install scripts and download URLs. The default is a placeholder that will not resolve, so set it to `gitea.hostxtra.co.uk` |
| `GUACD_ADDR` | no | `guacd:4822` | The [browser console](../vantage/browser-console.md) daemon |
| `PROXY_ADVERTISE_HOST` | no | `server` | The hostname **guacd** uses to reach the control plane's console relay. Wrong here and every console session fails at connect with guacd unable to resolve the relay |
| `PROXY_LISTEN_HOST` | no | `0.0.0.0` | Interface the ephemeral relay listeners bind. Narrow it only if guacd shares a known interface |
| `APP_ROOT_LABEL` | no | `vantage` | The label Vantage expects in its own hostname, used to match a browser session to the right instance |
| `VANTAGE_LICENSE` | no | | A licence supplied at startup, so an automated install does not have to paste one in |
| `VANTAGE_TRIVY_DB_REF` | no | `ghcr.io/aquasecurity/trivy-db:2` | Where the vulnerability database is pulled from. Point it at a mirror for an air-gapped install |
| `VANTAGE_VULNDB_DISABLED` | no | | `true` switches [vulnerability scanning](../vantage/vulnerabilities.md) off entirely. Findings already stored are still served, and still shown as stale |
| `TRUSTED_PROXIES` | no | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. The shipped Docker Compose and Helm chart default to the private RFC1918 ranges, which covers Nginx Proxy Manager on the Docker bridge network and Traefik on a Kubernetes pod CIDR. An operator whose proxy sits on a public address must set this themselves, or every visitor behind it shares one address for rate-limiting purposes. Unset entirely (outside those shipped defaults) trusts none, so the client address is the direct peer. **On a LAN-only install, narrow this to your proxy's address.** The RFC1918 default trusts every private range, so a client on 192.168.0.0/16 reaching the server directly is itself a "trusted proxy" and can put whatever it likes in `X-Forwarded-For` — and, on the public status route, in `X-Forwarded-Host`. Behind a proxy on a public address, or with no proxy at all, that is not reachable; on a flat LAN it is |
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
credentials. Lose it and all of them are unreadable. Back it up separately from
the database it protects.
:::
:::info A wrong `APP_ROOT_LABEL` fails quietly
It does not error. It simply stops matching, and the host/session guard stops
protecting anything.
:::
### Not configurable
The HTTP port (`8080`) and the gRPC port (`9090`) are fixed in the server. The
`HTTP_PORT` and `GRPC_PORT` entries in the shipped Compose file have no effect.
Remap the ports with Docker instead.
## Agent
The agent reads no environment variables. Everything is in its
[config file](./agent-config.md).
@@ -1,106 +0,0 @@
---
id: ports-and-networking
title: Ports and networking
sidebar_label: Ports and networking
---
## Control plane ports
| Port | Service | Who connects | Expose publicly |
| ------- | ----------- | -------------------------------- | --------------- |
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
| `8080` | server API | Your reverse proxy | Not directly — proxied |
| `9090` | server gRPC | Agents | **Yes** |
| `4822` | guacd | The server | No, firewall it |
| `27017` | MongoDB | The server | No |
| `6379` | Redis | The server | No |
## Direction of travel
```mermaid
flowchart LR
B["Browser"] -->|HTTPS| P["Reverse proxy"]
P -->|"everything else"| W["web :3000"]
P -->|"/api /auth /public /install* /update*"| S["server :8080"]
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
S --> G["guacd :4822"]
G -->|"relayed over the :9090 stream"| A
A -->|"SSH / RDP / VNC, loopback"| T["Target machine (same host as agent)"]
```
Two things are worth reading off that diagram.
**Agents connect outbound.** No inbound rule is needed on a managed server, and
NAT is not an obstacle. The only requirement is that the machine can reach
`GRPC_HOST`.
**The console rides the agent's connection too.** guacd never dials the target
directly. Vantage sends the request down the connection the agent already holds
on port `9090`, and the agent connects to the service locally on that machine. No route from the control plane to the target's address is needed,
and no new inbound port opens on the target. The same connection that keeps keys
in sync carries console traffic, which is what makes the console work for a
machine behind NAT on a private subnet, as long as its agent is online.
## What to open
### On your firewall, inbound to the control plane
- Your web port, from wherever people are.
- `9090`, from every network holding managed machines.
### Outbound from the control plane
- `gitea.hostxtra.co.uk`, for agent releases and version checks.
- Anything a server-run [monitor](../vantage/monitors.md) checks.
- SMTP, if you use an SMTP notification channel.
No route to the machines you intend to console is needed. That traffic uses the
connection the agent already holds.
### Outbound from a managed machine
- `GRPC_HOST`.
- `gitea.hostxtra.co.uk`, for install and self-update.
- Its package mirrors, for OS updates.
## TLS
Terminate TLS for the web UI at your reverse proxy.
Vantage does not terminate TLS itself, so port `9090` needs the same treatment:
put it behind your proxy with a certificate valid for the name in `GRPC_HOST`.
The proxy has to pass HTTP/2 through to Vantage. Many do not do that by default,
and the symptom is agents that register once and then stop responding.
On a private network you can skip TLS instead, by setting `tls: false` in each
[agent's config](./agent-config.md).
## Reverse proxy notes
- **The proxy routes two backends on one hostname**, and both are required:
| Path | Backend |
| ------------------------------------------------------------- | ------------- |
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
| everything else | `web:3000` |
The web app forwards nothing to the API. Sending the whole hostname to
`web:3000` loads the interface and every request it makes answers `404`
including the login form.
- Both backends must be the **same** hostname and certificate. The browser
calls `/api` relative to the page it is on, and the session cookie is
host-only.
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
not forward upgrade headers breaks the console and nothing else.
- Workflow log streaming is a long-lived response. A short proxy read timeout
truncates live logs while the run itself continues.
## Air-gapped and restricted networks
The control plane needs outbound access to fetch agent releases. Managed
machines need it too, unless you distribute the agent yourself and write its
config by hand, which is all the install script does.
Licence verification is entirely local, so a licensed install works with no
outbound access to HQ at all.
-59
View File
@@ -1,59 +0,0 @@
---
id: rest-api
title: Automating Vantage
sidebar_label: Automating Vantage
---
Everything the web UI does, it does through Vantage's own API, so anything you
can do on screen you can also do from a script.
The routes mirror the product: `/api/servers`, `/api/keys`, `/api/workflows`,
`/api/monitors`, `/api/secrets`, `/api/audit`, and so on.
## Where it is
On a self-hosted install the API is served on port `8080`, behind the same
reverse proxy as the web UI, under `/api` and `/auth`. On a cloud instance it is
your instance hostname.
## Signing in
Most calls use a session, exactly as the browser does:
```bash
curl -c cookies.txt -X POST https://vantage.example.com/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"..."}'
curl -b cookies.txt https://vantage.example.com/api/servers
```
Sessions last 24 hours. Your role applies exactly as it does in the UI: a
`member` calling an owner-only route is refused.
## The one exception
Kubernetes reads secret groups with a token instead of a session, so that it
does not need an account. See
[Secrets](../vantage/secrets.md#kubernetes-external-secrets-operator).
## Things worth knowing
- **Reads always work. Changes need a valid licence.** Without one, the instance
is read-only and any call that changes something is refused. Deleting things,
applying OS updates and installing a licence are always allowed, so you can
always get back under your allowance or out of read-only mode.
- **Some features are licensed.** The browser console, single sign-on and
vulnerability scanning are refused if your licence does not include them.
- **Some things cannot be changed here.** A cloud instance refuses a pasted
licence, and people managed by Vantage HQ cannot be re-roled or deleted inside
the instance.
- **Errors are JSON**, with an `error` field naming the reason.
- **Everything that changes something is audited**, whether it came from the UI
or from a script. See [Audit log](../vantage/audit-log.md).
## Vantage HQ
The portal is a separate service with its own sign-in, described in the
[Vantage HQ](../hq/accounts-and-signup.md) section. A Vantage session does not
work there, and an HQ session does not work in your instance.
-212
View File
@@ -1,212 +0,0 @@
---
id: troubleshooting
title: Troubleshooting
sidebar_label: Troubleshooting
---
Symptoms, in the order people hit them.
## The server will not start
**Exits immediately on boot.** Almost always a missing `GRPC_HOST`. The server
refuses to start rather than guess a value that would break every agent later.
**Fails while preparing the database.** Vantage stops rather than run without
the safeguards it sets up at startup. Check the MongoDB user's permissions and
whether an old, conflicting index is already there.
**Starts, but every secret operation errors.** `KEY_ENCRYPTION_KEY` is missing
or is not 64 hex characters.
## Nobody can sign in
**Every request 404s and the interface loads fine.** Your reverse proxy sends
the whole hostname to `web:3000`. `/api`, `/auth`, `/public`, `/install*` and
`/update*` belong to `server:8080` and the web app forwards nothing — see
[Ports and networking](./ports-and-networking.md#reverse-proxy-notes). The
tell is `curl -si https://<your-host>/auth/bootstrap-status` returning HTML
with `x-powered-by: Next.js` instead of JSON.
**`/setup` appears when users already exist.** The server is pointed at a
different database than you think. Check the database name in `MONGO_URI`,
which is taken from the end of the URI.
**Sessions do not stick.** Redis is unreachable, or the cookie is being dropped
because the site is served over plain HTTP.
**"Wrong organisation" style rejections.** Vantage compares the address you
browsed to against the instance your session belongs to. On a custom domain,
check `APP_ROOT_LABEL`.
**OIDC redirects and then fails.** The callback URL registered with the provider
must match exactly. Keep one local owner account so a broken provider is not a
lockout.
## A server never becomes active
Work through it in this order:
1. Is the agent running? `systemctl status vantage-agent`.
2. What does it say? `journalctl -u vantage-agent -f`.
3. Can that machine reach the endpoint? Test `GRPC_HOST` **from the machine**,
not from the control plane host.
4. Was the token already used, or older than an hour? Generate a fresh install
command rather than reusing the old one.
| Symptom | Cause |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Registers, then goes `offline` within minutes | The short registration call gets through but the long-lived connection is dropped, usually by a proxy or an idle timeout |
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
Remember the offline sweep runs every two minutes, so status is never
instantaneous.
## Keys are not appearing on a machine
- **It is a Windows server.** Key management is Linux-only, by design.
- **The agent is not running.** Nothing polls, nothing writes.
- **The key is assigned but revoked.** Revocation is soft; check the assignment
state rather than the key.
- **Someone edited `authorized_keys` by hand.** The agent rewrites the file to
match the desired set; hand-added keys disappear on the next change.
## A workflow run fails or hangs
- **Hangs at dispatch.** The target's command stream is not connected; the
server may be `offline`.
- **Fails immediately with an interpreter error.** A bash step on a Windows
target, or PowerShell on Linux.
- **A value does not reach the next step.** Values pass through the file at
`$WORKFLOW_ENV`, one `KEY=value` per line. Listing an output does not pass it
on by itself.
- **A secret is empty.** The group is not attached to that step, or the key name
differs from the variable you are reading.
- **Logs stop mid-run.** A reverse proxy read timeout cut the stream. The run
itself continues; reload the page.
## The console will not connect
| Symptom | Cause |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
| SSH rejects the key | The stored key has no private half, or is not on the target |
| RDP fails on retry | Credentials are single-use and consumed at tunnel open. Enter them again |
| Hangs, then disconnects | The agent could not reach the service on that machine, or setting up the session timed out. The audit log records which |
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
## Monitors report down when the service is up
- The check is running from the control plane and the endpoint is only reachable
internally. Switch the runner to an agent on a machine that can see it.
- The keyword no longer appears in the response body.
- Retries are `0`, so a single dropped packet flips the state.
### The check gets a 403, 429 or a CAPTCHA page
The endpoint is fine and answers a browser normally, but the monitor records a
status it never sees by hand. Something between Vantage and the service is
blocking automated traffic: a CDN, a WAF, a bot-protection product, a reverse
proxy rule, or a rate limiter. The response usually comes from that layer and
never reaches the origin at all, so nothing appears in the application's own
logs.
Two things make it hard to spot. The check runs from the control plane's or the
agent's address rather than yours, and those addresses are often datacenter
ranges that bot protection scores badly. And a browser test proves nothing,
because a browser is exactly what the blocking layer is willing to serve.
Every HTTP check Vantage makes identifies itself:
```
User-Agent: Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)
```
That string is the hook to allow the check through. In whichever product is
doing the blocking, add a rule that skips bot protection, managed rules and rate
limiting for requests carrying it — Cloudflare, AWS WAF, Azure Front Door,
Akamai, Fastly, Imperva, Sucuri, ModSecurity, nginx and HAProxy all match on a
request header. The shape of the rule is the same everywhere:
> If the host is *yours*, the path is *the one being monitored*, and the
> User-Agent contains `Vantage-Monitor`, then skip the protection.
Three details are worth getting right:
- **Match on `contains`, not equality.** The version in the string moves. An
exact match breaks silently on an upgrade, and the symptom is a monitor that
goes down on deploy day.
- **Keep the rule narrow.** Scope it to the specific host and path being
monitored. A User-Agent is not a secret — anyone can send it — so a rule that
skips protection site-wide on that string alone is a bypass you have
published.
- **Allow the source address too, where you can.** Combining the User-Agent with
the checker's IP is stronger than either alone. Find the address in your
blocking product's own event log; it is whichever client IP was blocked on the
monitored path.
If the endpoint genuinely needs authentication rather than an exception, monitor
a purpose-built health path that does not, and leave the protected paths
protected.
## Notifications are not arriving
Use the channel **Test** button. It goes through the real delivery path, so a
test that arrives proves credentials, network path and destination.
If the test fails: a webhook returning 300 or above counts as a failure, SMTP
needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
`chat_id`.
## Licence problems
| Symptom | Cause |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| "Managed by Vantage HQ" when pasting | It is a cloud instance, which is licensed for you. There is nothing to paste |
| Licence rejected as not matching | It was issued to a different instance ID. Relink it in Vantage HQ |
| Instance degraded despite a valid-looking licence | It expired more than a few days ago. Pasting a new one still works, which is how you recover |
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
## A status page 404s or shows no data
**404, and it should be published.** Check the **Published** toggle on the
page's editor — an unpublished page answers *not found* for everyone,
including you, with no session exemption. Also check the host: the public URL
is `<your-instance>.vantage.<yourdomain>/status/<page-id>`, the same
per-instance subdomain everything else in Vantage uses. A wrong or missing
subdomain resolves to no instance at all, which is also a 404.
Third possibility: `/public` is not routed to the server. Check with
`curl -si https://<your-instance>.vantage.<yourdomain>/public/status/<page-id>`
— JSON is correct, HTML carrying `x-powered-by: Next.js` means the proxy sent
that prefix to the web app.
**Loads, but shows an explanation instead of components.** This is not a
fault — it is the page working as designed. It means either the licence has
lapsed (a self-hosted instance past its grace period, or a cloud instance
between billing events) or the current tier does not include the **Status
pages** feature. Fix the licence or the plan and the same link starts serving
data again with no republish needed.
**One component reads `Unknown`.** The monitor behind it was deleted while
still listed on the page. Nothing is checking it any more, so the page says so
rather than showing a stale up or down. Remove the component from the page,
or point it at a replacement monitor, in the page's editor.
## HQ portal problems
The portal is a hosted service, so problems with it are ours to fix rather than
yours to configure. If a page fails to load, an action reports an error, or a
plan or price looks wrong after a change, contact support with your instance
UUID and roughly when it happened.
## Gathering information before asking for help
```bash
docker compose ps
docker compose logs --tail=200 server
journalctl -u vantage-agent --no-pager -n 200 # on the affected machine
```
Include your instance ID from the **Licence** page, which is the reference
support works from.
-59
View File
@@ -1,59 +0,0 @@
---
id: audit-log
title: Audit log
sidebar_label: Audit log
---
Anything that changes something is recorded, whether it was done in the UI or by
a script. The log is at **Audit Log**.
## What an event carries
| Field | Meaning |
| ------ | -------------------------------------------------------- |
| Action | A dotted name, e.g. `server.created`, `settings.updated` |
| Actor | Who did it |
| Target | The object acted on |
| Detail | A short human-readable note |
| Time | When |
## What is recorded
Creation, modification and deletion across the product: servers and enrolments,
keys and assignments, workflow and step changes, runs triggered, monitors and
channels, secret groups and reveals, console sessions opened, settings and
member changes, licence installs.
Simply looking at something is not recorded, with one exception: **revealing a
secret** is.
## What is not recorded
- Sign-ins and sign-outs.
- Anything inside a console session.
- Step output. That lives in the run log, kept under the workflow retention
setting rather than with the audit log.
## Retention
How long audit events are kept comes from your licence: 30 days on Free, a year
on Professional, and unlimited on Enterprise. See
[Licensing and entitlements](../hq/licensing-and-entitlements.md).
This is separate from the workflow log retention setting, which covers run logs
only.
:::warning It is a log, not a control
The audit log tells you what happened. It does not restrict what can happen, and
an admin can do anything an admin can do. Use roles for restriction and the log
for accountability.
:::
## Searching and exporting
The page searches by actor, detail and event type, and filters by category, such
as `workflow`, `key` or `server`. The count shown is the number of matching
events, not the number on screen.
The same events are available from the API if you want them in a log system of
your own. See [Automating Vantage](../reference/rest-api.md).
-62
View File
@@ -1,62 +0,0 @@
---
id: browser-console
title: Browser console
sidebar_label: Browser console
---
An SSH, RDP or VNC session in a browser tab, with no client software to install
and no new port to open on the target machine.
:::info Requires the console feature on your licence
The console is a per-instance feature you enable on a paid plan. Without it, the
Console button is unavailable. See
[Licensing and entitlements](../hq/licensing-and-entitlements.md).
:::
## What you need
- The target server's **agent must be online**. Console traffic travels over the
connection the agent already holds, so an offline agent means no session.
- `KEY_ENCRYPTION_KEY` set on a self-hosted install, since every credential
involved is stored encrypted.
- The service you are connecting to listening on the machine itself. It does not
have to be reachable from anywhere else, because the agent connects to it
locally.
## Opening a session
From a server's page, choose **Console**, pick the protocol and connect. Vantage
issues a one-time ticket for that session, and the connection is refused rather
than left hanging if the agent is not online.
## Credentials
### SSH
Uses a private key from your [key library](./ssh-keys.md). The key must have had
its private half uploaded; a public key alone cannot open a session.
### RDP and VNC
You type the credentials when you connect. They are encrypted, used once and
discarded, so the next session asks again.
## During and after a session
Closing the tab ends the session. There is no reconnect: opening it again starts
a fresh session.
Opening a console is recorded in the [audit log](./audit-log.md), with who did
it, which server and when. What happens inside the session is not recorded.
There is no session replay or keystroke capture, so if you need that, it has to
come from the target machine itself.
## When it does not work
| Symptom | What to check |
| -------------------------------- | ------------------------------------------------------------------------------------------ |
| Connects, then closes at once | The console daemon is unreachable. On a self-hosted install, check that `guacd` is running |
| SSH refuses the key | The stored key has no private half, or is not assigned to that server |
| RDP fails when you retry | Credentials are used once. Enter them again |
| Hangs, then disconnects | The agent could not reach the service on the machine, or the session timed out setting up. The audit log records the reason |
| Works locally, fails in production | Your reverse proxy is not forwarding WebSocket connections |
-77
View File
@@ -1,77 +0,0 @@
---
id: monitors
title: Monitors
sidebar_label: Monitors
---
Monitors check that something is answering. Four types, two places they can run
from, and a notification path when they stop being satisfied.
## Types
| Type | Checks | Options |
| ------ | --------------------------------------- | --------------------------------------------------------------------------------- |
| `http` | An HTTP(S) URL | method, expected status, keyword that must appear in the body, allow insecure TLS |
| `tcp` | A host and port accept a connection | |
| `icmp` | A host answers ping | |
| `tls` | A certificate is valid and not expiring | warn N days before expiry |
An `http` monitor with a keyword is usually the one you want for an application:
a 200 that returns an error page still fails the keyword.
## Where a check runs
Every monitor has a **runner**:
| Runner | Meaning |
| ----------- | -------------------------------------------------------------- |
| `server` | The control plane's scheduler performs the check |
| a server ID | That server's agent performs it locally and reports the result |
Use `server` for anything Vantage itself can reach, such as your public website
or API. Use an agent for anything only reachable from inside the
target network: a database on a private subnet, a service bound to localhost, a
device on a management VLAN.
:::tip The two answer different questions
A check from Vantage tells you the service is reachable over the network. A
check on the machine tells you the process is running. Watch both where it
matters.
:::
## Interval, retries and state
- **Interval** is how often to check.
- **Retries** is how many failures in a row are tolerated before the state
changes.
A monitor sits in `pending` until its first result. Failures accumulate; once
they exceed `retries`, the monitor goes `down`, an **incident** opens and the
attached notification channels fire. A subsequent success closes the incident.
Retries are what keeps one dropped packet from paging you. Set them to at least
`1` for anything crossing the public internet.
## Notifications
Attach one or more [notification channels](./notification-channels.md) to a
monitor. Channels are shared, so one Slack destination can serve every monitor
you have.
You get a message when a monitor goes down and another when it recovers, not one
per check while it stays down.
## Uptime and incidents
The monitor detail page shows:
- **Uptime**, summarised per hour: how many checks ran, how many passed and the
average response time.
- **Incidents**, each with a start, a resolution and the cause recorded at the
moment it opened.
## Disabling versus deleting
Disabling stops the checks and keeps the history. Deleting removes the monitor.
Disable anything seasonal rather than deleting it, since the uptime record is
usually the part worth keeping.
@@ -1,108 +0,0 @@
---
id: notification-channels
title: Notification channels
sidebar_label: Notification channels
---
A channel is a destination for alerts. One channel serves as many
[monitors](./monitors.md) as you like.
Manage them at **Settings → Notifications**.
## Types
### Webhook
Posts JSON to a URL you choose.
| Setting | |
| ------- | -------- |
| `url` | Required |
```json
{
"monitor": "API front door",
"type": "http",
"old_status": "up",
"new_status": "down",
"message": "HTTP 502",
"time": "2026-07-28T09:14:02Z"
}
```
Any response of 300 or above counts as a delivery failure. The request times out
after 10 seconds.
### Discord
| Setting | |
| ------- | ------------------- |
| `url` | Discord webhook URL |
Posts the alert as message content.
### Slack
| Setting | |
| ------- | -------------------------- |
| `url` | Slack incoming webhook URL |
### Telegram
| Setting | |
| --------- | ----------- |
| `token` | Bot token |
| `chat_id` | Target chat |
### SMTP
| Setting | |
| ---------------------- | ---------------------------------------------------- |
| `host`, `port` | Required |
| `from`, `to` | Required |
| `username`, `password` | Optional; auth is skipped when the username is empty |
Port `465` uses implicit TLS; anything else uses STARTTLS.
### Credentials are never read back
The SMTP `password`, the Telegram `token` and the webhook, Slack and Discord
`url`s come back from `GET /api/channels` as `••••••••` — a webhook URL is the
authorisation to post to that channel, so it is treated as a credential like
the rest. Writing that value back unchanged keeps the stored one, which is what
lets you rename a channel without retyping its password. Anything else you send
is written as given, so clearing the field clears the credential.
Alert emails look like the rest of the mail Vantage sends you.
## The message
Non-webhook channels all send the same one-line title:
```
[Vantage] API front door (http) is DOWN: HTTP 502
```
Recoveries read `recovered` in place of `is DOWN`. The webhook payload carries
the same information as fields, which is the one to use if you are routing into
something that needs to branch on status.
## Testing
Every channel has a **Test** button. It dispatches a fabricated down event for a
monitor called "Test monitor" and sends it the same way a real alert goes out, so
a test that arrives proves the credentials, the network path and the destination
as well as the form.
:::tip Test after every change
Channel settings are only exercised when something breaks, which is the worst
time to discover a stale webhook URL. Re-test after rotating a token.
:::
## Choosing destinations
- Use a **chat channel** for awareness, and make sure someone owns it.
- Use **SMTP** where a durable record matters.
- Use a **webhook** to reach an on-call system that does escalation properly.
Vantage does not do escalation, rotas or acknowledgement; a webhook into
something that does is the intended answer.
-73
View File
@@ -1,73 +0,0 @@
---
id: secrets
title: Secrets vault
sidebar_label: Secrets
---
Key/value pairs, grouped by name, encrypted at rest with AES-256-GCM under
`KEY_ENCRYPTION_KEY`. Two things consume them: workflow steps, and Kubernetes
External Secrets Operator.
## Groups and values
A **group** is a named bundle, such as `prod-db`, `registry` or `acme-api`.
Inside it are key/value pairs.
Group by who uses them rather than by what they are. A workflow step references
a whole group, so a group that matches one job stays tidy, while a group holding
everything hands all of it to every step that needs any of it.
## Managing them
**Secrets → New group**, then add keys.
Once saved, a value is hidden. The list shows key names only. **Reveal** is a
separate action, and it is written to the audit log.
Deleting a single key and deleting the whole group are separate operations.
## Using secrets in workflows
Add the group to a step's secret references. When the step runs, the group's
pairs are available to it as environment variables:
```bash
# with the "registry" group attached to this step
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
```
A workflow can change which groups a step uses without changing the step in the
library.
:::warning A step can print its own secrets
Values arrive as environment variables. If your script prints them, or runs with
`set -x`, they end up in the run log, which anyone who can see the run can read.
Vantage does not filter step output.
:::
## Kubernetes External Secrets Operator
Kubernetes can read a secret group directly, using a token rather than a
sign-in.
1. Generate the token at **Settings → Integrations**. It is shown once, and
Vantage stores only a fingerprint of it.
2. Put it in a Kubernetes secret.
3. Point an External Secrets Operator `SecretStore` at your Vantage address with
that token.
Generating a new token replaces the old one immediately.
:::danger This token reads every group
It is instance-wide, not scoped to one group. Treat it as a credential to the
whole vault: store it as a secret in the cluster, never in a manifest in git,
and rotate it when anyone with access leaves.
:::
## What the vault is not
- **Not a password manager.** There is no sharing, expiry or per-user
visibility. Anyone who can sign in and reveal, can reveal.
- **Not versioned.** Overwriting a value loses the previous one.
- **Not recoverable without the key.** If `KEY_ENCRYPTION_KEY` is lost, so is
every value. Back it up separately from the database.
-140
View File
@@ -1,140 +0,0 @@
---
id: servers
title: Servers
sidebar_label: Servers
---
The fleet. Every managed machine runs an agent that connects outbound to the
control plane. Keys, workflows, monitors and consoles all point at these
records.
## Enrolling a server
Covered step by step in [Add your first server](../getting-started/first-server.md).
In short: **Servers → Add server** issues a single-use, one-hour token and shows
a one-liner to run as root on the target machine.
## Lifecycle
| Status | Meaning |
| --------- | --------------------------------------------------- |
| `pending` | Enrolment created; the agent has not registered yet |
| `active` | The agent registered and is syncing |
| `offline` | Last-seen passed the threshold |
The offline sweep runs every two minutes, so a machine that has just gone away
takes a little while to be marked as such.
## Tags
A tag is a `key:value` label you put on a server. Tags are how you say what a
machine is, such as `env:prod`, `role:web` or `team:core-infra`, so that you can
find it later and so a [workflow](./workflows.md) can target it without you
naming it by hand.
There is no tag library to set up first. A tag exists as soon as a server
carries it, and disappears when the last server carrying it drops it.
### The rules
| Rule | Value |
| ---------- | ------------------------------------------------- |
| Characters | lowercase letters, digits, `-` and `_`, on both halves |
| Key length | up to 32 characters |
| Value length | up to 64 characters |
| Per server | up to 20 tags |
Neither half may be empty, and keys beginning `sys:` are reserved for Vantage's
own use.
Anything outside those rules is refused, with a message naming the rule.
Uppercase is not corrected for you, so `Env` and `env` are different tags.
### Editing a server's tags
On the server's page, click **Edit** beside the tags. Saving replaces the whole
set, so what you see in the editor is exactly what the server ends up with. If
two people edit the same server at once, the last save wins.
### Filtering the fleet
The **Servers** list has a picker per tag key in use. Choosing values from more
than one key narrows the list, because a server must match **all** of them.
Untagged servers appear only when no filter is set.
:::tip A filtered fleet view is a link
The filter lives in the URL (`/servers?tag=env:prod&tag=role:web`). Copy the
address bar and you have sent someone the same view, not a description of how to
reproduce it.
:::
## The server detail page
### Keys
Which SSH keys are assigned to this machine, and their state. See
[SSH keys](./ssh-keys.md).
### Inventory
Agents report:
| Data | Refreshed |
| ---------------------------------------- | ---------------- |
| CPU, memory, swap, load | every 30 seconds |
| Partitions, kernel, full static snapshot | every 15 minutes |
The two carry separate timestamps, so a stale static snapshot beside fresh
metrics is normal rather than a fault.
### OS updates
Agents check for pending package updates hourly and report the count — the
machine's own package manager on Linux, the Windows Update COM API on Windows.
From the server page you can:
- **Apply updates** runs that check's install path and reports back. The agent
never reboots the machine; if one is owed, a **reboot required** badge
appears on the next inventory snapshot instead.
- **Update agent** upgrades the Vantage agent on that machine. See
[Agent updates](../operations/agent-updates.md).
:::warning Applying updates is not scheduled or staged
It runs immediately, on that machine. If you need ordering, health checks or a
test machine first, build it as a [workflow](./workflows.md) instead.
:::
### Console
Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console.md).
## Windows servers
Windows agents register, heartbeat, run workflow steps, report inventory,
check and apply OS updates, report workloads (services and containers), and
serve the browser console. They do not manage `authorized_keys`, and they are
not covered by package inventory or CVE scanning — the vulnerability feeds
this project uses carry no Windows data.
## Removing a server
Deleting the server record removes it from the fleet. It does **not** uninstall
the agent, which will keep trying to sync and failing. Uninstall it on the
machine too:
```bash
systemctl disable --now vantage-agent
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
rm -rf /etc/vantage
systemctl daemon-reload
```
Keys previously written to `authorized_keys` stay on disk, because the agent is
no longer running to remove them. Revoke and let the agent apply the change
**before** you delete the server if that matters to you.
## Agent tokens
Each server has its own token, which exists in full only in the agent's config
file on that machine. Vantage stores a fingerprint of it and cannot show it to
you again. If a token is lost, enrol the machine again.
-123
View File
@@ -1,123 +0,0 @@
---
id: settings
title: Settings
sidebar_label: Settings
---
One page, three groups: **Access**, **Monitoring** and **Integrations**. Your
licence has its own page.
Settings need the `owner` or `admin` role.
## Access
### People
Add, remove and re-role the people who can sign in.
| Role | Can |
| -------- | ---------------------------------------------------- |
| `owner` | Everything |
| `admin` | Everything except owner-only settings |
| `member` | Servers, keys, workflows, monitors, secrets, console |
Local members sign in with an email address and a password.
#### People managed by Vantage HQ
On a cloud instance, anyone granted access from the Vantage HQ portal appears
here as a read-only row with a link back to the portal.
:::warning You cannot edit those people here
Their role, password and access are owned by Vantage HQ, so changing or removing
them has to be done there. See
[People and roles](../hq/people-and-roles.md).
:::
### Single sign-on
:::info Requires the single sign-on feature on your licence
It is a per-instance feature you enable on a paid plan.
:::
Add as many identity providers as you need. Each has its own name, its own
button on the login page and its own callback URL.
If you configured single sign-on on an older version, see
[Upgrading](../operations/upgrading.md#single-sign-on-after-an-upgrade).
Start from a preset:
| Preset | You provide |
| ---------------------- | ---------------------------------------- |
| Microsoft Entra ID | Directory (tenant) ID |
| Google Workspace | Nothing further |
| Okta | Your Okta org domain |
| GitHub | Client ID and client secret only |
| Other (OpenID Connect) | The issuer URL of your provider |
Every provider also needs a **Client ID** and **Client secret**. The secret is
stored encrypted and is never shown again after you save it.
:::info GitHub needs a verified primary email
Vantage identifies people by email address, and it only accepts a GitHub address
that is both the account's primary address and confirmed by GitHub.
:::
#### Callback URL
Each provider's card shows its callback URL with a copy button. That is the
address you register with the identity provider when you set up the application
on their side. Register each provider separately, even where several use the
same identity provider.
#### Turning off password sign-in
Once at least one provider is enabled you can turn off email and password
sign-in. Vantage refuses any change that would leave nobody able to sign in,
whether that is switching off passwords or disabling your last provider. Keep
one route open until everyone who needs access can use the new one.
## Monitoring
- **Offline threshold**, how long a server may go unheard from before it is
marked offline. The default is 5 minutes.
- **Offline alerts**, the [notification channels](./notification-channels.md) to
tell when that happens.
- **Notification channels** have [their own page](./notification-channels.md).
- **Workflow log retention**, how long run logs are kept.
| Value | Meaning |
| -------- | -------------- |
| unset | 30 days |
| a number | that many days |
| `0` | keep forever |
## Integrations
### External Secrets Operator token
The token Kubernetes uses to read your secret groups. It is shown once, stored
only as a fingerprint, and can be replaced at any time. See
[Secrets](./secrets.md#kubernetes-external-secrets-operator).
## Licence
The **Licence** page, in the sidebar, shows your instance ID, whether you are
cloud or self-hosted, your tier, server allowance, enabled features and expiry
date.
On a **self-hosted** install you paste your licence here. This works even while
your current licence has expired, which is how you get an instance out of
read-only mode.
On a **cloud** instance there is nothing to paste. Licences are installed for
you, and the page links to the portal instead.
See [Licensing and entitlements](../hq/licensing-and-entitlements.md).
## Sessions
Signing in gives you a session that lasts 24 hours. There is no session list to
manage. On a self-hosted install, restarting Redis signs everyone out and affects
nothing else.
-78
View File
@@ -1,78 +0,0 @@
---
id: ssh-keys
title: SSH keys
sidebar_label: SSH keys
---
Vantage holds a library of public keys and decides, per server, which ones
belong in `/root/.ssh/authorized_keys`. The agent makes the file match.
:::info root only
Vantage manages `/root/.ssh/authorized_keys` and nothing else. There is no
per-user key management. The agent runs as root because writing that file
requires it.
:::
## Adding a key
### Upload one you already have
**Keys → Add key**, paste the public half. Vantage stores the public key and its
fingerprint, and never needs the private half for this path.
### Generate one on a server
Vantage can have a managed machine generate a keypair for you. The public half
comes back to the library. You may optionally upload the private half too, in which case it is
stored **AES-256-GCM encrypted** under `KEY_ENCRYPTION_KEY`.
Vantage never displays stored private key material in a list. Retrieving one is
a separate, deliberate action, and it is written to the audit log.
:::tip Why store a private key at all
The [browser console](./browser-console.md) needs one to open an SSH session. If
you are not using the console, do not upload private halves.
:::
## Assigning
Assign a key to one or more servers. The agent picks up the change within about
30 seconds.
## Revoking
Revoking marks the assignment revoked, with a timestamp, rather than erasing it,
so the record of who had access to what, and when, survives.
The agent treats a revoked assignment as "not desired" and removes the line from
`authorized_keys` on its next sync.
:::warning Revoking does not close open sessions
It removes the key from the file. An SSH session already established stays up
until it ends. Kill sessions on the machine if that matters.
:::
## What the agent actually does
Each poll:
1. The control plane returns the desired set of public keys for that server.
2. The agent reads `/root/.ssh/authorized_keys` and computes fingerprints.
3. **If they match, it writes nothing.** That is true of almost every check.
4. If they differ, it writes the new file alongside the old one and swaps it in
one step.
The swap cannot be interrupted halfway, so a machine that loses power mid-change
keeps its old, working file.
:::danger Vantage owns the whole file
The agent rewrites `authorized_keys` to match the desired set. Keys added by
hand on the machine are removed on the next change. If a key must survive, put
it in Vantage.
:::
## Recovering from a lockout
If you have removed every key from a machine and cannot get in, you still have
the console, provided a private key is stored, or whatever out-of-band access
your hosting provider offers. Vantage has no backdoor and does not keep a break-glass key.
-125
View File
@@ -1,125 +0,0 @@
---
id: status-pages
title: Status pages
sidebar_label: Status pages
---
A status page is a public page reporting a chosen set of monitors as up-front
components, with a 90-day history and an uptime percentage per component. It
needs no session and no token to read — anyone with the link can open it,
which is the point: it is what you hand a customer instead of an incident
email.
Requires the **Status pages** licence feature. If the licence lapses, or the
tier does not include the feature, the page keeps serving — it renders an
explanation rather than data or a broken page, so a customer who follows an
old link never sees an error.
## Creating a page
From **Status pages**, choose a page id and a title. The id is 340 characters
of lowercase letters, digits and `-`, starting and ending with a letter or
digit. It becomes part of the public URL:
```
https://<your-vantage-address>/status/<page-id>
```
On **Vantage Cloud** that address is your instance's own subdomain, so the page
is at `https://<your-instance>.vantage.hostxtra.co.uk/status/<page-id>`.
On a **self-hosted** install it is whatever address you reach Vantage on —
`https://vantage.acme.com/status/<page-id>`, or an IP and port on a LAN
install. A self-hosted install serves exactly one Vantage instance, so no
subdomain is needed to say which one you mean. The **Copy** control next to the
page address in the editor gives you the exact URL for your install, which is
the one to hand out.
**The page id cannot be changed after creation.** Once you have shared the
link, changing the id would break it, so pick something you would still be
happy with in a year — `platform`, `api`, a customer's own name for a
dedicated page.
## Draft versus published
A new page starts unpublished. Unpublished pages answer *not found* to
anyone who requests them, including you, from a browser without a session —
so you can build out the components and copy before announcing it. Toggle
**Published** when it is ready. Un-publishing later takes it back to *not
found* rather than deleting anything.
**Delete page**, in the editor header, is the only way to correct a page id you
regret — the id is fixed once created. It takes the page, its sections and its
authored incidents with it; monitors and their history are untouched. If you
only want the page off the internet, un-publish it instead.
## Sections and components
A page is organised into **sections** — arbitrary groupings such as "API" or
"Region: EU" — each holding one or more **components**. A component is a
monitor plus a **display name** you choose for this page.
The display name is never the monitor's own name unless you type it in. An
internal monitor name ("prod-db-primary-eu1") is rarely what you want a
customer reading; give it whatever name makes sense to them, and change it
for a different page without touching the monitor.
If a monitor listed on a page is later deleted, its component still appears —
reading `Unknown` rather than up or down, because nothing is checking it any
more and claiming otherwise would be a false claim of health.
## What a visitor sees
- Component name, current state (up / down / under maintenance / pending /
unknown) and a 90-day uptime percentage. **Pending** is a monitor that has
been added but has not produced a result yet; **unknown** is one nothing is
checking any more.
- A 90-day history bar per component.
- Any active incidents, upcoming maintenance, and a rolling history of both.
- An optional banner across the top of the page, for anything you want said
regardless of component state. It is one notice with one appearance — there
are no severity levels to choose between.
A visitor never sees a target URL, host or port, the check's expected status
or keyword, latency, a certificate expiry date, failure text, or which
notification channel is attached. That is a deliberate boundary, not an
oversight: nothing that would tell a stranger how your infrastructure is
reachable is on this page.
## Incidents and maintenance
Two kinds of entries appear on a page's timeline:
- **Automatic** — a monitor going down opens an incident on any page that
lists it, with no action from you. These appear the moment the monitor's
state changes and close the moment it recovers.
- **Authored** — an incident or maintenance window you create by hand, with
its own title, impact and a set of affected components you choose. You
post updates to it (Investigating → Identified → Monitoring → Resolved) as
the situation develops, and each update is timestamped and kept on the
page's history.
An authored incident is attached to one or more pages explicitly when you
create it — it does not follow a monitor onto every page that monitor happens
to be listed on.
### Scheduling maintenance
A maintenance window has a scheduled start and end (the end must be after the
start) and moves through Scheduled → In progress → Completed. While a window
is in progress and its affected components are within the scheduled time,
those components are drawn as "under maintenance" instead of up or down.
**Maintenance changes how a day is drawn, never the uptime number itself.**
The 90-day percentage is computed from what actually happened — a component
that stayed up throughout a maintenance window still shows as up in its
history, it is only the live status pill that reads "under maintenance" for
the duration.
## Delay before an update appears
A visitor's read of a page is cached for up to 30 seconds, so posting an
update or flipping Published does not necessarily change what a visitor sees
instantly — though most authoring actions invalidate that cache immediately,
so in practice it usually shows within a second or two. If a change genuinely
does not appear, reloading after 30 seconds always will.
-91
View File
@@ -1,91 +0,0 @@
---
id: vulnerabilities
title: Vulnerabilities
sidebar_label: Vulnerabilities
---
Each Linux server reports the packages it has installed. Vantage matches them
against the security advisories published by that server's own distribution and
raises a finding for anything not yet patched.
Requires the **vulnerability scanning** feature on your licence. Without it,
nothing is collected and there is no findings page. See
[Licensing and entitlements](../hq/licensing-and-entitlements.md).
## What gets scanned
Linux servers running `apt`, `dnf`/`yum`, `apk`, `zypper` or `pacman`. Agents
report their package list hourly, and only when it has changed since the last
report.
Windows servers are not scanned.
Some distributions publish no security advisories Vantage can read. Those
servers are shown as **unsupported**, rather than as having no vulnerabilities.
Those are very different answers, and only one of them is good news.
## Why versions look "wrong"
A finding names the version your distribution ships, not the upstream release.
Ubuntu's `openssl 3.0.2-0ubuntu1.15` carries security fixes backported into
what still calls itself 3.0.2, so public CVE databases listing "3.0.2" as
vulnerable are describing upstream, not your machine.
Vantage matches against your distribution's own advisories, which is why a
server can be running a version some scanners flag while Vantage correctly
reports it as patched.
For the same reason a severity here may be lower than the one you find on a CVE
website. Debian and Red Hat routinely downgrade a rating when the vulnerable
code path is not reachable in the way they build the package. Their rating is
the accurate one for the package you are actually running.
## The board
The **Vulnerabilities** page groups findings by CVE, one row each, with the
number of servers affected. Expand a row to see them. The same CVE across forty
machines is usually one decision, not forty.
Severity counts at the top filter the list when clicked. The state tabs switch
between **open**, **accepted** and **fixed**.
The vulnerability database's age is shown above the board. If a pull has failed
for long enough for the data to be stale, that becomes a warning: a low count
against three-week-old data is not the same as a low count.
## Fixing something
A finding with a known fixed version gets an **Apply updates** button, which
runs the same OS update the server page offers. There is no separate patching
mechanism.
Vantage never patches automatically. Applying updates is always something you
ask for.
## Accepting a finding
Some findings cannot be fixed today: a kernel CVE waiting on a reboot window,
or one with no vendor fix published at all.
**Accept** hides a finding from counts and alerts until a date you choose, with
a reason that is recorded in the audit log along with your name. On that date it
reopens by itself.
An expiry date is required, so nothing is dismissed permanently by accident.
## Alerts
Alert rules live with your
[notification channels](./notification-channels.md). A rule has a minimum
severity, an optional server tag filter, and the channels to notify.
A rule sends **one summary per scan** covering everything newly found, rather
than one message per finding. A single update to the security data can raise
hundreds at once.
Findings that were already open do not re-alert.
## Fleet-wide package search
Search your whole fleet for a package by name to see which servers have it and
at what version. Useful during an incident, before there is a finding for it.
-218
View File
@@ -1,218 +0,0 @@
---
id: workflows
title: Workflows and steps
sidebar_label: Workflows
---
A **step** is a reusable script with its own inputs, outputs and secrets. A
**workflow** puts steps in order and aims them at a set of servers. Running one
sends the steps to each server and streams the output back as it happens.
## Steps
A step has:
| Field | Meaning |
| --------------------- | ----------------------------------------------- |
| Name and description | How you recognise it in the library |
| Interpreter | `bash` or `powershell` |
| Script | What it runs |
| Inputs | Named parameters, with defaults |
| Outputs | Values this step passes on |
| Secrets | Vault groups made available to it |
### Passing values between steps
Each step runs with `WORKFLOW_ENV` set to a file path. Anything written there as
`KEY=value` becomes an environment variable for the **later steps of the same
run on the same server**.
```bash
HOSTNAME=$(hostname)
echo "$HOSTNAME"
echo "HOSTNAME=$HOSTNAME" >> $WORKFLOW_ENV
```
That is the whole mechanism. Listing a step's outputs documents them for the
designer, but writing to that file is what actually passes a value on.
### Secrets
Add a vault group to a step and its pairs are available as environment variables
while it runs. They do not appear in the run log unless your own script prints
them. See [Secrets](./secrets.md).
### The workspace
Every run gets its own working directory on each server. Steps share it, so one
step can leave a file for the next. It is deleted when the run finishes.
Do not use it for anything that must outlive the run.
## Default steps
Vantage ships a small library of ready-made steps, so a new install is not
staring at an empty page.
:::warning Default steps are read-only
They are reinstalled every time Vantage restarts, so any edit or deletion would
come back anyway. Vantage refuses the change rather than letting it quietly
revert.
To adapt one, override its script inside the workflow that uses it. That change
belongs to the workflow and is left alone.
:::
## Building a workflow
1. **Workflows → New**.
2. Add steps in order from the library.
3. Set inputs per step.
4. Set failure behaviour per step.
5. Choose targets: named servers, a tag selector, or both. See
[Targeting](#targeting).
### Failure behaviour
| On failure | Effect |
| ---------- | ------------------------------------------------------------- |
| Stop | Stop this server's run. Other servers carry on |
| Continue | Record the failure and run the next step anyway |
| Retry | Try again up to the limit you set, then count it as a failure |
### Per-step overrides
A workflow can override a step's script or its secret references without
touching the library entry. This is how you adapt a default step, and it is
scoped to that workflow.
## Targeting
A workflow names servers two ways, and it can use both at once:
- **Target servers**, a list you pick by hand.
- **Target tags**, matched against [server tags](./servers.md#tags). Give more
than one tag and a server must carry all of them to match.
A run goes to both sets combined. A server that is named by hand and also matched
by a tag runs once. That is how a workflow can say "every production web server,
plus this one machine I am watching" without you keeping a list up to date.
The designer shows the resolved count as you edit, so you can see how many
machines a change to the selector just added or removed before you save.
:::warning An empty selector matches nothing
Clearing the tags does not mean "all servers". A workflow with no servers and no
tags matches nothing, and running it is refused rather than reported as a
success over zero machines.
:::
Tags are read **at run time**, not when you save. Tag a new machine `env:prod`
and the next run of an `env:prod` workflow includes it, with nothing to update on
the workflow itself. The same is true in reverse: removing a tag removes the
machine from every workflow that selected on it.
### Offline servers are still targeted
A server is still targeted when its agent is offline, and the run fails visibly
on that machine. Vantage does not quietly drop unreachable servers from a run,
because a patch run that skipped three servers and called itself a success is
harder to spot than one that failed.
Re-run the workflow once they are back, or fix the agent first.
## Running
**Run** records the exact steps being run, then sends them to each server
straight away.
:::info Runs freeze their steps
Editing a step tomorrow never changes what a past run shows. A run always
displays the script that actually ran.
:::
Targets run **in parallel**; steps within one server run **in order**.
## Schedules
A workflow can run on a schedule. A scheduled run is an ordinary run, on the
same run page, with the schedule recorded as what started it.
Open a workflow, choose **Edit**, and tick **Run on a schedule**. The expression
is standard five-field cron:
```
minute hour day-of-month month day-of-week
```
The presets write cron underneath, so you can start from one and adjust:
| Preset | Cron |
| ------------------- | ----------- |
| Hourly | `0 * * * *` |
| Nightly, 02:00 | `0 2 * * *` |
| Weekly, Sun 02:00 | `0 2 * * 0` |
| Monthly, 1st 02:00 | `0 2 1 * *` |
There is no seconds field and no `@daily`-style shorthand. The next three
occurrences are shown as you type, and they are computed by the server rather
than the browser, so what you see is exactly what will fire.
### Timezones
A schedule stores a timezone by name, such as `Europe/London`, rather than an
offset. That keeps a 02:00 job at 02:00 across daylight-saving changes instead
of drifting by an hour for half the year. A timezone Vantage does not recognise
is refused when you save it.
### Overlaps are skipped, not queued
If a run of the same workflow is still going when the next occurrence comes
round, the occurrence is **skipped** and the reason recorded. It is not queued
behind the running one. A patch workflow that takes longer than its interval
should fall behind visibly rather than pile up.
### Missed occurrences
If Vantage was not running when a scheduled run was due, it still runs when
Vantage comes back, as long as that is within **one hour** of the due time.
Anything older is recorded as missed and skipped, so a job missed during a short
upgrade catches up, while one missed for two days does not suddenly start at
lunchtime.
Either kind of skip is shown on the workflow's schedule panel, with the time it
was due and why it did not run.
## Watching a run
Output streams back as it happens and the page follows it live. Each step
records its status, how many attempts it took, its exit code and any values it
passed on.
**Cancel** stops a run in progress. Steps already running on an agent finish;
nothing further is dispatched.
## Log retention
How long run logs are kept is set under **Settings → Monitoring**:
| Value | Meaning |
| -------- | -------------- |
| unset | 30 days |
| a number | that many days |
| `0` | keep forever |
## Import and export
A step exports to a file and imports back, which is how you move one between
instances or keep it in version control. You can also paste a script and have
Vantage turn it into a draft step for you.
## Practical notes
- A step is a script. It runs as root, on the target, with no sandbox. Review
what you import.
- Keep steps small and single-purpose, and combine them in the workflow. A
library of small steps stays reusable.
- PowerShell steps only make sense on Windows targets and bash steps on Linux
ones. Nothing stops you targeting the wrong one; the step simply fails.
-81
View File
@@ -1,81 +0,0 @@
---
id: workloads
title: Workloads
sidebar_label: Workloads
---
A **workload** is one Docker container or one service — a systemd unit on
Linux, a Windows service on Windows. Every server reports what it is running,
and you can start, stop and restart those workloads, and read their recent
logs, without opening a console.
Available on every instance. No licence feature is required.
## What gets reported
Every server, Linux and Windows, reported every 60 seconds.
- **Containers**: every container, running or not, with its image, published
ports, health, restart count and the compose stack it belongs to. Requires
Docker (or Docker Desktop on Windows).
- **Services**: systemd units on Linux that are running, failed, or enabled but
stopped, and Windows services in the equivalent states. The operating
system's own units and platform services are hidden, since a typical host
has hundreds of them and they bury the ones you care about.
## Docker not in use is not an error
Three states look similar, and only one of them is a problem:
| What you see | What it means |
| ------------ | ------------- |
| "Docker is not in use on this server" | Docker is not installed. Normal, and not a fault |
| "Docker is installed but not responding" | The daemon is down or the socket is unreachable |
| An empty container list | Docker is running and there are no containers |
## Stacks are grouped
Compose stacks appear first, grouped under the stack name, then individual
containers, then services.
The stack name comes from Docker itself, so it reflects what is actually
running.
## Controlling a workload
Start, stop and restart are **owner or admin only**, and every action is
written to the audit log naming you, the server and the target.
The Vantage agent will not act on itself, and its buttons are disabled. A server
that stopped its own agent would go offline, and getting it back would need SSH
or physical access.
A stop that never finishes is not reported as a success. Vantage waits up to 90
seconds and then reports the failure.
## Reading logs
Reading logs is **owner or admin only**, and every read is audited. A
container's output cannot be filtered the way a workflow's can, and a startup
banner or stack trace may contain credentials nobody expected.
You get the most recent output, up to **500 lines or 256KB**, whichever comes
first. If it was cut short, the dialog says so.
There is no live tail here. For that, use the
[browser console](./browser-console.md), which gives you a real terminal on the
same server.
## Refreshing
Opening a server's Workloads panel asks its agent to report straight away, so
what you see is current rather than up to a minute old. That matters when the
next thing you click is Restart.
If the agent is offline, the refresh reports a failure rather than waiting.
## Fleet view
**Workloads** in the sidebar searches your whole fleet by image, stack or state,
which is how you answer questions like "which servers are still on the old
image". Each result links back to its server.
-123
View File
@@ -1,123 +0,0 @@
import type * as Preset from "@docusaurus/preset-classic";
import type { Config } from "@docusaurus/types";
import { themes as prismThemes } from "prism-react-renderer";
// Served as a path on the marketing host (vantage.hostxtra.co.uk/docs), routed
// by its own Nginx Proxy Manager location rather than by site/. A path and not
// a subdomain on purpose: *.vantage.hostxtra.co.uk is the per-tenant instance
// namespace, and APP_ROOT_LABEL resolves an org from the label before
// "vantage", so a docs. label there would be read as a tenant slug.
//
// DOCS_BASE_URL has to agree with three things at once: the NPM location, the
// directory the runtime image copies the build into, and this value. When they
// disagree the HTML still loads and every asset 404s.
const url = process.env.DOCS_URL || "https://vantage.hostxtra.co.uk";
const baseUrl = process.env.DOCS_BASE_URL || "/docs/";
const appUrl = process.env.APP_URL || "https://vantage.hostxtra.co.uk";
const hqUrl = process.env.HQ_URL || "https://vantage-hq.hostxtra.co.uk";
const config: Config = {
title: "Vantage Docs",
tagline: "Fleet management for servers you actually own",
favicon: "img/favicon.svg",
url,
baseUrl,
organizationName: "hostxtra",
projectName: "vantage",
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
i18n: { defaultLocale: "en", locales: ["en"] },
markdown: {
mermaid: true,
hooks: { onBrokenMarkdownLinks: "throw" },
},
themes: [
"@docusaurus/theme-mermaid",
[
// Compile-time index served from this origin. No Algolia account,
// no external host, nothing to key or rotate.
"@easyops-cn/docusaurus-search-local",
{
hashed: true,
indexBlog: false,
docsRouteBasePath: "/",
highlightSearchTermsOnTargetPage: true,
},
],
],
presets: [
[
"classic",
{
docs: {
// Docs-only mode: the documentation is the site.
routeBasePath: "/",
sidebarPath: "./sidebars.ts",
},
blog: false,
theme: { customCss: "./src/css/custom.css" },
} satisfies Preset.Options,
],
],
themeConfig: {
colorMode: {
// Light default, matching site/ and adminsite/. web/ is the only
// app locked to dark, and that contrast is deliberate.
defaultMode: "light",
respectPrefersColorScheme: true,
},
navbar: {
// Wordmark only. The logo mark is drawn with currentColor in
// site/components/Logo.tsx, which an <img> src cannot inherit, and
// baking a fill into the file would mean a hex that stops tracking
// the theme.
title: "Vantage",
items: [
{
type: "docSidebar",
sidebarId: "docs",
position: "left",
label: "Documentation",
},
{ href: appUrl, label: "Control plane", position: "right" },
{ href: hqUrl, label: "Vantage HQ", position: "right" },
],
},
footer: {
style: "light",
links: [
{
title: "Documentation",
items: [
{ label: "Getting started", to: "/getting-started/what-is-vantage" },
{ label: "Vantage", to: "/vantage/servers" },
{ label: "Vantage HQ", to: "/hq/accounts-and-signup" },
{ label: "Reference", to: "/reference/environment-variables" },
],
},
{
title: "Product",
items: [
{ label: "Control plane", href: appUrl },
{ label: "Vantage HQ", href: hqUrl },
],
},
],
copyright: `© ${new Date().getFullYear()} HostXtra.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ["bash", "powershell", "yaml", "json", "protobuf", "nginx"],
},
} satisfies Preset.ThemeConfig,
};
export default config;
-32
View File
@@ -1,32 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Hitting the container directly is a 403 on an empty document root
# otherwise, which reads as a broken deploy rather than "wrong path".
location = / {
return 302 /docs/;
}
# Hashed assets are immutable — the filename changes when the content does.
location /docs/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /docs/ {
# Docusaurus emits a real page per route, so a miss is a genuine 404
# rather than something a SPA fallback should paper over.
try_files $uri $uri/ $uri.html /docs/404.html;
}
error_page 404 /docs/404.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
-20082
View File
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
{
"name": "vantage-docs",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "docusaurus start",
"build": "docusaurus build",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@docusaurus/theme-mermaid": "^3.10.2",
"@easyops-cn/docusaurus-search-local": "^0.52.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@docusaurus/types": "^3.10.2",
"typescript": "~5.6.2"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}
-57
View File
@@ -1,57 +0,0 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
// Authored by hand rather than autogenerated, so ordering is a decision and
// not a filename accident.
const sidebars: SidebarsConfig = {
docs: [
"index",
{
type: "category",
label: "Getting started",
collapsed: false,
items: [
"getting-started/what-is-vantage",
"getting-started/cloud-vs-self-hosted",
"getting-started/self-hosted-install",
"getting-started/first-login",
"getting-started/claim-free-licence",
"getting-started/first-server",
],
},
{
type: "category",
label: "Vantage",
items: [
"vantage/servers",
"vantage/ssh-keys",
"vantage/workflows",
"vantage/monitors",
"vantage/vulnerabilities",
"vantage/workloads",
"vantage/notification-channels",
"vantage/status-pages",
"vantage/secrets",
"vantage/browser-console",
"vantage/audit-log",
"vantage/settings",
],
},
{
type: "category",
label: "Vantage HQ",
items: ["hq/accounts-and-signup", "hq/people-and-roles", "hq/cloud-instances", "hq/self-hosted-instances", "hq/licensing-and-entitlements", "hq/billing", "hq/free-tier"],
},
{
type: "category",
label: "Reference",
items: ["reference/environment-variables", "reference/rest-api", "reference/api-tokens", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"],
},
{
type: "category",
label: "Operations",
items: ["operations/upgrading", "operations/backups", "operations/backup-and-restore", "operations/agent-updates"],
},
],
};
export default sidebars;
-236
View File
@@ -1,236 +0,0 @@
/* ==========================================================================
Vantage documentation design tokens
The token block below is COPIED VERBATIM from site/app/globals.css — same
names, same values. adminsite/ holds a copy too, web/ holds the dark half,
and shared/mail/templates/layout.html.tmpl holds it a fifth time as literal
hex because email clients support neither var() nor prefers-color-scheme.
Nothing enforces the match automatically: change a token in one file and you
change it in all of them, in the same commit.
Everything below the token block maps Docusaurus's --ifm-* variables onto
these. No rule in this file, and no component in this app, may carry a hex
value outside the two token blocks.
Docusaurus always stamps data-theme on <html>, so unlike site/ there is no
prefers-color-scheme branch to keep in step — the theme toggle is the only
writer.
========================================================================== */
:root,
:root[data-theme="light"] {
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
--rail: 1200px;
}
:root[data-theme="dark"] {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
}
/* ---------- Docusaurus mapping ---------- */
:root {
--ifm-color-primary: var(--accent);
--ifm-color-primary-dark: var(--accent);
--ifm-color-primary-darker: var(--accent);
--ifm-color-primary-darkest: var(--accent);
--ifm-color-primary-light: var(--accent);
--ifm-color-primary-lighter: var(--accent);
--ifm-color-primary-lightest: var(--accent);
--ifm-background-color: var(--ground);
--ifm-background-surface-color: var(--panel);
--ifm-font-family-base: var(--sans);
--ifm-font-family-monospace: var(--mono);
--ifm-font-size-base: var(--s-0);
--ifm-line-height-base: 1.65;
--ifm-heading-font-weight: 800;
--ifm-heading-line-height: 1.15;
--ifm-h1-font-size: var(--s-3);
--ifm-h2-font-size: var(--s-2);
--ifm-h3-font-size: var(--s-1);
--ifm-font-color-base: var(--ink);
--ifm-heading-color: var(--ink);
--ifm-link-color: var(--accent);
--ifm-link-hover-color: var(--accent);
--ifm-toc-link-color: var(--ink-2);
--ifm-navbar-background-color: var(--panel);
--ifm-navbar-shadow: none;
--ifm-navbar-link-color: var(--ink-2);
--ifm-navbar-link-hover-color: var(--ink);
--ifm-footer-background-color: var(--panel-2);
--ifm-footer-color: var(--ink-2);
--ifm-footer-link-color: var(--ink-2);
--ifm-footer-title-color: var(--ink);
--ifm-menu-color: var(--ink-2);
--ifm-menu-color-active: var(--accent);
--ifm-menu-color-background-active: var(--panel-2);
--ifm-menu-color-background-hover: var(--panel-2);
--ifm-toc-border-color: var(--rule-soft);
--ifm-hr-border-color: var(--rule-soft);
--ifm-table-border-color: var(--rule-soft);
--ifm-table-stripe-background: var(--panel-2);
--ifm-table-head-background: var(--panel-2);
--ifm-code-background: var(--panel-2);
--ifm-code-font-size: 0.88em;
--ifm-pre-background: var(--panel-2);
--ifm-blockquote-color: var(--ink-2);
--ifm-blockquote-border-color: var(--rule);
--ifm-global-radius: 4px;
--ifm-alert-border-radius: 4px;
--ifm-button-border-radius: 4px;
--ifm-global-shadow-lw: var(--shadow);
--ifm-global-shadow-md: var(--shadow);
--ifm-breadcrumb-color-active: var(--accent);
--ifm-breadcrumb-item-background-active: var(--panel-2);
--docusaurus-highlighted-code-line-bg: var(--rule-soft);
}
/* Admonitions carry the semantic three. They read by shape and label as well
as colour, which is the rule everywhere else in the product too. */
.theme-admonition-note,
.theme-admonition-info {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--rule);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-tip {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--up);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-warning {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--pend);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-danger {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--down);
--ifm-alert-foreground-color: var(--ink);
}
/* ---------- small corrections ---------- */
.navbar {
border-bottom: 1px solid var(--rule-soft);
}
.navbar__title {
font-weight: 800;
letter-spacing: -0.02em;
}
.footer {
border-top: 1px solid var(--rule-soft);
}
.markdown h1,
.markdown h2,
.markdown h3 {
letter-spacing: -0.03em;
text-wrap: balance;
}
.markdown h2 {
margin-top: 2.4rem;
padding-top: 1.6rem;
border-top: 1px solid var(--rule-soft);
}
.markdown > p,
.markdown li {
color: var(--ink-2);
}
.markdown strong {
color: var(--ink);
}
/* Machine output — install one-liners, key blobs, run logs — sits on a floor
beneath the panel, the same distinction web/ draws with --well. */
.theme-code-block {
border: 1px solid var(--rule-soft);
box-shadow: none !important;
}
table {
display: table;
width: 100%;
}
.menu {
font-size: var(--s--1);
padding: 1rem;
}
.menu__list-item-collapsible .menu__link--sane,
.menu__link {
border-radius: 4px;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* The reference tables in Reference/ are wide by nature; they scroll inside
their own container rather than pushing the page sideways. */
.markdown table {
display: block;
overflow-x: auto;
max-width: 100%;
}
-12
View File
@@ -1,12 +0,0 @@
<!--
The Vantage mark, traced from site/components/Logo.tsx. A favicon is an asset
rather than a component, and a browser tab has no access to the token block,
so the logo navy is a literal here — the same concession the email layout
makes. Keep it in step with --logo.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="246 207 533 610">
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="#0b2a58" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z" />
<path d="M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-7
View File
@@ -1,7 +0,0 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
},
"exclude": [".docusaurus", "build"]
}
-1
View File
@@ -3,6 +3,5 @@ go 1.26
use (
./agent
./server
./sitesvc
./vantagectl
)
+2 -2
View File
@@ -2,6 +2,6 @@ package models
import shared "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
// Instance is defined in the shared module because sitesvc and the admin
// control plane write the same documents.
// Instance is defined in the shared module because the control plane and
// Vantage HQ write the same documents from separate repositories.
type Instance = shared.Instance
+1 -1
View File
@@ -25,7 +25,7 @@ func EnsureAuthIndexes() error {
defer cancel()
// users.email and instances.slug are declared in the shared module so the
// control plane and sitesvc cannot disagree about them.
// control plane and Vantage HQ cannot disagree about them.
if err := indexes.EnsureCoreIndexes(ctx, db.Database); err != nil {
return err
}
+3 -3
View File
@@ -101,9 +101,9 @@ func AdoptInstance(instanceID, name string) (*models.Instance, error) {
// CreateInstance creates an organisation and seeds its default workflow steps.
//
// The creation rules live in shared/provision because sitesvc creates
// organisations too. Seeding stays here: shared must not know about workflow
// steps.
// The creation rules live in shared/provision because Vantage HQ's cloudprov
// creates instances too, from another repository. Seeding stays here: shared
// must not know about workflow steps.
func CreateInstance(name string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
-5
View File
@@ -1,5 +0,0 @@
node_modules
.next
out
.env*
npm-debug.log*
-54
View File
@@ -1,54 +0,0 @@
# Dependencies stage
FROM node:26-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Build stage
FROM node:26-alpine AS builder
WORKDIR /app
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 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"
# Browser-reachable admin URL; site/lib/submit.ts posts account signups here.
ARG NEXT_PUBLIC_ADMIN_API_URL=""
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
RUN npm run build
# Runtime stage
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
-132
View File
@@ -1,132 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description: "Vantage is built by HostXtra, a small UK company that manages servers for a living. Who we are, why we built it, and how we plan to keep it going.",
};
export default function AboutPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">About</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1.1rem", maxWidth: "20ch" }}>We built this because we needed it.</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="prose">
<p>
Vantage is made by HostXtra, a small company in the UK. We manage servers for a living our own and other people&apos;s and we built Vantage because we were doing that job
badly with a spreadsheet and a folder of scripts.
</p>
<p>
It started after a laptop went missing. Working out which servers that laptop could still get into meant logging into every one of them and reading a file by hand. The list
of servers lived in somebody&apos;s head, and two of them weren&apos;t on it. That took an afternoon, and it should have taken a minute.
</p>
<p>
Everything that existed to fix this was built for companies far larger than ours. We wanted one screen that answered &quot;who can get into this machine&quot; and let us
change the answer. Once that worked, the rest followed: if there&apos;s already a way to talk to every server you own, you may as well use it to run your scripts, check
things are still up, and open a terminal when something breaks at midnight.
</p>
<p>We use Vantage every day to run our own servers. When something about it is annoying, it&apos;s annoying us too, which is the main reason it keeps getting better.</p>
</div>
<div>
<span className="tag">The company</span>
<div className="specs">
<div className="spec">
<span className="spec__k">WHO</span>
<div>
<h3>HostXtra</h3>
<p>A small independent team in the United Kingdom. No investors, nobody to answer to but the people paying us.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">HOW WE EARN</span>
<div>
<h3>Subscriptions, and that&apos;s all</h3>
<p>We&apos;re paid by our customers. We don&apos;t sell data, we don&apos;t sell ads, and there&apos;s no free tier being mined for anything.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">WHO YOU GET</span>
<div>
<h3>The people who wrote it</h3>
<p>Support goes to the same small group that builds the product. There is no first line, and nobody reading from a script.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">IF WE STOP</span>
<div>
<h3>You can host it yourself</h3>
<p>Every tier can run on your own hardware, and your data is exportable whenever you want it. You are not stuck with us.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Who uses it</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>People with more servers than time.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Home labs</span>
<h3>A rack in the spare room</h3>
<p>A handful of machines you built yourself, and no interest in running enterprise software to keep track of them.</p>
</article>
<article className="cap">
<span className="cap__k">Small teams</span>
<h3>Two or three people, one estate</h3>
<p>Everyone needs access to everything, and everyone needs to know what the others changed. Usually the point where the spreadsheet stops working.</p>
</article>
<article className="cap">
<span className="cap__k">Agencies</span>
<h3>Servers you inherited</h3>
<p>Machines set up by somebody who has since left, for a client who now wants a straight answer about who can get into them.</p>
</article>
<article className="cap">
<span className="cap__k">Hosting</span>
<h3>Small providers</h3>
<p>Lots of machines, thin margins, and a need to prove access is controlled without buying something priced per seat.</p>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">How we work</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>A few things we&apos;ve decided.</h2>
<div className="specs" style={{ maxWidth: "70ch" }}>
<div className="spec">
<span className="spec__k">SMALL</span>
<div>
<h3>We say no to most things</h3>
<p>Every feature has to earn its place. A tool that manages access to your servers is one you have to be able to understand completely.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">HONEST</span>
<div>
<h3>The price is on the website</h3>
<p>All three tiers, including the largest, can be bought without speaking to anybody. There is no hidden pricing and no &quot;contact us&quot; tier.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">OPEN</span>
<div>
<h3>Bugs are in public</h3>
<p>Our issue tracker is open to read. If something is broken, you can see whether we already know about it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CAREFUL</span>
<div>
<h3>We take security reports seriously</h3>
<p>Report something and you&apos;ll hear back from a person within three days. We would much rather you told us than didn&apos;t.</p>
</div>
</div>
</div>
</section>
</>
);
}
-60
View File
@@ -1,60 +0,0 @@
import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Questions about pricing, licensing for self-hosted installs, security reports and bugs.",
};
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: "Our public issue 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: "Whether the hosted service is up, and a history of past incidents.",
link: "status.vantage.hostxtra.co.uk",
href: "https://status.vantage.hostxtra.co.uk",
},
];
export default function ContactPage() {
return (
<section className="rail band band--open">
<span className="tag">Contact</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Get in touch.</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="card">
<ContactForm />
</div>
<div>
<p className="prose">If one of these is a better fit than the form, use it you&apos;ll get an answer sooner.</p>
{CHANNELS.map((channel) => (
<div className="chan" key={channel.title}>
<h3>{channel.title}</h3>
<p>{channel.body}</p>
<a href={channel.href}>{channel.link}</a>
</div>
))}
</div>
</div>
</section>
);
}
-1342
View File
File diff suppressed because it is too large Load Diff
-39
View File
@@ -1,39 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
import { Footer } from "@/components/Footer";
import { Nav } from "@/components/Nav";
import { ThemeScript } from "@/components/ThemeScript";
export const metadata: Metadata = {
metadataBase: new URL("https://vantage.hostxtra.co.uk"),
title: {
default: "Vantage: one place to manage every server you look after",
template: "%s · Vantage",
},
description: "Manage access to your servers, run your scripts, check your services are up, store your passwords and open a terminal in the browser. Host it yourself or let us host it.",
openGraph: {
type: "website",
siteName: "Vantage",
title: "Vantage: one place to manage every server you look after",
description: "Server access, scripts, uptime checks, stored passwords and a terminal in the browser. Host it yourself or let us host it.",
},
icons: { icon: "/images/vantage_logo.svg" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-GB">
<head>
<ThemeScript />
</head>
<body>
<a className="skip" href="#main">
Skip to content
</a>
<Nav />
<main id="main">{children}</main>
<Footer />
</body>
</html>
);
}
-183
View File
@@ -1,183 +0,0 @@
import Link from "next/link";
import { InstrumentPanel } from "@/components/InstrumentPanel";
export default function OverviewPage() {
return (
<>
<div className="heroband">
<section className="rail hero">
<span className="tag">Server management you can host yourself</span>
<h1>One place to manage every server you look after.</h1>
<p className="lede">
Vantage keeps track of who has access to which server, runs your scripts, checks your services are still up, stores your passwords and gives you a terminal in the browser. You
install one small program on each server, and you can run the whole thing on your own hardware.
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Get started free
</Link>
<Link className="btn btn--line" href="/platform">
What it does
</Link>
</div>
<p className="hero__foot">Free for 3 servers · Linux and Windows · Hosted by us or by you</p>
</section>
</div>
<InstrumentPanel />
<section className="rail band band--open">
<span className="tag">The problem</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>Nothing knows about anything else.</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
Access ends up in a spreadsheet. Scripts end up in somebody&apos;s home directory. Uptime checks live in a separate service, passwords get pasted into a chat thread, and nobody
writes down who ran what. So when you need to know who can get into a particular box, or what was done to it last week, you go and work it out by hand.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">ONE AGENT</span>
<div>
<h3>One thing to install</h3>
<p>Access, scripts, uptime checks and hardware stats all come down the same connection. You don&apos;t install a second thing to get the second feature.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ONE RECORD</span>
<div>
<h3>Everything is written down</h3>
<p>Who was given access and who took it away, who ran which script, who opened a session. It all goes in the same log with a name against it.</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Capabilities</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>What it does.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Access</span>
<h3>SSH keys</h3>
<p>
Choose which servers a key can reach, and take that access away again when someone leaves. Each server is kept matching what you set, without you logging in to change it.
</p>
<ul>
<li>spots the same key added twice</li>
<li>can create a key on the server itself</li>
<li>keeps a record of access you removed</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Scripts</span>
<h3>Workflows</h3>
<p>Keep your shell and PowerShell scripts in one library instead of scattered across machines, then run them in order across a group of servers.</p>
<ul>
<li>watch the output as it happens</li>
<li>stop, carry on or retry when a step fails</li>
<li>pass values from one step to the next</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Uptime</span>
<h3>Monitors</h3>
<p>
Check that a site, a port, a machine or a certificate is still answering. Checks can run from us, or from an agent inside your own network for things the internet
can&apos;t see.
</p>
<ul>
<li>a history of outages and uptime</li>
<li>warnings before a certificate expires</li>
<li>alerts by email, Slack, Discord or Telegram</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Passwords</span>
<h3>Vault</h3>
<p>Keep passwords, tokens and licence keys in one encrypted place. Your scripts are handed what they need as they run, and it never shows up in the logs afterwards.</p>
<ul>
<li>grouped by the thing they belong to</li>
<li>encrypted with a key only you hold</li>
<li>looking at one is recorded</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Access</span>
<h3>Browser console</h3>
<p>Open a terminal or a remote desktop from the browser. Nothing to install on the machine you&apos;re sitting at, and every session goes in the log.</p>
<ul>
<li>links that only work once</li>
<li>passwords used once, then discarded</li>
<li>works from a borrowed laptop</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Health</span>
<h3>Hardware and updates</h3>
<p>See processor, memory, disk and operating system for every server, along with the updates waiting to be installed and install them without logging in.</p>
<ul>
<li>refreshed every 30 seconds</li>
<li>updates applied from the same screen</li>
<li>agents keep themselves up to date</li>
</ul>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">Getting started</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>Getting your first server in.</h2>
<div className="flow">
<div className="flow__c">
<span className="flow__n">FIRST</span>
<h3>Set up your account</h3>
<p>You&apos;re the owner. Nobody outside your account can see anything in it.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Add a server</h3>
<p>Copy the install command we show you and paste it into the server. It works once, then expires.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Give yourself access</h3>
<p>Paste your public key and tick the servers it should reach. It arrives within 30 seconds.</p>
</div>
<div className="flow__c">
<span className="flow__n">AFTER</span>
<h3>Add the rest as you need it</h3>
<p>Set up an uptime check, save a script, invite the people you work with.</p>
</div>
</div>
<pre className="code" style={{ marginTop: "1.6rem" }}>
<i># Linux</i>
{"\n"}
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
{"\n\n"}
<i># Windows</i>
{"\n"}
<b>irm</b> https://vantage.hostxtra.co.uk/install.ps1 | <b>iex</b>
</pre>
</section>
<section className="rail band band--flush">
<div className="card card--cta">
<div style={{ maxWidth: "48ch" }}>
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Set up an account, add one server, and see it working before you decide anything.</p>
</div>
<Link className="btn btn--solid" href="/start">
Get started
</Link>
</div>
</section>
</>
);
}
-151
View File
@@ -1,151 +0,0 @@
import type { Metadata } from "next";
import { TopologyDiagram } from "@/components/TopologyDiagram";
export const metadata: Metadata = {
title: "Platform",
description: "How Vantage fits together: the part you run, a small program on each server, and one connection between them that the server opens itself.",
};
export default function PlatformPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">Platform</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "19ch" }}>How the pieces fit together.</h1>
<p className="lede">There are only three parts: Vantage itself, a small program on each of your servers, and one connection between them that your server opens.</p>
<TopologyDiagram />
<div className="split" style={{ marginTop: "3rem" }}>
<div>
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>Your servers call us, not the other way round.</h2>
<div className="prose" style={{ marginTop: "1rem" }}>
<p>
Each server reaches out to Vantage over an encrypted connection it opens itself. You don&apos;t open a port in your firewall, you don&apos;t need a fixed address, and a
machine sitting behind a home router works exactly like one in a data centre.
</p>
<p>
Once that connection is open it stays open, so anything you ask for from the browser run this script, open a session, install these updates happens straight away rather
than on a timer.
</p>
</div>
</div>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">CHECKING</span>
<div>
<h3>Access, every 30 seconds</h3>
<p>The server asks who should be able to reach it. If nothing has changed, nothing is touched.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ASKING</span>
<div>
<h3>Anything you press</h3>
<p>Run a script, open a session, install updates, create a key. Sent down the connection as you ask for it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REPORTING</span>
<div>
<h3>Health and uptime</h3>
<p>Load and free space every 30 seconds, full hardware details every 15 minutes, uptime checks as they finish.</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Changing access</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>Careful about the file it owns.</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
The only thing Vantage changes on your server is the list of keys allowed to log in. It compares that list against what you have set and leaves it alone when they already
match, so most of the time it writes nothing at all. When it does need to make a change, it prepares the new list first and swaps it in one go a server that loses power halfway
through keeps the list it already had, rather than ending up with half of one.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">SCOPE</span>
<div>
<h3>One file, nothing else</h3>
<p>It doesn&apos;t manage your packages, your configuration or your users. Anything else that happens is a script you wrote and asked it to run.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REMOVING</span>
<div>
<h3>Taking access away</h3>
<p>Untick a server and the key is gone from it within 30 seconds. We keep the record that it was there, and who removed it.</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">People and access</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Who can see what.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Separation</span>
<h3>Your account is its own world</h3>
<p>Servers, keys, scripts, checks and passwords all belong to one account. There is no shared view, and no setting that could accidentally expose one account to another.</p>
</article>
<article className="cap">
<span className="cap__k">Roles</span>
<h3>Owner, admin, member</h3>
<p>Members get on with the day-to-day work. Admins and owners are the ones who add and remove people and change how the account is set up.</p>
</article>
<article className="cap">
<span className="cap__k">Signing in</span>
<h3>Email, or your own login</h3>
<p>Email and password works out of the box. If your organisation already has a single sign-on provider, you can point Vantage at it instead.</p>
</article>
<article className="cap">
<span className="cap__k">Storage</span>
<h3>Encrypted, and yours</h3>
<p>Private keys, passwords and saved credentials are encrypted before they are stored. Host it yourself and the key that unlocks them is one only you have.</p>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">What it deliberately doesn&apos;t do</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Things we left out on purpose.</h2>
<div className="specs" style={{ maxWidth: "70ch" }}>
<div className="spec">
<span className="spec__k">NOT IN THE WAY</span>
<div>
<h3>Your connections don&apos;t go through us</h3>
<p>Vantage decides who is allowed in; when you log in you go straight to the server. If Vantage is down for any reason, you can still get to your machines.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NOT A KEYRING</span>
<div>
<h3>We&apos;d rather not hold your private keys</h3>
<p>A key created on a server stays there unless you choose to upload it, and we only ever need the public half to give someone access.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NOT PER-PERSON</span>
<div>
<h3>Access to the server, not to accounts on it</h3>
<p>Vantage manages administrator access to each machine. Handing individual people their own separate logins on every box is a different job, and a bigger one.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO ADD-ONS</span>
<div>
<h3>Nothing to extend, on purpose</h3>
<p>The program on your servers is small and has no plugin system. It runs with full privileges, so we would rather it be something you could sit down and read.</p>
</div>
</div>
</div>
</section>
</>
);
}
-223
View File
@@ -1,223 +0,0 @@
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing",
description: "Three tiers, cloud or self-hosted, priced per managed server. Free for 3 servers. Professional from £19 a month, Enterprise from £49 a month, £5 per extra server.",
};
/* Prices and allowances are the shipped catalogue, not marketing rounding:
* allowances come from shared/license/plans.go and the amounts from the Paddle
* catalogue. Annual is ten times monthly everywhere, which is the "two months
* free" the portal's own term picker states. */
const COMPARISON: [string, string, string, string][] = [
["Managed servers", "3", "5, then £2.50 each", "10, then £2.50 each"],
["People", "Unlimited", "Unlimited", "Unlimited"],
["SSH key assignment", "Yes", "Yes", "Yes"],
["Workflows and step library", "Yes", "Yes", "Yes"],
["Monitors", "3", "Unlimited", "Unlimited"],
["Secret groups", "1", "Unlimited", "Unlimited"],
["Notification channels", "1", "Unlimited", "Unlimited"],
["Audit history", "30 days", "365 days", "Forever"],
["Browser console", "Not Available", "Add-on", "Add-on"],
["Single sign-on", "Not Available", "Add-on", "Add-on"],
["Vulnerability scanning", "Not Available", "Add-on", "Add-on"],
["Support", "Community", "Email, 24/5", "Email and phone, 24/7"],
["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"],
["Self-hosted term", "Annual, £0", "Annual", "Annual"],
];
export default function PricingPage() {
return (
<section className="rail band band--open">
<span className="tag">Pricing</span>
<h1
style={{
fontSize: "var(--s-3)",
margin: "0.8rem 0 1rem",
maxWidth: "17ch",
}}
>
Pick a tier, then pay per server.
</h1>
<p className="lede">You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs the same whether we host it or you do.</p>
<div className="plans">
<div className="plan">
<div>
<div className="plan__n">Free</div>
<p className="plan__d">For a home lab or a couple of servers you look after on your own.</p>
</div>
<div className="plan__p">
£0 <span>3 servers</span>
</div>
<p className="plan__t">Cloud or self-hosted. Renewed once a year from the portal, one free instance per deployment.</p>
<ul>
<li>3 servers and 3 monitors</li>
<li>Keys, workflows and the step library</li>
<li>One secret group, one alert channel</li>
<li>30 days of audit history</li>
<li>Community support</li>
</ul>
<Link className="btn btn--line" href="/start">
Get started
</Link>
</div>
<div className="plan plan--pick">
<div>
<div className="plan__n">Professional</div>
<p className="plan__d">For servers people rely on, looked after by more than one person.</p>
</div>
<div className="plan__p">
£19 <span>/ month, 3 servers</span>
</div>
<p className="plan__t">£2.50 per extra server. £190 a year saves two months. Self-hosted is £190 a year.</p>
<ul>
<li>3 servers included, add as many as you like</li>
<li>Unlimited monitors, secrets and channels</li>
<li>365 days of audit history</li>
<li>Browser console, single sign-on and vulnerability scanning as add-ons</li>
<li>Email support, 24/5</li>
</ul>
<Link className="btn btn--solid" href="/start">
Get started
</Link>
</div>
<div className="plan">
<div>
<div className="plan__n">Enterprise</div>
<p className="plan__d">For larger estates, and for anyone who has to answer to an auditor.</p>
</div>
<div className="plan__p">
£49 <span>/ month, 10 servers</span>
</div>
<p className="plan__t">£2.50 per extra server. £490 a year saves two months. Self-hosted is £490 a year.</p>
<ul>
<li>10 servers included, add as many as you like</li>
<li>Everything in Professional</li>
<li>Audit history kept forever</li>
<li>Email and phone support, 24/7</li>
<li>Buy it yourself, no sales call</li>
</ul>
<Link className="btn btn--line" href="/start">
Get started
</Link>
</div>
</div>
<div className="specs" style={{ marginTop: "2.6rem", maxWidth: "66ch" }}>
<span className="tag">Add-ons, on Professional and Enterprise</span>
<div className="spec">
<span className="spec__k">£5 / MO</span>
<div>
<h3>Browser console</h3>
<p>SSH, RDP and VNC sessions in the browser, audited like everything else. £50 a year.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">£9 / MO</span>
<div>
<h3>Single sign-on</h3>
<p>Connect your own OIDC provider to an instance. £90 a year.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">£7 / MO</span>
<div>
<h3>Vulnerability Scanning</h3>
<p>Agents will scan and report known Vulnerabilites. £70 a year.</p>
</div>
</div>
</div>
<p className="scroll__hint">Scroll the table sideways for all three tiers</p>
<div className="scroll" tabIndex={0} role="region" aria-label="Plan comparison">
<table className="cmp">
<thead>
<tr>
<th>Capability</th>
<th>Free</th>
<th>Professional</th>
<th>Enterprise</th>
</tr>
</thead>
<tbody>
{COMPARISON.map(([capability, free, professional, enterprise]) => (
<tr key={capability}>
<td>{capability}</td>
<td>{free}</td>
<td>{professional}</td>
<td>{enterprise}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: "3.2rem", maxWidth: "66ch" }}>
<span className="tag">Fine print, in plain words</span>
<div className="specs">
<div className="spec">
<span className="spec__k">COUNTING</span>
<div>
<h3>What counts as a server</h3>
<p>Anything with Vantage installed on it counts as one. You set the limit yourself rather than being metered, so the bill is always the number you chose.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ADDING</span>
<div>
<h3>Going up mid-term</h3>
<p>Raise the server count and the new cap applies straight away, prorated for the rest of the period. Your renewal date does not move.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REDUCING</span>
<div>
<h3>Going down</h3>
<p>A reduction takes effect at your next renewal. You keep what you have already paid for until then, and we show you the date it changes.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">LIMITS</span>
<div>
<h3>Reaching your limit</h3>
<p>Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove one.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CANCELLING</span>
<div>
<h3>What happens when you stop paying</h3>
<p>You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your servers keep the access they have. You just can&apos;t change anything until you renew.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">SELF-HOSTED</span>
<div>
<h3>Why self-hosted is yearly only</h3>
<p>When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can&apos;t switch one off partway through, so we sell it a year at a time.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">EXIT</span>
<div>
<h3>Leaving</h3>
<p>You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.</p>
</div>
</div>
</div>
</div>
<p className="prose" style={{ marginTop: "2.4rem" }}>
Prices exclude VAT, which is calculated at checkout. Billing is handled by Paddle as merchant of record.{" "}
<Link href="/contact" style={{ color: "var(--accent)" }}>
Ask us anything
</Link>{" "}
before you buy.
</p>
</section>
);
}
-66
View File
@@ -1,66 +0,0 @@
import type { Metadata } from "next";
import { AccountForm } from "@/components/AccountForm";
export const metadata: Metadata = {
title: "Vantage Cloud",
description: "Set up an account, confirm your email, and get a free Vantage for three servers. Hosted by us, or installed on your own hardware.",
};
export default function StartPage() {
return (
<section className="rail band band--open">
<div className="split">
<div>
<span className="tag">Vantage Cloud</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Create your account.</h1>
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
This is where your billing and your Vantage sites live. Confirm your email address and you can set one up right away free, for three servers, hosted by us.
</p>
<div className="card" style={{ marginTop: "1.9rem" }}>
<AccountForm />
</div>
</div>
<div>
<span className="tag">What happens next</span>
<div className="specs">
<div className="spec">
<span className="spec__k">FIRST</span>
<div>
<h3>Confirm your email</h3>
<p>We send you a link that works once. Nothing is created until you open it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Set up your Vantage</h3>
<p>One click. It gets its own web address, and you&apos;re the owner of it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a key and a server</h3>
<p>Paste in your public key, then run the install command on the server. It works once and expires after an hour.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Watch it appear</h3>
<p>The server turns from pending to active as soon as it checks in, usually within 30 seconds.</p>
</div>
</div>
</div>
<pre className="code" style={{ marginTop: "1.8rem" }}>
<b>curl</b> -fsSL https://vantage.mydomain.com/install | \{"\n"}
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
</pre>
</div>
</div>
</section>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

-90
View File
@@ -1,90 +0,0 @@
"use client";
import { useState } from "react";
import { Honeypot } from "@/components/Honeypot";
import { submitAccountSignup, type SubmitResult } from "@/lib/submit";
const MIN_PASSWORD = 12;
export function AccountForm() {
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitAccountSignup({
name: String(data.get("name") ?? ""),
email: String(data.get("email") ?? ""),
password: String(data.get("password") ?? ""),
website: String(data.get("website") ?? ""),
}),
);
}
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We&apos;ve sent you a confirmation link. Open it and your account is ready to use. The link works once, and expires after 24 hours.
</p>
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
Nothing is created until you confirm if the email doesn&apos;t arrive, start again or email support@hostxtra.co.uk.
</p>
</div>
);
}
const fieldError = (name: string) => result.fields?.[name];
return (
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="o-name">Your organisation</label>
<input id="o-name" name="name" type="text" placeholder="Northgate Systems" required aria-describedby="o-name-err" />
{fieldError("name") && (
<small id="o-name-err" className="field__err">
{fieldError("name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-email">Owner email</label>
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
<small>You become the first owner and can invite the rest of the team afterwards.</small>
{fieldError("email") && (
<small id="o-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-pass">Password</label>
<input id="o-pass" name="password" type="password" autoComplete="new-password" minLength={MIN_PASSWORD} required aria-describedby="o-pass-err" />
<small>At least {MIN_PASSWORD} characters. Use a manager you are about to manage SSH keys with it.</small>
{fieldError("password") && (
<small id="o-pass-err" className="field__err">
{fieldError("password")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Get started"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
-111
View File
@@ -1,111 +0,0 @@
"use client";
import { useState } from "react";
import { Honeypot } from "@/components/Honeypot";
import { submitContact, type SubmitResult } from "@/lib/submit";
export function ContactForm() {
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitContact({
name: String(data.get("name") ?? ""),
email: String(data.get("email") ?? ""),
servers: String(data.get("servers") ?? ""),
topic: String(data.get("topic") ?? ""),
message: String(data.get("message") ?? ""),
website: String(data.get("website") ?? ""),
})
);
}
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Message sent.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We reply within one business day. If it is urgent, email support@hostxtra.co.uk directly.
</p>
</div>
);
}
const fieldError = (name: string) => result.fields?.[name];
return (
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="c-name">Your name</label>
<input id="c-name" name="name" type="text" autoComplete="name" required aria-describedby="c-name-err" />
{fieldError("name") && (
<small id="c-name-err" className="field__err">
{fieldError("name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="c-email">Work email</label>
<input id="c-email" name="email" type="email" autoComplete="email" required aria-describedby="c-email-err" />
{fieldError("email") && (
<small id="c-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="c-servers">Roughly how many servers?</label>
<select id="c-servers" name="servers" defaultValue="425">
<option>13</option>
<option>425</option>
<option>26100</option>
<option>More than 100</option>
</select>
</div>
<div className="field">
<label htmlFor="c-topic">What is this about?</label>
<select id="c-topic" name="topic" defaultValue="Evaluating Vantage">
<option>Evaluating Vantage</option>
<option>Self-hosted licensing</option>
<option>Migrating from something else</option>
<option>Security disclosure</option>
</select>
</div>
<div className="field">
<label htmlFor="c-message">What are you trying to solve?</label>
<textarea
id="c-message"
name="message"
required
placeholder="We inherit client servers and can never prove who still has access…"
aria-describedby="c-message-err"
/>
{fieldError("message") && (
<small id="c-message-err" className="field__err">
{fieldError("message")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Send message"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
-18
View File
@@ -1,18 +0,0 @@
import Link from "next/link";
import { NAV_LINKS } from "@/components/nav-links";
export function Footer() {
return (
<footer className="foot">
<div className="rail foot__in">
<p>Vantage server management for people who own their servers.</p>
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href}>
{link.label}
</Link>
))}
<Link href="/start">Get started</Link>
</div>
</footer>
);
}
-14
View File
@@ -1,14 +0,0 @@
/*
* A field no person ever sees or tabs into, but an automated form-filler will
* happily complete. The server treats any value here as a bot. Hidden with
* inline styles rather than a utility class so it stays hidden even if the
* stylesheet fails to load.
*/
export function Honeypot() {
return (
<div style={{ position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" }} aria-hidden="true">
<label htmlFor="website">Website</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" defaultValue="" />
</div>
);
}
-166
View File
@@ -1,166 +0,0 @@
"use client";
import { useEffect, useState } from "react";
type LogLine = { time: string; body: React.ReactNode };
type Beat = {
at: number;
line: LogLine;
effect?: "incident" | "runDone" | "revoked";
};
const BEATS: Beat[] = [
{ at: 600, line: { time: "14:22:02", body: "running · step 1/3 · pull image" } },
{ at: 1500, line: { time: "14:22:04", body: "running · step 2/3 · migrate database" } },
{
at: 2600,
line: {
time: "14:22:07",
body: (
<>
<span className="ok">ok</span> · migrate database · exit 0
</>
),
},
},
{
at: 3400,
line: { time: "14:22:08", body: "running · step 3/3 · restart service" },
effect: "incident",
},
{
at: 4300,
line: {
time: "14:22:10",
body: (
<>
<span className="er">monitor</span> · edge-gw-02 tls · connection refused
</>
),
},
},
{
at: 5200,
line: {
time: "14:22:11",
body: (
<>
<span className="ok">ok</span> · restart service · exit 0
</>
),
},
effect: "runDone",
},
{
at: 6000,
line: {
time: "14:22:12",
body: (
<>
run finished · <span className="ok">success</span> · 3 steps · 1 server
</>
),
},
effect: "revoked",
},
];
const FIRST_LINE: LogLine = { time: "14:22:01", body: "queued · deploy-app · 1 server" };
const MAX_LINES = 7;
export function InstrumentPanel() {
const [lines, setLines] = useState<LogLine[]>([FIRST_LINE]);
const [incident, setIncident] = useState(false);
const [runActive, setRunActive] = useState(true);
const [revoked, setRevoked] = useState(false);
const [resting, setResting] = useState(false);
useEffect(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const apply = (effect: Beat["effect"]) => {
if (effect === "incident") setIncident(true);
if (effect === "runDone") setRunActive(false);
if (effect === "revoked") setRevoked(true);
};
if (reduced) {
setLines([FIRST_LINE, ...BEATS.map((b) => b.line)].slice(-MAX_LINES));
BEATS.forEach((b) => apply(b.effect));
setResting(true);
return;
}
const timers = BEATS.map((beat) =>
window.setTimeout(() => {
setLines((prev) => [...prev, beat.line].slice(-MAX_LINES));
apply(beat.effect);
}, beat.at),
);
timers.push(window.setTimeout(() => setResting(true), 6600));
return () => timers.forEach(window.clearTimeout);
}, []);
return (
<div className="instrument">
<div className="rail">
<div className="instrument__bar">
<span>
<b>northgate</b> · fleet
</span>
<span>12 servers</span>
<span>{incident ? "10 up" : "11 up"}</span>
<span>{incident ? "2 down" : "1 down"}</span>
<span>3 monitors</span>
<span>{runActive ? "1 run active" : "no runs active"}</span>
<span className="instrument__clock">14:22:12 UTC</span>
</div>
<div className="panes">
<section className="pane" aria-label="Fleet status">
<h2 className="pane__h">
Fleet <span>{revoked ? "key revoked · 1 server updated" : "agents polling"}</span>
</h2>
<Row host="proxmox-node-1" sub="4 keys · 12% cpu" state="up" label="Active" />
<Row host="db-primary" sub="3 keys · 61% cpu" state="up" label="Active" />
<Row host="edge-gw-02" sub={incident ? "2 keys · tls refused" : "2 keys · tls 41d"} state={incident ? "down" : "up"} label={incident ? "Incident" : "Active"} />
<Row host="win-build-01" sub="agent 1.4.1 · update ready" state="pend" label="Pending" />
<Row host="app-worker-03" sub={revoked ? "4 keys · 1 revoked" : "5 keys · idle"} state="up" label="Active" />
</section>
<section className="pane" aria-label="Workflow run">
<h2 className="pane__h">
Run <span>run_8f31c2</span>
</h2>
<div className="stream" aria-live="polite">
{lines.map((line, i) => (
<div key={`${line.time}-${i}`}>
<span className="t">{line.time}</span> {line.body}
</div>
))}
{resting && (
<div>
<span className="caret">_</span>
</div>
)}
</div>
</section>
</div>
</div>
</div>
);
}
function Row({ host, sub, state, label }: { host: string; sub: string; state: "up" | "down" | "pend"; label: string }) {
return (
<div className="frow">
<span className={state === "up" ? "dot" : `dot dot--${state}`} />
<span className="frow__host">{host}</span>
<span className="frow__sub">{sub}</span>
<span className={`chip chip--${state}`}>{label}</span>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
export function Logo({ className }: { className?: string }) {
return (
<svg viewBox="246 207 533 610" className={className} aria-hidden="true" focusable="false">
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432 -256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177 0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177 -2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157 -95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39 3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8 -75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165 52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397 -233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185 -321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6 -710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79 46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147 -86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10 708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107 c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590 -6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365 l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393 -194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136 -292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548 -5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188 -112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3 178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73 -40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417 -247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116 343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6 -292 176 -301 174 -2 0 -24 -13 -49 -28z" />
</g>
</svg>
);
}
-72
View File
@@ -1,72 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { NAV_LINKS } from "@/components/nav-links";
import { ThemeToggle } from "@/components/ThemeToggle";
export function Nav() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [pathname]);
function current(href: string) {
return pathname === href || pathname === `${href}/` ? "page" : undefined;
}
return (
<>
<header className="nav">
<div className="rail nav__in">
<Link className="brand" href="/">
<Logo />
<b>Vantage</b>
</Link>
<nav className="nav__links" aria-label="Main">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
{link.label}
</Link>
))}
</nav>
<ThemeToggle />
<button
type="button"
className="icon-btn nav__menu"
aria-expanded={open}
aria-controls="nav-drawer"
onClick={() => setOpen((v) => !v)}
>
{open ? "Close" : "Menu"}
</button>
</div>
</header>
{open && (
<div className="drawer" id="nav-drawer">
<div className="rail">
<nav aria-label="Main, mobile">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
{link.label}
</Link>
))}
<Link className="btn btn--solid" href="/start">
Get started
</Link>
</nav>
</div>
</div>
)}
</>
);
}
-16
View File
@@ -1,16 +0,0 @@
const script = `
(function(){
try {
var t = localStorage.getItem("vantage-theme");
if (t === "light" || t === "dark") {
document.documentElement.setAttribute("data-theme", t);
}
} catch (e) {}
})();
`;
export function ThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { useEffect, useState } from "react";
const STORAGE_KEY = "vantage-theme";
type Theme = "light" | "dark";
function currentTheme(): Theme {
const set = document.documentElement.getAttribute("data-theme");
if (set === "light" || set === "dark") return set;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme | null>(null);
useEffect(() => {
setTheme(currentTheme());
}, []);
function toggle() {
const next: Theme = currentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
window.localStorage.setItem(STORAGE_KEY, next);
setTheme(next);
}
const label = theme === null ? "Theme" : theme === "dark" ? "Light" : "Dark";
return (
<button type="button" className="icon-btn" onClick={toggle} aria-label={`Switch to ${label.toLowerCase()} theme`}>
{label}
</button>
);
}
-450
View File
@@ -1,450 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Mode = "cloud" | "self";
type Palette = {
ink: string;
ink3: string;
rule: string;
panel: string;
accent: string;
up: string;
pend: string;
mono: string;
sans: string;
};
type Node = {
label: string;
sub: string;
x: number;
y: number;
w: number;
h: number;
state: "up" | "pend";
flash: number;
};
type Wire = {
node: number;
ax: number;
ay: number;
bx: number;
by: number;
c1x: number;
c1y: number;
c2x: number;
c2y: number;
};
type Packet = {
wire: number;
t: number;
speed: number;
/** true when travelling control plane to agent, which only happens down a wire the agent opened. */
inbound: boolean;
};
const AGENTS: { label: string; sub: string; state: "up" | "pend" }[] = [
{ label: "proxmox-node-1", sub: "4 keys", state: "up" },
{ label: "db-primary", sub: "3 keys", state: "up" },
{ label: "edge-gw-02", sub: "2 keys", state: "up" },
{ label: "win-build-01", sub: "windows", state: "pend" },
];
const MODES: { id: Mode; label: string }[] = [
{ id: "cloud", label: "Hosted by us" },
{ id: "self", label: "Hosted by you" },
];
const NOTES: Record<Mode, string> = {
cloud: "We run Vantage. Your servers reach out to it, so nothing needs to be open to the internet on your side.",
self: "You run Vantage too, on your own hardware. Nothing leaves your network at all, and there is nothing to phone home to.",
};
function readPalette(el: HTMLElement): Palette {
const s = getComputedStyle(el);
const v = (name: string, fallback: string) => s.getPropertyValue(name).trim() || fallback;
return {
ink: v("--ink", "#0a1b33"),
ink3: v("--ink-3", "#6c7f96"),
rule: v("--rule", "#cdd6e2"),
panel: v("--panel", "#ffffff"),
accent: v("--accent", "#0b2a58"),
up: v("--up", "#2f8a60"),
pend: v("--pend", "#b0801f"),
mono: `11px ${v("--mono", "monospace")}`,
sans: `600 15px ${v("--sans", "sans-serif")}`,
};
}
function pointAt(w: Wire, t: number) {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return {
x: a * w.ax + b * w.c1x + c * w.c2x + d * w.bx,
y: a * w.ay + b * w.c1y + c * w.c2y + d * w.by,
};
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export function TopologyDiagram() {
const [mode, setMode] = useState<Mode>("cloud");
const canvasRef = useRef<HTMLCanvasElement>(null);
const modeRef = useRef<Mode>(mode);
modeRef.current = mode;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let palette = readPalette(document.documentElement);
let width = 0;
let height = 0;
let nodes: Node[] = [];
let wires: Wire[] = [];
let plane = { x: 0, y: 0, w: 0, h: 0 };
let boundary: { x: number; y: number; w: number; h: number; label: string; split: number | null } | null = null;
let stacked = false;
const packets: Packet[] = [];
let planePulse = 0;
let sinceSpawn = 0;
let sinceCommand = 2.2;
let raf = 0;
let last = 0;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const layout = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
stacked = width < 660;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const pad = stacked ? 14 : 22;
const nodeW = stacked ? Math.min(150, (width - pad * 2 - 16) / 2) : 168;
const nodeH = 46;
if (stacked) {
plane = { x: width / 2 - 100, y: pad + 30, w: 200, h: 78 };
const cols = 2;
const gapX = 14;
const gapY = 14;
const gridW = cols * nodeW + gapX;
const startX = width / 2 - gridW / 2;
const startY = plane.y + plane.h + (mode === "cloud" ? 84 : 66);
nodes = AGENTS.map((a, i) => ({
label: a.label,
sub: a.sub,
state: a.state,
flash: 0,
w: nodeW,
h: nodeH,
x: startX + (i % cols) * (nodeW + gapX),
y: startY + Math.floor(i / cols) * (nodeH + gapY),
}));
} else {
const colX = pad + 8;
plane = { x: width - pad - 214, y: height / 2 - 46, w: 206, h: 92 };
const total = AGENTS.length * nodeH + (AGENTS.length - 1) * 16;
const startY = height / 2 - total / 2;
nodes = AGENTS.map((a, i) => ({
label: a.label,
sub: a.sub,
state: a.state,
flash: 0,
w: nodeW,
h: nodeH,
x: colX,
y: startY + i * (nodeH + 16),
}));
}
wires = nodes.map((n, i) => {
if (stacked) {
const ax = n.x + n.w / 2;
const ay = n.y;
const bx = plane.x + plane.w * (0.2 + 0.2 * i);
const by = plane.y + plane.h;
const mid = (ay + by) / 2;
return { node: i, ax, ay, bx, by, c1x: ax, c1y: mid, c2x: bx, c2y: mid };
}
const ax = n.x + n.w;
const ay = n.y + n.h / 2;
const bx = plane.x;
const by = plane.y + plane.h * (0.24 + 0.17 * i);
const mid = ax + (bx - ax) * 0.5;
return { node: i, ax, ay, bx, by, c1x: mid, c1y: ay, c2x: mid, c2y: by };
});
if (modeRef.current === "self") {
boundary = { x: pad - 8, y: pad - 8, w: width - (pad - 8) * 2, h: height - (pad - 8) * 2, label: "YOUR NETWORK", split: null };
} else if (stacked) {
const y = plane.y + plane.h + 34;
boundary = { x: pad - 8, y, w: width - (pad - 8) * 2, h: height - y - (pad - 8), label: "YOUR NETWORK", split: y };
} else {
const split = plane.x - 46;
boundary = { x: pad - 8, y: pad - 8, w: split - (pad - 8), h: height - (pad - 8) * 2, label: "YOUR NETWORK", split };
}
};
const spawn = () => {
if (packets.length > 60) return;
const wire = Math.floor(Math.random() * wires.length);
packets.push({ wire, t: 0, speed: 0.34 + Math.random() * 0.16, inbound: false });
};
const drawWire = (w: Wire) => {
ctx.beginPath();
ctx.moveTo(w.ax, w.ay);
ctx.bezierCurveTo(w.c1x, w.c1y, w.c2x, w.c2y, w.bx, w.by);
ctx.stroke();
};
const draw = (dt: number) => {
ctx.clearRect(0, 0, width, height);
const mono = palette.mono;
// Boundary of what you own.
if (boundary) {
ctx.save();
ctx.setLineDash([5, 5]);
ctx.lineWidth = 1;
ctx.strokeStyle = palette.rule;
roundRect(ctx, boundary.x, boundary.y, boundary.w, boundary.h, 10);
ctx.stroke();
ctx.restore();
ctx.font = mono;
ctx.fillStyle = palette.ink3;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(boundary.label, boundary.x + 12, boundary.y + 13);
if (modeRef.current === "cloud") {
ctx.textAlign = stacked ? "left" : "right";
const x = stacked ? boundary.x + 12 : width - 22;
const y = stacked ? 18 : boundary.y + 13;
ctx.fillText("HOSTED BY US", x, y);
}
}
// Wires. Drawn under everything, because they are infrastructure.
ctx.lineWidth = 1.25;
ctx.strokeStyle = palette.rule;
wires.forEach(drawWire);
// Packets travelling the wires.
for (let i = packets.length - 1; i >= 0; i--) {
const p = packets[i];
p.t += p.speed * dt;
if (p.t >= 1) {
if (p.inbound) nodes[wires[p.wire].node].flash = 1;
else planePulse = 1;
packets.splice(i, 1);
continue;
}
const w = wires[p.wire];
const pos = pointAt(w, p.inbound ? 1 - p.t : p.t);
const fade = Math.min(1, Math.sin(p.t * Math.PI) * 2.2);
ctx.globalAlpha = fade;
ctx.fillStyle = p.inbound ? palette.accent : palette.up;
ctx.beginPath();
ctx.arc(pos.x, pos.y, p.inbound ? 3.4 : 2.4, 0, Math.PI * 2);
ctx.fill();
if (p.inbound) {
ctx.globalAlpha = fade * 0.22;
ctx.beginPath();
ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// Agents.
for (const n of nodes) {
if (n.flash > 0) {
ctx.globalAlpha = n.flash * 0.5;
ctx.strokeStyle = palette.accent;
ctx.lineWidth = 2;
roundRect(ctx, n.x - 2, n.y - 2, n.w + 4, n.h + 4, 8);
ctx.stroke();
ctx.globalAlpha = 1;
n.flash = Math.max(0, n.flash - dt * 1.6);
}
ctx.fillStyle = palette.panel;
ctx.strokeStyle = palette.rule;
ctx.lineWidth = 1;
roundRect(ctx, n.x, n.y, n.w, n.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = n.state === "up" ? palette.up : palette.pend;
ctx.beginPath();
ctx.arc(n.x + 14, n.y + n.h / 2, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = mono;
ctx.fillStyle = palette.ink;
ctx.fillText(n.label, n.x + 26, n.y + n.h / 2 - 6);
ctx.fillStyle = palette.ink3;
ctx.fillText(n.sub, n.x + 26, n.y + n.h / 2 + 8);
}
// Control plane.
if (planePulse > 0) {
ctx.globalAlpha = planePulse * 0.35;
ctx.strokeStyle = palette.accent;
ctx.lineWidth = 2;
roundRect(ctx, plane.x - 3 - planePulse * 4, plane.y - 3 - planePulse * 4, plane.w + 6 + planePulse * 8, plane.h + 6 + planePulse * 8, 10);
ctx.stroke();
ctx.globalAlpha = 1;
planePulse = Math.max(0, planePulse - dt * 1.5);
}
ctx.fillStyle = palette.accent;
roundRect(ctx, plane.x, plane.y, plane.w, plane.h, 8);
ctx.fill();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const cx = plane.x + plane.w / 2;
ctx.fillStyle = palette.panel;
ctx.font = palette.sans;
ctx.fillText("Vantage", cx, plane.y + plane.h / 2 - 11);
ctx.font = mono;
ctx.globalAlpha = 0.78;
ctx.fillText(modeRef.current === "cloud" ? "vantage.hostxtra.co.uk" : "your own hardware", cx, plane.y + plane.h / 2 + 8);
ctx.globalAlpha = 1;
// Direction of travel, stated once rather than on every wire.
if (!stacked) {
ctx.font = mono;
ctx.fillStyle = palette.ink3;
ctx.textAlign = "center";
const midX = (nodes[0].x + nodes[0].w + plane.x) / 2;
ctx.fillText("your servers dial out", midX, height - 26);
}
};
const frame = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
sinceSpawn += dt;
if (sinceSpawn > 0.42) {
sinceSpawn = 0;
spawn();
}
sinceCommand += dt;
if (sinceCommand > 4.4) {
sinceCommand = 0;
const wire = Math.floor(Math.random() * wires.length);
packets.push({ wire, t: 0, speed: 0.62, inbound: true });
}
draw(dt);
raf = requestAnimationFrame(frame);
};
const start = () => {
cancelAnimationFrame(raf);
layout();
if (reduced.matches) {
packets.length = 0;
wires.forEach((_, i) => packets.push({ wire: i, t: 0.35 + i * 0.12, speed: 0, inbound: false }));
draw(0);
return;
}
last = performance.now();
raf = requestAnimationFrame(frame);
};
const onResize = () => start();
const observer = new ResizeObserver(onResize);
observer.observe(canvas);
const repaint = () => {
palette = readPalette(document.documentElement);
if (reduced.matches) draw(0);
};
const themeObserver = new MutationObserver(repaint);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
const scheme = window.matchMedia("(prefers-color-scheme: dark)");
const onScheme = repaint;
scheme.addEventListener("change", onScheme);
reduced.addEventListener("change", start);
start();
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
themeObserver.disconnect();
scheme.removeEventListener("change", onScheme);
reduced.removeEventListener("change", start);
};
}, [mode]);
return (
<figure className="topo">
<div className="topo__head">
<div className="topo__seg" role="group" aria-label="Where Vantage runs">
{MODES.map((m) => (
<button key={m.id} type="button" className={m.id === mode ? "topo__btn is-on" : "topo__btn"} aria-pressed={m.id === mode} onClick={() => setMode(m.id)}>
{m.label}
</button>
))}
</div>
<p className="topo__key">
<span className="topo__dot topo__dot--out" /> your server checking in
<span className="topo__dot topo__dot--in" /> something you asked for
</p>
</div>
<canvas
ref={canvasRef}
className="topo__canvas"
role="img"
aria-label={
mode === "cloud"
? "Four of your servers, inside your own network, each opening a connection out to Vantage running on our hardware. Nothing connects inward."
: "Four of your servers and Vantage itself, all inside your own network. The servers connect to Vantage, and nothing leaves your network."
}
/>
<figcaption className="topo__note">{NOTES[mode]}</figcaption>
</figure>
);
}
-6
View File
@@ -1,6 +0,0 @@
export const NAV_LINKS = [
{ href: "/platform", label: "Platform" },
{ href: "/pricing", label: "Pricing" },
{ href: "/about", label: "About" },
{ href: "/contact", label: "Contact" },
];
-79
View File
@@ -1,79 +0,0 @@
/*
* The marketing site is a static bundle. Both forms post to sitesvc, which owns
* the contact mailer and the signup flow; the control plane is not involved.
*
* The base URL is baked in at build time. Contact falls back to composing an
* email when sitesvc is not configured, so it never silently swallows what
* someone typed. Signup has no fallback: an account cannot be created over
* mailto, so the form says so rather than pretending.
*/
const SITE_API = (process.env.NEXT_PUBLIC_SITE_API ?? "").replace(/\/$/, "");
const ADMIN_API = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
const FALLBACK_ADDRESS = process.env.NEXT_PUBLIC_CONTACT_EMAIL ?? "support@hostxtra.co.uk";
export type SubmitState = "idle" | "sending" | "sent" | "error";
export type SubmitResult = {
state: SubmitState;
/** Message to show when the submission was refused. */
message?: string;
/** Per-field messages, keyed by field name. */
fields?: Record<string, string>;
};
type FieldProblem = { field: string; message: string };
async function post(url: string, payload: unknown): Promise<SubmitResult> {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) return { state: "sent" };
const data = await res.json().catch(() => null);
const problems: FieldProblem[] = data?.fields ?? [];
return {
state: "error",
message: data?.error ?? "That did not go through. Try again in a moment.",
fields: Object.fromEntries(problems.map((p) => [p.field, p.message])),
};
} catch {
return { state: "error", message: "We could not reach the server. Check your connection and try again." };
}
}
export async function submitContact(fields: { name: string; email: string; servers: string; topic: string; message: string; website: string }): Promise<SubmitResult> {
if (!SITE_API) {
return {
state: "error",
message: `The contact form is not connected yet. Email ${FALLBACK_ADDRESS} directly.`,
};
}
return post(`${SITE_API}/api/contact`, fields);
}
/*
* Account signup posts to the admin service, not sitesvc. The two form targets
* are deliberately separate variables rather than one base URL: contact and
* signup are owned by different services, and an implied shared host is how they
* silently end up pointing at the wrong one.
*/
export async function submitAccountSignup(fields: {
name: string;
email: string;
password: string;
website: string;
}): Promise<SubmitResult> {
if (!ADMIN_API) {
return {
state: "error",
message: `Signup is not connected yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
};
}
return post(`${ADMIN_API}/auth/signup`, fields);
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-9
View File
@@ -1,9 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
-6170
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
{
"name": "vantage-site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.11",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

-52
View File
@@ -1,52 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="533.000000pt" height="610.000000pt" viewBox="246.000000 207.000000 533.000000 610.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#0B2A58" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187
-702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559
1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459
272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84
-1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297
-331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0
-1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204
-447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256
-13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333
805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432
-256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177
0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177
-2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157
-95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39
3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8
-75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165
52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397
-233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185
-321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471
483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160
160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73
-158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838
l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6
-710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79
46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147
-86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10
708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107
c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590
-6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365
l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393
-194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136
-292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548
-5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188
-112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3
178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73
-40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417
-247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116
343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6
-292 176 -301 174 -2 0 -24 -13 -49 -28z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

-41
View File
@@ -1,41 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
-1
View File
@@ -1 +0,0 @@
.env
-36
View File
@@ -1,36 +0,0 @@
# Context is sitesvc/ itself. It used to be the repository root, so that
# shared/ could be copied in beside it; shared is now the private module
# gitea.hostxtra.co.uk/vantage/vantage-shared, fetched like any other
# dependency. The credential for it arrives as a BuildKit secret rather than a
# build arg, which would be baked into this stage's layer history.
FROM golang:1.26-alpine AS builder
WORKDIR /src
ENV GOPRIVATE=gitea.hostxtra.co.uk/*
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
go mod download
COPY . .
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
FROM alpine:3.20 AS runner
# Needed to verify the SMTP server's TLS certificate.
RUN apk add --no-cache ca-certificates && \
addgroup --system --gid 1001 sitesvc && \
adduser --system --uid 1001 --ingroup sitesvc sitesvc
COPY --from=builder /out/sitesvc /usr/local/bin/sitesvc
USER sitesvc
EXPOSE 8082
ENV PORT=8082
CMD ["/usr/local/bin/sitesvc"]
-69
View File
@@ -1,69 +0,0 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/api"
"gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
"github.com/joho/godotenv"
)
func main() {
godotenv.Load()
addr := ":" + getEnv("PORT", "8082")
sender := mail.FromEnv()
contactTo := getEnv("SMTP_TO", "support@hostxtra.co.uk")
if sender.Enabled() {
log.Printf("smtp enabled (%s) contact form delivers to %s", sender.Host, contactTo)
} else {
log.Println("warning: SMTP_HOST/SMTP_FROM not set the contact form will refuse submissions")
}
if os.Getenv("SITE_ORIGIN") == "" {
log.Println("warning: SITE_ORIGIN is unset cross-origin browser requests will be refused")
}
srv := &http.Server{
Addr: addr,
Handler: api.New(sender, contactTo).Routes(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("sitesvc listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
log.Println("sitesvc stopped")
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
-7
View File
@@ -1,7 +0,0 @@
module gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc
go 1.26
require github.com/joho/godotenv v1.5.1
require gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
-4
View File
@@ -1,4 +0,0 @@
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-219
View File
@@ -1,219 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"sort"
"strings"
"time"
"gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
)
const (
maxBodyBytes = 32 << 10
perIPLimit = 5
perIPWindow = 10 * time.Minute
)
type Server struct {
mail mail.Sender
contact string // where enquiries go; the sender itself has no default recipient
limiter *limiter
allowOrigin map[string]bool
trustProxy bool
}
func New(sender mail.Sender, contactTo string) *Server {
return &Server{
mail: sender,
contact: contactTo,
limiter: newLimiter(perIPLimit, perIPWindow),
allowOrigin: parseOrigins(os.Getenv("SITE_ORIGIN")),
trustProxy: os.Getenv("TRUST_PROXY") == "true",
}
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/contact", s.handleContact)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
return s.withCORS(mux)
}
func parseOrigins(raw string) map[string]bool {
out := map[string]bool{}
for _, o := range strings.Split(raw, ",") {
if o = strings.TrimSpace(o); o != "" {
out[o] = true
}
}
return out
}
func (s *Server) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && s.allowOrigin[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) clientIP(r *http.Request) string {
if s.trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(first)
}
return strings.TrimSpace(xff)
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
type contactBody struct {
Name string `json:"name"`
Email string `json:"email"`
Servers string `json:"servers"`
Topic string `json:"topic"`
Message string `json:"message"`
Website string `json:"website"`
}
var (
serverBands = []string{"13", "425", "26100", "More than 100"}
topics = []string{
"Evaluating Vantage",
"Self-hosted licensing",
"Migrating from something else",
"Security disclosure",
}
)
func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
var body contactBody
if !decode(w, r, &body) {
return
}
if strings.TrimSpace(body.Website) != "" {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
return
}
fields := map[string]string{}
var problems []fieldError
if v, err := text("name", body.Name, true, maxShort); err != nil {
problems = append(problems, *err)
} else {
fields["name"] = v
}
if v, err := text("message", body.Message, true, maxLong); err != nil {
problems = append(problems, *err)
} else {
fields["message"] = v
}
addr, emailErr := email("email", body.Email)
if emailErr != nil {
problems = append(problems, *emailErr)
}
if v, err := oneOf("servers", body.Servers, serverBands); err != nil {
problems = append(problems, *err)
} else {
fields["servers"] = v
}
if v, err := oneOf("topic", body.Topic, topics); err != nil {
problems = append(problems, *err)
} else {
fields["topic"] = v
}
if len(problems) > 0 {
sort.Slice(problems, func(i, j int) bool { return problems[i].Field < problems[j].Field })
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "Some fields need another look.",
"fields": problems,
})
return
}
if !s.limiter.allow(s.clientIP(r)) {
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(perIPWindow.Seconds())))
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "That is a lot of messages in a short time. Try again shortly.",
})
return
}
if !s.mail.Enabled() || s.contact == "" {
log.Println("contact submission dropped: smtp is not configured")
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "The contact form is unavailable right now. Email support@hostxtra.co.uk directly.",
})
return
}
if err := s.mail.SendEnquiry(s.contact, mail.Enquiry{
Name: fields["name"],
Email: addr,
Servers: fields["servers"],
Topic: fields["topic"],
Message: fields["message"],
}); err != nil {
log.Printf("contact send: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "We could not send that. Try again, or email support@hostxtra.co.uk directly.",
})
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
}
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "We could not read that submission.",
})
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Printf("write response: %v", err)
}
}
-60
View File
@@ -1,60 +0,0 @@
package api
import (
"sync"
"time"
)
type limiter struct {
mu sync.Mutex
hits map[string]*window
limit int
window time.Duration
lastGC time.Time
}
type window struct {
count int
start time.Time
}
func newLimiter(limit int, per time.Duration) *limiter {
return &limiter{
hits: make(map[string]*window),
limit: limit,
window: per,
lastGC: time.Now(),
}
}
func (l *limiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.gc(now)
w, ok := l.hits[key]
if !ok || now.Sub(w.start) > l.window {
l.hits[key] = &window{count: 1, start: now}
return true
}
if w.count >= l.limit {
return false
}
w.count++
return true
}
func (l *limiter) gc(now time.Time) {
if now.Sub(l.lastGC) < l.window {
return
}
for key, w := range l.hits {
if now.Sub(w.start) > l.window {
delete(l.hits, key)
}
}
l.lastGC = now
}
-61
View File
@@ -1,61 +0,0 @@
package api
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
)
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`)
const (
maxShort = 200
maxLong = 4000
)
type fieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (e fieldError) Error() string { return e.Field + ": " + e.Message }
func text(name, value string, required bool, max int) (string, *fieldError) {
v := strings.TrimSpace(value)
if v == "" {
if required {
return "", &fieldError{Field: name, Message: "This field is required."}
}
return "", nil
}
if utf8.RuneCountInString(v) > max {
return "", &fieldError{
Field: name,
Message: fmt.Sprintf("Keep this under %d characters.", max),
}
}
return v, nil
}
func email(name, value string) (string, *fieldError) {
v, err := text(name, value, true, maxShort)
if err != nil {
return "", err
}
v = strings.ToLower(v)
if !emailRe.MatchString(v) {
return "", &fieldError{Field: name, Message: "Enter an email address we can reply to."}
}
return v, nil
}
func oneOf(name, value string, allowed []string) (string, *fieldError) {
v := strings.TrimSpace(value)
for _, a := range allowed {
if v == a {
return v, nil
}
}
return "", &fieldError{Field: name, Message: "Choose one of the listed options."}
}