chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
Deploy / deploy (push) Successful in 2m47s

This commit is contained in:
2026-09-10 09:18:57 +00:00
parent e8455c99fe
commit 7762629634
22 changed files with 126 additions and 122 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
# DOCS_URL, DOCS_BASE_URL and APP_URL are repo variables, and editing one
# pushes no commit so nothing would rebuild on its own. This is the
# pushes no commit - so nothing would rebuild on its own. This is the
# escape hatch for that, and DOCS_BASE_URL in particular is baked into
# every asset path in the build.
workflow_dispatch:
@@ -27,7 +27,7 @@ jobs:
docker login ${{ vars.DOCKER_HOST }} \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
# One image, one directory the whole repository is the docs site,
# One image, one directory - the whole repository is the docs site,
# so there is nothing to detect a change in. It rebuilds on every
# push, which is what a docs site wants anyway.
#
+13 -9
View File
@@ -6,18 +6,18 @@ tracking `main`, search indexed at build time by
`@easyops-cn/docusaurus-search-local` so nothing external is keyed or called.
Extracted from the `vantage` monorepo with its history, where it was
`docsite/`. The whole repository is the site there is no subdirectory.
`docsite/`. The whole repository is the site - there is no subdirectory.
It documents the **product**, not the codebase: each repository's own CLAUDE.md
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
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.
## A path, not a subdomain
`*.vantage.hostxtra.co.uk` is the per-tenant instance namespace, and the control
plane's `APP_ROOT_LABEL` resolves an instance from the label before `vantage`
plane's `APP_ROOT_LABEL` resolves an instance from the label before `vantage` -
so a `docs.` label would be read as a tenant slug. Hence `/docs` on the
marketing host instead.
@@ -25,7 +25,7 @@ marketing host instead.
asset 404s:**
1. `DOCS_BASE_URL` (default `/docs/` in the Dockerfile)
2. the proxy location that routes here which must also sort **above** the
2. the proxy location that routes here - which must also sort **above** the
catch-all forwarding to `site:3003`, or Next answers the 404 first
3. the directory the runtime stage serves from,
`/usr/share/nginx/html/docs`
@@ -39,7 +39,7 @@ Unlike the three Next apps this builds to static files, so its runtime stage is
## Design tokens
`src/css/custom.css` holds `vantage-site`'s `web/app/globals.css` token blocks
**copied verbatim** same names, same values. This is the one place the tokens
**copied verbatim** - same names, same values. This is the one place the tokens
are not consumed through Tailwind: everything below the 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
@@ -47,7 +47,7 @@ built-in toggle needed no wiring.
The rule holds all the same: **no rule in that file outside the token blocks may
carry a hex.** The one concession is `static/img/favicon.svg`, which must, for
the same reason the email layout must a browser tab cannot read a token.
the same reason the email layout must - a browser tab cannot read a token.
**Nothing enforces the match across the four front ends, and they are now in
four repositories.** A token change here follows one in `vantage-site`; there is
@@ -71,13 +71,13 @@ Not part of a self-hosted install.
## CI/CD
`.gitea/workflows/deploy.yml`, on every push to `main` plus `workflow_dispatch`.
One image, `vantage/vantage-docs:latest`, from the repository root the whole
One image, `vantage/vantage-docs:latest`, from the repository root - the whole
repository is the site, so there is nothing to detect a change in and it
rebuilds on every push.
**No build args are passed, deliberately.** `DOCS_URL`, `DOCS_BASE_URL`,
`APP_URL` and `HQ_URL` all have correct defaults in the Dockerfile, and passing
an unset repo variable would blank one rather than leave it alone which for
an unset repo variable would blank one rather than leave it alone - which for
`DOCS_BASE_URL` means every asset path in the build. Change them in the
Dockerfile. The monorepo's workflow passed none either; this preserves that.
@@ -88,3 +88,7 @@ Dockerfile. The monorepo's workflow passed none either; this preserves that.
| `DOCKER_HOST` | Variable | registry host used for image tags |
Despite the name the workflow **does not deploy**; it only builds and pushes.
## Writing style
Never use em dashes (the long dash character) anywhere: code, comments, UI copy, docs, commit messages. Use a plain hyphen ` - `, a comma, a colon, or split the sentence instead.
+1 -1
View File
@@ -27,7 +27,7 @@ RUN npm run build
#
# 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
# of caddy:alpine, and nothing here needs automatic TLS - the host proxy
# terminates it.
FROM nginx:alpine-slim AS runner
+3 -3
View File
@@ -1,6 +1,6 @@
# The user documentation, served at vantage.hostxtra.co.uk/docs.
#
# A fragment, composed on top of the vantage repository's own file see the
# A fragment, composed on top of the vantage repository's own file - see the
# vantage-site fragment for the full command.
#
# A path on the marketing host, not a subdomain: *.vantage.hostxtra.co.uk is
@@ -8,8 +8,8 @@
# would read a `docs.` label as a tenant slug.
#
# The proxy location that routes here must sort ABOVE the catch-all forwarding
# to site:3003, or Next answers the 404. It forwards the FULL path it does
# not strip /docs so the proxy location, DOCS_BASE_URL and the directory the
# to site:3003, or Next answers the 404. It forwards the FULL path - it does
# not strip /docs - so the proxy location, DOCS_BASE_URL and the directory the
# image serves from (/usr/share/nginx/html/docs) must all agree. When they do
# not, the HTML loads and every asset 404s.
#
+1 -1
View File
@@ -58,7 +58,7 @@ as a Windows service and starts it.
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
feature, and so is package inventory and CVE scanning - the vulnerability
feeds this project uses carry no Windows data.
:::
+1 -1
View File
@@ -114,7 +114,7 @@ The `nginx` service routes **one hostname to two backends**:
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. If you replace
`404` to every request it makes - starting with the login form. If you replace
the bundled nginx with your own proxy, it must route the same way.
This is the shipped `nginx/vantage.conf`:
+1 -1
View File
@@ -27,7 +27,7 @@ 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.
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.
+2 -2
View File
@@ -15,7 +15,7 @@ Install first, then link and claim. Step by step in
## 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
has to exist and report an instance ID before you can buy for it - the same
precondition Free has.
```mermaid
@@ -36,7 +36,7 @@ flowchart LR
## Upgrading an instance you already have
Paste the same instance ID you already hold an install on Free moves to the
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.
+26 -26
View File
@@ -7,16 +7,16 @@ 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.
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.
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.
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
@@ -26,7 +26,7 @@ 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
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.
@@ -37,8 +37,8 @@ nobody can read.
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.
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
@@ -80,7 +80,7 @@ backup:
```
`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
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
@@ -88,13 +88,13 @@ 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
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
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`:
@@ -109,7 +109,7 @@ Into S3:
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
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.
@@ -123,23 +123,23 @@ vantagectl verify /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \
Each line of output answers a different question:
- **`Archive`** every member's checksum still matches; the tarball has not
- **`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
- **`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
- **`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
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
`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:
@@ -173,8 +173,8 @@ 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.
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.
@@ -190,7 +190,7 @@ 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.
`KEY_ENCRYPTION_KEY` in your environment - see "When the key is wrong" below.
## The restore drill
@@ -210,7 +210,7 @@ monthly:
```
3. Drop the scratch database. It served its purpose.
The failure this catches is not "the archive is corrupt" `verify` alone
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.
@@ -221,13 +221,13 @@ If `restore` finds the archive's key fingerprint does not match the
`--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
- `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
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.
+2 -2
View File
@@ -27,7 +27,7 @@ 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
`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
@@ -52,7 +52,7 @@ docker compose exec -T mongo mongodump --archive --gzip --db vantage \
:::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
and is not - every secret in it is silently unreadable until something tries
to decrypt one.
:::
+19 -19
View File
@@ -5,7 +5,7 @@ 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.
needs something it can hold onto instead - an API token.
## Creating one
@@ -15,7 +15,7 @@ admin additionally see every key in the instance.
**Create key** opens a dialog with four decisions, in the order they matter:
1. **Name and role.** The name says what will use the key the CI pipeline,
1. **Name and role.** The name says what will use the key - the CI pipeline,
the script, the cluster. The role list offers your own role and everything
below it, never above.
2. **Scopes.** A grid of resources against read and write. Ticking write also
@@ -26,7 +26,7 @@ admin additionally see every key in the instance.
4. **Expiry.** Each option names the date it resolves to, so "90 days" and
"7 December 2026" are the same choice read two ways.
Beneath them sits a preview line that reads the key back as a sentence
Beneath them sits a preview line that reads the key back as a sentence -
*"gitea-ci-deploy acts as admin, may read and write servers and workflows,
read secrets, and stops working on 7 December 2026."* Read it before you
submit; an over-granted key is far easier to spot in a sentence than in a grid
@@ -40,7 +40,7 @@ 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.
it back - create a new token and revoke the old one.
The panel showing it also carries a ready-made `curl` line and a summary of
what was granted, so the key can go straight into a secret store without a
@@ -50,7 +50,7 @@ second trip to the list.
A token can reach only what its scopes name. Each resource has
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.
satisfies a `:read` requirement for it - you do not need to tick both.
| Resource | Covers |
| ----------- | --------------------------------------------------- |
@@ -64,7 +64,7 @@ satisfies a `:read` requirement for it — you do not need to tick both.
| `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
cannot run a workflow against them, touch a key, or read a secret - each of
those needs its own scope.
## Tag restrictions
@@ -72,12 +72,12 @@ those needs its own scope.
A key can be pinned to part of the fleet as well as part of the API. **Restrict
to servers tagged** in the create dialog offers the tag keys and values already
in use across your servers, and the key then reaches only servers carrying
**every** pair listed the restriction is an AND, not an OR. Leaving it empty
**every** pair listed - the restriction is an AND, not an OR. Leaving it empty
is the opposite: no restriction at all, the whole fleet.
The restriction is fixed at creation, like the role and the scopes. Changing
what a credential already deployed in CI can reach, with no record of what it
could reach before, is worse than requiring a rotation so to widen or narrow
could reach before, is worse than requiring a rotation - so to widen or narrow
one, create a replacement and revoke the old key.
Restricted keys show their tags as chips beside their scopes in the list.
@@ -86,7 +86,7 @@ Unrestricted keys show nothing there, which is the common case.
The restriction applies to every API token, not only ones handed to an
[MCP agent](../vantage/mcp.md). A CI token, a monitoring script's token and
an agent's token are all held to the same tag scope check wherever the
service layer resolves servers the mechanism does not know or care what
service layer resolves servers - the mechanism does not know or care what
kind of caller is holding the token.
Nor can a token created under a restricted token reach further than its
@@ -98,27 +98,27 @@ token that also sees production.
Each key is one record rather than a row of bare strings:
- **Key** the name, the `vt_` prefix hint, the holder (when viewing all
- **Key** - the name, the `vt_` prefix hint, the holder (when viewing all
keys) and the role.
- **Scopes** one chip per resource, its access half tinted: `rw` in accent,
- **Scopes** - one chip per resource, its access half tinted: `rw` in accent,
`r` in grey. A key with nothing granted says so in words rather than showing
a dash.
- **Lifetime** a bar showing how much of the key's issued life is left,
- **Lifetime** - a bar showing how much of the key's issued life is left,
with the date beside it. Four states, and the label always says which:
| Bar | Means |
| --- | ----- |
| Green | More than seven days left |
| Amber | Seven days or fewer rotate it |
| Amber | Seven days or fewer - rotate it |
| Red | Already expired; the key no longer authenticates |
| Grey, full width | No expiry at all |
A key issued before the instance's maximum lifetime was tightened also
carries *"outside the current policy rotate when convenient"*. That is a
carries *"outside the current policy - rotate when convenient"*. That is a
prompt, not a failure: the cap is never applied retroactively and the key
keeps working.
- **Last call** when the key last authenticated, or *Never used*.
- **Last call** - when the key last authenticated, or *Never used*.
Above the list, four counts summarise the same thing at fleet scale: keys
listed, keys expiring within seven days, keys that never expire, and keys
@@ -128,7 +128,7 @@ change with the **My keys** / **All keys** toggle.
## 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
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
@@ -154,7 +154,7 @@ curl -H "Authorization: Bearer vt_…" https://acme.vantage.example.com/api/serv
```
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
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.
@@ -178,12 +178,12 @@ already been deleted.
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
`/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
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
+1 -1
View File
@@ -25,7 +25,7 @@ it is absent.
| `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 |
| `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
+2 -2
View File
@@ -9,7 +9,7 @@ sidebar_label: Ports and networking
| 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 |
| `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 |
@@ -85,7 +85,7 @@ On a private network you can skip TLS instead, by setting `tls: false` in each
| 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`
`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
+6 -6
View File
@@ -22,7 +22,7 @@ or is not 64 hex characters.
**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
`/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.
@@ -124,7 +124,7 @@ 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,
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:
@@ -137,7 +137,7 @@ Three details are worth getting right:
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
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
@@ -170,7 +170,7 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
## 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,
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
@@ -178,11 +178,11 @@ 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
- 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
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
+22 -22
View File
@@ -4,8 +4,8 @@ title: Agent access (MCP)
sidebar_label: Agent access (MCP)
---
The Model Context Protocol is how an LLM agent Claude, or any other MCP
client talks to a tool server over a defined wire format instead of a
The Model Context Protocol is how an LLM agent - Claude, or any other MCP
client - talks to a tool server over a defined wire format instead of a
one-off integration. Connecting Vantage to an agent this way lets it answer
questions about your fleet and, if you choose to let it, act on it: run a
workflow, apply updates, or draft a step for you to review.
@@ -24,18 +24,18 @@ An agent authenticates the same way any script does: with an
on the MCP endpoint specifically, on top of whatever resource scopes
(`servers:read`, `workflows:write`, and so on) it also needs:
- **`mcp:read`** permission to reach the MCP endpoint at all and use its
- **`mcp:read`** - permission to reach the MCP endpoint at all and use its
read-only tools.
- **`mcp:write`** permission to use its write tools: running a workflow,
- **`mcp:write`** - permission to use its write tools: running a workflow,
applying updates, assigning a key, or creating a step, workflow or monitor.
Without `mcp:write`, write tools are not merely refused when called they
Without `mcp:write`, write tools are not merely refused when called - they
are left out of the tool listing entirely, so a read-only agent never learns
they exist. A tool it cannot see is not one it can talk a user into asking
for.
Each tool additionally needs the ordinary resource scope for what it touches
`run_workflow` needs `workflows:write`, `list_servers` needs
- `run_workflow` needs `workflows:write`, `list_servers` needs
`servers:read`, and so on, exactly as the [scopes table](../reference/api-tokens.md#scopes)
describes for any other token. `mcp:read` or `mcp:write` decides whether the
MCP surface is open to this token at all; the resource scope decides what it
@@ -56,7 +56,7 @@ and cannot run a workflow that would touch one.
It can list and inspect staging servers and run a workflow already
configured to target them. Point it at a workflow whose saved targets
include even one production host, though, and `run_workflow` refuses the
call outright see [what an agent cannot do](#what-an-agent-cannot-do)
call outright - see [what an agent cannot do](#what-an-agent-cannot-do)
below for why that refusal is all-or-nothing rather than "run on the
servers it can see."
@@ -64,7 +64,7 @@ servers it can see."
Agent access lives at `/api/mcp` on your instance, over the streamable HTTP
transport, and takes a bearer token the same way the REST API does. It has
no separate authentication of its own a browser tab with a signed-in
no separate authentication of its own - a browser tab with a signed-in
session does not count, only a real API token does.
Point an MCP client at it with a configuration block like this:
@@ -83,15 +83,15 @@ Point an MCP client at it with a configuration block like this:
The endpoint is stateless and answers POST requests only: it holds no
per-connection session, so each call is self-contained and there is nothing
to resume. A GET request a client probing for the endpoint, or a browser
navigating to it directly gets a `405`, by design, rather than a page. That
to resume. A GET request - a client probing for the endpoint, or a browser
navigating to it directly - gets a `405`, by design, rather than a page. That
is the protocol-correct response from a server that does not offer the
GET/SSE half of MCP, not a misconfiguration.
## The tool list
Every tool below calls the same service code the REST API and the Vantage
UI call. Nothing in this list has its own path to the database the MCP
UI call. Nothing in this list has its own path to the database - the MCP
surface is a presentation layer over existing authority, not a new one.
| Tool | What it does | Scope required |
@@ -111,7 +111,7 @@ surface is a presentation layer over existing authority, not a new one.
| `get_server_packages` | List installed packages on one server, optionally filtered by name. | `mcp:read`, `vulns:read` |
| `search_fleet` | Search every server's installed packages by name across the fleet. | `mcp:read`, `vulns:read` |
| `list_audit_events` | List recent audit log events. | `mcp:read`, `settings:read` |
| `list_secret_names` | List secret group and key names metadata only, never a value. | `mcp:read`, `secrets:read` |
| `list_secret_names` | List secret group and key names - metadata only, never a value. | `mcp:read`, `secrets:read` |
| `run_workflow` | Run a workflow against the servers it is already saved to target. | `mcp:write`, `workflows:write` |
| `cancel_run` | Cancel an in-progress workflow run. | `mcp:write`, `workflows:write` |
| `apply_updates` | Apply pending OS package updates on real servers. | `mcp:write`, `servers:write` |
@@ -132,13 +132,13 @@ or the agent to filter.
## Running a workflow: targets are fixed, not chosen
`run_workflow` does not take a target list. A workflow runs the servers and
tags it was already saved with an agent cannot redirect it at a different
tags it was already saved with - an agent cannot redirect it at a different
part of the fleet on the fly. Before starting the run, Vantage checks that
those saved targets sit entirely inside the calling token's tag
restriction. If even one of them falls outside it, the whole call is
refused; there is no partial run against just the servers the token happens
to be able to see. A token scoped to `env=staging` cannot use a
production-and-staging workflow to touch only the staging half it has to
production-and-staging workflow to touch only the staging half - it has to
be pointed at a workflow whose saved targets are staging-only from the
start.
@@ -184,7 +184,7 @@ This is the section to read before turning the feature on.
that runs a script, but it cannot open an interactive session on a
machine.
- **It cannot exceed its tag restriction.** Every tool that resolves
servers by ID, by tag, or through a saved workflow resolves them
servers - by ID, by tag, or through a saved workflow - resolves them
through the token's own scope. A server outside that scope is invisible
to `list_servers` and `get_server`, not-found to anything asking about it
by ID, and excluded from what a write tool will act on.
@@ -193,7 +193,7 @@ This is the section to read before turning the feature on.
sees those tools in the listing.
- **It cannot touch more than 25 servers in one call without saying so
explicitly.** Any write tool that would affect more than 25 servers is
refused with a message asking for `confirm:true` before it will proceed
refused with a message asking for `confirm:true` before it will proceed -
cheap insurance against a mis-parsed tag selector reaching the whole
fleet on one badly phrased instruction.
- **It cannot edit or delete anything that already exists.** The creation
@@ -201,10 +201,10 @@ This is the section to read before turning the feature on.
workflow or monitor a person defined.
A monitor whose runner server sits outside a token's scope is not hidden
from that token it still appears in `list_monitors`. The REST API reaches
from that token - it still appears in `list_monitors`. The REST API reaches
that outcome by redacting: it replaces the runner with a
`RunnerRestricted` placeholder when the runner falls outside the caller's
visible servers. The MCP tools reach the same outcome a different way
visible servers. The MCP tools reach the same outcome a different way -
`list_monitors` and `get_monitor_status` project a monitor onto a summary
that never carries a runner field for any caller, restricted or not, so
there is nothing there to redact in the first place. Two mechanisms, chosen
@@ -219,16 +219,16 @@ many.
Every tool call reaches the audit log, whether it read something or changed
something:
- A successful call read or write is recorded as `mcp.tool_call`.
- A call refused by policy missing scope, an out-of-scope target, or a
fan-out over 25 servers without confirmation is recorded as
- A successful call - read or write - is recorded as `mcp.tool_call`.
- A call refused by policy - missing scope, an out-of-scope target, or a
fan-out over 25 servers without confirmation - is recorded as
`mcp.tool_denied`, naming the specific gate that refused it, so a human
reading the log can tell "the agent was stopped" from "the agent tried
and the underlying action failed."
- A write tool that reached the underlying service and that service
rejected is recorded as `mcp.tool_failed`.
- Creating a step, workflow or monitor is recorded separately as
`mcp.created`, naming what was made and its ID a question like "what
`mcp.created`, naming what was made and its ID - a question like "what
has this agent added to my instance" should not require digging through
hundreds of read rows to answer.
+1 -1
View File
@@ -67,7 +67,7 @@ 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
`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
+2 -2
View File
@@ -89,7 +89,7 @@ metrics is normal rather than a fault.
### OS updates
Agents check for pending package updates hourly and report the count the
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:
@@ -113,7 +113,7 @@ Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console
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
not covered by package inventory or CVE scanning - the vulnerability feeds
this project uses carry no Windows data.
## Removing a server
+15 -15
View File
@@ -6,12 +6,12 @@ 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,
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
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.
@@ -28,7 +28,7 @@ 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
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
@@ -37,26 +37,26 @@ 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
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
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
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
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
@@ -64,7 +64,7 @@ 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
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.
@@ -77,7 +77,7 @@ more and claiming otherwise would be a false claim of health.
- 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
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
@@ -90,17 +90,17 @@ reachable is on this page.
Two kinds of entries appear on a page's timeline:
- **Automatic** a monitor going down opens an incident on any page that
- **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
- **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
create it - it does not follow a monitor onto every page that monitor happens
to be listed on.
### Scheduling maintenance
@@ -111,7 +111,7 @@ 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
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.
@@ -120,6 +120,6 @@ the duration.
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,
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.
+1 -1
View File
@@ -4,7 +4,7 @@ title: Workloads
sidebar_label: Workloads
---
A **workload** is one Docker container or one service a systemd unit on
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.
+1 -1
View File
@@ -11,7 +11,7 @@ server {
return 302 /docs/;
}
# Hashed assets are immutable the filename changes when the content does.
# Hashed assets are immutable - the filename changes when the content does.
location /docs/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
+3 -3
View File
@@ -1,7 +1,7 @@
/* ==========================================================================
Vantage documentation design tokens
The token block below is COPIED VERBATIM from site/app/globals.css same
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.
@@ -13,7 +13,7 @@
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
prefers-color-scheme branch to keep in step - the theme toggle is the only
writer.
========================================================================== */
@@ -200,7 +200,7 @@
color: var(--ink);
}
/* Machine output install one-liners, key blobs, run logs sits on a floor
/* 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);
+1 -1
View File
@@ -1,7 +1,7 @@
<!--
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
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">

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB