chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
||||
# it must come from a tag someone chose, not from whatever landed on main.
|
||||
# No `paths` filter on push, deliberately. A paths filter applies to tag
|
||||
# pushes too, so tagging a commit that happened not to touch the chart
|
||||
# would skip the publish entirely — a release that silently does nothing.
|
||||
# would skip the publish entirely - a release that silently does nothing.
|
||||
# Validation is seconds of helm rendering; running it on every push to main
|
||||
# is cheaper than that failure mode.
|
||||
push:
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
--set server.env.grpcHost=agents.example.com:443 > /dev/null
|
||||
|
||||
# The shape the cloud deployment actually uses: a wildcard tenant
|
||||
# namespace, /api and /auth routed at the edge, and no apex — that
|
||||
# namespace, /api and /auth routed at the edge, and no apex - that
|
||||
# belongs to the marketing site, which this chart does not deploy.
|
||||
- name: Render a wildcard host with edge-routed API paths
|
||||
run: |
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
# Chart.yaml is the source of truth for the version; the tag only
|
||||
# says "publish this one". A mismatch is a mistake worth stopping
|
||||
# for — the alternative is stamping the tag over Chart.yaml, which
|
||||
# for - the alternative is stamping the tag over Chart.yaml, which
|
||||
# leaves the repository disagreeing with what was published.
|
||||
- name: Check the tag matches Chart.yaml
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 HQ_URL
|
||||
# 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:
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
# file makes every filter below match, so there is no second
|
||||
# code path to keep correct.
|
||||
if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
||||
echo "No usable base commit — building every image."
|
||||
echo "No usable base commit - building every image."
|
||||
git ls-files > /tmp/changed.txt
|
||||
else
|
||||
git diff --name-only "$BEFORE" HEAD > /tmp/changed.txt
|
||||
|
||||
@@ -22,7 +22,7 @@ A central server (Go + Next.js + MongoDB + Redis) drives a lightweight Go agent
|
||||
│ monitor scheduler · workflow runner │
|
||||
│ guacd tunnel proxy for browser console │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ gRPC (TLS) — outbound from agent only
|
||||
│ gRPC (TLS) - outbound from agent only
|
||||
┌───────────────▼──────────────────────────────┐
|
||||
│ Go Agent (per server, Linux + Windows) │
|
||||
│ polls SyncKeys · CommandStream │
|
||||
@@ -66,7 +66,7 @@ left here is the control plane and its UI, and nothing else.
|
||||
|
||||
| Repository | What it holds |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `vantage-shared` | the private Go module below — `mail`, `license`, `models`, `provision`, `backup`, `grpc/pb`, … |
|
||||
| `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/`) |
|
||||
@@ -75,13 +75,13 @@ left here is the control plane and its UI, and nothing else.
|
||||
|
||||
**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
|
||||
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
|
||||
repository must honour are documented where they bite — `users.auth_source ==
|
||||
repository must honour are documented where they bite - `users.auth_source ==
|
||||
"hq"` and `services.ErrHQManaged`, `POST /license` answering 409
|
||||
`cloud_managed`, and `FREE_INSTANCE_REAP_AFTER` needing to match admin's value.
|
||||
The rest lives in that repository's own CLAUDE.md.
|
||||
@@ -92,7 +92,7 @@ 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/`, and `proto/vantage/v1/vantage.proto`, which documents `grpc/pb`
|
||||
and moved there to sit beside it. **One** module here depends on it — `server` —
|
||||
and moved there to sit beside it. **One** module here depends on it - `server` -
|
||||
pinning a version in its own `go.mod`, as do `vantage-admin`, `vantage-site`,
|
||||
`vantage-agent` and `vantage-ctl`. It was a
|
||||
directory in this repository until it was extracted with its history; the
|
||||
@@ -103,13 +103,13 @@ with it.
|
||||
directory, every service in a given commit built against exactly one `shared/`,
|
||||
and a change there rebuilt several images at once whether or not they were ready
|
||||
for it. Now a service moves when somebody bumps its pin, which is a commit under
|
||||
that service's own directory — so the existing per-directory rebuild triggers
|
||||
that service's own directory - so the existing per-directory rebuild triggers
|
||||
already cover it, and there is no longer any way to ship a service against a
|
||||
`shared/` it was never built against. The cost is the obvious one: a fix in
|
||||
`vantage-shared` is live nowhere until each consumer's pin is bumped, and
|
||||
nothing in this repository will remind you.
|
||||
|
||||
Every Go build now needs a credential for it — `GOPRIVATE=gitea.hostxtra.co.uk/*`
|
||||
Every Go build now needs a credential for it - `GOPRIVATE=gitea.hostxtra.co.uk/*`
|
||||
plus a netrc. CI writes one per job from `REGISTRY_USER` + `RELEASE_TOKEN`
|
||||
(**that token needs read access to the `vantage` org, not only `mrhid6`**), and
|
||||
the four Go Dockerfiles take it as a **BuildKit secret** rather than a build
|
||||
@@ -138,24 +138,24 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
|
||||
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to MongoDB (`workflow_log_lines`, one document per line); the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` - that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this - the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step - but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
|
||||
### Scheduled workflows
|
||||
|
||||
A workflow may carry `schedule{enabled, cron, tz}` — standard **5-field** cron
|
||||
A workflow may carry `schedule{enabled, cron, tz}` - standard **5-field** cron
|
||||
and an IANA zone name, both validated at save time. `next_run_at` is
|
||||
**persisted on the document, not held in memory**: a leader handover between
|
||||
computing an occurrence and firing it would otherwise lose it or fire it twice,
|
||||
the same argument that put `workflow_log_seq` in MongoDB.
|
||||
|
||||
`server/internal/workflowsched` ticks every 30s inside the **existing**
|
||||
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched` and the sweepers —
|
||||
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched` and the sweepers -
|
||||
one role, one lock. **The atomic claim, not the lock, is what prevents a double
|
||||
fire**: the `UpdateOne` matches on the document *and* its current `next_run_at`
|
||||
while setting the recomputed one, so a second process reaching the same workflow
|
||||
matches nothing and does nothing. The lock only makes it cheap.
|
||||
|
||||
`workflowsched` **must not import `services`** — `services` already imports it
|
||||
`workflowsched` **must not import `services`** - `services` already imports it
|
||||
for `SetSchedule`'s call to `NextOccurrence`, and Go has no cycles.
|
||||
`TriggerWorkflow` and `LogEvent` are therefore injected as `workflowsched.Deps`
|
||||
from `main.go`. Firing goes through the same `TriggerWorkflow` a person uses,
|
||||
@@ -165,7 +165,7 @@ detail page needed no changes.
|
||||
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
|
||||
runs on `scratch`, which ships no zone database, so without it
|
||||
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
|
||||
back to UTC — an hour wrong for half the year, in the direction nobody notices
|
||||
back to UTC - an hour wrong for half the year, in the direction nobody notices
|
||||
until a maintenance window lands in business hours. It works on a developer
|
||||
machine either way, which is exactly why it gets forgotten.
|
||||
|
||||
@@ -175,12 +175,12 @@ parses is disabled rather than left spinning the loop every 30 seconds forever.
|
||||
|
||||
### Server tags and workflow targeting
|
||||
|
||||
A server carries `tags map[string]string` — lowercase `[a-z0-9_-]`, key ≤32,
|
||||
A server carries `tags map[string]string` - lowercase `[a-z0-9_-]`, key ≤32,
|
||||
value ≤64, 20 per server, `sys:` reserved. **There is no `tags` collection**: a
|
||||
tag is a property of a server, not an entity, so `KnownTags` aggregates over
|
||||
`servers` rather than reading a registry that would need reference counting to
|
||||
know when a tag stopped existing. `PUT /api/servers/:id/tags` replaces the whole
|
||||
map — last-write-wins over a small map beats merge semantics between two people
|
||||
map - last-write-wins over a small map beats merge semantics between two people
|
||||
editing one server. The index is `{instance_id: 1, "tags.$**": 1}`, wildcard
|
||||
because the queried key is chosen by the user at request time and cannot be named
|
||||
in advance; `EnsureServerIndexes` warns rather than being fatal, since a missing
|
||||
@@ -188,19 +188,19 @@ index degrades tag filtering to a scan of a small collection and is no reason to
|
||||
refuse to serve the fleet list.
|
||||
|
||||
`services.ResolveTargets` is the **single** answer to which servers a workflow
|
||||
touches — the run path and validation both go through it, so the readout and the
|
||||
touches - the run path and validation both go through it, so the readout and the
|
||||
dispatch cannot disagree. It is the distinct union of `target_server_ids` and
|
||||
`target_tags` (AND across keys), ordered by the fleet rather than by the
|
||||
arguments, so two runs naming the same servers differently are still comparable
|
||||
line by line. **An empty selector matches nothing** on purpose: "matches
|
||||
everything" turns a cleared field in the designer into a fleet-wide run. Both
|
||||
empty is `ErrNoTargets` (400), not a success over zero servers. Offline servers
|
||||
are **not** filtered out — the dispatcher already answers 503 per server, and a
|
||||
are **not** filtered out - the dispatcher already answers 503 per server, and a
|
||||
patch run that silently omits an unreachable machine is worse than one that
|
||||
visibly fails on it.
|
||||
|
||||
**Both halves of the selector are edited in `EditWorkflowModal`** — the named
|
||||
servers in a `DualListBox`, the tag rows directly beneath it — and saved
|
||||
**Both halves of the selector are edited in `EditWorkflowModal`** - the named
|
||||
servers in a `DualListBox`, the tag rows directly beneath it - and saved
|
||||
together by one `updateWorkflow`. The designer's Targets panel is **read-only**:
|
||||
it reports the count and the tags and links to Edit. Splitting the two halves
|
||||
across two screens meant a workflow's reach was decided in two places with no
|
||||
@@ -209,10 +209,10 @@ one view showing both.
|
||||
`web/lib/targets.ts` **duplicates the match logic in TypeScript** to draw the
|
||||
resolved count without a round trip, since the browser already holds the fleet.
|
||||
It is a second implementation of `UnionTargets` / `MatchesTags` and must change
|
||||
in the same commit as the Go one — the same shape of hazard as the mirrored
|
||||
in the same commit as the Go one - the same shape of hazard as the mirrored
|
||||
token blocks. It is a shared module rather than inline in a component because
|
||||
the logic had already been written twice, and the second copy — the workflows
|
||||
list — counted `target_server_ids` alone, so a **tag-only workflow reported zero
|
||||
the logic had already been written twice, and the second copy - the workflows
|
||||
list - counted `target_server_ids` alone, so a **tag-only workflow reported zero
|
||||
targets** while running fine.
|
||||
|
||||
The server picker is a hand-built two-pane list, not `<select multiple>`: a
|
||||
@@ -239,7 +239,7 @@ upgrades to a WebSocket and proxies to **guacd** using `github.com/wwt/guac`.
|
||||
|
||||
guacd never dials the managed server. The server binds a single-use ephemeral
|
||||
listener, pushes `OpenProxyCmd` down the agent's command stream, and the agent
|
||||
opens a `ProxyStream` and relays the connection from its own **`127.0.0.1`** —
|
||||
opens a `ProxyStream` and relays the connection from its own **`127.0.0.1`** -
|
||||
the host is hardcoded agent-side, so the control plane can name only a port.
|
||||
This is what makes the console work on Vantage Cloud, where the customer's
|
||||
server is behind NAT on a private address. It also means the console now
|
||||
@@ -250,12 +250,12 @@ server is behind NAT on a private address. It also means the console now
|
||||
build the allow-list of sources permitted to claim a relay listener; a ClusterIP
|
||||
resolves to the Service's virtual address while guacd connects from its *pod*
|
||||
IP, so every relay connection is rejected and every session dies with
|
||||
`waiting for guacd: i/o timeout`. Compose is immune — there the name resolves to
|
||||
`waiting for guacd: i/o timeout`. Compose is immune - there the name resolves to
|
||||
the address that connects.
|
||||
|
||||
SSH connections authenticate with a stored private key; RDP/VNC credentials are
|
||||
encrypted, single-use, and consumed when the tunnel opens. None of them reach
|
||||
the agent — the session is negotiated end-to-end between guacd and the target
|
||||
the agent - the session is negotiated end-to-end between guacd and the target
|
||||
daemon, so the agent relays bytes it cannot read.
|
||||
|
||||
### Running more than one server replica
|
||||
@@ -266,7 +266,7 @@ replicas, the process asked to do something to an agent is almost never the
|
||||
process holding that agent's stream.
|
||||
|
||||
`server/internal/bus` is the Redis message bus that closes the gap. It adds no
|
||||
infrastructure — Redis was already required for sessions — and it is **not
|
||||
infrastructure - Redis was already required for sessions - and it is **not
|
||||
optional on a single-replica deployment**: dispatch takes the bus path always,
|
||||
so the code running in production is the code running everywhere, rather than a
|
||||
rare cross-pod branch that only fails under load.
|
||||
@@ -274,16 +274,16 @@ rare cross-pod branch that only fails under load.
|
||||
| Concern | How it crosses replicas |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Which pod owns an agent | `vantage:agent:<server_id>` holds the owner's node ID with a 30s TTL, renewed every 10s. `Dispatcher.IsConnected` is an `EXISTS` on it |
|
||||
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue. The envelope carries `node`, the presence holder resolved at publish time, and a pod ignores envelopes addressed elsewhere: the channel is a fan-out, and during a reconnect a half-open stream's pod is still subscribed. Unaddressed, it could ack first and queue the command onto a dead stream — the operator told it worked, the agent never seeing it. Presence renewal is owner-only (`RenewPresence`) for the same reason: a blind `SET` let the stale pod steal the key back every 10s |
|
||||
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** - a command whose owner died must fail loudly (503) rather than queue. The envelope carries `node`, the presence holder resolved at publish time, and a pod ignores envelopes addressed elsewhere: the channel is a fan-out, and during a reconnect a half-open stream's pod is still subscribed. Unaddressed, it could ack first and queue the command onto a dead stream - the operator told it worked, the agent never seeing it. Presence renewal is owner-only (`RenewPresence`) for the same reason: a blind `SET` let the stale pod steal the key back every 10s |
|
||||
| Step results | the owner pod publishes to `vantage:res:<command_id>`; the pod driving the run subscribes **before** dispatching, or a fast agent answers into a channel nobody has joined |
|
||||
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself — unmasked bytes stay off the bus |
|
||||
| Console relay | **not routed to the owner pod at all.** A `ProxyStream` is its own HTTP/2 request and an L7 proxy balances requests, not connections, so it does not follow the command stream — the listener therefore cannot be bound in advance. Whichever pod receives the stream binds it and announces **its own** address on `vantage:proxyaddr:<proxy_id>`; `vantage:proxypending:<proxy_id>` (30s, consumed atomically) is what authorises the claim, and the failure reason comes back on `vantage:proxyend:<proxy_id>` |
|
||||
| Background jobs | `bus.RunAsLeader` — one Redis lock named `housekeeping` |
|
||||
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself - unmasked bytes stay off the bus |
|
||||
| Console relay | **not routed to the owner pod at all.** A `ProxyStream` is its own HTTP/2 request and an L7 proxy balances requests, not connections, so it does not follow the command stream - the listener therefore cannot be bound in advance. Whichever pod receives the stream binds it and announces **its own** address on `vantage:proxyaddr:<proxy_id>`; `vantage:proxypending:<proxy_id>` (30s, consumed atomically) is what authorises the claim, and the failure reason comes back on `vantage:proxyend:<proxy_id>` |
|
||||
| Background jobs | `bus.RunAsLeader` - one Redis lock named `housekeeping` |
|
||||
|
||||
**Workflow logs are in MongoDB** (`workflow_log_lines`, one document per line,
|
||||
with a `workflow_log_seq` counter document per run/server). Two pods write the
|
||||
same log concurrently — the run's pod emits markers, the agent's pod emits
|
||||
output — so ordering only means anything if both draw sequence numbers from the
|
||||
same log concurrently - the run's pod emits markers, the agent's pod emits
|
||||
output - so ordering only means anything if both draw sequence numbers from the
|
||||
same counter. `StepRun.log_offset` is that sequence number now, not a byte
|
||||
offset. Writes are batched (128 lines or 250ms) and capped: 8 KB per line,
|
||||
200k lines per server-run, after which one final `[vantage] log truncated`
|
||||
@@ -294,7 +294,7 @@ is a database incident. **Nothing writes to `/data` any more**, which is why
|
||||
**Shutdown order is load-bearing.** `main` traps SIGTERM, stops gRPC
|
||||
(`GracefulStop`, 10s cap) and only then drains HTTP. Each `CommandStream`
|
||||
handler releases its agent's presence claim on return, so a killed process
|
||||
leaves `vantage:agent:<server_id>` behind for the rest of its 30s TTL — during
|
||||
leaves `vantage:agent:<server_id>` behind for the rest of its 30s TTL - during
|
||||
which other replicas dispatch to a pod that has exited and the caller sees
|
||||
`agent offline` for a perfectly healthy agent. Draining HTTP first would hold
|
||||
those claims for the length of the drain, which is why gRPC goes first. The
|
||||
@@ -305,7 +305,7 @@ handling buys nothing.
|
||||
The agent side of the same failure: `runCommandStream` resets its backoff only
|
||||
after a stream that survived `streamHealthyAfter`. `connectAndHandleStream`
|
||||
returns an error on *every* stream end, healthy ones included, so without that
|
||||
reset the backoff only ever climbed — an agent pinned itself at the ceiling
|
||||
reset the backoff only ever climbed - an agent pinned itself at the ceiling
|
||||
after a handful of ordinary deploys and stayed there. The ceiling is 30s, not
|
||||
minutes, because while the stream is down the agent still polls `SyncKeys` and
|
||||
still reads as `active` in the fleet list while answering no commands at all.
|
||||
@@ -315,7 +315,7 @@ scheduler means each check fires N times, each incident notification reaches the
|
||||
customer N times, and each hourly rollup is written N times; N reapers race to
|
||||
purge the same Free instance. `monitorsched`, `StartReaper`, `StartLogSweeper`,
|
||||
`StartAuditSweeper` and the offline sweep therefore all run inside one
|
||||
`RunAsLeader("housekeeping", …)` — one role, one lock. Each takes a context
|
||||
`RunAsLeader("housekeeping", …)` - one role, one lock. Each takes a context
|
||||
cancelled the instant leadership is lost, and must return when it is.
|
||||
|
||||
Redis rather than a Kubernetes `Lease` so Compose takes the identical path: one
|
||||
@@ -323,13 +323,13 @@ implementation to reason about, not two.
|
||||
|
||||
Two deployment requirements come with `replicaCount > 1`: every replica must
|
||||
share **one** Redis (a per-pod Redis partitions the bus and every agent looks
|
||||
offline to two thirds of the fleet), and `POD_IP` must be set — the chart does
|
||||
it from the downward API — because `PROXY_ADVERTISE_HOST` names the Service, and
|
||||
offline to two thirds of the fleet), and `POD_IP` must be set - the chart does
|
||||
it from the downward API - because `PROXY_ADVERTISE_HOST` names the Service, and
|
||||
a Service cannot address the one pod holding a console listener.
|
||||
|
||||
### Inventory and OS updates
|
||||
|
||||
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
|
||||
Agents report CPU/memory/swap/partitions/kernel - metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
|
||||
|
||||
Windows update checking and applying go through the Windows Update COM API
|
||||
(`Microsoft.Update.Session`) rather than the PSWindowsUpdate module, which would
|
||||
@@ -347,7 +347,7 @@ every 15 minutes. Linux fills it too, from `/var/run/reboot-required` or
|
||||
Agents report their installed packages hourly; the control plane matches them
|
||||
against distribution security feeds and raises findings that link to the
|
||||
existing `ApplyUpdatesCmd` patching path. Gated by the `vuln_scanning` licence
|
||||
feature, **checked at collection rather than display** — an ungated instance
|
||||
feature, **checked at collection rather than display** - an ungated instance
|
||||
stores no inventory, and storage is the expensive half.
|
||||
|
||||
**Matching uses distribution feeds, never NVD version ranges.** Distributions
|
||||
@@ -369,7 +369,7 @@ replicas rescanning the same fleet and sending N digests. The tick is also the
|
||||
digest's batch boundary, which is what makes "one message, not five hundred"
|
||||
structural rather than a debounce someone maintains.
|
||||
|
||||
Findings are **never deleted when a package is patched** — the state moves to
|
||||
Findings are **never deleted when a package is patched** - the state moves to
|
||||
`fixed`, so "what did we remediate last quarter" stays answerable. Acceptance
|
||||
requires a reason and an expiry, and reopens automatically: permanent dismissal
|
||||
is where risk goes to be forgotten. An unsupported distribution reports
|
||||
@@ -379,7 +379,7 @@ unknown is the same lie as a silently stale database, which is why
|
||||
|
||||
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
|
||||
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
|
||||
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
|
||||
and a scratch image has none - the failure is `vulnsched: temp dir: stat /tmp:
|
||||
no such file or directory`, logged once at boot while every other subsystem
|
||||
runs normally, so the only symptom is a fleet that never reports a finding.
|
||||
|
||||
@@ -389,19 +389,19 @@ scheduler off entirely.
|
||||
|
||||
### Workload registry
|
||||
|
||||
A **workload** is one Docker container or one systemd unit — one word for the
|
||||
A **workload** is one Docker container or one systemd unit - one word for the
|
||||
page, the collection and the commands, rather than saying "container or
|
||||
service" in every identifier.
|
||||
|
||||
On Windows a workload is a Docker container or a Windows **service**, reported
|
||||
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields —
|
||||
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields -
|
||||
one wire shape, worded per platform in the UI, which is the only layer that
|
||||
knows the host's OS. The platform split lives entirely in the agent, as build
|
||||
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
|
||||
and `logs_` pairs); the control plane is OS-blind and needed no changes.
|
||||
Windows collection runs PowerShell through the agent's `internal/winexec`. Every
|
||||
script that reports data emits JSON that a build-tag-free parser reads, so
|
||||
those parsers are tested on Linux — the agent module has no Windows CI. The
|
||||
those parsers are tested on Linux - the agent module has no Windows CI. The
|
||||
control verbs and `serviceDisplayName` emit no JSON and have no parser; they
|
||||
are exercised only by running the agent on Windows.
|
||||
|
||||
@@ -428,7 +428,7 @@ it, and a stale row is a wrong action aimed at a container that already died.
|
||||
Two operations do answer back, both over the bus, both with `Await` called
|
||||
**before** dispatch: control actions reuse the existing `CommandResult`, and log
|
||||
reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
|
||||
`CommandResult` onto `bus.ResultChannel` — publishing with no subscriber is a
|
||||
`CommandResult` onto `bus.ResultChannel` - publishing with no subscriber is a
|
||||
no-op, so this costs nothing and avoids a second result path.
|
||||
|
||||
**The protected set is computed agent-side and enforced agent-side.**
|
||||
@@ -439,13 +439,13 @@ with the console relay hardcoding
|
||||
will do to itself; a server-side denylist alone would be bypassed by the next
|
||||
dispatch path someone adds, and the failure is unrecoverable from the UI. The
|
||||
reported `Protected` flag is the courtesy that greys the button; the agent's own
|
||||
check is the boundary. The API answers **409** when it fires — nothing failed.
|
||||
check is the boundary. The API answers **409** when it fires - nothing failed.
|
||||
|
||||
Collection avoids parsing English: `docker ps -aq` then
|
||||
`docker inspect --format '{{json .}}'`, because `docker ps` reports health and
|
||||
uptime inside a human `Status` string that is localised and reworded between
|
||||
releases. Compose stacks come from the `com.docker.compose.project` label, never
|
||||
from YAML on disk — a compose file there may not be what is running. systemd
|
||||
from YAML on disk - a compose file there may not be what is running. systemd
|
||||
uses **column** output, not `--output=json`, which needs systemd 246+.
|
||||
|
||||
`DockerOK`/`DockerError` are two fields because there are three states: not
|
||||
@@ -453,13 +453,13 @@ installed (common on this fleet, and not a fault), installed but not responding,
|
||||
and running nothing. The UI must render the first as "not in use here" rather
|
||||
than an empty list.
|
||||
|
||||
Logs are capped at **500 lines and 256KB, whichever binds first** — a line count
|
||||
Logs are capped at **500 lines and 256KB, whichever binds first** - a line count
|
||||
alone does not bound size, and 500 lines of 4KB JSON is 2MB across the bus. The
|
||||
cap is mirrored in `services.MaxWorkloadLogLines` because the agent is a
|
||||
separate module — a separate repository now — with an `internal/` tree, and the
|
||||
separate module - a separate repository now - with an `internal/` tree, and the
|
||||
constant cannot be shared; change one, change the other. There is **no follow mode**: the browser console already gives
|
||||
a real terminal where `docker logs -f` works properly. Log reads and control
|
||||
actions are **owner|admin and audited**, unlike the read-only snapshot — a
|
||||
actions are **owner|admin and audited**, unlike the read-only snapshot - a
|
||||
container's stdout is arbitrary and cannot be masked the way a workflow's can.
|
||||
|
||||
`server_workloads` is one document per server, mirroring `server_packages`, and
|
||||
@@ -469,7 +469,7 @@ is no history: a workload list is state, not a record.
|
||||
**The wire contract is not in this repository at all.** `shared/grpc/pb` is
|
||||
hand-written JSON-tagged structs over the custom codec in `shared/grpc/codec`,
|
||||
and `proto/vantage/v1/vantage.proto` is documentation of them rather than a
|
||||
generator input — nothing compiles it. Both live in `vantage-shared`, together,
|
||||
generator input - nothing compiles it. Both live in `vantage-shared`, together,
|
||||
because that co-location is the only thing making "add the message to both in
|
||||
the same commit" possible.
|
||||
|
||||
@@ -482,7 +482,7 @@ code, which the linker drops.
|
||||
A message added to `vantage-shared` is not a message either side has until its
|
||||
pin is bumped. What that buys is a mismatch that is a compile error rather than
|
||||
two copies that both compiled and disagreed on the wire. What it costs is
|
||||
ordering — **a wire change is three steps**: release `vantage-shared`, bump the
|
||||
ordering - **a wire change is three steps**: release `vantage-shared`, bump the
|
||||
pin in `server/` (live at the next push to main), bump the pin in `agent/` (live
|
||||
only at the next `agent/v*` tag). The control plane runs ahead of the fleet in
|
||||
between, which was true before too; it is now explicit in two `go.mod` files
|
||||
@@ -490,7 +490,7 @@ rather than implicit in a shared directory.
|
||||
|
||||
### Status pages
|
||||
|
||||
Two collections: `status_pages` is the page itself — title, banner, published
|
||||
Two collections: `status_pages` is the page itself - title, banner, published
|
||||
flag, and an ordered list of sections each holding entries that pair a
|
||||
`monitor_id` with a per-page display name. `status_incidents` holds both
|
||||
operator-authored incidents and maintenance windows, sharing one document
|
||||
@@ -505,7 +505,7 @@ one.** It takes a `snapshotInput` built from already-fetched
|
||||
`StatusSnapshot` built entirely from a parallel, deliberately smaller
|
||||
vocabulary (`PublicComponent`, `PublicIncident`, …) that has no field for a
|
||||
target URL, host, port, expected status, keyword, failure message,
|
||||
certificate expiry, latency, runner or notification channel — `models.Monitor`
|
||||
certificate expiry, latency, runner or notification channel - `models.Monitor`
|
||||
itself never reaches an anonymous caller, only the handful of fields
|
||||
`assembleSnapshot` chooses to copy out of it. Being a pure function of already-
|
||||
fetched data (no DB calls inside it) is what makes the boundary testable
|
||||
@@ -515,7 +515,7 @@ a field to `PublicComponent` and that field being a hostname.
|
||||
**An incident may only name components the page already carries.**
|
||||
`services.checkAffectedOnPages` refuses an `affected_monitors` entry that no
|
||||
page in the incident's `page_ids` lists, and the editor offers only the saved
|
||||
page's components — labelled by their per-page display name, since that is the
|
||||
page's components - labelled by their per-page display name, since that is the
|
||||
name the reader sees. Naming an arbitrary monitor would publish a machine the
|
||||
page deliberately does not, which is the same leak `assembleSnapshot`'s
|
||||
redaction boundary exists to prevent, reached from the authoring side instead
|
||||
@@ -531,7 +531,7 @@ snapshot assembly reads recent `incidents` for the page's monitors and folds
|
||||
them into the timeline alongside the authored ones. There is no second
|
||||
incidents table for automatic ones and no reconciliation between two records
|
||||
of the same outage. A maintenance window in progress **repaints how a day is
|
||||
drawn, never the uptime number** — `buildDays` computes each day's up/down
|
||||
drawn, never the uptime number** - `buildDays` computes each day's up/down
|
||||
state and the 90-day percentage from rollups first, and
|
||||
`applyMaintenanceRepaint` only overwrites today's display state afterward, so
|
||||
a component that stayed up throughout a maintenance window still shows as up
|
||||
@@ -540,12 +540,12 @@ in its history.
|
||||
The public route, `GET /public/status/:pageId`, is mounted on the gin **root**,
|
||||
outside `/api`, on purpose: `/api` carries `auth.Middleware`, `RequireScopes`,
|
||||
`RateLimitTokens` and `RequireActiveLicense` by virtue of where it is mounted,
|
||||
and a public route living there would need four exemptions — each one a hole a
|
||||
and a public route living there would need four exemptions - each one a hole a
|
||||
later change to any of those four could widen back open. A missing page, an
|
||||
unpublished page, and a page on the wrong host all answer the same 404;
|
||||
inventing a distinct code for "exists but unpublished" would itself leak that
|
||||
the page exists. A lapsed licence or a tier lacking `status_pages` answers 200
|
||||
with `available:false` and a `reason`, never a 403 or a blank page — the
|
||||
with `available:false` and a `reason`, never a 403 or a blank page - the
|
||||
reader is a member of the public who can do nothing about either condition and
|
||||
deserves an explanation, not a browser error.
|
||||
|
||||
@@ -556,7 +556,7 @@ saw `server:8080` and every status page 404'd on every deployment. `web/`
|
||||
forwards the visitor's host in `X-Forwarded-Host` (and their address in
|
||||
`X-Forwarded-For`, or the whole deployment shares one rate-limit bucket), and
|
||||
`publicStatusInstance` honours that header **only when `c.RemoteIP()` is in
|
||||
`TRUSTED_PROXIES`** — it selects a tenant, so an untrusted peer must not be
|
||||
`TRUSTED_PROXIES`** - it selects a tenant, so an untrusted peer must not be
|
||||
able to name one. It uses `RemoteIP()` and not `ClientIP()` deliberately: the
|
||||
latter is reconstructed from the very headers being judged.
|
||||
|
||||
@@ -565,13 +565,13 @@ deployment.** `hostSlug` requires `<slug>.vantage.<tld>`; a self-hosted install
|
||||
at `vantage.acme.com` or an IP has no slug and would otherwise 404 forever. It
|
||||
has exactly one instance, resolved with the same count-then-read bootstrap
|
||||
uses, cached alongside the slug lookups. More than one instance is a 404, not a
|
||||
guess. A host that *does* name a slug which does not exist stays a 404 —
|
||||
guess. A host that *does* name a slug which does not exist stays a 404 -
|
||||
falling back there would serve one tenant's page on another's address.
|
||||
|
||||
Assembled snapshots are cached in Redis for **30 seconds**, keyed per
|
||||
instance and page, and every authoring write (`UpdateStatusPage`,
|
||||
`DeleteStatusPage`, and every incident mutation) invalidates its page's entry
|
||||
immediately rather than waiting out the TTL — an operator posting an update
|
||||
immediately rather than waiting out the TTL - an operator posting an update
|
||||
mid-incident should not wonder for half a minute whether it saved. A cache
|
||||
miss, on Redis being down or on any read error, degrades to reassembly rather
|
||||
than an error: the status page has to survive the outage it exists to report.
|
||||
@@ -581,7 +581,7 @@ pattern as `RateLimitTokens`.
|
||||
|
||||
**`TRUSTED_PROXIES` is load-bearing for that limiter, not cosmetic.** `main.go`
|
||||
always calls `gin.SetTrustedProxies` with it; left unset, gin trusts no proxy
|
||||
and `c.ClientIP()` falls back to the direct peer address — which, sat behind a
|
||||
and `c.ClientIP()` falls back to the direct peer address - which, sat behind a
|
||||
real reverse proxy, is the proxy's own address for every visitor. The rate
|
||||
limiter then keys on one address for the whole fleet of readers, and the first
|
||||
burst of legitimate traffic during an incident is what trips it. Set it to the
|
||||
@@ -608,14 +608,14 @@ build that knows the new address, after which self-update works again.
|
||||
The ordering matters. This server must be deployed with the new paths *before*
|
||||
the one-liner is any use, because it is this server that hands out the URL.
|
||||
|
||||
The six generators — two install scripts, two update scripts,
|
||||
The six generators - two install scripts, two update scripts,
|
||||
`GET /api/agent/latest-version` in `services/dispatch.go`, and the tag lookup
|
||||
inside each — all name that repository. They must agree with wherever
|
||||
inside each - all name that repository. They must agree with wherever
|
||||
`agent-release.yml` actually publishes, and nothing checks that they do.
|
||||
|
||||
### Backup and restore
|
||||
|
||||
`vantagectl` is `vantage-ctl` now — its own repository, with the command still
|
||||
`vantagectl` is `vantage-ctl` now - its own repository, with the command still
|
||||
named `vantagectl`. It is not a subcommand of `server` and never was: `server`
|
||||
imports the whole control-plane dependency graph, `spf13/cobra` has no business
|
||||
in a process that also terminates gRPC streams, and above all **it has to run
|
||||
@@ -626,15 +626,15 @@ binary whose failure is the reason you reached for it.
|
||||
Almost none of its logic is in that repository either: dump, restore, verify,
|
||||
manifest and fingerprint are `shared/backup` in `vantage-shared`, and
|
||||
`internal/cmd` holds only argument parsing and operator-facing output. That
|
||||
split is what would let `server` import `shared/backup` later — a scheduled
|
||||
in-process backup, say — without a second implementation to keep in sync.
|
||||
split is what would let `server` import `shared/backup` later - a scheduled
|
||||
in-process backup, say - without a second implementation to keep in sync.
|
||||
`shared/cryptobox` is the same move one layer down: it is the **single**
|
||||
AES-256-GCM implementation, and `server/internal/services/crypto.go` delegates
|
||||
to it rather than keeping its own copy that `shared/backup` would otherwise have
|
||||
had to duplicate to decrypt a probe value during `verify`.
|
||||
|
||||
**The one thing this repository owes it is `backup.ciphertextFields`**, which
|
||||
lives in `vantage-shared` and mirrors `server/internal/models` **by hand** —
|
||||
lives in `vantage-shared` and mirrors `server/internal/models` **by hand** -
|
||||
`shared/` is a separate module and cannot import it. The map naming each
|
||||
collection's `*_enc` fields (`keys`, `secrets`, `auth_providers`,
|
||||
`console_sessions`) must change in the same commit as any of those bson tags,
|
||||
@@ -642,47 +642,47 @@ and that commit is now in a different repository from the tags it tracks. Wrong
|
||||
field names are **silent**: `verify`'s live probe finds no ciphertext and
|
||||
reports "this database stores no ciphertext yet", so the one gate that catches
|
||||
what a key fingerprint cannot becomes a no-op. `settings` is deliberately in
|
||||
neither that map nor `CiphertextCollections()` — its ESO read token is a
|
||||
neither that map nor `CiphertextCollections()` - its ESO read token is a
|
||||
SHA-256 hash, not ciphertext.
|
||||
|
||||
The chart's optional backup CronJob runs that image, and **`backup.image` has
|
||||
no default** — the template `fail`s without one rather than guessing, so a
|
||||
no default** - the template `fail`s without one rather than guessing, so a
|
||||
cluster set up before the move keeps working until someone changes the value.
|
||||
It is `gitea.hostxtra.co.uk/vantage/vantage-ctl:latest` now, was
|
||||
`mrhid6/vantage/vantagectl:latest`; `chart-release.yml`'s render checks name the
|
||||
new path.
|
||||
|
||||
The rest — the refusals around `--force` and `--confirm-db`, the key
|
||||
The rest - the refusals around `--force` and `--confirm-db`, the key
|
||||
fingerprint, live collection enumeration, verbatim index replay, the `.partial`
|
||||
rename — is documented in `vantage-ctl`.
|
||||
rename - is documented in `vantage-ctl`.
|
||||
|
||||
### API tokens and OpenAPI
|
||||
|
||||
A token is `vt_` plus 32 random bytes hex, shown once at creation and stored
|
||||
only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
|
||||
only as sha256 - the same shape as `servers.agent_token_hash` and the ESO read
|
||||
token, and for the same reason: nothing downstream ever needs the plaintext
|
||||
back. It belongs to the user who created it, and its role can never exceed
|
||||
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
|
||||
true across a demotion rather than only at issuance. Scopes are nine
|
||||
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
|
||||
`workloads`, `status`, `settings` — each split into `:read` and `:write`, with `:write`
|
||||
resources - `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
|
||||
`workloads`, `status`, `settings` - each split into `:read` and `:write`, with `:write`
|
||||
satisfying a `:read` requirement on the same resource so a caller does not have
|
||||
to hold both. Any signed-in member may mint and revoke their **own** tokens —
|
||||
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
|
||||
to hold both. Any signed-in member may mint and revoke their **own** tokens -
|
||||
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` - because
|
||||
`roleRank` already bounds what a token can do to no more than its creator's
|
||||
own role, so a member cannot use a token to reach past themselves. Owner and
|
||||
admin additionally see and revoke every token in the instance — `all=true` on
|
||||
admin additionally see and revoke every token in the instance - `all=true` on
|
||||
`GET /tokens` is gated by `elevated()` in `api/tokens.go`, and
|
||||
`RevokeAPIToken` in `services/tokens.go` checks the same owner/admin condition
|
||||
before letting a revoke target somebody else's token — neither is a
|
||||
before letting a revoke target somebody else's token - neither is a
|
||||
`RequireRole` middleware.
|
||||
The `settings:read`/`settings:write` entries in `routeScopes` govern a
|
||||
**token-authenticated** caller reaching the token endpoints — `RequireScopes`
|
||||
no-ops entirely for a cookie session — so they say nothing about which human
|
||||
**token-authenticated** caller reaching the token endpoints - `RequireScopes`
|
||||
no-ops entirely for a cookie session - so they say nothing about which human
|
||||
role may call these routes with a session; that is `roleRank` and `elevated()`,
|
||||
not the scope map. Expiry is optional per token; `settings.api_token_max_days` caps how
|
||||
far out a new one may be set, and when that cap is set a token requested with
|
||||
no expiry is refused rather than silently capped — the policy governs
|
||||
no expiry is refused rather than silently capped - the policy governs
|
||||
issuance only and never reaches back to invalidate a token already issued.
|
||||
`RateLimitTokens` holds every token to 600 requests/minute in a Redis fixed
|
||||
window, answering 429 with `Retry-After`; cookie sessions are untouched; it
|
||||
@@ -695,12 +695,12 @@ The page is reachable at **every** role, which is the whole reason it is a page:
|
||||
member has. `settings.api_token_max_days` stays on `/settings` because it is
|
||||
instance policy rather than one person's credentials, and that split is exactly
|
||||
what lets the page be ungated. The label differs from the identifiers on
|
||||
purpose — the collection is `api_tokens`, the prefix is `vt_`, the routes are
|
||||
purpose - the collection is `api_tokens`, the prefix is `vt_`, the routes are
|
||||
`/api/tokens`, and renaming a published endpoint to match a nav label would
|
||||
break every script already written against it.
|
||||
|
||||
`server/internal/api/docs/openapi.json` is a **generated, committed** OpenAPI
|
||||
3.1 document — `swag v2` reading `@…` annotations off the handlers — served at
|
||||
3.1 document - `swag v2` reading `@…` annotations off the handlers - served at
|
||||
`GET /api/openapi.json` and rendered as a reference page by a vendored Scalar
|
||||
bundle at `GET /api/docs`. `server-deploy.yml` regenerates it on every server
|
||||
build and runs `git diff --exit-code` against the committed copy: a handler
|
||||
@@ -708,7 +708,7 @@ whose annotation drifted from its code fails CI rather than shipping a
|
||||
reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
|
||||
`GET /api/docs/scalar.js`) rather than pulled from a CDN, because the
|
||||
reference page has to work on an air-gapped install with no outbound access at
|
||||
all — the same requirement licence verification already meets.
|
||||
all - the same requirement licence verification already meets.
|
||||
|
||||
### The public host
|
||||
|
||||
@@ -719,10 +719,10 @@ and its contact-form service are `vantage-site`; the documentation at `/docs` is
|
||||
them on top of this one:
|
||||
|
||||
```bash
|
||||
# self-hosted install — the control plane and nothing else
|
||||
# self-hosted install - the control plane and nothing else
|
||||
docker compose -f deploy/docker/docker-compose.yml up -d
|
||||
|
||||
# vantage.hostxtra.co.uk — every repository's fragment together
|
||||
# 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 \
|
||||
@@ -737,8 +737,8 @@ 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
|
||||
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.
|
||||
|
||||
@@ -749,7 +749,7 @@ 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; the contact-form service has no signup flow at
|
||||
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.
|
||||
@@ -759,7 +759,7 @@ all, and lives in another repository besides.
|
||||
- Rate limited per client IP, plus a honeypot field.
|
||||
|
||||
An account is a team, not a person. `customer_users.account_role` is `owner`,
|
||||
`admin` or `member` — the same three words as the control plane's roles, on
|
||||
`admin` or `member` - the same three words as the control plane's roles, on
|
||||
purpose. Owners and admins invite people, create instances and grant instance
|
||||
access; billing is owner-only.
|
||||
|
||||
@@ -777,13 +777,13 @@ left unspent.
|
||||
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
|
||||
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 `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 —
|
||||
`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`.
|
||||
@@ -794,13 +794,13 @@ define `subject`, `title`, `pill` and `body`; `layout.html.tmpl` and
|
||||
`layout.txt.tmpl` provide the chrome and the helper templates (`p`, `lead`,
|
||||
`button`, `well`, `note`, `rows`, `chip`) that the bodies compose. One template
|
||||
set is parsed per message rather than one big set, because every message
|
||||
defines those same four names. **`subject` is defined in the txt file only** —
|
||||
defines those same four names. **`subject` is defined in the txt file only** -
|
||||
`html/template` would escape an ampersand in an instance name and mail clients
|
||||
show subjects verbatim.
|
||||
|
||||
`shared/mail/render_test.go` renders all of them and fails if a template exists
|
||||
that no case covers, which is the only thing standing between a mistyped field
|
||||
and a boot-time panic — the templates are parsed in `init()`.
|
||||
and a boot-time panic - the templates are parsed in `init()`.
|
||||
|
||||
### Shared provisioning
|
||||
|
||||
@@ -811,7 +811,7 @@ and a boot-time panic — the templates are parsed in `init()`.
|
||||
Granting someone access to a cloud instance writes a real control-plane `users`
|
||||
row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The
|
||||
instance authenticates it exactly as it authenticates anyone else, with **no
|
||||
runtime dependency on admin**. Revoking deletes that row — the control plane has
|
||||
runtime dependency on admin**. Revoking deletes that row - the control plane has
|
||||
no disabled state, and a row that exists is a row that can sign in.
|
||||
|
||||
`instance_members` in admin's database is only admin's _index_ of those
|
||||
@@ -826,7 +826,7 @@ The HQ password is the single source of truth for every `hq`-sourced row.
|
||||
`PUT /api/account/password` rehashes and has `cloudprov` copy the hash to every
|
||||
projected row; propagation is best-effort, and `admin/internal/hqsync` compares
|
||||
and repairs every 15 minutes. It is **its own package rather than a pass inside
|
||||
`inject`** — `inject` writes three licence fields and nothing else, and that
|
||||
`inject`** - `inject` writes three licence fields and nothing else, and that
|
||||
narrowness is what makes admin's reach into the control plane reviewable.
|
||||
|
||||
The control plane refuses to change an `hq`-sourced user's role or delete it
|
||||
@@ -836,7 +836,7 @@ password-change endpoint at all, so there is no competing writer for the hash.
|
||||
|
||||
**A rename moves the host, and the licence does not care.** `PUT
|
||||
/api/instances/:id/name` re-derives the slug from the new name through
|
||||
`provision.RenameSlug` — the same rules that named the instance at creation —
|
||||
`provision.RenameSlug` - the same rules that named the instance at creation -
|
||||
and writes the control plane first, because `instances.slug`'s unique index is
|
||||
what settles a race between two accounts reaching for one name. A taken slug is
|
||||
a refusal, not an `acme-2`: creation appends a counter because any free slug
|
||||
@@ -844,7 +844,7 @@ will do, and a rename is a request for one specific host. A licence binds the
|
||||
instance UUID, so nothing is reissued and Paddle is not called. The old host
|
||||
keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot
|
||||
reach into), and `km_session` is host-only, so the customer signs in again on
|
||||
the new address — the portal says so rather than redirecting them into a login
|
||||
the new address - the portal says so rather than redirecting them into a login
|
||||
screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at`
|
||||
because it is admin's policy; staff bypass it and must not write the field.
|
||||
|
||||
@@ -852,16 +852,16 @@ because it is admin's policy; staff bypass it and must not write the field.
|
||||
|
||||
## Auth and Orgs
|
||||
|
||||
- **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
|
||||
- **Local auth** — email + password (bcrypt), `POST /auth/login`.
|
||||
- **Auth providers** — configured _per instance_ in `auth_providers`, any number of them, each named and independently enabled. Issuer, client ID and an encrypted client secret per provider. `/auth/oidc/:providerId/start` → `/auth/oidc/:providerId/callback`. Presets (Entra, Google, Okta, GitHub) are a Go table in `server/internal/auth/presets.go` and expand to a real issuer on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes its own branch, requiring an address that is both primary **and** verified — an unverified address is not proof of control.
|
||||
- **Local login** — `settings.local_login_enabled`, a `*bool` because absent must mean enabled; a plain bool would disable password sign-in fleet-wide at upgrade. `services.CheckLockout` refuses any change leaving neither local login nor an enabled provider, and is enforced in the service layer so the settings path and the provider path cannot disagree.
|
||||
- **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
|
||||
- **Roles** — `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
|
||||
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
|
||||
- **Bootstrap** - first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
|
||||
- **Local auth** - email + password (bcrypt), `POST /auth/login`.
|
||||
- **Auth providers** - configured _per instance_ in `auth_providers`, any number of them, each named and independently enabled. Issuer, client ID and an encrypted client secret per provider. `/auth/oidc/:providerId/start` → `/auth/oidc/:providerId/callback`. Presets (Entra, Google, Okta, GitHub) are a Go table in `server/internal/auth/presets.go` and expand to a real issuer on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes its own branch, requiring an address that is both primary **and** verified - an unverified address is not proof of control.
|
||||
- **Local login** - `settings.local_login_enabled`, a `*bool` because absent must mean enabled; a plain bool would disable password sign-in fleet-wide at upgrade. `services.CheckLockout` refuses any change leaving neither local login nor an enabled provider, and is enforced in the service layer so the settings path and the provider path cannot disagree.
|
||||
- **Sessions** - opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
|
||||
- **Roles** - `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
|
||||
- **Host/org guard** - `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
|
||||
|
||||
Unique indexes are a **security property**, not an optimisation. `users` is
|
||||
unique on `(instance_id, email)` — one address is one user _within_ an instance,
|
||||
unique on `(instance_id, email)` - one address is one user _within_ an instance,
|
||||
and the same address may hold a user in several instances, because an account's
|
||||
people are projected into each instance they are granted. This is sufficient only
|
||||
because **every lookup by email is scoped by instance**; there is deliberately no
|
||||
@@ -898,7 +898,7 @@ treats 70s of silence as a dead stream and reconnects. Keepalive cannot do this
|
||||
job behind an L7 proxy: the agent's HTTP/2 connection terminates at the proxy,
|
||||
which answers pings on its own behalf, so a control-plane pod that dies leaves
|
||||
the agent blocked in `Recv` on a stream that never delivers another message and
|
||||
never errors — commands dispatched into it are silently lost while `SyncKeys`
|
||||
never errors - commands dispatched into it are silently lost while `SyncKeys`
|
||||
keeps succeeding and the fleet list still shows the server `active`. The agent's
|
||||
watchdog arms only **after** it has seen a first ping, so an older server that
|
||||
sends none is treated as working rather than put into a reconnect loop.
|
||||
@@ -918,7 +918,7 @@ GET /update /update.ps1
|
||||
GET /auth/bootstrap-status
|
||||
POST /auth/bootstrap /auth/login /auth/logout
|
||||
GET /auth/me
|
||||
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} — no issuer, client ID or secret
|
||||
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} - no issuer, client ID or secret
|
||||
GET /api/secrets/:group/values # bearer token (ESO)
|
||||
```
|
||||
|
||||
@@ -966,11 +966,11 @@ tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens
|
||||
GET /openapi.json · GET /docs
|
||||
```
|
||||
|
||||
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
|
||||
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection - it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
|
||||
|
||||
`POST /license` is also in `licenceExemptPaths`: pasting a valid licence has to work while the current one is expired, because it is the way out of degraded mode.
|
||||
|
||||
Free exists in both deployments, so it is no longer cloud-only by construction. The one-Free-per-account rule is enforced per account **and deployment**, in `licensing.checkFreeLimit` and in `createInstance`'s friendly pre-check — the two must stay scoped identically, because a pre-check stricter than the issuer refuses what would have worked. A self-hosted Free licence is claimed with `POST /api/instances/:id/claim-free` after the install is linked; the metered server count and per-instance feature toggles come from the instance's `entitlement`, which a licence snapshots at issue time (see the `catalogue`/`entitlements` note under MongoDB Collections).
|
||||
Free exists in both deployments, so it is no longer cloud-only by construction. The one-Free-per-account rule is enforced per account **and deployment**, in `licensing.checkFreeLimit` and in `createInstance`'s friendly pre-check - the two must stay scoped identically, because a pre-check stricter than the issuer refuses what would have worked. A self-hosted Free licence is claimed with `POST /api/instances/:id/claim-free` after the install is linked; the metered server count and per-instance feature toggles come from the instance's `entitlement`, which a licence snapshots at issue time (see the `catalogue`/`entitlements` note under MongoDB Collections).
|
||||
|
||||
---
|
||||
|
||||
@@ -986,13 +986,13 @@ plane, each of which this codebase enforces:
|
||||
|
||||
- **Licences are injected, not pasted.** `inject` writes three licence fields
|
||||
straight into `instances`. `GET /license` reports `deployment`, and **`POST
|
||||
/license` answers 409 `cloud_managed` when it is `cloud`** — the refusal
|
||||
/license` answers 409 `cloud_managed` when it is `cloud`** - the refusal
|
||||
cannot break injection, it only stops a customer pasting over a licence they
|
||||
do not own.
|
||||
- **Instances and owners are provisioned through `shared/provision`**, the same
|
||||
code path bootstrap uses, so there is one implementation of the slug rules
|
||||
and reserved names rather than two — see "Shared provisioning".
|
||||
- **Members are projected, not federated** — `users.auth_source: "hq"` with
|
||||
and reserved names rather than two - see "Shared provisioning".
|
||||
- **Members are projected, not federated** - `users.auth_source: "hq"` with
|
||||
`hq_user_id` set, refused for role changes and deletion by
|
||||
`services.ErrHQManaged`. See "Grants project, they do not federate", which is
|
||||
the contract in full.
|
||||
@@ -1004,7 +1004,7 @@ plane, each of which this codebase enforces:
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `migrations`
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth - see `server/internal/models/`.
|
||||
|
||||
Notes that are not obvious from the structs:
|
||||
|
||||
@@ -1015,15 +1015,15 @@ Notes that are not obvious from the structs:
|
||||
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `auth_providers.provider_id` is a short random identifier, not the Mongo `_id`: it appears in the callback URL a customer pastes into their IdP, and an `_id` there would publish a database key. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed.
|
||||
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
|
||||
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` - the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
|
||||
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
|
||||
- `server_packages` holds a server's whole package set in **one** document, not one per package. The hash already established that something changed, so a report is a single atomic upsert with no delta logic to get wrong; ~2000 packages is ~150KB, well inside the 16MB limit. `scan_pending` lives on the document rather than in memory so a leader handover cannot lose it.
|
||||
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable".
|
||||
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists - a real state, never "not vulnerable".
|
||||
- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`.
|
||||
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
|
||||
- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token.
|
||||
- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it - a route added without an entry would otherwise be silently unreachable by every token.
|
||||
|
||||
Admin's database is its own and lives with `vantage-admin` — `accounts`,
|
||||
Admin's database is its own and lives with `vantage-admin` - `accounts`,
|
||||
`admin_instances`, `licenses`, `subscriptions`, `plans`, `catalogue`,
|
||||
`entitlements`, `paddle_events`, `staff_users`, `customer_users`,
|
||||
`instance_members`, `admin_audit`. Nothing here reads or writes it. Note in
|
||||
@@ -1036,9 +1036,9 @@ database is the access.
|
||||
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
|
||||
|
||||
- `0001_default_org_backfill`
|
||||
- `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
|
||||
- `0002_settings_org_backfill` (must run before 0003 - 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
|
||||
- `0003_missed_org_scopes`
|
||||
- `0005_auth_providers` — copies each `instance_oidc` document into `auth_providers`, ciphertext verbatim rather than decrypted and re-encrypted, so it does not need `KEY_ENCRYPTION_KEY` and cannot strand an instance's SSO configuration that has none set.
|
||||
- `0005_auth_providers` - copies each `instance_oidc` document into `auth_providers`, ciphertext verbatim rather than decrypted and re-encrypted, so it does not need `KEY_ENCRYPTION_KEY` and cannot strand an instance's SSO configuration that has none set.
|
||||
|
||||
Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn.
|
||||
|
||||
@@ -1076,7 +1076,7 @@ tls: true
|
||||
|
||||
```
|
||||
1. SyncKeys(server_id, agent_token, agent_version)
|
||||
2. Non-Linux hosts stop here — the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
|
||||
2. Non-Linux hosts stop here - the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
|
||||
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
|
||||
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
|
||||
```
|
||||
@@ -1084,7 +1084,7 @@ tls: true
|
||||
### Install
|
||||
|
||||
Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`,
|
||||
`Restart=always`, runs as root — written by the install script this server
|
||||
`Restart=always`, runs as root - written by the install script this server
|
||||
generates, not shipped as a file. Windows: MSI built by `vantage-agent`'s CI
|
||||
(WiX), or its `installer/setup.ps1` registering the agent as a service via NSSM.
|
||||
|
||||
@@ -1110,10 +1110,10 @@ generates, not shipped as a file. Windows: MSI built by `vantage-agent`'s CI
|
||||
|
||||
| Name | Required | Notes |
|
||||
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it - there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
|
||||
| `MONGO_DB` | no | default `vantage` |
|
||||
| `REDIS_USERNAME` | no | Redis 6+ ACL user. Leave empty for a legacy `requirepass` instance — go-redis then sends AUTH with one argument instead of two |
|
||||
| `REDIS_USERNAME` | no | Redis 6+ ACL user. Leave empty for a legacy `requirepass` instance - go-redis then sends AUTH with one argument instead of two |
|
||||
| `REDIS_PASSWORD` | no | empty for an unauthenticated Redis |
|
||||
| `REDIS_ADDR` | no | default `localhost:6379` |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
|
||||
@@ -1121,13 +1121,13 @@ generates, not shipped as a file. Windows: MSI built by `vantage-agent`'s CI
|
||||
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
|
||||
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `TRUSTED_PROXIES` | no | comma-separated CIDRs or addresses gin trusts for `X-Forwarded-For`. Empty means trust none: `c.ClientIP()` falls back to the direct peer address, which behind a real reverse proxy is that proxy's own address for every visitor — the public status page's per-address rate limit then keys on one address for the whole fleet of readers. Also the address recorded in `audit_logs` and `console_sessions`. Compose and the Helm chart default it to the RFC1918 ranges, right for their own bundled proxy and wrong the moment another one is inserted in front |
|
||||
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
|
||||
| `TRUSTED_PROXIES` | no | comma-separated CIDRs or addresses gin trusts for `X-Forwarded-For`. Empty means trust none: `c.ClientIP()` falls back to the direct peer address, which behind a real reverse proxy is that proxy's own address for every visitor - the public status page's per-address rate limit then keys on one address for the whole fleet of readers. Also the address recorded in `audit_logs` and `console_sessions`. Compose and the Helm chart default it to the RFC1918 ranges, right for their own bundled proxy and wrong the moment another one is inserted in front |
|
||||
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** - a console relay listener belongs to one replica, and a Service address names all of them |
|
||||
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
|
||||
| `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` 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 |
|
||||
| `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)
|
||||
|
||||
@@ -1139,23 +1139,23 @@ generates, not shipped as a file. Windows: MSI built by `vantage-agent`'s CI
|
||||
| `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, 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.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.
|
||||
|
||||
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
|
||||
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service - so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
|
||||
|
||||
**`server.env.grpcHost` is not derived from `ingress.grpc.host`, and the chart refuses to render if they disagree.** Agents dial whatever `grpcHost` says, and it is baked into every install one-liner; left pointing at the in-cluster Service while agents arrive through the ingress, every install succeeds and every agent then fails to connect, with nothing in the control plane explaining why. Guessing at the port (443? 9090?) would be worse than stopping.
|
||||
|
||||
TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) **or** `certResolver` (Traefik ACME). Setting neither while `tls.enabled` produces a TLS router with no certificate, so Traefik serves its self-signed default — which looks valid and is trusted by nothing. NOTES.txt warns on install rather than the chart failing, since it is a real if unusual choice behind another terminator.
|
||||
TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) **or** `certResolver` (Traefik ACME). Setting neither while `tls.enabled` produces a TLS router with no certificate, so Traefik serves its self-signed default - which looks valid and is trusted by nothing. NOTES.txt warns on install rather than the chart failing, since it is a real if unusual choice behind another terminator.
|
||||
|
||||
---
|
||||
|
||||
**The compose file ships an `nginx` service, and the UI does not work without it or an equivalent.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. `deploy/docker/nginx/vantage.conf` routes `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000`, on plain HTTP at `${NGINX_HTTP_PORT:-80}`. The self-hosted install docs reproduce that file, so a routing change there must change in `vantage-docs` too. On vantage.hostxtra.co.uk the Nginx Proxy Manager already in front does the same routing, so the host port must not collide with it.
|
||||
|
||||
`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".
|
||||
`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".
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1192,7 +1192,7 @@ repository they sit in.
|
||||
|
||||
`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
|
||||
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
|
||||
@@ -1203,7 +1203,7 @@ rather than a refactor.
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -1224,15 +1224,15 @@ colour alone in either: every pill carries a distinct shape and a text label.
|
||||
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 —
|
||||
`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/` 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 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.
|
||||
`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
|
||||
@@ -1250,18 +1250,18 @@ tokens, which is the only reason they are mentioned here at all.
|
||||
| `/steps` | Reusable step library |
|
||||
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
|
||||
| `/secrets`, `/secrets/[group]` | Vault |
|
||||
| `/tokens` | Personal API keys — reachable at **every** role, unlike `/settings` |
|
||||
| `/tokens` | Personal API keys - reachable at **every** role, unlike `/settings` |
|
||||
| `/audit` | Audit log |
|
||||
| `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence |
|
||||
|
||||
**The sidebar is grouped, and the groups are the nav's structure rather than
|
||||
decoration.** `web/components/Sidebar.tsx` holds `navGroups` — Fleet, Access,
|
||||
Automation, Instance — each rendered with a mono small-caps heading and a
|
||||
decoration.** `web/components/Sidebar.tsx` holds `navGroups` - Fleet, Access,
|
||||
Automation, Instance - each rendered with a mono small-caps heading and a
|
||||
hairline rule above it, the first group excepted. Grouping is by what the
|
||||
operator is doing, not by which service answers: SSH keys, vault secrets and
|
||||
API keys sit together under Access because all three are credentials. A group
|
||||
whose every item is `adminOnly` disappears **whole**, heading and rule
|
||||
included, for a member — a labelled section with nothing under it reads as
|
||||
included, for a member - a labelled section with nothing under it reads as
|
||||
something that failed to load rather than something withheld.
|
||||
|
||||
**`/settings` is one page, not a section.** Members and single sign-on used to
|
||||
@@ -1272,28 +1272,28 @@ behaves" made two half-pages and a nav entry called Instance that no one could
|
||||
distinguish from Settings. `next.config.ts` keeps a permanent redirect from the
|
||||
old path. The cards live in `web/components/settings/` rather than in the page,
|
||||
which is also where the `Field`/`inputClass` pair the three of them share now
|
||||
lives — one copy instead of the three that existed while they were apart.
|
||||
lives - one copy instead of the three that existed while they were apart.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD — Gitea Actions
|
||||
## CI/CD - Gitea Actions
|
||||
|
||||
### `server-deploy.yml` — triggered on every push to `main`
|
||||
### `server-deploy.yml` - triggered on every push to `main`
|
||||
|
||||
Builds and pushes **two** images to the Gitea container registry: `server` and `web`. That is now the whole of this workflow. Everything else that was once built here belongs to the repository that owns it — `vantage-site`, `vantage-docs`, `vantage-admin` and `vantage-ctl` each publish their own, and `vantage-agent` cuts releases rather than images.
|
||||
Builds and pushes **two** images to the Gitea container registry: `server` and `web`. That is now the whole of this workflow. Everything else that was once built here belongs to the repository that owns it - `vantage-site`, `vantage-docs`, `vantage-admin` and `vantage-ctl` each publish their own, and `vantage-agent` cuts releases rather than images.
|
||||
|
||||
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:
|
||||
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
|
||||
# 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"
|
||||
# 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:
|
||||
**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:
|
||||
|
||||
| Image | Rebuilds when |
|
||||
| ---------------------------- | -------------------------------- |
|
||||
@@ -1311,7 +1311,7 @@ Every Go build in these workflows writes a netrc from `REGISTRY_USER` +
|
||||
`RELEASE_TOKEN` before it runs, and sets `GOPRIVATE=gitea.hostxtra.co.uk/*`.
|
||||
There is exactly **one** such place left here: `server-deploy.yml`'s single job.
|
||||
The other repositories each carry their own, one per job, because jobs do not
|
||||
share a filesystem — `vantage-agent`'s `msi` job is the one to remember, because
|
||||
share a filesystem - `vantage-agent`'s `msi` job is the one to remember, because
|
||||
it is Windows, where Go reads `%USERPROFILE%\_netrc` and not `.netrc`. The docker builds pass
|
||||
it on as `--secret id=netrc`, never a build arg. **`RELEASE_TOKEN` needs read
|
||||
access to the `vantage` org** on top of its existing scopes; without it every Go
|
||||
@@ -1321,15 +1321,15 @@ a build arg is baked into the image. So does anything that leaves no
|
||||
trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a
|
||||
force-push whose old head is gone.
|
||||
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `HQ_URL`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `HQ_URL`, run the workflow manually - that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
|
||||
|
||||
### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags
|
||||
### `chart-release.yml` - validates on every chart change, publishes on `chart/v*` tags
|
||||
|
||||
Two jobs' worth of work in one, split by trigger. Any push or PR touching `deploy/chart/` lints the chart and renders it four ways: defaults, a multi-replica install, external Redis and MongoDB, and a set of values that **must be refused**. That last one is the point — every safety rail in this chart is a template `fail`, and `helm lint` happily accepts a chart whose templates never execute, so only rendering proves they still fire.
|
||||
Two jobs' worth of work in one, split by trigger. Any push or PR touching `deploy/chart/` lints the chart and renders it four ways: defaults, a multi-replica install, external Redis and MongoDB, and a set of values that **must be refused**. That last one is the point - every safety rail in this chart is a template `fail`, and `helm lint` happily accepts a chart whose templates never execute, so only rendering proves they still fire.
|
||||
|
||||
Publishing runs only on a `chart/v*` tag, to the Gitea Helm registry at `/api/packages/<owner>/helm/api/charts`. **`Chart.yaml` is the source of truth for the version**; the tag only selects which one to publish, and a tag that disagrees with `Chart.yaml` fails rather than stamping over it — the alternative leaves the repository disagreeing with what shipped. A version already in the registry is rejected by Gitea, which is intended: published chart versions are immutable.
|
||||
Publishing runs only on a `chart/v*` tag, to the Gitea Helm registry at `/api/packages/<owner>/helm/api/charts`. **`Chart.yaml` is the source of truth for the version**; the tag only selects which one to publish, and a tag that disagrees with `Chart.yaml` fails rather than stamping over it - the alternative leaves the repository disagreeing with what shipped. A version already in the registry is rejected by Gitea, which is intended: published chart versions are immutable.
|
||||
|
||||
The registry host comes from `github.server_url`, so it cannot drift from the instance the workflow is running on. It authenticates with `REGISTRY_USER` + **`RELEASE_TOKEN`** — the pair `server-deploy.yml` actually uses for `docker login`. `REGISTRY_PASSWORD` is listed in the secrets table below but set by no workflow; passing an unset secret yields an empty password and Gitea answers `401 Failed to authenticate user`, which reads like a scope problem on a token that was never sent. The publish step therefore checks both are non-empty before it calls curl. `RELEASE_TOKEN` needs `write:package` in addition to `write:release`.
|
||||
The registry host comes from `github.server_url`, so it cannot drift from the instance the workflow is running on. It authenticates with `REGISTRY_USER` + **`RELEASE_TOKEN`** - the pair `server-deploy.yml` actually uses for `docker login`. `REGISTRY_PASSWORD` is listed in the secrets table below but set by no workflow; passing an unset secret yields an empty password and Gitea answers `401 Failed to authenticate user`, which reads like a scope problem on a token that was never sent. The publish step therefore checks both are non-empty before it calls curl. `RELEASE_TOKEN` needs `write:package` in addition to `write:release`.
|
||||
|
||||
```bash
|
||||
helm repo add vantage https://gitea.hostxtra.co.uk/api/packages/mrhid6/helm
|
||||
@@ -1352,17 +1352,17 @@ reads it programmatically.
|
||||
|
||||
| Name | Type | Value |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart) and **read access to the `vantage` org**, which is where the private `vantage-shared` module lives — without that last one every Go build fails at `go mod download` with what looks like a missing tag. **This is the only token any workflow authenticates with** — `docker login`, the chart publish and the module netrc all pair it with `REGISTRY_USER` |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart) and **read access to the `vantage` org**, which is where the private `vantage-shared` module lives - without that last one every Go build fails at `go mod download` with what looks like a missing tag. **This is the only token any workflow authenticates with** - `docker login`, the chart publish and the module netrc all pair it with `REGISTRY_USER` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected |
|
||||
| ~~`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` |
|
||||
| ~~`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. |
|
||||
| ~~`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. |
|
||||
| `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. |
|
||||
|
||||
`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
|
||||
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`.
|
||||
@@ -1372,28 +1372,28 @@ into the marketing site's signup form, `vantage-admin` into its own console) and
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **gRPC for agent traffic** — strong typing and cheap versioning; polling for state, one bidirectional stream for commands.
|
||||
- **Outbound-only agents** — no inbound ports on managed servers, works behind NAT.
|
||||
- **Poll for keys, push for commands** — a 30s key poll is fine, but running a workflow step should not wait up to 30s.
|
||||
- **Atomic `authorized_keys` rewrite** — temp file plus `os.Rename()`; a machine that dies mid-write keeps the old file.
|
||||
- **Fingerprint diffing before write** — no disk churn on unchanged state.
|
||||
- **Soft revocation** — `revoked_at` rather than deletes; preserves audit history.
|
||||
- **Run snapshots** — workflow runs freeze their resolved steps so editing a step never rewrites past runs.
|
||||
- **Monitors run in two places** — server-side for external endpoints, agent-side for anything only reachable from inside the target network.
|
||||
- **Redis for sessions only** — all durable state stays in MongoDB; losing Redis logs everyone out and nothing else.
|
||||
- **guacd for console** — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
|
||||
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents cover the fleet-management path** — register, heartbeat, run
|
||||
- **gRPC for agent traffic** - strong typing and cheap versioning; polling for state, one bidirectional stream for commands.
|
||||
- **Outbound-only agents** - no inbound ports on managed servers, works behind NAT.
|
||||
- **Poll for keys, push for commands** - a 30s key poll is fine, but running a workflow step should not wait up to 30s.
|
||||
- **Atomic `authorized_keys` rewrite** - temp file plus `os.Rename()`; a machine that dies mid-write keeps the old file.
|
||||
- **Fingerprint diffing before write** - no disk churn on unchanged state.
|
||||
- **Soft revocation** - `revoked_at` rather than deletes; preserves audit history.
|
||||
- **Run snapshots** - workflow runs freeze their resolved steps so editing a step never rewrites past runs.
|
||||
- **Monitors run in two places** - server-side for external endpoints, agent-side for anything only reachable from inside the target network.
|
||||
- **Redis for sessions only** - all durable state stays in MongoDB; losing Redis logs everyone out and nothing else.
|
||||
- **guacd for console** - protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
|
||||
- **`org_id` on every document** - isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** - manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents cover the fleet-management path** - register, heartbeat, run
|
||||
steps, report inventory, OS updates through the Windows Update COM API, and
|
||||
workloads (services plus containers, with control and logs). They still do no
|
||||
`authorized_keys` management, and no package inventory or CVE matching: a
|
||||
Windows agent never calls `ReportPackages`, so no `server_packages` document
|
||||
exists for it and it reports no package inventory at all — a different,
|
||||
exists for it and it reports no package inventory at all - a different,
|
||||
earlier state than the `unsupported` a Linux distribution reaches when its
|
||||
family has no security feed.
|
||||
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
|
||||
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
|
||||
- **Both `server` and `web` scale horizontally** - see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
|
||||
- **Deletion lives in the control plane** - admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
|
||||
|
||||
## graphify
|
||||
|
||||
@@ -1404,3 +1404,7 @@ Rules:
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -16,7 +16,7 @@ Services created:
|
||||
- {{ .Release.Name }}-web ({{ .Values.web.service.type }} {{ .Values.web.service.port }})
|
||||
|
||||
Scaling (server.replicaCount / web.replicaCount):
|
||||
- Both scale. Pin the image tags first — replicas on different builds serve
|
||||
- Both scale. Pin the image tags first - replicas on different builds serve
|
||||
mismatched web asset hashes, and mixed server versions share one bus.
|
||||
- server replicas route agent commands, step results and console relays to
|
||||
each other over Redis, so every replica must use the SAME Redis. Workflow
|
||||
@@ -71,7 +71,7 @@ Quick access via port-forward, e.g.:
|
||||
|
||||
No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and
|
||||
SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere
|
||||
but your own configuration — a database restored without it is permanently
|
||||
but your own configuration - a database restored without it is permanently
|
||||
unreadable.
|
||||
|
||||
Set backup.enabled, backup.image and backup.pvcName, and store
|
||||
|
||||
@@ -6,12 +6,12 @@ Browsers reach the web host, and the path decides what answers: /api, /auth,
|
||||
/public, /install* and /update* go to the server, everything else to `web`.
|
||||
|
||||
That split is not optional and ingress.api.enabled defaults to true. `web`
|
||||
proxies nothing — it holds no address for the server at all — so with these
|
||||
proxies nothing - it holds no address for the server at all - so with these
|
||||
paths absent the UI loads and every request it makes 404s against Next. The
|
||||
setting remains a value only so an installation terminating in front of this
|
||||
ingress can route the prefixes itself; it must be routed somewhere.
|
||||
|
||||
The web host is normally a wildcard — `*.vantage.example.com` — because that is
|
||||
The web host is normally a wildcard - `*.vantage.example.com` - because that is
|
||||
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
|
||||
@@ -19,7 +19,7 @@ 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
|
||||
Agents reach the server's gRPC port, which is plain h2c - the server holds no
|
||||
certificates of its own, TLS has always been terminated by whatever sits in
|
||||
front. Traefik will not speak h2c to a backend unless told to, and it is told
|
||||
per Service, which is why the gRPC route gets a Service of its own below rather
|
||||
@@ -103,7 +103,7 @@ spec:
|
||||
GRPC_HOST is what an agent is told to dial, and it is baked into every install
|
||||
one-liner. Left pointing at the in-cluster Service while agents are expected to
|
||||
arrive through the ingress, every install would succeed and every agent would
|
||||
fail to connect — with nothing in the control plane saying why.
|
||||
fail to connect - with nothing in the control plane saying why.
|
||||
*/}}
|
||||
{{- $grpcEnv := tpl .Values.server.env.grpcHost . }}
|
||||
{{- if contains (printf "%s-server" .Release.Name) $grpcEnv }}
|
||||
|
||||
@@ -9,7 +9,7 @@ corruption, not a retry.
|
||||
|
||||
A Helm hook Job runs it once, before any pod of the new version starts. The
|
||||
Deployment then sets VANTAGE_SKIP_MIGRATIONS, which is what makes the Job's
|
||||
existence load-bearing rather than decorative — if you disable the Job, the
|
||||
existence load-bearing rather than decorative - if you disable the Job, the
|
||||
pods go back to migrating themselves and you must go back to one replica.
|
||||
|
||||
hook-weight orders this after the dependency waits; before-hook-creation deletes
|
||||
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
# The server stops gRPC before draining HTTP, so that every CommandStream
|
||||
# handler returns and releases its agent's presence claim. A claim left
|
||||
# behind outlives the pod for its 30s TTL, and during that window other
|
||||
# replicas dispatch commands to a process that has exited — surfacing to
|
||||
# replicas dispatch commands to a process that has exited - surfacing to
|
||||
# the operator as "agent offline" on an agent that is perfectly healthy.
|
||||
# 10s for gRPC plus 10s for the HTTP drain, with headroom.
|
||||
terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }}
|
||||
@@ -62,7 +62,7 @@ spec:
|
||||
{{- if or .Values.redis.enabled .Values.mongo.enabled }}
|
||||
# Wait for the dependencies this chart deploys to be reachable,
|
||||
# approximating compose's `depends_on: condition: service_healthy`. An
|
||||
# external Redis or Mongo is assumed to be up already — waiting on one
|
||||
# external Redis or Mongo is assumed to be up already - waiting on one
|
||||
# would only turn someone else's outage into a stuck pod.
|
||||
initContainers:
|
||||
{{- if .Values.redis.enabled }}
|
||||
@@ -128,7 +128,7 @@ spec:
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
{{- if .Values.server.persistence.enabled }}
|
||||
# Nothing in the server writes here any more — workflow logs moved to
|
||||
# Nothing in the server writes here any more - workflow logs moved to
|
||||
# MongoDB so that every replica can read and write them. The mount
|
||||
# remains only so an operator upgrading from a file-log release can
|
||||
# still reach the old files before turning persistence off.
|
||||
|
||||
@@ -39,7 +39,7 @@ spec:
|
||||
ports:
|
||||
- containerPort: {{ .Values.web.service.port }}
|
||||
# /healthz is served by this Next process. /api never reaches this
|
||||
# pod at all — the ingress routes it to the server — so there is no
|
||||
# pod at all - the ingress routes it to the server - so there is no
|
||||
# backend address to configure and no probe here that could report
|
||||
# the backend's health by accident.
|
||||
startupProbe:
|
||||
|
||||
@@ -115,7 +115,7 @@ imagePullSecrets: []
|
||||
# Scheduled backups.
|
||||
#
|
||||
# Off by default, deliberately. A backup with nowhere durable to land is a
|
||||
# false sense of safety, and the chart cannot know where that is — pvcName
|
||||
# false sense of safety, and the chart cannot know where that is - pvcName
|
||||
# must name a volume you have decided will outlive the cluster.
|
||||
#
|
||||
# There is no restore manifest here on purpose: a restore is an operator
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Vantage self-hosted — copy to .env and fill in.
|
||||
# Vantage self-hosted - copy to .env and fill in.
|
||||
# Used by: docker compose up -d
|
||||
|
||||
# --- Required ---
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Goal:** Give the Windows agent working OS update check/apply and a working workload registry (services and containers, with control and logs), matching what the Linux agent already does.
|
||||
|
||||
**Architecture:** The platform split moves into the agent as Go build tags, following the existing `inventory/collect_linux.go` / `collect_windows.go` / `collect_other.go` pattern. Windows work is done by PowerShell scripts invoked through a small `winexec` helper; every script emits JSON, and the JSON parsers live in build-tag-free files so they are testable on a Linux development machine. The control plane stays OS-blind — a Windows service is reported as the same `unit` kind a systemd service is — so the only wire change in the whole project is one new `reboot_required` field on `InventoryReport`.
|
||||
**Architecture:** The platform split moves into the agent as Go build tags, following the existing `inventory/collect_linux.go` / `collect_windows.go` / `collect_other.go` pattern. Windows work is done by PowerShell scripts invoked through a small `winexec` helper; every script emits JSON, and the JSON parsers live in build-tag-free files so they are testable on a Linux development machine. The control plane stays OS-blind - a Windows service is reported as the same `unit` kind a systemd service is - so the only wire change in the whole project is one new `reboot_required` field on `InventoryReport`.
|
||||
|
||||
**Tech Stack:** Go 1.26 (agent is its own module, `agent/go.mod`), PowerShell 5.1 (`powershell.exe`, present on every supported Windows), Windows Update COM (`Microsoft.Update.Session`), CIM (`Win32_Service`), `Get-WinEvent`, Next.js 16 + Tailwind for `web/`.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `winexec` — running PowerShell from the agent
|
||||
### Task 1: `winexec` - running PowerShell from the agent
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/internal/winexec/encode.go`
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `winexec.EncodeCommand(script string) string` (base64 of UTF-16LE, used by the runner and directly testable); `winexec.Run(ctx context.Context, script string) (string, error)` — Windows-only, returns the script's stdout.
|
||||
- Produces: `winexec.EncodeCommand(script string) string` (base64 of UTF-16LE, used by the runner and directly testable); `winexec.Run(ctx context.Context, script string) (string, error)` - Windows-only, returns the script's stdout.
|
||||
|
||||
Scripts are passed with `-EncodedCommand` rather than `-Command` or a temp `.ps1` file. `-Command` requires quoting a multi-line script through Go, `cmd.exe` and PowerShell's own parser, and every one of the scripts in this plan contains both quote characters. A temp file needs a writable path and cleanup on a host where the agent may be killed mid-run.
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestEncodeCommandMultiline(t *testing.T) {
|
||||
cd agent && go test ./internal/winexec/ -run TestEncodeCommand -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `undefined: EncodeCommand`.
|
||||
Expected: FAIL - `undefined: EncodeCommand`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -76,8 +76,8 @@ Create `agent/internal/winexec/encode.go`:
|
||||
```go
|
||||
// Package winexec runs PowerShell on Windows hosts.
|
||||
//
|
||||
// It exists because three subsystems — updates, workload collection and
|
||||
// workload logs — all need the same invocation, and because getting a
|
||||
// It exists because three subsystems - updates, workload collection and
|
||||
// workload logs - all need the same invocation, and because getting a
|
||||
// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own
|
||||
// parser is a problem worth solving once.
|
||||
package winexec
|
||||
@@ -209,7 +209,7 @@ func RebootRequired() bool { return rebootRequired() }
|
||||
|
||||
- [ ] **Step 2: Move the Linux implementation into its own file**
|
||||
|
||||
Create `agent/internal/updates/updates_linux.go` containing every function the old `updates.go` had — `detectPM`, `checkApt`, `checkDnfYum`, `checkPacman`, `checkZypper`, `checkApk`, `apkName`, `apkVersion` — verbatim, with its imports (`bufio`, `bytes`, `context`, `os/exec`, `strings`, `time`), plus these three entry points. `CheckAvailable`'s old body becomes `checkAvailable`; `ApplyAll`'s old body becomes `applyAll`:
|
||||
Create `agent/internal/updates/updates_linux.go` containing every function the old `updates.go` had - `detectPM`, `checkApt`, `checkDnfYum`, `checkPacman`, `checkZypper`, `checkApk`, `apkName`, `apkVersion` - verbatim, with its imports (`bufio`, `bytes`, `context`, `os/exec`, `strings`, `time`), plus these three entry points. `CheckAvailable`'s old body becomes `checkAvailable`; `ApplyAll`'s old body becomes `applyAll`:
|
||||
|
||||
```go
|
||||
package updates
|
||||
@@ -284,7 +284,7 @@ func rebootRequired() bool { return false }
|
||||
cd agent && go build ./... && GOOS=windows go build ./...
|
||||
```
|
||||
|
||||
Expected: the Linux build succeeds. The Windows build **fails** with `undefined: checkAvailable` — Task 3 supplies it. Confirm the failure names exactly those three functions and nothing else; anything else means something was moved wrong.
|
||||
Expected: the Linux build succeeds. The Windows build **fails** with `undefined: checkAvailable` - Task 3 supplies it. Confirm the failure names exactly those three functions and nothing else; anything else means something was moved wrong.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
@@ -388,7 +388,7 @@ func TestParseUpdateSearchPrefixedKB(t *testing.T) {
|
||||
cd agent && go test ./internal/updates/ -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `undefined: parseUpdateSearch`.
|
||||
Expected: FAIL - `undefined: parseUpdateSearch`.
|
||||
|
||||
- [ ] **Step 3: Write the parser**
|
||||
|
||||
@@ -657,7 +657,7 @@ In `agent/internal/sync/sync.go`, change `runInventory`'s `report` closure so th
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = cfg.ServerID
|
||||
r.AgentToken = cfg.AgentToken
|
||||
// Static snapshots only — every 15 minutes, not every 30 seconds. On
|
||||
// Static snapshots only - every 15 minutes, not every 30 seconds. On
|
||||
// Windows this spawns a PowerShell process, which is not something to
|
||||
// do twice a minute forever, and a host rebooted by hand clearing the
|
||||
// flag within a quarter of an hour is soon enough.
|
||||
@@ -760,7 +760,7 @@ func Collect(ctx context.Context) Result {
|
||||
}
|
||||
```
|
||||
|
||||
The `SystemdOK` / `SystemdError` names stay as they are. A Windows service is reported as the same `unit` kind, and renaming these would cost a proto change, both pb copies, the server model, the service layer and the web client — to describe the same thing. The naming is corrected where it is read, in the UI, which knows the server's OS.
|
||||
The `SystemdOK` / `SystemdError` names stay as they are. A Windows service is reported as the same `unit` kind, and renaming these would cost a proto change, both pb copies, the server model, the service layer and the web client - to describe the same thing. The naming is corrected where it is read, in the UI, which knows the server's OS.
|
||||
|
||||
- [ ] **Step 2: Rename the systemd collector and its entry point**
|
||||
|
||||
@@ -995,7 +995,7 @@ Create `agent/internal/workloads/units_other.go`:
|
||||
```go
|
||||
//go:build !linux && !windows
|
||||
|
||||
// The build constraint is load-bearing — see updates_other.go.
|
||||
// The build constraint is load-bearing - see updates_other.go.
|
||||
package workloads
|
||||
|
||||
import (
|
||||
@@ -1019,7 +1019,7 @@ func logsPlatform(context.Context, string, string, int) (string, error) {
|
||||
|
||||
- [ ] **Step 6: Remove the Linux gates from the reporting loop**
|
||||
|
||||
In `agent/internal/sync/workloads.go`, delete all three `runtime.GOOS != "linux"` early returns — the two at the top of `runWorkloads` and `reportWorkloads` — and drop the now-unused `runtime` import.
|
||||
In `agent/internal/sync/workloads.go`, delete all three `runtime.GOOS != "linux"` early returns - the two at the top of `runWorkloads` and `reportWorkloads` - and drop the now-unused `runtime` import.
|
||||
|
||||
- [ ] **Step 7: Verify both platforms build**
|
||||
|
||||
@@ -1027,7 +1027,7 @@ In `agent/internal/sync/workloads.go`, delete all three `runtime.GOOS != "linux"
|
||||
cd agent && go build ./... && GOOS=windows go build ./...
|
||||
```
|
||||
|
||||
Expected: the Linux build succeeds. The Windows build fails with `undefined: collectUnits`, `undefined: controlPlatform`, `undefined: logsPlatform`, `undefined: isProtectedUnit`, `undefined: ownContainerID` — and nothing else. Tasks 6 and 7 supply them.
|
||||
Expected: the Linux build succeeds. The Windows build fails with `undefined: collectUnits`, `undefined: controlPlatform`, `undefined: logsPlatform`, `undefined: isProtectedUnit`, `undefined: ownContainerID` - and nothing else. Tasks 6 and 7 supply them.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
@@ -1047,7 +1047,7 @@ git commit -m "refactor: Split the agent workloads package by build tag"
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Workload` from `docker.go`; `winexec.Run` from Task 1; the `collectUnits` signature from Task 5.
|
||||
- Produces: `parseServices(jsonText, systemRoot string) ([]Workload, error)`, `servicePath(pathName string) string`, `psQuote(s string) string` — all build-tag-free — and `collectUnits` for `GOOS=windows`.
|
||||
- Produces: `parseServices(jsonText, systemRoot string) ([]Workload, error)`, `servicePath(pathName string) string`, `psQuote(s string) string` - all build-tag-free - and `collectUnits` for `GOOS=windows`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
@@ -1117,7 +1117,7 @@ func TestParseServicesFilters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1077 means "no attempt to start since boot" — a clean stopped service, not a
|
||||
// 1077 means "no attempt to start since boot" - a clean stopped service, not a
|
||||
// failure, and reporting it red would cry wolf on every host.
|
||||
func TestParseServicesExitCode1077(t *testing.T) {
|
||||
in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]`
|
||||
@@ -1161,7 +1161,7 @@ func TestPSQuote(t *testing.T) {
|
||||
cd agent && go test ./internal/workloads/ -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `undefined: servicePath`, `undefined: parseServices`, `undefined: psQuote`.
|
||||
Expected: FAIL - `undefined: servicePath`, `undefined: parseServices`, `undefined: psQuote`.
|
||||
|
||||
- [ ] **Step 3: Write the parser**
|
||||
|
||||
@@ -1189,7 +1189,7 @@ type winService struct {
|
||||
}
|
||||
|
||||
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
|
||||
// carrying it has not failed — it has not run since boot — and painting that
|
||||
// carrying it has not failed - it has not run since boot - and painting that
|
||||
// red would cry wolf on every host.
|
||||
const exitCodeNeverStarted = 1077
|
||||
|
||||
@@ -1439,7 +1439,7 @@ Add `"strings"` to that file's imports.
|
||||
cd agent && go test ./internal/workloads/ -run TestParseEvents -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `undefined: parseEvents`.
|
||||
Expected: FAIL - `undefined: parseEvents`.
|
||||
|
||||
- [ ] **Step 3: Write the event parser**
|
||||
|
||||
@@ -1519,7 +1519,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
// AgentUnit is the service this agent runs as — the NSSM service name written
|
||||
// AgentUnit is the service this agent runs as - the NSSM service name written
|
||||
// by installer/setup.ps1. Change one, change the other.
|
||||
const AgentUnit = "VantageAgent"
|
||||
|
||||
@@ -1616,7 +1616,7 @@ func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error
|
||||
|
||||
// Timestamps are formatted PowerShell-side rather than left to
|
||||
// ConvertTo-Json, whose DateTime rendering differs between PowerShell
|
||||
// versions — one of them emits /Date(1699...)/.
|
||||
// versions - one of them emits /Date(1699...)/.
|
||||
//
|
||||
// -ErrorAction SilentlyContinue because Get-WinEvent treats "no events
|
||||
// matched" as a terminating error, and a quiet service is normal.
|
||||
@@ -1653,7 +1653,7 @@ ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
}
|
||||
|
||||
// serviceDisplayName resolves a service's display name, which is what Service
|
||||
// Control Manager events name it by. An empty answer is fine — the filter then
|
||||
// Control Manager events name it by. An empty answer is fine - the filter then
|
||||
// matches on the service name alone.
|
||||
func serviceDisplayName(ctx context.Context, id string) string {
|
||||
out, err := winexec.Run(ctx,
|
||||
@@ -1697,7 +1697,7 @@ git commit -m "feat: Control Windows services and read their event log as worklo
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Web — Windows wording and the reboot badge
|
||||
### Task 8: Web - Windows wording and the reboot badge
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts:10-19` (`Inventory`)
|
||||
@@ -1757,8 +1757,8 @@ and replace the empty state and the systemd status lines (currently lines 117–
|
||||
{/* One wire field, two honest words for it: the agent
|
||||
reports Windows services under the same `unit` kind
|
||||
systemd units use, and only the UI knows which host
|
||||
this is. On Windows there is no "not in use" case —
|
||||
every Windows host has a service controller — so a
|
||||
this is. On Windows there is no "not in use" case -
|
||||
every Windows host has a service controller - so a
|
||||
failure is the only thing worth saying. */}
|
||||
{data.systemd_error ? (
|
||||
<p className="text-warning">
|
||||
@@ -1864,7 +1864,7 @@ Install or update the agent on a Windows server registered to a development cont
|
||||
In the Design Decisions list, replace the Windows line:
|
||||
|
||||
```markdown
|
||||
- **Windows agents cover the fleet-management path** — register, heartbeat, run
|
||||
- **Windows agents cover the fleet-management path** - register, heartbeat, run
|
||||
steps, report inventory, OS updates through the Windows Update COM API, and
|
||||
workloads (services plus containers, with control and logs). They still do no
|
||||
`authorized_keys` management, and no package inventory or CVE matching: the
|
||||
@@ -1876,14 +1876,14 @@ In the "Workload registry" section, after the sentence beginning "A **workload**
|
||||
|
||||
```markdown
|
||||
On Windows a workload is a Docker container or a Windows **service**, reported
|
||||
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields —
|
||||
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields -
|
||||
one wire shape, worded per platform in the UI, which is the only layer that
|
||||
knows the host's OS. The platform split lives entirely in the agent, as build
|
||||
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
|
||||
and `logs_` pairs); the control plane is OS-blind and needed no changes.
|
||||
Windows collection runs PowerShell through `agent/internal/winexec`, and every
|
||||
script emits JSON that a build-tag-free parser reads, so the parsers are tested
|
||||
on Linux — the agent module has no Windows CI.
|
||||
on Linux - the agent module has no Windows CI.
|
||||
```
|
||||
|
||||
In the "Inventory and OS updates" section, add:
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
.board__route{font-family:var(--mono);font-size:.7rem;color:var(--ink-3)}
|
||||
.frame{border:1px solid var(--rule);border-radius:var(--r);background:var(--ground);box-shadow:var(--shadow);overflow:hidden}
|
||||
|
||||
/* address strip — shows the URL scheme being approved */
|
||||
/* address strip - shows the URL scheme being approved */
|
||||
.addr{display:flex;align-items:center;gap:10px;background:var(--well);border-bottom:1px solid var(--rule);padding:9px 14px}
|
||||
.addr__dots{display:flex;gap:5px}
|
||||
.addr__dots i{width:8px;height:8px;border-radius:999px;background:var(--rule);display:block}
|
||||
@@ -350,7 +350,7 @@
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Public API unavailable</p>
|
||||
<p class="inc__meta">2 Aug 2026, 14:02 UTC — resolved 14:19 UTC</p>
|
||||
<p class="inc__meta">2 Aug 2026, 14:02 UTC - resolved 14:19 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
@@ -360,7 +360,7 @@
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Slow dashboard loads in Europe</p>
|
||||
<p class="inc__meta">17 Jul 2026, 08:30 UTC — resolved 10:05 UTC</p>
|
||||
<p class="inc__meta">17 Jul 2026, 08:30 UTC - resolved 10:05 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
@@ -376,7 +376,7 @@
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Redacted</span><span>No target URL, host, port or failure text anywhere on this page. <b>Search index</b> shows the no-data tail as grey cells rather than claiming 100% for days before it existed.</span></li>
|
||||
<li><span class="k">Maintenance</span><span><b>Object storage</b> reads as Maintenance, not Down — but its uptime figure is untouched. The window changes how it is drawn, never what the numbers say.</span></li>
|
||||
<li><span class="k">Maintenance</span><span><b>Object storage</b> reads as Maintenance, not Down - but its uptime figure is untouched. The window changes how it is drawn, never what the numbers say.</span></li>
|
||||
<li><span class="k">Colour</span><span>Every state carries a word and a shape as well as a hue. The page is readable with colour vision differences and in greyscale print.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -472,7 +472,7 @@
|
||||
<div class="field">
|
||||
<label for="f-id">Page address</label>
|
||||
<input class="in in--mono" id="f-id" value="api" disabled>
|
||||
<span class="hint">Fixed once created — the link is already out there.</span>
|
||||
<span class="hint">Fixed once created - the link is already out there.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-desc">Description</label>
|
||||
@@ -602,9 +602,9 @@
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Naming</span><span>The monitor's own identifier stays visible on the left; the <b>public name</b> is a separate field beside it. An empty field falls back to the identifier, which the placeholder shows — so publishing an internal name is always a visible choice.</span></li>
|
||||
<li><span class="k">Naming</span><span>The monitor's own identifier stays visible on the left; the <b>public name</b> is a separate field beside it. An empty field falls back to the identifier, which the placeholder shows - so publishing an internal name is always a visible choice.</span></li>
|
||||
<li><span class="k">Address</span><span>The page address is fixed after creation and the record line carries the whole URL, click to copy. It is what gets pasted into a support article.</span></li>
|
||||
<li><span class="k">Copy</span><span>Buttons name the outcome: <b>Open incident</b>, <b>Post update</b>, <b>Schedule maintenance</b> — the same words the public timeline then shows.</span></li>
|
||||
<li><span class="k">Copy</span><span>Buttons name the outcome: <b>Open incident</b>, <b>Post update</b>, <b>Schedule maintenance</b> - the same words the public timeline then shows.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Goal:** Publish operator-configured, completely public status pages at `<slug>.vantage.<tld>/status/<page-id>`, showing chosen monitors plus hand-authored incidents and maintenance windows.
|
||||
|
||||
**Architecture:** Two new instance-scoped MongoDB collections (`status_pages`, `status_incidents`) hold the page and its authored incidents. A pure assembly function combines them with existing monitor, incident and rollup data into a purpose-built public struct — that function is the redaction boundary and nothing else may serve monitor data to an anonymous caller. The public route is mounted on the gin root, outside `/api` and therefore outside authentication, scope enforcement and the licence gate; it is cached in Redis for 30s and rate limited per client address.
|
||||
**Architecture:** Two new instance-scoped MongoDB collections (`status_pages`, `status_incidents`) hold the page and its authored incidents. A pure assembly function combines them with existing monitor, incident and rollup data into a purpose-built public struct - that function is the redaction boundary and nothing else may serve monitor data to an anonymous caller. The public route is mounted on the gin root, outside `/api` and therefore outside authentication, scope enforcement and the licence gate; it is cached in Redis for 30s and rate limited per client address.
|
||||
|
||||
**Tech Stack:** Go 1.x (gin, mongo-driver v2, go-redis), Next.js 16 App Router + React 18 + Tailwind 3 + TanStack Query.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**Design:** Approved 2026-08-24. Mockup of both screens:
|
||||
`docs/superpowers/plans/2026-08-24-status-pages-mockup.html`, also published at
|
||||
https://claude.ai/code/artifact/13cfe71a-dda7-4780-a8f0-57ea8ae0d57d — open the
|
||||
https://claude.ai/code/artifact/13cfe71a-dda7-4780-a8f0-57ea8ae0d57d - open the
|
||||
local file in a browser if the link is unavailable. Tasks 9 and 10 implement
|
||||
what it shows; where this plan's code and the mockup disagree, the mockup is
|
||||
the approved artefact and the code is the error.
|
||||
@@ -35,7 +35,7 @@ the approved artefact and the code is the error.
|
||||
|
||||
## File Structure
|
||||
|
||||
**Server — created:**
|
||||
**Server - created:**
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
@@ -48,7 +48,7 @@ the approved artefact and the code is the error.
|
||||
| `server/internal/api/statuspages.go` | Authoring handlers |
|
||||
| `server/internal/api/publicstatus.go` | The one public handler plus its rate limiter |
|
||||
|
||||
**Server — modified:**
|
||||
**Server - modified:**
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
@@ -58,15 +58,15 @@ the approved artefact and the code is the error.
|
||||
| `server/internal/api/scopes.go:27` | add the nine `status:*` route entries |
|
||||
| `server/cmd/main.go` | `EnsureStatusPageIndexes`, `SetTrustedProxies` |
|
||||
|
||||
**Web — created:** `web/app/status/[pageId]/page.tsx`, `web/app/status/[pageId]/StatusPageView.tsx`, `web/components/status/` (`ComponentRow.tsx`, `HistoryBar.tsx`, `IncidentCard.tsx`), `web/app/(app)/status-pages/page.tsx`, `web/app/(app)/status-pages/[pageId]/page.tsx`.
|
||||
**Web - created:** `web/app/status/[pageId]/page.tsx`, `web/app/status/[pageId]/StatusPageView.tsx`, `web/components/status/` (`ComponentRow.tsx`, `HistoryBar.tsx`, `IncidentCard.tsx`), `web/app/(app)/status-pages/page.tsx`, `web/app/(app)/status-pages/[pageId]/page.tsx`.
|
||||
|
||||
**Web — modified:** `web/lib/api.ts` (types + methods), `web/components/Sidebar.tsx:198` (Instance group), `web/next.config.ts:29` (`/public` rewrite).
|
||||
**Web - modified:** `web/lib/api.ts` (types + methods), `web/components/Sidebar.tsx:198` (Instance group), `web/next.config.ts:29` (`/public` rewrite).
|
||||
|
||||
**Docs — modified:** `docsite/docs/vantage/status-pages.md` (new), `docsite/sidebars.ts`, `CLAUDE.md`, `docsite/docs/reference/environment-variables.md`.
|
||||
**Docs - modified:** `docsite/docs/vantage/status-pages.md` (new), `docsite/sidebars.ts`, `CLAUDE.md`, `docsite/docs/reference/environment-variables.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schema — models, feature constant, scoped collections, indexes
|
||||
### Task 1: Schema - models, feature constant, scoped collections, indexes
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/models/statuspage.go`
|
||||
@@ -112,7 +112,7 @@ func TestStatusCollectionsAreScoped(t *testing.T) {
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./server/internal/services/ -run TestStatusCollectionsAreScoped -v`
|
||||
Expected: FAIL — `ScopedCollections is missing "status_pages"` and the same for `status_incidents`.
|
||||
Expected: FAIL - `ScopedCollections is missing "status_pages"` and the same for `status_incidents`.
|
||||
|
||||
- [ ] **Step 3: Add the licence feature constant**
|
||||
|
||||
@@ -396,7 +396,7 @@ Add `"strings"` to that file's imports.
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./server/internal/services/ -run 'TestValidatePageID|TestStatusCacheKey' -v`
|
||||
Expected: FAIL — `undefined: ValidatePageID`, `undefined: statusCacheKey`.
|
||||
Expected: FAIL - `undefined: ValidatePageID`, `undefined: statusCacheKey`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
@@ -440,7 +440,7 @@ git commit -m "feat: status page id validation and cache key"
|
||||
|
||||
---
|
||||
|
||||
### Task 3: The redaction boundary — `assembleSnapshot`
|
||||
### Task 3: The redaction boundary - `assembleSnapshot`
|
||||
|
||||
This is the security-critical task. Everything else is plumbing around it.
|
||||
|
||||
@@ -639,7 +639,7 @@ func TestAssembleSnapshotOnlyIncludesAuthoredIncidentsForThisPage(t *testing.T)
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./server/internal/services/ -run TestAssembleSnapshot -v`
|
||||
Expected: FAIL — `undefined: snapshotInput`, `undefined: assembleSnapshot`.
|
||||
Expected: FAIL - `undefined: snapshotInput`, `undefined: assembleSnapshot`.
|
||||
|
||||
- [ ] **Step 3: Implement the public types and the assembler**
|
||||
|
||||
@@ -701,7 +701,7 @@ type PublicIncidentUpdate struct {
|
||||
}
|
||||
|
||||
// PublicIncident covers both authored incidents and derived monitor outages.
|
||||
// A derived one carries no updates and no impact — and never a cause, which is
|
||||
// A derived one carries no updates and no impact - and never a cause, which is
|
||||
// where internal hostnames live.
|
||||
type PublicIncident struct {
|
||||
ID string `json:"id"`
|
||||
@@ -1542,7 +1542,7 @@ git commit -m "feat: authored status incidents and maintenance windows"
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `PublicStatusSnapshot` — reads, feature gate, Redis cache
|
||||
### Task 6: `PublicStatusSnapshot` - reads, feature gate, Redis cache
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/services/statussnapshot.go`
|
||||
@@ -1783,7 +1783,7 @@ import (
|
||||
const publicStatusRateLimit = 120
|
||||
|
||||
// RateLimitPublicStatus counts requests per client address in a one-minute
|
||||
// fixed window, exactly as RateLimitTokens does — including the part that
|
||||
// fixed window, exactly as RateLimitTokens does - including the part that
|
||||
// matters most: when Redis is unavailable it allows rather than denies. A
|
||||
// status page must survive the outage it exists to report.
|
||||
func RateLimitPublicStatus() gin.HandlerFunc {
|
||||
@@ -1870,7 +1870,7 @@ In `server/internal/api/handlers.go`, inside `RegisterRoutes`, after the `/auth/
|
||||
|
||||
- [ ] **Step 3: Configure trusted proxies**
|
||||
|
||||
Nothing calls `SetTrustedProxies` today, so gin trusts every proxy and `c.ClientIP()` returns whatever `X-Forwarded-For` says — spoofable per request, which would make the limiter above decorative.
|
||||
Nothing calls `SetTrustedProxies` today, so gin trusts every proxy and `c.ClientIP()` returns whatever `X-Forwarded-For` says - spoofable per request, which would make the limiter above decorative.
|
||||
|
||||
In `server/cmd/main.go`, immediately after `r := gin.New()`:
|
||||
|
||||
@@ -1879,7 +1879,7 @@ In `server/cmd/main.go`, immediately after `r := gin.New()`:
|
||||
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
|
||||
// only produced audit strings; the public status limiter makes it load
|
||||
// bearing. Empty means trust nobody, which is correct for a direct
|
||||
// exposure and wrong behind a proxy — hence the explicit setting.
|
||||
// exposure and wrong behind a proxy - hence the explicit setting.
|
||||
if err := r.SetTrustedProxies(trustedProxies()); err != nil {
|
||||
log.Fatalf("trusted proxies: %v", err)
|
||||
}
|
||||
@@ -1914,7 +1914,7 @@ Ensure `"os"` and `"strings"` are imported in `main.go`.
|
||||
|
||||
Add a row to the server table in `docsite/docs/reference/environment-variables.md`:
|
||||
|
||||
| `TRUSTED_PROXIES` | no | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. Unset trusts none, so the client address is the direct peer — behind a reverse proxy that makes every visitor share one address for rate-limiting purposes. Set it to your proxy's range. |
|
||||
| `TRUSTED_PROXIES` | no | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. Unset trusts none, so the client address is the direct peer - behind a reverse proxy that makes every visitor share one address for rate-limiting purposes. Set it to your proxy's range. |
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
@@ -2329,14 +2329,14 @@ In `server/internal/api/handlers.go`, inside the `apiGroup` block alongside the
|
||||
- [ ] **Step 6: Build and confirm the scope map is complete**
|
||||
|
||||
Run: `go build ./server/... && go run ./server/cmd 2>&1 | head -20`
|
||||
Expected: no `api scope map:` fatal. If one appears it names the route missing from `routeScopes` — add it rather than removing the assertion. Stop the process once it reports listening.
|
||||
Expected: no `api scope map:` fatal. If one appears it names the route missing from `routeScopes` - add it rather than removing the assertion. Stop the process once it reports listening.
|
||||
|
||||
- [ ] **Step 7: Regenerate the OpenAPI document**
|
||||
|
||||
Run the same command `server-deploy.yml` uses (check the workflow for the exact invocation, it is `swag v2`), then:
|
||||
|
||||
Run: `git diff --stat server/internal/api/docs/openapi.json`
|
||||
Expected: the ten new paths appear. Commit the regenerated file — CI runs `git diff --exit-code` against it.
|
||||
Expected: the ten new paths appear. Commit the regenerated file - CI runs `git diff --exit-code` against it.
|
||||
|
||||
- [ ] **Step 8: Manual check**
|
||||
|
||||
@@ -2378,7 +2378,7 @@ No test runner exists in `web/`. Verification is `npm run build`, `npm run lint`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /public/status/:pageId` from Task 7.
|
||||
- Produces: TypeScript types `StatusSnapshot`, `PublicSection`, `PublicComponent`, `PublicDay`, `PublicIncident` exported from `web/lib/api.ts` (added in Task 10; declare them locally in `StatusPageView.tsx` for this task and move them in Task 10 — or do Task 10's type block first if executing in order).
|
||||
- Produces: TypeScript types `StatusSnapshot`, `PublicSection`, `PublicComponent`, `PublicDay`, `PublicIncident` exported from `web/lib/api.ts` (added in Task 10; declare them locally in `StatusPageView.tsx` for this task and move them in Task 10 - or do Task 10's type block first if executing in order).
|
||||
|
||||
- [ ] **Step 0: Open the approved mockup**
|
||||
|
||||
@@ -2415,7 +2415,7 @@ export const dynamic = "force-dynamic";
|
||||
async function fetchSnapshot(host: string, pageId: string): Promise<StatusSnapshot | null> {
|
||||
const base = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
// The instance is resolved server-side from the Host header, so it has to
|
||||
// be forwarded explicitly — the server-to-server fetch does not carry it.
|
||||
// be forwarded explicitly - the server-to-server fetch does not carry it.
|
||||
const res = await fetch(`${base}/public/status/${encodeURIComponent(pageId)}`, {
|
||||
headers: { Host: host },
|
||||
cache: "no-store",
|
||||
@@ -2442,7 +2442,7 @@ export default async function PublicStatusPage({
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) {
|
||||
const { pageId } = await params;
|
||||
return { title: `Status — ${pageId}` };
|
||||
return { title: `Status - ${pageId}` };
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2601,7 +2601,7 @@ export default function StatusPageView({
|
||||
}
|
||||
```
|
||||
|
||||
The class names above are the real ones from `web/tailwind.config.ts:24-54`. If a name is ever missing, add the token to the config — never reach for a hex.
|
||||
The class names above are the real ones from `web/tailwind.config.ts:24-54`. If a name is ever missing, add the token to the config - never reach for a hex.
|
||||
|
||||
Match the approved mockup for layout and copy: overall banner above the notice, incidents before components, sections in page order, the refresh line in the footer.
|
||||
|
||||
@@ -2713,7 +2713,7 @@ export default function IncidentCard({ incident }: { incident: PublicIncident })
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{new Date(incident.started_at).toLocaleString()}
|
||||
{incident.resolved_at
|
||||
? ` — resolved ${new Date(incident.resolved_at).toLocaleString()}`
|
||||
? ` - resolved ${new Date(incident.resolved_at).toLocaleString()}`
|
||||
: ""}
|
||||
</p>
|
||||
{incident.updates && incident.updates.length > 0 ? (
|
||||
@@ -2773,7 +2773,7 @@ git commit -m "feat: public status page"
|
||||
|
||||
Open `docs/superpowers/plans/2026-08-24-status-pages-mockup.html` (artboard 2). It is the approved design for the editor: back link, title with the full public URL as a click-to-copy record line, View page and Save changes, then the Details, Components and Incidents panels in that order.
|
||||
|
||||
Two details are decisions: the monitor's own identifier stays visible beside the **public name** field, and the field's placeholder is that identifier — so publishing an internal name is a visible choice rather than a default. And the page address is locked after creation, because the link has already been handed out.
|
||||
Two details are decisions: the monitor's own identifier stays visible beside the **public name** field, and the field's placeholder is that identifier - so publishing an internal name is a visible choice rather than a default. And the page address is locked after creation, because the link has already been handed out.
|
||||
|
||||
- [ ] **Step 1: Add the types**
|
||||
|
||||
@@ -2834,7 +2834,7 @@ export interface StatusIncident {
|
||||
}
|
||||
|
||||
// The public shapes. These mirror services.StatusSnapshot and must change with
|
||||
// it — the public endpoint is the contract between them.
|
||||
// it - the public endpoint is the contract between them.
|
||||
export interface PublicDay {
|
||||
date: string;
|
||||
state: "up" | "down" | "maintenance" | "no_data";
|
||||
@@ -2962,11 +2962,11 @@ Read `web/app/(app)/monitors/page.tsx` first and follow its query keys, panel cl
|
||||
|
||||
Create `web/app/(app)/status-pages/[pageId]/page.tsx` with three panels:
|
||||
|
||||
1. **Details** — title, description, logo URL, published toggle, banner (enabled, level, text). Saves via `api.updateStatusPage`.
|
||||
2. **Sections** — add or remove a named section; within each, add monitors from a picker fed by `api.listMonitors()`, with an optional display-name field per entry. Reorder is out of scope for v1; adding to the end is enough.
|
||||
3. **Incidents** — list from `api.listStatusIncidents(pageId)`, a form to open an incident or schedule maintenance, and a "post update" control on each open one calling `api.postStatusIncidentUpdate`.
|
||||
1. **Details** - title, description, logo URL, published toggle, banner (enabled, level, text). Saves via `api.updateStatusPage`.
|
||||
2. **Sections** - add or remove a named section; within each, add monitors from a picker fed by `api.listMonitors()`, with an optional display-name field per entry. Reorder is out of scope for v1; adding to the end is enough.
|
||||
3. **Incidents** - list from `api.listStatusIncidents(pageId)`, a form to open an incident or schedule maintenance, and a "post update" control on each open one calling `api.postStatusIncidentUpdate`.
|
||||
|
||||
The monitor picker must show the monitor's real name (this is the authenticated side) while making clear the display name is what gets published — label the field "Public name" with the monitor name as its placeholder. Build all three panels to match artboard 2 of the mockup, including its copy: buttons are named for their outcome ("Open incident", "Post update", "Schedule maintenance"), the published toggle spells out that unpublished pages return not found, and the notice field says clearing it removes the notice.
|
||||
The monitor picker must show the monitor's real name (this is the authenticated side) while making clear the display name is what gets published - label the field "Public name" with the monitor name as its placeholder. Build all three panels to match artboard 2 of the mockup, including its copy: buttons are named for their outcome ("Open incident", "Post update", "Schedule maintenance"), the published toggle spells out that unpublished pages return not found, and the notice field says clearing it removes the notice.
|
||||
|
||||
- [ ] **Step 5: Add the sidebar entry**
|
||||
|
||||
@@ -2985,7 +2985,7 @@ Expected: both clean.
|
||||
|
||||
- [ ] **Step 7: Browser check**
|
||||
|
||||
As an owner: create a page, add a section with one monitor and a public name, publish it, open the public URL in a private window and confirm the public name appears rather than the monitor's own. Open an incident, post an update, and confirm it appears on the public page within a few seconds — that verifies cache invalidation.
|
||||
As an owner: create a page, add a section with one monitor and a public name, publish it, open the public URL in a private window and confirm the public name appears rather than the monitor's own. Open an incident, post an update, and confirm it appears on the public page within a few seconds - that verifies cache invalidation.
|
||||
|
||||
As a member: confirm `/status-pages` is absent from the sidebar and that visiting it directly is refused by the API.
|
||||
|
||||
@@ -3038,4 +3038,4 @@ git commit -m "docs: status pages"
|
||||
|
||||
## Deferred to Vantage HQ
|
||||
|
||||
The `status_pages` feature must be added to admin's `plans` rows per `(deployment, tier)`. Until that happens every instance reads the feature as absent and every status page renders "not enabled" — the feature ships dark. That work is in the admin service and its plan seeding, not in this plan.
|
||||
The `status_pages` feature must be added to admin's `plans` rows per `(deployment, tier)`. Until that happens every instance reads the feature as absent and every status page renders "not enabled" - the feature ships dark. That work is in the admin service and its plan seeding, not in this plan.
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestWrongKeySizeRejected(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd shared && go test ./cryptobox/...`
|
||||
Expected: FAIL — the package does not compile, `undefined: Seal`.
|
||||
Expected: FAIL - the package does not compile, `undefined: Seal`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -383,7 +383,7 @@ func TestParseKeyAcceptsUppercase(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd shared && go test ./backup/...`
|
||||
Expected: FAIL — `undefined: FingerprintHex`.
|
||||
Expected: FAIL - `undefined: FingerprintHex`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -567,7 +567,7 @@ func TestCiphertextCollections(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd shared && go test ./backup/...`
|
||||
Expected: FAIL — `undefined: Manifest`.
|
||||
Expected: FAIL - `undefined: Manifest`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -916,7 +916,7 @@ Add `"archive/tar"` and `"compress/gzip"` to the test file's imports.
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd shared && go test ./backup/...`
|
||||
Expected: FAIL — `undefined: NewWriter`.
|
||||
Expected: FAIL - `undefined: NewWriter`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -1023,8 +1023,8 @@ func indexMember(name string) string { return "indexes/" + name + ".json" }
|
||||
// Reader is an opened archive.
|
||||
//
|
||||
// Open extracts to a temporary directory rather than streaming, because gzip
|
||||
// offers no random access and the manifest — which carries the checksums every
|
||||
// other member is judged against — is written last. Verifying before writing a
|
||||
// offers no random access and the manifest - which carries the checksums every
|
||||
// other member is judged against - is written last. Verifying before writing a
|
||||
// single document to the target is worth one pass over local disk. This is why
|
||||
// the container image needs a /tmp.
|
||||
type Reader struct {
|
||||
@@ -1474,7 +1474,7 @@ Add `"io"` and `"time"` to this file's imports.
|
||||
- [ ] **Step 3: Run the test and verify it fails**
|
||||
|
||||
Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Dump`
|
||||
Expected: FAIL — `undefined: Dump`.
|
||||
Expected: FAIL - `undefined: Dump`.
|
||||
|
||||
If no MongoDB is available locally, start one: `docker run -d --rm -p 27017:27017 --name vantage-test-mongo mongo:7`.
|
||||
|
||||
@@ -1974,7 +1974,7 @@ Add `"go.mongodb.org/mongo-driver/v2/mongo/options"` to this file's imports.
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Restore`
|
||||
Expected: FAIL — `undefined: Restore`.
|
||||
Expected: FAIL - `undefined: Restore`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -2055,7 +2055,7 @@ func (o RestoreOptions) warn(format string, args ...any) {
|
||||
// The order is fixed and every check that can refuse does so before the first
|
||||
// write: format, checksums (done by Open), key policy, then target inspection.
|
||||
// A restore that has begun writing and then fails leaves a partial database
|
||||
// which the next run refuses to touch, which is correct — the alternative is a
|
||||
// which the next run refuses to touch, which is correct - the alternative is a
|
||||
// silent merge, and merging two control planes reconciles nothing.
|
||||
func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) {
|
||||
m := opt.Archive.Manifest()
|
||||
@@ -2213,8 +2213,8 @@ func splitBSON(raw []byte) (bson.Raw, []byte, error) {
|
||||
// replayIndexes recreates the archived indexes.
|
||||
//
|
||||
// A unique index that will not build means the restored data violates it, and
|
||||
// the unique indexes here — (instance_id, email), instance slug, settings
|
||||
// instance, the ESO token hash — are tenant-isolation properties rather than
|
||||
// the unique indexes here - (instance_id, email), instance slug, settings
|
||||
// instance, the ESO token hash - are tenant-isolation properties rather than
|
||||
// optimisations. That aborts. A non-unique index failing is a performance
|
||||
// problem and warns.
|
||||
func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, name string) (int, error) {
|
||||
@@ -2603,7 +2603,7 @@ func TestVerifyProbeAbsentCiphertextIsNotAFailure(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Verify`
|
||||
Expected: FAIL — `undefined: Verify`.
|
||||
Expected: FAIL - `undefined: Verify`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -2721,8 +2721,8 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error {
|
||||
rep.ProbeDecrypted = true
|
||||
return nil
|
||||
}
|
||||
// No ciphertext anywhere is an ordinary state — a deployment that has
|
||||
// stored no secrets, keys or SSO configuration yet — and is not a failure.
|
||||
// No ciphertext anywhere is an ordinary state - a deployment that has
|
||||
// stored no secrets, keys or SSO configuration yet - and is not a failure.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2994,7 +2994,7 @@ func TestVersionIsReported(t *testing.T) {
|
||||
- [ ] **Step 4: Run the test and verify it fails**
|
||||
|
||||
Run: `cd vantagectl && go test ./internal/cmd/...`
|
||||
Expected: FAIL — `undefined: NewRoot`.
|
||||
Expected: FAIL - `undefined: NewRoot`.
|
||||
|
||||
- [ ] **Step 5: Write the root command**
|
||||
|
||||
@@ -3208,7 +3208,7 @@ Expected: PASS, six tests.
|
||||
cd server && go build ./... && cd ../admin && go build ./... && cd ../sitesvc && go build ./...
|
||||
git diff --stat server/go.sum admin/go.sum sitesvc/go.sum
|
||||
```
|
||||
Expected: builds succeed, `git diff --stat` prints nothing — cobra stayed out of their module graphs.
|
||||
Expected: builds succeed, `git diff --stat` prints nothing - cobra stayed out of their module graphs.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
@@ -3314,7 +3314,7 @@ func TestRenderManifestFlagsAMissingFingerprint(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd vantagectl && go test ./internal/cmd/... -run 'ArchiveName|RenderManifest'`
|
||||
Expected: FAIL — `undefined: archiveName`.
|
||||
Expected: FAIL - `undefined: archiveName`.
|
||||
|
||||
- [ ] **Step 3: Write `inspect`**
|
||||
|
||||
@@ -3362,7 +3362,7 @@ func renderManifest(w io.Writer, m backup.Manifest) {
|
||||
fmt.Fprintf(w, "Format version %d\n", m.FormatVersion)
|
||||
|
||||
if m.KeyFingerprint == nil {
|
||||
fmt.Fprintf(w, "Key none recorded — this archive cannot be checked "+
|
||||
fmt.Fprintf(w, "Key none recorded - this archive cannot be checked "+
|
||||
"against any KEY_ENCRYPTION_KEY\n")
|
||||
} else {
|
||||
fmt.Fprintf(w, "Key %s\n", *m.KeyFingerprint)
|
||||
@@ -3533,7 +3533,7 @@ go run . inspect /tmp/vantage-backup-vantage_smoke-*.tar.gz
|
||||
```
|
||||
|
||||
Expected: `backup` reports what it wrote; `inspect` prints the manifest with a
|
||||
key fingerprint and a collection table. An empty database is fine — the point
|
||||
key fingerprint and a collection table. An empty database is fine - the point
|
||||
here is that both commands run.
|
||||
|
||||
Then confirm the refusal:
|
||||
@@ -3642,7 +3642,7 @@ func TestConfirmDestructionTTYFlagSkipsThePrompt(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run: `cd vantagectl && go test ./internal/cmd/... -run Confirm`
|
||||
Expected: FAIL — `undefined: confirmDestruction`.
|
||||
Expected: FAIL - `undefined: confirmDestruction`.
|
||||
|
||||
- [ ] **Step 3: Write `restore`**
|
||||
|
||||
@@ -3753,8 +3753,8 @@ func newRestoreCmd() *cobra.Command {
|
||||
|
||||
// confirmDestruction gates a --force restore.
|
||||
//
|
||||
// On a terminal the operator types the database name. Without one — a
|
||||
// Kubernetes Job, a CI step, a cron entry — the same assurance comes from
|
||||
// On a terminal the operator types the database name. Without one - a
|
||||
// Kubernetes Job, a CI step, a cron entry - the same assurance comes from
|
||||
// --confirm-db, whose value must equal the target. Naming the database in the
|
||||
// argument means a copy-pasted command carries its intended target with it and
|
||||
// cannot destroy a different one.
|
||||
@@ -3969,7 +3969,7 @@ RUN cd vantagectl && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantagectl .
|
||||
|
||||
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
|
||||
# itself — scratch has no shell.
|
||||
# itself - scratch has no shell.
|
||||
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
|
||||
|
||||
# Runtime stage
|
||||
@@ -3999,7 +3999,7 @@ Expected: the help text lists `backup`, `restore`, `inspect` and `verify`.
|
||||
Temporarily comment out the `COPY --from=builder /staging/tmp /tmp` line,
|
||||
rebuild as `vantagectl:notmp`, and run a restore against any archive. It must
|
||||
fail with a `/tmp` error. Restore the line and rebuild. This is a manual check,
|
||||
not a committed test — the point is that the next person to trim the Dockerfile
|
||||
not a committed test - the point is that the next person to trim the Dockerfile
|
||||
learns why the line is there.
|
||||
|
||||
- [ ] **Step 4: Add the release workflow**
|
||||
@@ -4132,7 +4132,7 @@ shared/ now fans out to four Go images rather than three."
|
||||
Run: `sed -n '1,80p' deploy/chart/vantage/templates/server.yaml`
|
||||
|
||||
Match whatever that file does for `MONGO_URI` and `KEY_ENCRYPTION_KEY` exactly.
|
||||
The CronJob must reference the same secret keys rather than declaring its own —
|
||||
The CronJob must reference the same secret keys rather than declaring its own -
|
||||
a backup job with its own copy of the encryption key is a second place for it to
|
||||
be wrong.
|
||||
|
||||
@@ -4144,7 +4144,7 @@ Append to `deploy/chart/vantage/values.yaml`:
|
||||
# Scheduled backups.
|
||||
#
|
||||
# Off by default, deliberately. A backup with nowhere durable to land is a
|
||||
# false sense of safety, and the chart cannot know where that is — pvcName
|
||||
# false sense of safety, and the chart cannot know where that is - pvcName
|
||||
# must name a volume you have decided will outlive the cluster.
|
||||
#
|
||||
# There is no restore manifest here on purpose: a restore is an operator
|
||||
@@ -4266,7 +4266,7 @@ Append to `deploy/chart/vantage/templates/NOTES.txt`:
|
||||
|
||||
No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and
|
||||
SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere
|
||||
but your own configuration — a database restored without it is permanently
|
||||
but your own configuration - a database restored without it is permanently
|
||||
unreadable.
|
||||
|
||||
Set backup.enabled, backup.image and backup.pvcName, and store
|
||||
@@ -4318,7 +4318,7 @@ the sibling pages' shape. Content, in this order:
|
||||
database restored without it is permanently unreadable. Store it wherever you
|
||||
store the credentials you could not rebuild.
|
||||
2. **What a backup holds:** every collection in the database, the index
|
||||
definitions, and a SHA-256 fingerprint of the key — never the key.
|
||||
definitions, and a SHA-256 fingerprint of the key - never the key.
|
||||
3. **What it does not hold:** Redis sessions (everyone signs in again, which is
|
||||
already true whenever Redis restarts), the vulnerability database (re-pulled
|
||||
automatically), and any agent state on managed servers. Agents reconnect on
|
||||
|
||||
@@ -108,7 +108,7 @@ input[type=text],select{font:inherit;background:var(--well);border:1px solid var
|
||||
input[type=text]::placeholder{color:var(--ink-3)}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
|
||||
/* scope matrix in the modal — one grid, not 9 cards */
|
||||
/* scope matrix in the modal - one grid, not 9 cards */
|
||||
.matrix{border:1px solid var(--rule);border-radius:var(--r);overflow:hidden}
|
||||
.mx{display:grid;grid-template-columns:1fr 64px 64px;align-items:center}
|
||||
.mx.head{background:var(--panel-2);border-bottom:1px solid var(--rule);font-family:var(--mono);font-size:11px;color:var(--ink-3)}
|
||||
@@ -123,7 +123,7 @@ input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);backgroun
|
||||
.linky{background:none;border:0;font:inherit;color:var(--accent);cursor:pointer;padding:0}
|
||||
.linky:hover{color:var(--accent-hover);text-decoration:underline}
|
||||
|
||||
/* live preview line — what this key will be able to do, in one sentence */
|
||||
/* live preview line - what this key will be able to do, in one sentence */
|
||||
.preview{background:var(--well);border:1px solid var(--rule-soft);border-radius:var(--r);padding:11px 13px;font-family:var(--mono);font-size:12px;color:var(--ink-2);line-height:1.7}
|
||||
.preview b{color:var(--ink);font-weight:500}
|
||||
.preview .cap{color:var(--pend)}
|
||||
@@ -146,7 +146,7 @@ dl.meta dt{color:var(--ink-3)}
|
||||
.two{grid-template-columns:1fr}
|
||||
|
||||
/* Each key becomes a stacked record. The header row is gone, so every
|
||||
cell carries its own label — an unlabelled date under an unlabelled
|
||||
cell carries its own label - an unlabelled date under an unlabelled
|
||||
scope list is unreadable once the columns are gone. */
|
||||
.lhead{display:none}
|
||||
.lrow{grid-template-columns:1fr;gap:12px;align-items:stretch;padding:16px 16px 12px;position:relative}
|
||||
@@ -368,7 +368,7 @@ dl.meta dt{color:var(--ink-3)}
|
||||
|
||||
<label class="f">
|
||||
<span>Expires <em>this instance caps new keys at 90 days</em></span>
|
||||
<select><option>90 days — 7 December 2026</option><option>60 days</option><option>30 days</option><option disabled>365 days (over the cap)</option><option disabled>Never (over the cap)</option></select>
|
||||
<select><option>90 days - 7 December 2026</option><option>60 days</option><option>30 days</option><option disabled>365 days (over the cap)</option><option disabled>Never (over the cap)</option></select>
|
||||
</label>
|
||||
|
||||
<p class="preview">
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebuild `/tokens` — the API keys page and its create dialog — around what an operator actually needs to decide: which credentials are about to expire, what each one can reach, and whether a new one is over-granted. The current page is a seven-column table where every column is a bare string.
|
||||
**Goal:** Rebuild `/tokens` - the API keys page and its create dialog - around what an operator actually needs to decide: which credentials are about to expire, what each one can reach, and whether a new one is over-granted. The current page is a seven-column table where every column is a bare string.
|
||||
|
||||
**Reference mockup:** `docs/superpowers/plans/2026-09-08-api-keys-redesign-mockup.html`. Open it in a browser. It is the visual contract for this plan: the posture strip, the record layout, the lifetime bar, the scope matrix and both dialog states are all drawn there, in the app's own tokens and font stacks. Where this plan and the mockup disagree, the plan wins — the mockup carries example data and static markup, not logic.
|
||||
**Reference mockup:** `docs/superpowers/plans/2026-09-08-api-keys-redesign-mockup.html`. Open it in a browser. It is the visual contract for this plan: the posture strip, the record layout, the lifetime bar, the scope matrix and both dialog states are all drawn there, in the app's own tokens and font stacks. Where this plan and the mockup disagree, the plan wins - the mockup carries example data and static markup, not logic.
|
||||
|
||||
**Tech Stack:** Next.js 16 App Router, React 18, Tailwind 3 (tokens only, no hex), TanStack Query. No new dependencies.
|
||||
|
||||
@@ -12,21 +12,21 @@
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No component may carry a hex value.** Every colour comes from the Tailwind token map (`accent`, `danger`, `warning`, `success`, `text-primary/secondary/tertiary`, `surface`, `surface-2`, `well`, `border`). This is a repository-wide rule, not a preference for this page — see the Frontend section of `CLAUDE.md`.
|
||||
- **No component may carry a hex value.** Every colour comes from the Tailwind token map (`accent`, `danger`, `warning`, `success`, `text-primary/secondary/tertiary`, `surface`, `surface-2`, `well`, `border`). This is a repository-wide rule, not a preference for this page - see the Frontend section of `CLAUDE.md`.
|
||||
- **`web/` is dark only.** Do not add a light variant or a theme toggle.
|
||||
- **State never reads by colour alone.** Every coloured element in the mockup also carries a text label — an amber lifetime bar always sits above the words "5 days left".
|
||||
- **State never reads by colour alone.** Every coloured element in the mockup also carries a text label - an amber lifetime bar always sits above the words "5 days left".
|
||||
- **There is no test runner in `web/`.** Verification for each task is `npm run lint` and `npm run build` from `vantage-app/web`, plus a stated browser check. Pure logic goes in `web/lib/` so it is at least readable in isolation.
|
||||
- **The API is the boundary; the UI is the courtesy.** Nothing here may be the only thing enforcing a rule. Disabled expiry options, hidden MCP scopes and the role cap are all mirrors of server behaviour that already exists.
|
||||
- Conventional commits (`feat:`, `refactor:`, `fix:`), one per task.
|
||||
- Branch: this work is UI-only and independent of the MCP server tasks, but it **collides with them in one file** — read the next section before starting.
|
||||
- Branch: this work is UI-only and independent of the MCP server tasks, but it **collides with them in one file** - read the next section before starting.
|
||||
|
||||
## Relationship to the MCP server plan
|
||||
|
||||
`docs/superpowers/plans/2026-09-08-mcp-server.md` is in progress on branch `feat/mcp-server`. Tasks 1–5 are committed: the `mcp` scope resource exists, and `api_tokens.tag_selector` is modelled, accepted at creation (`POST /api/tokens`) and enforced at the server-resolution chokepoints. Tasks 6–15 are not started.
|
||||
|
||||
**That plan's Task 12 rewrites the same file this plan rewrites**, and its file list is stale — it names `web/app/(app)/settings/`, but the page moved to `web/app/(app)/tokens/` with its body in `web/components/apikeys/ApiKeysPanel.tsx`. Two plans editing one 500-line component from opposite ends is a guaranteed conflict.
|
||||
**That plan's Task 12 rewrites the same file this plan rewrites**, and its file list is stale - it names `web/app/(app)/settings/`, but the page moved to `web/app/(app)/tokens/` with its body in `web/components/apikeys/ApiKeysPanel.tsx`. Two plans editing one 500-line component from opposite ends is a guaranteed conflict.
|
||||
|
||||
Resolution, and it is a decision this plan makes deliberately: **this plan absorbs MCP Task 12 steps 2, 3, 4 and 5** — the tag selector field, the MCP scope gating, the tag chip in the list, and the agent access panel. They are built here, on the redesigned surfaces, because a tag selector is a field in the create dialog and a tag chip is a column in the ledger, and both are cheaper to design once than to design and then redesign.
|
||||
Resolution, and it is a decision this plan makes deliberately: **this plan absorbs MCP Task 12 steps 2, 3, 4 and 5** - the tag selector field, the MCP scope gating, the tag chip in the list, and the agent access panel. They are built here, on the redesigned surfaces, because a tag selector is a field in the create dialog and a tag chip is a column in the ledger, and both are cheaper to design once than to design and then redesign.
|
||||
|
||||
What stays with MCP Task 12: **step 1 only**, the `Agent Access (MCP)` row on `settings/license/page.tsx`, which is a different file and a different page.
|
||||
|
||||
@@ -42,7 +42,7 @@ Tasks 6 and 7 are written to be skippable and are marked so. Nothing in Tasks 1
|
||||
> it was built rather than deferred. MCP Task 12 has been amended in place: its
|
||||
> steps 2 and 4 are struck as done here, steps 3 and 5 point at Task 7, and only
|
||||
> its step 1 (the licence-page row) remains its own work. The six browser checks
|
||||
> in the final checklist are the only items left unticked — they need a running
|
||||
> in the final checklist are the only items left unticked - they need a running
|
||||
> instance with fixture keys.
|
||||
|
||||
Whichever route is taken, **strike steps 2–5 from MCP Task 12 and leave a pointer to this plan**, so the next worker through does not build the tag picker twice.
|
||||
@@ -51,7 +51,7 @@ Whichever route is taken, **strike steps 2–5 from MCP Task 12 and leave a poin
|
||||
|
||||
### Task 1: The lifetime model
|
||||
|
||||
The redesign's one visual idea is that a key's expiry is a bar, not a date — how much of its issued life is left, coloured by urgency. That calculation is the only real logic on the page, so it goes in a module of its own rather than inline in a cell.
|
||||
The redesign's one visual idea is that a key's expiry is a bar, not a date - how much of its issued life is left, coloured by urgency. That calculation is the only real logic on the page, so it goes in a module of its own rather than inline in a cell.
|
||||
|
||||
**Files:**
|
||||
- Create: `web/lib/keyLifetime.ts`
|
||||
@@ -82,10 +82,10 @@ export type Lifetime = {
|
||||
|
||||
Rules the implementation must honour:
|
||||
|
||||
- `soon` is seven days or fewer remaining — the same `SEVEN_DAYS_MS` threshold the current file already uses. Keep the constant here and delete it there.
|
||||
- `soon` is seven days or fewer remaining - the same `SEVEN_DAYS_MS` threshold the current file already uses. Keep the constant here and delete it there.
|
||||
- `remainingPct` is measured against the token's **own** issued span (`created_at` → `expires_at`), not against the instance cap. A 30-day key at day 15 is half gone; a 365-day key at day 15 is barely started. Clamp to 0–100, and guard the zero-length span (`created_at === expires_at`) so it cannot divide by zero.
|
||||
- A token with no `expires_at` is `eternal`, drawn full-width and grey. It is not `healthy` — "runs forever" is the state the posture strip counts as a risk.
|
||||
- `outsidePolicy` keeps the existing rule verbatim: with a cap set, a token that never expires, or that expires further out than the cap allows, is outside it. **The cap is not applied retroactively** — this is a prompt to rotate, never an error, and the copy must not imply the key has stopped working.
|
||||
- A token with no `expires_at` is `eternal`, drawn full-width and grey. It is not `healthy` - "runs forever" is the state the posture strip counts as a risk.
|
||||
- `outsidePolicy` keeps the existing rule verbatim: with a cap set, a token that never expires, or that expires further out than the cap allows, is outside it. **The cap is not applied retroactively** - this is a prompt to rotate, never an error, and the copy must not imply the key has stopped working.
|
||||
- Accept `now` as an argument with a `Date.now()` default. A function that reads the clock itself cannot be reasoned about.
|
||||
|
||||
- [x] **Step 2: Verify it compiles**
|
||||
@@ -115,7 +115,7 @@ git commit -m "feat: model an api key's remaining lifetime as a single value"
|
||||
|
||||
- [x] **Step 1: Move the existing pieces out, unchanged**
|
||||
|
||||
This step is a pure refactor — **no visual change, no behaviour change.** Move `summariseScopes` and `ScopeChips` into `ScopeChips.tsx` verbatim, exporting both. Move `ExpiryCell` into `LifetimeBar.tsx` as-is for now (Task 3 rewrites its body). Move the `<Table>` block into `KeyLedger.tsx`, the `<Modal>` block into `CreateKeyDialog.tsx`.
|
||||
This step is a pure refactor - **no visual change, no behaviour change.** Move `summariseScopes` and `ScopeChips` into `ScopeChips.tsx` verbatim, exporting both. Move `ExpiryCell` into `LifetimeBar.tsx` as-is for now (Task 3 rewrites its body). Move the `<Table>` block into `KeyLedger.tsx`, the `<Modal>` block into `CreateKeyDialog.tsx`.
|
||||
|
||||
Keep every explanatory comment with the code it explains. Those comments are the record of why `write` implies `rw` in a chip and why Copy outranks Done, and they are worth more than the lines they sit above.
|
||||
|
||||
@@ -127,7 +127,7 @@ Keep every explanatory comment with the code it explains. Those comments are the
|
||||
npm run lint && npm run build
|
||||
```
|
||||
|
||||
Then run the app and compare `/tokens` against the page before the split — key list, create dialog, revoke dialog, empty state. It must be pixel-identical. Any difference is a mistake made during the move, and it is far cheaper to find now than under the redesign.
|
||||
Then run the app and compare `/tokens` against the page before the split - key list, create dialog, revoke dialog, empty state. It must be pixel-identical. Any difference is a mistake made during the move, and it is far cheaper to find now than under the redesign.
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
@@ -156,19 +156,19 @@ Give the track `role="img"` with an `aria-label` carrying the same text as the v
|
||||
|
||||
- [x] **Step 2: Rewrite the row as a grid, not a `<Table>`**
|
||||
|
||||
The mockup's row is a CSS grid, because the identity column stacks four things and the existing `Table`/`Td` primitives assume one value per cell. Columns: `minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto`, `gap-5`, rows separated by `border-border/60` — reach for the `rule-soft` token if a softer divider is wanted; do not invent a colour.
|
||||
The mockup's row is a CSS grid, because the identity column stacks four things and the existing `Table`/`Td` primitives assume one value per cell. Columns: `minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto`, `gap-5`, rows separated by `border-border/60` - reach for the `rule-soft` token if a softer divider is wanted; do not invent a colour.
|
||||
|
||||
Keep the header row as a mono, tracked-out strip on `surface-2`. Keep the hover fill. Keep the owner column conditional on `showAll` — but fold it **into** the identity column as a third line rather than adding a fifth grid column, exactly as the mockup does. `showAll` then changes what a record says, not how the page is laid out.
|
||||
Keep the header row as a mono, tracked-out strip on `surface-2`. Keep the hover fill. Keep the owner column conditional on `showAll` - but fold it **into** the identity column as a third line rather than adding a fifth grid column, exactly as the mockup does. `showAll` then changes what a record says, not how the page is laid out.
|
||||
|
||||
Keep the role `Badge` inline in that identity column, and keep `roleVariant` as-is: `owner` accent, `admin` warning, `member` neutral.
|
||||
|
||||
- [x] **Step 3: Make the scope chips two-part**
|
||||
|
||||
Each chip becomes resource plus a tinted access half — `rw` on `accent/18`, `r` on a neutral wash — as in the mockup. `summariseScopes` already produces exactly this shape and does not change. A token with no scopes keeps its dashed "no scopes granted" chip rather than an em dash; an em dash reads as "unknown", and "this key can call nothing" is a fact worth stating.
|
||||
Each chip becomes resource plus a tinted access half - `rw` on `accent/18`, `r` on a neutral wash - as in the mockup. `summariseScopes` already produces exactly this shape and does not change. A token with no scopes keeps its dashed "no scopes granted" chip rather than an em dash; an em dash reads as "unknown", and "this key can call nothing" is a fact worth stating.
|
||||
|
||||
- [x] **Step 4: Rewrite the mobile layout**
|
||||
|
||||
Below `900px` the grid collapses to a stacked record. Hide the header row and give each cell its own label via `data-label` and a `::before` rule, as the mockup does — an unlabelled date sitting under an unlabelled chip list is unreadable once the columns are gone. The scope list scrolls horizontally in its own track instead of wrapping to four lines. Revoke pins to the top-right of the record and gains a border so it is a real tap target.
|
||||
Below `900px` the grid collapses to a stacked record. Hide the header row and give each cell its own label via `data-label` and a `::before` rule, as the mockup does - an unlabelled date sitting under an unlabelled chip list is unreadable once the columns are gone. The scope list scrolls horizontally in its own track instead of wrapping to four lines. Revoke pins to the top-right of the record and gains a border so it is a real tap target.
|
||||
|
||||
Below `520px`: the posture strip goes single-column, the filter segment goes full width with its hint on its own line, and the dialog footer stacks with the primary button on top.
|
||||
|
||||
@@ -178,7 +178,7 @@ Copy these breakpoints from the mockup rather than re-deriving them; they were t
|
||||
|
||||
`TableSkeleton` assumes a table. Either keep it for the loading state and accept a one-frame shape change, or add a small ledger-shaped skeleton beside it. Do not leave the loading state as an empty box.
|
||||
|
||||
The empty state keeps both existing copy variants — instance-wide versus personal — and the "Create your first key" action.
|
||||
The empty state keeps both existing copy variants - instance-wide versus personal - and the "Create your first key" action.
|
||||
|
||||
- [x] **Step 6: Verify**
|
||||
|
||||
@@ -207,7 +207,7 @@ Four counts above the list, answering "is anything wrong here" before the operat
|
||||
|
||||
- [x] **Step 1: Build it**
|
||||
|
||||
Four cells in a bordered grid: total keys, expiring within seven days (warning), never expiring (danger), and never used (muted). Derive all four from the `tokens` array already in hand with `keyLifetime` — **no new request, and no new endpoint.**
|
||||
Four cells in a bordered grid: total keys, expiring within seven days (warning), never expiring (danger), and never used (muted). Derive all four from the `tokens` array already in hand with `keyLifetime` - **no new request, and no new endpoint.**
|
||||
|
||||
"Never used" is `last_used_at == null`. It is muted rather than coloured: an unused key is a cleanup candidate, not an incident.
|
||||
|
||||
@@ -217,7 +217,7 @@ The counts describe the list as filtered, so the strip sits below the `My keys`
|
||||
|
||||
The `{count} key{s} · {scope}` line under the heading goes; the strip says it better. The masthead is left as the heading and the Create key button, vertically centred.
|
||||
|
||||
The descriptive paragraph about what API keys are for is **not** to be added — it was in an earlier draft of the mockup and was cut deliberately. The `sha256` and role-cap facts appear in the create dialog and the reveal panel, where they are actionable.
|
||||
The descriptive paragraph about what API keys are for is **not** to be added - it was in an earlier draft of the mockup and was cut deliberately. The `sha256` and role-cap facts appear in the create dialog and the reveal panel, where they are actionable.
|
||||
|
||||
- [x] **Step 3: Verify**
|
||||
|
||||
@@ -242,27 +242,27 @@ Name and role side by side, scopes as one matrix instead of nine mini-cards, an
|
||||
|
||||
- [x] **Step 1: Build the scope matrix**
|
||||
|
||||
One bordered grid: a resource per row, `read` and `write` checkbox columns, a mono header row. Resources come from `GET /api/tokens/scopes` exactly as now — **do not hardcode the nine resources**, the endpoint is the source of truth and MCP is about to add a tenth.
|
||||
One bordered grid: a resource per row, `read` and `write` checkbox columns, a mono header row. Resources come from `GET /api/tokens/scopes` exactly as now - **do not hardcode the nine resources**, the endpoint is the source of truth and MCP is about to add a tenth.
|
||||
|
||||
Each row carries a one-line description under the resource name ("fleet list, inventory, agent updates"). Those strings are UI copy with no server counterpart, so keep them in one exported record in this file, keyed by resource, and fall back to no description for an unknown key rather than rendering `undefined`.
|
||||
|
||||
The footer carries the running count ("3 of 9 resources · 5 scopes") and two bulk actions: **Read-only everywhere** and **Clear all**.
|
||||
|
||||
Checking `write` must also check `read` in the UI. The server treats write as satisfying read on the same resource, so a `:write`-only token works — but a matrix that lets you tick write while read sits empty invites the reader to conclude the key cannot read.
|
||||
Checking `write` must also check `read` in the UI. The server treats write as satisfying read on the same resource, so a `:write`-only token works - but a matrix that lets you tick write while read sits empty invites the reader to conclude the key cannot read.
|
||||
|
||||
- [x] **Step 2: Name the date in the expiry options**
|
||||
|
||||
Each option renders as "90 days — 7 December 2026", computed from `Date.now()`. Options beyond the cap, and Never, stay `disabled` with the existing hint, and the existing effect that defaults to the shortest allowed option stays as it is.
|
||||
Each option renders as "90 days - 7 December 2026", computed from `Date.now()`. Options beyond the cap, and Never, stay `disabled` with the existing hint, and the existing effect that defaults to the shortest allowed option stays as it is.
|
||||
|
||||
- [x] **Step 3: Add the preview line**
|
||||
|
||||
One mono line in a `well` box, assembled from the current form state: the name, the role, the resources it may read and write, and the date it stops working. It is the over-granting check — reading "may read and write servers, workflows, secrets and keys" out loud is what makes someone go back and untick two boxes.
|
||||
One mono line in a `well` box, assembled from the current form state: the name, the role, the resources it may read and write, and the date it stops working. It is the over-granting check - reading "may read and write servers, workflows, secrets and keys" out loud is what makes someone go back and untick two boxes.
|
||||
|
||||
Handle the empty states honestly: no name yet, no scopes granted, no expiry.
|
||||
|
||||
- [x] **Step 4: Rework the reveal panel**
|
||||
|
||||
Keep the warning bar, keep the `sha256` sentence, keep Copy as the primary action with Done as the ghost — all three are existing decisions and all three were right. Add the `curl` example line from the mockup so nobody leaves the dialog to find out how to use what they just made. Put the plaintext key beside its Copy button, stacking below `520px` so Copy is reachable without scrolling 64 characters of hex sideways.
|
||||
Keep the warning bar, keep the `sha256` sentence, keep Copy as the primary action with Done as the ghost - all three are existing decisions and all three were right. Add the `curl` example line from the mockup so nobody leaves the dialog to find out how to use what they just made. Put the plaintext key beside its Copy button, stacking below `520px` so Copy is reachable without scrolling 64 characters of hex sideways.
|
||||
|
||||
- [x] **Step 5: Verify**
|
||||
|
||||
@@ -281,7 +281,7 @@ git commit -m "feat: rebuild the create key dialog around a scope matrix and a p
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Tag restriction — absorbs MCP Task 12 steps 2 and 4
|
||||
### Task 6: Tag restriction - absorbs MCP Task 12 steps 2 and 4
|
||||
|
||||
**Requires MCP Tasks 3–5, which are already committed on `feat/mcp-server`.** Skip this task entirely on a branch that does not have them; `tag_selector` will be rejected by a server without them.
|
||||
|
||||
@@ -291,19 +291,19 @@ git commit -m "feat: rebuild the create key dialog around a scope matrix and a p
|
||||
|
||||
- [x] **Step 1: Carry the field in the API client**
|
||||
|
||||
Add `tag_selector?: Record<string, string> | null` to the `ApiToken` type, and `tag_selector?: Record<string, string>` to `createApiToken`'s body. The server already models, accepts and enforces it — `models/api_token.go` and `api/tokens.go` — so this is the client catching up, not a new contract.
|
||||
Add `tag_selector?: Record<string, string> | null` to the `ApiToken` type, and `tag_selector?: Record<string, string>` to `createApiToken`'s body. The server already models, accepts and enforces it - `models/api_token.go` and `api/tokens.go` - so this is the client catching up, not a new contract.
|
||||
|
||||
- [x] **Step 2: Add the field to the dialog**
|
||||
|
||||
Below the scope matrix, a "Restrict to servers tagged" control offering the key/value vocabulary from `GET /api/servers/tags` (`api.listKnownTags`, already in the client). Reuse the workflow target tag rows from `EditWorkflowModal` if that component can be lifted without dragging workflow state with it; build the smallest possible thing if it cannot.
|
||||
|
||||
Send `tag_selector` omitted or `{}` when unrestricted. **This field is not licence-gated** — tag scoping ships useful on its own and is shown to everyone.
|
||||
Send `tag_selector` omitted or `{}` when unrestricted. **This field is not licence-gated** - tag scoping ships useful on its own and is shown to everyone.
|
||||
|
||||
Two lines of copy earn their place here, because the asymmetry is genuinely surprising: an **empty** selector means unrestricted, and a selector matches a server only when **every** pair matches. Say both.
|
||||
|
||||
- [x] **Step 3: Show the restriction in the ledger**
|
||||
|
||||
Render a token's `tag_selector` as a chip beside its scopes — `env=prod` in mono. An unrestricted token renders nothing at all, not an empty chip and not "unrestricted": most tokens are unrestricted, and a chip on every row for the common case is noise. Include the selector in the preview line's sentence.
|
||||
Render a token's `tag_selector` as a chip beside its scopes - `env=prod` in mono. An unrestricted token renders nothing at all, not an empty chip and not "unrestricted": most tokens are unrestricted, and a chip on every row for the common case is noise. Include the selector in the preview line's sentence.
|
||||
|
||||
- [x] **Step 4: Verify**
|
||||
|
||||
@@ -318,7 +318,7 @@ git commit -m "feat: restrict an api key to tagged servers from the create dialo
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Agent access — absorbs MCP Task 12 steps 3 and 5
|
||||
### Task 7: Agent access - absorbs MCP Task 12 steps 3 and 5
|
||||
|
||||
**Requires MCP Tasks 6–11 (the endpoint itself) and the `mcp` licence feature.** Skip on a branch without them.
|
||||
|
||||
@@ -328,13 +328,13 @@ git commit -m "feat: restrict an api key to tagged servers from the create dialo
|
||||
|
||||
- [x] **Step 1: Gate the MCP scopes in the matrix**
|
||||
|
||||
`mcp:read` and `mcp:write` arrive from `GET /api/tokens/scopes` with no client change. Hide that row when `license.features.mcp` is false, following whatever the console-gated UI already does — check `web/lib/useLicense.ts` for the existing pattern rather than inventing a second one.
|
||||
`mcp:read` and `mcp:write` arrive from `GET /api/tokens/scopes` with no client change. Hide that row when `license.features.mcp` is false, following whatever the console-gated UI already does - check `web/lib/useLicense.ts` for the existing pattern rather than inventing a second one.
|
||||
|
||||
- [x] **Step 2: Build the panel**
|
||||
|
||||
Below the ledger, visible only when `license.features.mcp` is true: the endpoint URL (`${window.location.origin}/api/mcp`) with a copy button, the copyable client configuration JSON from MCP Task 12 step 5, and one line saying the token needs `mcp:read`, plus `mcp:write` for tools that change anything, linking to the docs page from MCP Task 15.
|
||||
|
||||
Style it as a `well` block, not a card — it is machine output being handed to the operator, the same treatment the install one-liner gets on `/servers/new`.
|
||||
Style it as a `well` block, not a card - it is machine output being handed to the operator, the same treatment the install one-liner gets on `/servers/new`.
|
||||
|
||||
- [x] **Step 3: Verify**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Goal:** Expose Vantage to LLM agents as an MCP tool surface at `/api/mcp`, authenticated by the existing API token, gated by a new licence feature, and restricted by a new tag selector on API tokens.
|
||||
|
||||
**Architecture:** A new `server/internal/mcp` package registers task-shaped tools that call the existing service layer in-process. It is mounted inside the existing `/api` gin group, so bearer auth, rate limiting, licence checks and scope enforcement apply unchanged. Authority is never invented in the MCP layer: three gates (licence feature, `mcp:*` scope, per-tool resource scope) are all made of machinery that already exists, plus one new general capability — tag-scoped API tokens — that ships useful on its own.
|
||||
**Architecture:** A new `server/internal/mcp` package registers task-shaped tools that call the existing service layer in-process. It is mounted inside the existing `/api` gin group, so bearer auth, rate limiting, licence checks and scope enforcement apply unchanged. Authority is never invented in the MCP layer: three gates (licence feature, `mcp:*` scope, per-tool resource scope) are all made of machinery that already exists, plus one new general capability - tag-scoped API tokens - that ships useful on its own.
|
||||
|
||||
**Tech Stack:** Go 1.26, gin, MongoDB (mongo-driver v2), `github.com/modelcontextprotocol/go-sdk`, Next.js 16 (web), and the `vantage-admin` HQ service plus Paddle for licensing.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Three repos are touched: `vantage-shared` (licence constant), `vantage-app` (server + web), `vantage-admin` (catalogue + labels). They have **no import cycle and no build dependency between app and admin** — do not create one.
|
||||
- Three repos are touched: `vantage-shared` (licence constant), `vantage-app` (server + web), `vantage-admin` (catalogue + labels). They have **no import cycle and no build dependency between app and admin** - do not create one.
|
||||
- Go module path for the app server is `gitea.hostxtra.co.uk/mrhid6/vantage/server`; for shared, `gitea.hostxtra.co.uk/vantage/vantage-shared`.
|
||||
- The licence feature key is exactly `"mcp"`, constant `license.FeatureMCP`.
|
||||
- The new scope resource is exactly `"mcp"`, producing `mcp:read` and `mcp:write`. Do **not** invent an `mcp:use` scope.
|
||||
@@ -66,7 +66,7 @@ go get gitea.hostxtra.co.uk/vantage/vantage-shared@latest
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
If the repos are wired with a `replace` directive to a local path, this step is a no-op — check `go.mod` first and skip if so. Do the same from `vantage-admin/server`.
|
||||
If the repos are wired with a `replace` directive to a local path, this step is a no-op - check `go.mod` first and skip if so. Do the same from `vantage-admin/server`.
|
||||
|
||||
- [ ] **Step 5: Commit the module bump if one happened**
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestAllScopesAdvertisesMCP(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/services/ -run 'TestMCP|TestAllScopes' -v`
|
||||
Expected: FAIL — `ValidScopes(mcp:read)` returns an invalid-scope error.
|
||||
Expected: FAIL - `ValidScopes(mcp:read)` returns an invalid-scope error.
|
||||
|
||||
- [ ] **Step 3: Add the resource**
|
||||
|
||||
@@ -153,7 +153,7 @@ In `internal/services/scopes.go`, append to `ScopeResources`:
|
||||
"mcp",
|
||||
```
|
||||
|
||||
Update the doc comment above `ScopeResources` — it says "Eight resources" and there are now ten. Count the slice and write the real number; the comment exists so the count is deliberate rather than drifted.
|
||||
Update the doc comment above `ScopeResources` - it says "Eight resources" and there are now ten. Count the slice and write the real number; the comment exists so the count is deliberate rather than drifted.
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
@@ -184,7 +184,7 @@ git commit -m "feat: add the mcp scope resource"
|
||||
- `services.ServerInTokenScope(srv models.Server, sel map[string]string) bool`
|
||||
- `services.IntersectSelectors(caller, requested map[string]string) (map[string]string, bool)`
|
||||
- `services.SelectorNarrowerOrEqual(child, parent map[string]string) bool`
|
||||
- `services.CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]string, expiresInDays *int, ip string)` — note the **new sixth parameter**, consumed by Task 4.
|
||||
- `services.CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]string, expiresInDays *int, ip string)` - note the **new sixth parameter**, consumed by Task 4.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestSelectorNarrowerOrEqual(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/services/ -run 'TokenScope|Intersect|Narrower' -v`
|
||||
Expected: FAIL to compile — `ServerInTokenScope`, `IntersectSelectors` and `SelectorNarrowerOrEqual` are undefined.
|
||||
Expected: FAIL to compile - `ServerInTokenScope`, `IntersectSelectors` and `SelectorNarrowerOrEqual` are undefined.
|
||||
|
||||
- [ ] **Step 3: Write the selector helpers**
|
||||
|
||||
@@ -394,12 +394,12 @@ And in the struct literal that builds `tok`, after `Scopes: scopes,`:
|
||||
TagSelector: tagSelector,
|
||||
```
|
||||
|
||||
The caller-narrowing check belongs in the handler rather than here, because the service does not know the caller — Task 4 adds it.
|
||||
The caller-narrowing check belongs in the handler rather than here, because the service does not know the caller - Task 4 adds it.
|
||||
|
||||
- [ ] **Step 7: Fix the call sites**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: FAIL, naming each caller of `CreateAPIToken` with the wrong argument count. Update each — in `internal/api/tokens.go` pass the value from the request body (Task 4 adds the field; for now pass `nil`), and in any test or seed caller pass `nil`.
|
||||
Expected: FAIL, naming each caller of `CreateAPIToken` with the wrong argument count. Update each - in `internal/api/tokens.go` pass the value from the request body (Task 4 adds the field; for now pass `nil`), and in any test or seed caller pass `nil`.
|
||||
|
||||
- [ ] **Step 8: Run the full service tests**
|
||||
|
||||
@@ -451,7 +451,7 @@ At the bottom of `internal/auth/middleware.go`, beside `Scopes` and `IsToken`:
|
||||
```go
|
||||
// ServerScope is the tag restriction the acting credential carries, or nil for
|
||||
// an unrestricted token and for every cookie session. Callers pass it to
|
||||
// services.ServerInTokenScope or services.IntersectSelectors — nil means the
|
||||
// services.ServerInTokenScope or services.IntersectSelectors - nil means the
|
||||
// whole fleet, never nothing.
|
||||
func ServerScope(c *gin.Context) map[string]string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
@@ -611,7 +611,7 @@ func GetServerScoped(instanceID, serverID string, tokenScope map[string]string)
|
||||
}
|
||||
```
|
||||
|
||||
Use whatever not-found error `GetServer` already returns — read it first and reuse that identifier rather than introducing a second one.
|
||||
Use whatever not-found error `GetServer` already returns - read it first and reuse that identifier rather than introducing a second one.
|
||||
|
||||
- [ ] **Step 4: Point the handlers at the scoped versions**
|
||||
|
||||
@@ -677,12 +677,12 @@ func AssertServerScopeMapComplete(routes []string) error {
|
||||
}
|
||||
```
|
||||
|
||||
Read `AssertScopeMapComplete` in `internal/api/scopes.go` first and mirror how it obtains the route list and how it is called at boot; call the new assertion immediately after it, and restrict the routes it checks to those whose path contains `server`, `console` or `workflows/:id/run` so unrelated routes are not swept in. Adjust the map above to the real route list the grep produces — the entries here are what the current `routeScopes` shows, and any route that exists but is absent must be added with a decision, not omitted.
|
||||
Read `AssertScopeMapComplete` in `internal/api/scopes.go` first and mirror how it obtains the route list and how it is called at boot; call the new assertion immediately after it, and restrict the routes it checks to those whose path contains `server`, `console` or `workflows/:id/run` so unrelated routes are not swept in. Adjust the map above to the real route list the grep produces - the entries here are what the current `routeScopes` shows, and any route that exists but is absent must be added with a decision, not omitted.
|
||||
|
||||
- [ ] **Step 6: Verify boot and tests**
|
||||
|
||||
Run: `go build ./... && go test ./...`
|
||||
Expected: PASS. If the assertion fires, that is the feature working — add the missing route to the map with a true/false decision.
|
||||
Expected: PASS. If the assertion fires, that is the feature working - add the missing route to the map with a true/false decision.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
@@ -705,7 +705,7 @@ git commit -m "feat: enforce token tag restrictions at the server resolution cho
|
||||
- `mcp.Tool` struct with fields `Name string`, `Description string`, `Scope string`, `Write bool`, `Handler ToolFunc`
|
||||
- `mcp.Caller` struct with fields `InstanceID string`, `Scopes []string`, `TokenScope map[string]string`, `TokenName string`
|
||||
- `mcp.Registry` with `Register(Tool)`, `Visible(Caller) []Tool`, `Lookup(name string) (Tool, bool)`
|
||||
- `mcp.Allowed(t Tool, c Caller) (bool, string)` — returns whether the call may proceed and, when not, the gate that refused
|
||||
- `mcp.Allowed(t Tool, c Caller) (bool, string)` - returns whether the call may proceed and, when not, the gate that refused
|
||||
- Consumed by Tasks 7, 8, 9, 10.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
@@ -809,7 +809,7 @@ func TestEveryRegisteredToolDeclaresAKnownScope(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/mcp/ -v`
|
||||
Expected: FAIL to build — the package does not exist.
|
||||
Expected: FAIL to build - the package does not exist.
|
||||
|
||||
- [ ] **Step 3: Write the registry**
|
||||
|
||||
@@ -821,8 +821,8 @@ Create `vantage-app/server/internal/mcp/registry.go`:
|
||||
// It is a presentation layer over the service layer and introduces no authority
|
||||
// of its own: every tool calls the same service functions the REST handlers
|
||||
// call, and every decision about who may do what is made by machinery that
|
||||
// already exists. Three gates apply to every call — the licence feature, the
|
||||
// mcp:* scope, and the tool's own resource scope — and all three must pass.
|
||||
// already exists. Three gates apply to every call - the licence feature, the
|
||||
// mcp:* scope, and the tool's own resource scope - and all three must pass.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -1035,7 +1035,7 @@ func TestCheckFanOutRequiresConfirmation(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/mcp/ -run 'Summarise|FanOut' -v`
|
||||
Expected: FAIL to build — `SummariseArgs` and `CheckFanOut` are undefined.
|
||||
Expected: FAIL to build - `SummariseArgs` and `CheckFanOut` are undefined.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
@@ -1305,7 +1305,7 @@ In the route registration in `internal/api/handlers.go`, alongside the other gro
|
||||
mcpGroup.GET("", mcp.Handler())
|
||||
```
|
||||
|
||||
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"` and the shared `license` package. Match the existing group registration style — check how `registerWorkflowRoutes` and the status pages group are wired and follow whichever pattern the file uses.
|
||||
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"` and the shared `license` package. Match the existing group registration style - check how `registerWorkflowRoutes` and the status pages group are wired and follow whichever pattern the file uses.
|
||||
|
||||
- [ ] **Step 4: Declare the scopes**
|
||||
|
||||
@@ -1322,7 +1322,7 @@ In `internal/api/scopes.go`, add to `routeScopes`:
|
||||
- [ ] **Step 5: Verify boot**
|
||||
|
||||
Run: `go build ./... && go test ./...`
|
||||
Expected: PASS. `AssertScopeMapComplete` failing here means the route pattern in the map does not match what gin registered — print the registered routes and copy the exact pattern.
|
||||
Expected: PASS. `AssertScopeMapComplete` failing here means the route pattern in the map does not match what gin registered - print the registered routes and copy the exact pattern.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
@@ -1415,7 +1415,7 @@ func TestServerSummaryStaysSmall(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/mcp/ -run 'ReadTools|SecretReveal|ServerSummary' -v`
|
||||
Expected: FAIL — the tools are not registered and `serverSummary` is undefined.
|
||||
Expected: FAIL - the tools are not registered and `serverSummary` is undefined.
|
||||
|
||||
- [ ] **Step 3: Write the fleet tools**
|
||||
|
||||
@@ -1548,11 +1548,11 @@ func init() {
|
||||
}
|
||||
```
|
||||
|
||||
`models.Server` field names must be checked before writing this — read `internal/models/server.go` and use the real names for hostname, OS, online state and tags. If `Online` is derived rather than stored, derive it the same way the REST handler does.
|
||||
`models.Server` field names must be checked before writing this - read `internal/models/server.go` and use the real names for hostname, OS, online state and tags. If `Online` is derived rather than stored, derive it the same way the REST handler does.
|
||||
|
||||
- [ ] **Step 4: Write the monitor tools**
|
||||
|
||||
Create `vantage-app/server/internal/mcp/tools_health.go`. `list_monitors` is the second worked example, because the health tools project differently from the fleet ones — a monitor's state matters more than its configuration:
|
||||
Create `vantage-app/server/internal/mcp/tools_health.go`. `list_monitors` is the second worked example, because the health tools project differently from the fleet ones - a monitor's state matters more than its configuration:
|
||||
|
||||
```go
|
||||
package mcp
|
||||
@@ -1633,7 +1633,7 @@ func init() {
|
||||
}
|
||||
```
|
||||
|
||||
`models.Monitor.State` is a `MonitorState` struct — read `internal/models/monitor.go` and use its real status field and type rather than the `.Status` guessed above.
|
||||
`models.Monitor.State` is a `MonitorState` struct - read `internal/models/monitor.go` and use its real status field and type rather than the `.Status` guessed above.
|
||||
|
||||
- [ ] **Step 5: Write the remaining read tools**
|
||||
|
||||
@@ -1643,14 +1643,14 @@ Fourteen tools remain, each built exactly like the two worked examples: a projec
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `get_monitor_status` | `monitors:read` | `monitor_id` | `GetMonitor` | name, type, state, last check time, last error |
|
||||
| `list_incidents` | `monitors:read` | `monitor_id` (optional), `limit` | the incidents service the `/monitors/:id/incidents` route uses | incident ID, monitor name, started, resolved, cause |
|
||||
| `get_monitor_samples` | `monitors:read` | `monitor_id`, `limit` | the samples service behind `/monitors/:id/samples` | timestamp, ok, latency — capped hard, samples are numerous |
|
||||
| `get_monitor_samples` | `monitors:read` | `monitor_id`, `limit` | the samples service behind `/monitors/:id/samples` | timestamp, ok, latency - capped hard, samples are numerous |
|
||||
| `list_workflows` | `workflows:read` | `limit` | `ListWorkflows` | workflow ID, name, step count, target count, whether scheduled |
|
||||
| `get_workflow` | `workflows:read` | `workflow_id` | `GetWorkflow` | name, ordered step names and IDs, targets, schedule |
|
||||
| `get_run` | `workflows:read` | `run_id` | the run fetch behind `/runs/:runId` | run ID, workflow name, status, started, finished, per-server status counts |
|
||||
| `get_run_logs` | `workflows:read` | `run_id`, `server_id`, `limit` | the log read behind `/runs/:runId/servers/:serverId/logs` | ordered lines, capped at 200 by default |
|
||||
| `list_pending_updates` | `servers:read` | `server_id` or `tags` | `GetServerScoped` plus the stored update list | per server: hostname, package name, current and new version |
|
||||
| `list_vulnerabilities` | `vulns:read` | `severity`, `status`, `limit` | the findings service behind `/vulnerabilities` | CVE, severity, package, affected server count, fixed_in |
|
||||
| `get_server_packages` | `vulns:read` | `server_id`, `name` (optional filter) | the packages service behind `/servers/:id/packages` | package name, version — filtered, never the whole 2000-entry set unfiltered |
|
||||
| `get_server_packages` | `vulns:read` | `server_id`, `name` (optional filter) | the packages service behind `/servers/:id/packages` | package name, version - filtered, never the whole 2000-entry set unfiltered |
|
||||
| `search_fleet` | `vulns:read` | `name`, `version_below` (optional) | `services` package search in `internal/services/packages.go` | per match: hostname, package, version |
|
||||
| `list_audit_events` | `settings:read` | `event_type`, `limit` | `ListAuditEvents` | timestamp, type, actor, detail |
|
||||
| `list_secret_names` | `secrets:read` | none | the secrets list service | group name and key names ONLY |
|
||||
@@ -1659,7 +1659,7 @@ Four rules that apply to every one of them:
|
||||
|
||||
- **Scope every server-derived result.** Any tool reaching a server resolves it through `services.GetServerScoped` or `services.ListServersFiltered` with the intersected selector. `list_pending_updates`, `get_server_packages` and `search_fleet` are the three where this is easy to forget, and forgetting it is the bug this whole feature guards against.
|
||||
- **`get_run_logs` must respect the run's own instance.** Check the run belongs to `c.InstanceID` before returning a line of it.
|
||||
- **`list_secret_names` must never call the reveal path.** Write a comment saying so at the call site — the next person to touch that file will be tempted.
|
||||
- **`list_secret_names` must never call the reveal path.** Write a comment saying so at the call site - the next person to touch that file will be tempted.
|
||||
- **`search_fleet` is the one tool with no REST equivalent.** It answers questions like "which hosts still run OpenSSL 1.1", and its description should say exactly that, because a model will not otherwise guess the tool exists for that purpose.
|
||||
|
||||
- [ ] **Step 6: Run the tests to verify they pass**
|
||||
@@ -1740,7 +1740,7 @@ func TestWriteToolsHiddenWithoutMCPWrite(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/mcp/ -run Write -v`
|
||||
Expected: FAIL — none of the five tools is registered.
|
||||
Expected: FAIL - none of the five tools is registered.
|
||||
|
||||
- [ ] **Step 3: Write the write tools**
|
||||
|
||||
@@ -1825,14 +1825,14 @@ func stringSliceArg(args map[string]any, key string) []string {
|
||||
}
|
||||
```
|
||||
|
||||
`services.StartWorkflowRun` is a placeholder for whatever the REST run handler actually calls — read `internal/api/workflows.go` for the run route and call the same function with the same arguments. Do not reimplement any part of the run path.
|
||||
`services.StartWorkflowRun` is a placeholder for whatever the REST run handler actually calls - read `internal/api/workflows.go` for the run route and call the same function with the same arguments. Do not reimplement any part of the run path.
|
||||
|
||||
Then add the remaining four the same way:
|
||||
|
||||
- **`cancel_run`** (`workflows:write`) — takes `run_id`, calls the same service the cancel route uses, and verifies the run belongs to the caller's instance.
|
||||
- **`apply_updates`** (`servers:write`) — takes `server_ids` and/or `tags`, resolves through `ResolveTargetsScoped`, applies `CheckFanOut`, calls the apply-updates service.
|
||||
- **`update_agent`** (`servers:write`) — same target resolution, calls the update-agent service.
|
||||
- **`assign_key`** (`keys:write`) — takes `key_id` and `server_ids`, resolves targets scoped, calls the key assignment service.
|
||||
- **`cancel_run`** (`workflows:write`) - takes `run_id`, calls the same service the cancel route uses, and verifies the run belongs to the caller's instance.
|
||||
- **`apply_updates`** (`servers:write`) - takes `server_ids` and/or `tags`, resolves through `ResolveTargetsScoped`, applies `CheckFanOut`, calls the apply-updates service.
|
||||
- **`update_agent`** (`servers:write`) - same target resolution, calls the update-agent service.
|
||||
- **`assign_key`** (`keys:write`) - takes `key_id` and `server_ids`, resolves targets scoped, calls the key assignment service.
|
||||
|
||||
Every one calls `LogCall` with the resolved server count before returning, and every one that resolves targets calls `CheckFanOut`.
|
||||
|
||||
@@ -1880,7 +1880,7 @@ git commit -m "feat: add mcp write tools with a fan-out guard"
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `mcp.Tool`, `mcp.Caller`, `mcp.LogCreated` (added in this task), `services.CreateStep`, `services.CreateWorkflow`, `services.CreateMonitor`, `models.WorkflowStep`, `models.Workflow`, `models.WorkflowStepRef`, `models.Monitor`.
|
||||
- Produces: three registered write tools — `create_step`, `create_workflow`, `create_monitor` — and `mcp.LogCreated(c Caller, kind, id, name string)`.
|
||||
- Produces: three registered write tools - `create_step`, `create_workflow`, `create_monitor` - and `mcp.LogCreated(c Caller, kind, id, name string)`.
|
||||
|
||||
These are the tools that make the surface generative rather than only observational, and the ones most able to surprise someone. Three rules on top of the ordinary write gates, all of them tested below: nothing created is armed, no step may reference a secret, and there is no update or delete counterpart.
|
||||
|
||||
@@ -2035,7 +2035,7 @@ func TestCreateMonitorRequiresNameAndType(t *testing.T) {
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/mcp/ -run 'Creation|Create|BuildWorkflow|NoUpdate' -v`
|
||||
Expected: FAIL to build — `buildStep`, `buildWorkflow` and `buildMonitor` are undefined.
|
||||
Expected: FAIL to build - `buildStep`, `buildWorkflow` and `buildMonitor` are undefined.
|
||||
|
||||
- [ ] **Step 3: Write the builders and the tools**
|
||||
|
||||
@@ -2161,7 +2161,7 @@ func init() {
|
||||
Write: true,
|
||||
Scope: "workflows:write",
|
||||
Description: "Create a reusable workflow step: a named script with an interpreter. " +
|
||||
"The step is SAVED to this Vantage instance but is not run by creating it — " +
|
||||
"The step is SAVED to this Vantage instance but is not run by creating it - " +
|
||||
"add it to a workflow with create_workflow, then run that with run_workflow. " +
|
||||
"Steps created this way cannot reference secrets.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
@@ -2254,8 +2254,8 @@ func init() {
|
||||
|
||||
Three things must be checked against the real code before this compiles, rather than assumed:
|
||||
|
||||
- `services.CreateStep` takes `models.WorkflowStep` by value and `services.CreateMonitor` takes `*models.Monitor` — confirmed, but check their return signatures and any validation errors worth surfacing verbatim to the model.
|
||||
- `models.Monitor.Target` is a `MonitorTarget` struct, not a map. Read `internal/models/monitor.go`, and decode the `target` argument into it properly — the test above only asserts that a target is required, so extend `buildMonitor` and its test together once the real shape is in front of you.
|
||||
- `services.CreateStep` takes `models.WorkflowStep` by value and `services.CreateMonitor` takes `*models.Monitor` - confirmed, but check their return signatures and any validation errors worth surfacing verbatim to the model.
|
||||
- `models.Monitor.Target` is a `MonitorTarget` struct, not a map. Read `internal/models/monitor.go`, and decode the `target` argument into it properly - the test above only asserts that a target is required, so extend `buildMonitor` and its test together once the real shape is in front of you.
|
||||
- `OnFailure: "stop"` must match whatever value the existing step-ref validation accepts. Read the workflow create route and use its vocabulary.
|
||||
|
||||
- [ ] **Step 4: Add the creation audit event**
|
||||
@@ -2267,7 +2267,7 @@ In `internal/mcp/audit.go`, beside `LogCall` and `LogDenied`:
|
||||
//
|
||||
// It is a distinct event type rather than another mcp.tool_call row because of
|
||||
// the question a human will actually ask, which is "what has this agent added
|
||||
// to my instance" — an answer buried among hundreds of read rows is not an
|
||||
// to my instance" - an answer buried among hundreds of read rows is not an
|
||||
// answer.
|
||||
func LogCreated(c Caller, kind, id, name string) {
|
||||
services.LogEvent(c.InstanceID, "mcp.created", c.TokenName, "", "",
|
||||
@@ -2299,12 +2299,12 @@ git commit -m "feat: let an agent create steps, workflows and monitors, inert un
|
||||
**Files:**
|
||||
- Modify: `vantage-app/web/app/(app)/settings/license/page.tsx:255-270`
|
||||
|
||||
> **Scope reduced.** Steps 2 and 4 of this task — the tag restriction field and its
|
||||
> chip in the key list — **are already built** by
|
||||
> **Scope reduced.** Steps 2 and 4 of this task - the tag restriction field and its
|
||||
> chip in the key list - **are already built** by
|
||||
> `docs/superpowers/plans/2026-09-08-api-keys-redesign.md`, which redesigned the
|
||||
> same surfaces and absorbed them rather than have two plans rewrite one
|
||||
> component from opposite ends. Steps 3 and 5 — gating the `mcp:*` scopes and the
|
||||
> agent access panel — belong to that plan's Task 7, which waits on this plan's
|
||||
> component from opposite ends. Steps 3 and 5 - gating the `mcp:*` scopes and the
|
||||
> agent access panel - belong to that plan's Task 7, which waits on this plan's
|
||||
> Tasks 6–11. **Only step 1 below is still this task's work.**
|
||||
>
|
||||
> Note also that the page is not under `settings/`: it is `/tokens`, rendered by
|
||||
@@ -2324,27 +2324,27 @@ In `vantage-app/web/app/(app)/settings/license/page.tsx`, beside the existing fe
|
||||
|
||||
`licenceResponse.Features` is already a `map[string]bool` built from the licence, so no server change is needed for this to populate.
|
||||
|
||||
- [x] ~~**Step 2: Add the tag restriction field to the token form**~~ — done in the API keys redesign, `web/components/apikeys/TagRestriction.tsx`.
|
||||
- [x] ~~**Step 2: Add the tag restriction field to the token form**~~ - done in the API keys redesign, `web/components/apikeys/TagRestriction.tsx`.
|
||||
|
||||
Locate the token creation form (grep the settings directory for the scope checkbox list). Add a tag restriction control below the scopes, shown for **every** token regardless of licence — tag scoping is not gated.
|
||||
Locate the token creation form (grep the settings directory for the scope checkbox list). Add a tag restriction control below the scopes, shown for **every** token regardless of licence - tag scoping is not gated.
|
||||
|
||||
It offers the tag keys and values already in use across servers, which the fleet already exposes via `GET /api/servers/tags`. The workflow target selector already consumes that endpoint; reuse its component if one exists rather than building a second tag picker.
|
||||
|
||||
The field sends `tag_selector` as an object of key/value strings, omitted or `{}` when unrestricted.
|
||||
|
||||
- [ ] **Step 3: Add the MCP scopes and gate them** — moved to the API keys redesign plan, Task 7.
|
||||
- [ ] **Step 3: Add the MCP scopes and gate them** - moved to the API keys redesign plan, Task 7.
|
||||
|
||||
`mcp:read` and `mcp:write` arrive automatically in the scope list from `GET /api/tokens/scopes`, so no hardcoding is needed. Hide or disable those two entries when `license.features.mcp` is false, matching how console-gated UI is handled elsewhere.
|
||||
|
||||
- [x] ~~**Step 4: Show the restriction in the token list**~~ — done in the API keys redesign, `TagChips` in the ledger.
|
||||
- [x] ~~**Step 4: Show the restriction in the token list**~~ - done in the API keys redesign, `TagChips` in the ledger.
|
||||
|
||||
In the token list, render a token's `tag_selector` as a chip beside its scopes, so "what can this credential reach" is answerable at a glance. An unrestricted token shows nothing rather than an empty chip.
|
||||
|
||||
- [ ] **Step 5: Add the agent access panel** — moved to the API keys redesign plan, Task 7.
|
||||
- [ ] **Step 5: Add the agent access panel** - moved to the API keys redesign plan, Task 7.
|
||||
|
||||
On the API tokens settings page, add an **Agent access** panel visible only when `license.features.mcp` is true, containing:
|
||||
|
||||
- The endpoint URL for this instance — `${window.location.origin}/api/mcp` — with a copy button.
|
||||
- The endpoint URL for this instance - `${window.location.origin}/api/mcp` - with a copy button.
|
||||
- A copyable client configuration snippet:
|
||||
|
||||
```json
|
||||
@@ -2447,7 +2447,7 @@ Show the user the exact product payload and wait for a yes. This writes to a rea
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Vantage — Agent Access (MCP)",
|
||||
"name": "Vantage - Agent Access (MCP)",
|
||||
"description": "AI agent access to a Vantage instance over the Model Context Protocol",
|
||||
"tax_category": "standard"
|
||||
}
|
||||
@@ -2462,7 +2462,7 @@ Use the connected `paddle-sandbox` MCP server. Record the returned `pro_…` pro
|
||||
```json
|
||||
{
|
||||
"product_id": "pro_… from step 2",
|
||||
"description": "Agent Access (MCP) — monthly",
|
||||
"description": "Agent Access (MCP) - monthly",
|
||||
"unit_price": { "amount": "900", "currency_code": "GBP" },
|
||||
"billing_cycle": { "interval": "month", "frequency": 1 }
|
||||
}
|
||||
@@ -2475,13 +2475,13 @@ Amounts are in minor units: `"900"` is £9.00. Record the returned `pri_…`.
|
||||
```json
|
||||
{
|
||||
"product_id": "pro_… from step 2",
|
||||
"description": "Agent Access (MCP) — annual",
|
||||
"description": "Agent Access (MCP) - annual",
|
||||
"unit_price": { "amount": "9000", "currency_code": "GBP" },
|
||||
"billing_cycle": { "interval": "year", "frequency": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
`"9000"` is £90.00 — ten months' money for twelve, matching the convention the other add-on rows use. Record the returned `pri_…`.
|
||||
`"9000"` is £90.00 - ten months' money for twelve, matching the convention the other add-on rows use. Record the returned `pri_…`.
|
||||
|
||||
- [ ] **Step 5: Record the price IDs through the staff UI**
|
||||
|
||||
@@ -2495,7 +2495,7 @@ In HQ, build a checkout for a Professional plan with the MCP feature selected an
|
||||
|
||||
- [ ] **Step 7: Record what was created**
|
||||
|
||||
Post the product ID and both price IDs in the session so they are recoverable, and note that production prices are still outstanding — they are created by hand in the Paddle dashboard at ship time and pasted into `price_ids.production` the same way.
|
||||
Post the product ID and both price IDs in the session so they are recoverable, and note that production prices are still outstanding - they are created by hand in the Paddle dashboard at ship time and pasted into `price_ids.production` the same way.
|
||||
|
||||
---
|
||||
|
||||
@@ -2516,11 +2516,11 @@ Create `vantage-docs/docs/vantage/mcp.md` with front matter matching the sibling
|
||||
- Connecting Claude and other clients, with the JSON configuration block from Task 11.
|
||||
- The full tool list in a table: name, what it does, scope required.
|
||||
- A short "what an agent can create" section: steps, workflows and monitors,
|
||||
and that nothing it creates is armed — a created workflow has no schedule, a
|
||||
and that nothing it creates is armed - a created workflow has no schedule, a
|
||||
created monitor is disabled, and neither runs or alerts until a human says so.
|
||||
Note that agent-authored steps are badged in the UI and that an agent can
|
||||
never edit or delete an existing definition.
|
||||
- **What an agent cannot do** — reveal secret plaintext, open a console or shell, exceed its tag restriction, act at all without `mcp:write`, or touch more than 25 servers without explicit confirmation. This section is the reason a cautious reader will turn the feature on, so give it real prominence rather than a footnote.
|
||||
- **What an agent cannot do** - reveal secret plaintext, open a console or shell, exceed its tag restriction, act at all without `mcp:write`, or touch more than 25 servers without explicit confirmation. This section is the reason a cautious reader will turn the feature on, so give it real prominence rather than a footnote.
|
||||
- That every tool call is recorded in the audit log, reads included.
|
||||
|
||||
- [ ] **Step 2: Document the tag restriction**
|
||||
@@ -2551,7 +2551,7 @@ git commit -m "docs: document the mcp server and token tag restrictions"
|
||||
Before calling this done, from `vantage-app/server`:
|
||||
|
||||
- [ ] `go build ./... && go test ./...` passes.
|
||||
- [ ] The server boots — both completeness assertions pass, which is the real check that no route was missed.
|
||||
- [ ] The server boots - both completeness assertions pass, which is the real check that no route was missed.
|
||||
- [ ] A token with only `servers:read` gets 403 at `/api/mcp`.
|
||||
- [ ] A token with `mcp:read` lists read tools and no write tools.
|
||||
- [ ] A token with `mcp:write` lists both.
|
||||
|
||||
@@ -54,7 +54,7 @@ agent/internal/updates/
|
||||
updates.go # PackageUpdate; CheckAvailable/ApplyAll declared once
|
||||
updates_linux.go # existing detectPM, checkApt/DnfYum/Pacman/Zypper/Apk, ApplyAll
|
||||
updates_windows.go # Windows Update COM, driven through PowerShell
|
||||
updates_other.go # //go:build !linux && !windows — no-ops
|
||||
updates_other.go # //go:build !linux && !windows - no-ops
|
||||
```
|
||||
|
||||
`updates_other.go` carries the build constraint for the same reason
|
||||
@@ -116,7 +116,7 @@ A new field `reboot_required` on `InventoryReport`, added to
|
||||
|
||||
It travels on the inventory report rather than the update report because it is a
|
||||
host property like the kernel version, and it is set on the **static** snapshot
|
||||
only — every 15 minutes rather than every 30 seconds. A host rebooted by hand
|
||||
only - every 15 minutes rather than every 30 seconds. A host rebooted by hand
|
||||
clears the flag in a quarter of an hour instead of showing it for up to a full
|
||||
one, and the detection costs a PowerShell process on Windows, which is not
|
||||
something to spawn twice a minute forever.
|
||||
@@ -142,7 +142,7 @@ Both platforms set it, since parity is free here:
|
||||
|
||||
```
|
||||
agent/internal/workloads/
|
||||
workloads.go # Result, Collect, Hash — Collect calls collectUnits
|
||||
workloads.go # Result, Collect, Hash - Collect calls collectUnits
|
||||
docker.go # unchanged, shared: shells to the docker binary
|
||||
systemd_linux.go # was systemd.go
|
||||
services_windows.go # new: Win32_Service collection
|
||||
@@ -167,7 +167,7 @@ not responding, and running nothing.
|
||||
|
||||
### Collecting Windows services
|
||||
|
||||
`Get-CimInstance Win32_Service` converted to JSON — not `Get-Service`, which
|
||||
`Get-CimInstance Win32_Service` converted to JSON - not `Get-Service`, which
|
||||
exposes neither `PathName` nor `StartMode`, and the filter needs both.
|
||||
|
||||
A service is reported when its executable does **not** resolve under
|
||||
@@ -193,7 +193,7 @@ Field mapping:
|
||||
`Kind: "unit"` and the existing `systemd_ok` / `systemd_error` fields are reused
|
||||
rather than a `service` kind and `services_ok` fields being added. That would
|
||||
cost a proto change, both pb copies, the server model, the service layer and the
|
||||
web client, and would teach every existing consumer a second kind — to describe
|
||||
web client, and would teach every existing consumer a second kind - to describe
|
||||
the same thing. The naming is corrected where it is read, in the UI, which knows
|
||||
the server's OS.
|
||||
|
||||
@@ -206,8 +206,8 @@ The protected set stays computed and enforced agent-side, as it is on Linux: the
|
||||
control plane may name a target, but the agent decides what it will do to
|
||||
itself.
|
||||
|
||||
On Windows the protected workload is the `VantageAgent` service — the NSSM
|
||||
service name written by `installer/setup.ps1` — matched case-insensitively,
|
||||
On Windows the protected workload is the `VantageAgent` service - the NSSM
|
||||
service name written by `installer/setup.ps1` - matched case-insensitively,
|
||||
because Windows service names are. `detectOwnContainer` and its
|
||||
`/proc/self/cgroup` read move to `control_linux.go`; the Windows build returns
|
||||
no own-container ID.
|
||||
@@ -257,13 +257,13 @@ rather than on `os_type`. `os_type` is stored and serialised but unread by
|
||||
the two come to disagree. `WorkloadList` takes the result as a prop, since it
|
||||
receives only a `serverId`:
|
||||
|
||||
1. `web/components/workloads/WorkloadList.tsx` — takes an `isWindows` prop from
|
||||
1. `web/components/workloads/WorkloadList.tsx` - takes an `isWindows` prop from
|
||||
the server detail page, and the systemd status lines become
|
||||
platform-worded. On Windows the error line reads "Windows services could not
|
||||
be read" and the "systemd is not in use on this server" line is not rendered
|
||||
at all. The empty-state line drops "on Linux only". The Docker lines are
|
||||
unchanged.
|
||||
2. Server detail — a `Reboot required` pill beside the update count when the
|
||||
2. Server detail - a `Reboot required` pill beside the update count when the
|
||||
flag is set, placed with the update panel because that is what caused it.
|
||||
3. The Updates panel's Windows copy describes a list of KB articles rather than
|
||||
package upgrades, since `current_version` is empty on that platform.
|
||||
@@ -272,7 +272,7 @@ receives only a `serverId`:
|
||||
|
||||
The Windows collectors are, in substance, parsers of PowerShell output. Parsing
|
||||
is separated from invocation and table-tested against captured real output. The
|
||||
`agent` module has no tests at all today, so these are the first — they live
|
||||
`agent` module has no tests at all today, so these are the first - they live
|
||||
beside the parsers as ordinary `_test.go` files, run with `go test ./...` from
|
||||
`agent/`, and need no new dependency:
|
||||
|
||||
@@ -292,6 +292,6 @@ service start/stop/restart, a protected refusal on `VantageAgent`, and logs on
|
||||
both a chatty service and a silent one.
|
||||
|
||||
`GOOS=windows go build ./...` and `GOOS=linux go build ./...` both belong in the
|
||||
implementation plan as explicit steps — a build-tag split is exactly the change
|
||||
implementation plan as explicit steps - a build-tag split is exactly the change
|
||||
that compiles on the machine you are sitting at and nowhere else. CI already
|
||||
cross-builds the agent on release, so no workflow change is needed.
|
||||
|
||||
@@ -36,7 +36,7 @@ instance:
|
||||
|
||||
Three things do not exist: any concept of a page, any operator-authored
|
||||
incident, and any unauthenticated read path. The third is the constraint that
|
||||
shapes the rest — every route under `/api` carries `auth.Middleware`,
|
||||
shapes the rest - every route under `/api` carries `auth.Middleware`,
|
||||
`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where
|
||||
it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with
|
||||
no scope entry.
|
||||
@@ -77,7 +77,7 @@ pages; a random identifier would be unguessable and unmemorable in equal
|
||||
measure.
|
||||
|
||||
`published` exists so a page can be composed before anyone sees it. An
|
||||
unpublished page answers the same 404 as a page that does not exist — a
|
||||
unpublished page answers the same 404 as a page that does not exist - a
|
||||
distinct 403 would confirm it exists.
|
||||
|
||||
Sections are page-local and unrelated to `Monitor.Group`, which is a display
|
||||
@@ -128,7 +128,7 @@ end and duration.
|
||||
refused` lives.
|
||||
|
||||
Copying auto-incidents into `status_incidents` would be a second writer for the
|
||||
same fact, arriving by a different route with its own opportunity to disagree —
|
||||
same fact, arriving by a different route with its own opportunity to disagree -
|
||||
the same argument that keeps `RefreshWorkloadsCmd` from returning workloads
|
||||
inline.
|
||||
|
||||
@@ -155,7 +155,7 @@ is private by default rather than published by accident.
|
||||
|
||||
What the snapshot contains, per entry: display name, current status, uptime
|
||||
percentage over the last 90 days, and a 90-day history bar of one cell per day.
|
||||
A cell is up, down, under maintenance, or no-data — `no-data` for days before
|
||||
A cell is up, down, under maintenance, or no-data - `no-data` for days before
|
||||
the monitor existed, which is a distinct thing from a day it was down. No
|
||||
latency, no addresses, no failure text.
|
||||
|
||||
@@ -167,7 +167,7 @@ GET /public/status/:pageId
|
||||
|
||||
Mounted on the gin root, not under `apiGroup`. Putting it under `/api` would
|
||||
require exempting it from authentication, scope enforcement, token rate
|
||||
limiting and the licence gate — four holes, each one something a later change
|
||||
limiting and the licence gate - four holes, each one something a later change
|
||||
can widen. Outside `/api` it needs none of them.
|
||||
|
||||
The instance is resolved from the request host through `auth.InstanceFromHost`.
|
||||
@@ -208,7 +208,7 @@ cache separately and two visitors would see different states during an incident.
|
||||
### Rate limit
|
||||
|
||||
Per client address, one-minute fixed window, 120 requests, 429 with
|
||||
`Retry-After` — the same shape as `RateLimitTokens`, including its most
|
||||
`Retry-After` - the same shape as `RateLimitTokens`, including its most
|
||||
important property: **when Redis is unavailable, allow rather than deny.** A
|
||||
status page must survive the outage it exists to report.
|
||||
|
||||
@@ -240,7 +240,7 @@ are required, not optional: `AssertScopeMapComplete` fails boot on an `/api`
|
||||
route with no scope entry, which is exactly the safeguard working.
|
||||
|
||||
Handlers need `@…` annotations and `openapi.json` must be regenerated and
|
||||
committed — `server-deploy.yml` runs `git diff --exit-code` against the
|
||||
committed - `server-deploy.yml` runs `git diff --exit-code` against the
|
||||
committed copy, so a handler whose annotation drifted fails CI.
|
||||
|
||||
## Frontend
|
||||
@@ -252,7 +252,7 @@ against the Go endpoint, with a client refresh every 60 seconds.
|
||||
`web/next.config.ts` gains a `/public/:path*` rewrite so that client refresh
|
||||
reaches the server.
|
||||
|
||||
The page stays dark, like the rest of `web/`, and carries no hex values — the
|
||||
The page stays dark, like the rest of `web/`, and carries no hex values - the
|
||||
existing token palette covers every state it needs.
|
||||
|
||||
Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar
|
||||
@@ -277,7 +277,7 @@ boundary made executable:
|
||||
|
||||
## Migration and rollout
|
||||
|
||||
No migration is needed — both collections are new and absent means empty. Index
|
||||
No migration is needed - both collections are new and absent means empty. Index
|
||||
builders follow the `EnsureWorkflowIndexes` precedent and warn rather than being
|
||||
fatal: a missing index on a small collection degrades to a scan, which is no
|
||||
reason to refuse to serve the fleet.
|
||||
|
||||
@@ -6,8 +6,8 @@ Status: approved, ready for implementation planning
|
||||
## Problem
|
||||
|
||||
Vantage has no backup story. A self-hosted deployment holds its entire state in
|
||||
MongoDB and encrypts the sensitive half of it — SSH private keys, key
|
||||
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials — with
|
||||
MongoDB and encrypts the sensitive half of it - SSH private keys, key
|
||||
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials - with
|
||||
AES-256-GCM under a single 32-byte key supplied as the `KEY_ENCRYPTION_KEY`
|
||||
environment variable.
|
||||
|
||||
@@ -48,7 +48,7 @@ such assertion available, so a second hand-maintained registry would drift
|
||||
silently and the first symptom would be a restore missing a collection nobody
|
||||
noticed was added.
|
||||
|
||||
`--exclude` accepts collection names for the volume-heavy ones —
|
||||
`--exclude` accepts collection names for the volume-heavy ones -
|
||||
`workflow_log_lines`, `monitor_samples`, `audit_logs`. Whatever is excluded is
|
||||
recorded in the manifest, so an archive can never claim to be complete when it
|
||||
is not.
|
||||
@@ -72,7 +72,7 @@ into the server binary.
|
||||
cobra command tree and nothing else. A separate module rather than a package
|
||||
under `shared/` because adding cobra to `shared/go.mod` would put cobra and
|
||||
pflag into the module graph of `server`, `admin` and `sitesvc`, none of which
|
||||
use them. Binaries are unaffected — Go links only what is imported — but three
|
||||
use them. Binaries are unaffected - Go links only what is imported - but three
|
||||
`go.sum` files would grow and three CI builds would fetch a dependency they do
|
||||
not need. `agent/` is already a separate module for the same reason.
|
||||
|
||||
@@ -80,7 +80,7 @@ The tool imports nothing from `server/`. No `db.Col()`, no `services`, no config
|
||||
loader, and it never dials the REST or gRPC API. It needs only network reach to
|
||||
MongoDB, a database name, and `KEY_ENCRYPTION_KEY` in its own environment. This
|
||||
is what lets it run against a control plane that is down, half-migrated, or was
|
||||
deleted an hour ago — which is the only condition under which anyone runs a
|
||||
deleted an hour ago - which is the only condition under which anyone runs a
|
||||
restore.
|
||||
|
||||
### Dump implementation
|
||||
@@ -149,7 +149,7 @@ environment:
|
||||
- Fingerprints differ: refuse, printing both.
|
||||
- Archive has a fingerprint, environment has no key: refuse.
|
||||
- `--ignore-key-mismatch`: proceed, having first printed exactly which
|
||||
collections hold ciphertext that will be undecryptable — `keys`, `secrets`,
|
||||
collections hold ciphertext that will be undecryptable - `keys`, `secrets`,
|
||||
`auth_providers`, `console_sessions`, `settings`.
|
||||
|
||||
### Restore semantics
|
||||
@@ -169,7 +169,7 @@ The order is fixed:
|
||||
|
||||
Restore is not idempotent, and says so. A second run without `--force` is
|
||||
refused because step 4 now finds data. A restore interrupted during step 5
|
||||
leaves a partial database that the next run refuses to touch — correct, because
|
||||
leaves a partial database that the next run refuses to touch - correct, because
|
||||
the alternative is a silent merge. There are no merge or upsert semantics at
|
||||
all: merging two control planes reconciles nothing and produces a fleet that
|
||||
half works, and upserting by `_id` resurrects rows deleted since the backup,
|
||||
@@ -178,8 +178,8 @@ costume of a convenience.
|
||||
|
||||
Index replay is fatal per collection when a unique index fails to build, and a
|
||||
warning when a non-unique one does. A unique index that cannot be created means
|
||||
the restored data violates it, and the unique indexes here — `(instance_id,
|
||||
email)`, instance slug, settings instance, the ESO token hash — are
|
||||
the restored data violates it, and the unique indexes here - `(instance_id,
|
||||
email)`, instance slug, settings instance, the ESO token hash - are
|
||||
tenant-isolation properties rather than optimisations. The failure names the
|
||||
offending index.
|
||||
|
||||
@@ -187,7 +187,7 @@ offending index.
|
||||
|
||||
Restore under `--force` requires a typed confirmation when stdin is a TTY.
|
||||
|
||||
When stdin is not a TTY — a Kubernetes Job, a CI step, a cron entry — the
|
||||
When stdin is not a TTY - a Kubernetes Job, a CI step, a cron entry - the
|
||||
confirmation comes from `--confirm-db <name>`, whose value must equal the
|
||||
resolved target database name or restore refuses. Naming the database in the
|
||||
argument means a copy-pasted restore command carries its intended target with
|
||||
@@ -219,13 +219,13 @@ system; this tool reads no configuration file, and pulling it in to call
|
||||
`os.Getenv` would make the largest dependency in the binary the one doing the
|
||||
smallest job.
|
||||
|
||||
`inspect` prints the manifest — when the archive was made, by what version,
|
||||
`inspect` prints the manifest - when the archive was made, by what version,
|
||||
which collections it holds, how many documents, what was excluded, and the key
|
||||
fingerprint — and contacts no database. It is what an operator runs to find out
|
||||
fingerprint - and contacts no database. It is what an operator runs to find out
|
||||
whether an archive they have found is worth anything.
|
||||
|
||||
`verify` adds a live check: whether the archive's fingerprint matches the key in
|
||||
the current environment, and — when `--mongo-uri` is given — whether that key
|
||||
the current environment, and - when `--mongo-uri` is given - whether that key
|
||||
actually decrypts the target database. The second half is a probe: read one
|
||||
ciphertext field from `secrets`, `keys` or `auth_providers` and attempt to open
|
||||
it. A fingerprint comparison proves two archives agree; only a probe proves the
|
||||
@@ -235,8 +235,8 @@ the documentation recommends running it on a schedule.
|
||||
|
||||
The probe needs AES-256-GCM open, which today lives in
|
||||
`server/internal/services/crypto.go` and cannot be imported from another module.
|
||||
Rather than copy it — the exact hazard `CLAUDE.md` names around mirrored token
|
||||
blocks and `web/lib/targets.ts` — the primitives move to a new `shared/cryptobox`
|
||||
Rather than copy it - the exact hazard `CLAUDE.md` names around mirrored token
|
||||
blocks and `web/lib/targets.ts` - the primitives move to a new `shared/cryptobox`
|
||||
package, and `services/crypto.go` becomes a thin delegation that keeps its
|
||||
existing unexported function names and its `KEY_ENCRYPTION_KEY` lookup. One
|
||||
implementation of the cipher, two callers.
|
||||
@@ -255,11 +255,11 @@ Kubernetes, or neither.
|
||||
`linux/arm64`, `darwin/arm64` and `windows/amd64` with `CGO_ENABLED=0`, writes
|
||||
`checksums.txt`, and creates a Gitea release.
|
||||
|
||||
**Container image.** `vantagectl/Dockerfile` — the repo's convention is a
|
||||
**Container image.** `vantagectl/Dockerfile` - the repo's convention is a
|
||||
Dockerfile per module built from the repository root, because every Go module
|
||||
depends on `shared` through a replace directive — produces a `scratch`
|
||||
depends on `shared` through a replace directive - produces a `scratch`
|
||||
image holding the static binary and an explicitly copied `/tmp`, which the
|
||||
archive is staged in before compression — the same omission that silently
|
||||
archive is staged in before compression - the same omission that silently
|
||||
disabled `vulnsched` on a scratch image. Pushed by `server-deploy.yml` as an
|
||||
eighth image.
|
||||
|
||||
@@ -281,8 +281,8 @@ Restore in Kubernetes is the same image run as a one-shot `Job`. The chart ships
|
||||
no restore manifest: a restore is an operator decision with a confirmation
|
||||
attached to it, and must never be something a `helm upgrade` can trigger.
|
||||
|
||||
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row —
|
||||
`vantagectl/`, `shared/`, `go.work` — which makes `shared/` fan out to four Go
|
||||
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row -
|
||||
`vantagectl/`, `shared/`, `go.work` - which makes `shared/` fan out to four Go
|
||||
images rather than three. That table is already called out in `CLAUDE.md` as a
|
||||
place where a missed entry ships a stale image.
|
||||
|
||||
@@ -294,8 +294,8 @@ variable that skips when unset.
|
||||
|
||||
Required cases:
|
||||
|
||||
- Round trip: seed one document of every awkward BSON type — `ObjectId`,
|
||||
`Decimal128`, `DateTime`, binary, null, nested arrays — back up, restore into
|
||||
- Round trip: seed one document of every awkward BSON type - `ObjectId`,
|
||||
`Decimal128`, `DateTime`, binary, null, nested arrays - back up, restore into
|
||||
a second database, assert byte-equal BSON.
|
||||
- A single corrupted byte in a `.bson` member causes restore to refuse before
|
||||
writing anything.
|
||||
@@ -317,7 +317,7 @@ Fingerprint computation is a pure function and is tested without a database.
|
||||
- A restore drill: restore into a scratch database and run `verify`, because an
|
||||
untested backup is a hypothesis.
|
||||
- What is not covered: Redis sessions, the vulnerability database (re-pulled
|
||||
automatically), and agent state on managed servers — agents reconnect on their
|
||||
automatically), and agent state on managed servers - agents reconnect on their
|
||||
own and `servers.agent_token_hash` is in the backup, so no re-enrolment is
|
||||
needed.
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ Date: 2026-09-08
|
||||
## Goal
|
||||
|
||||
Expose Vantage to LLM agents as a first-class tool surface, so that an agent
|
||||
acting for a user can answer questions about the fleet and — when explicitly
|
||||
permitted — act on it, under the same identity, scopes, licence and audit trail
|
||||
acting for a user can answer questions about the fleet and - when explicitly
|
||||
permitted - act on it, under the same identity, scopes, licence and audit trail
|
||||
as every other API caller.
|
||||
|
||||
Concretely: a user mints a Vantage API token, points Claude (or any MCP client)
|
||||
@@ -67,8 +67,8 @@ unchanged.
|
||||
The design principle throughout: **MCP is a presentation layer over the service
|
||||
layer, and introduces no new authority.** It calls the same service functions
|
||||
the REST handlers call, and every decision about who may do what is made by
|
||||
machinery that already exists. Where MCP needs something new — tag-scoped
|
||||
tokens — that thing is built as a general capability of the API, not as an MCP
|
||||
machinery that already exists. Where MCP needs something new - tag-scoped
|
||||
tokens - that thing is built as a general capability of the API, not as an MCP
|
||||
feature.
|
||||
|
||||
Three independent gates gate every tool call, and all three must pass:
|
||||
@@ -84,16 +84,16 @@ Three independent gates gate every tool call, and all three must pass:
|
||||
them, `ScopeSatisfied` already implements write-implies-read, and the token
|
||||
creation UI advertises them without modification.
|
||||
|
||||
A bespoke `mcp:use` scope was rejected. The vocabulary is deliberately uniform —
|
||||
every resource has exactly `:read` and `:write` — and one special-cased action
|
||||
A bespoke `mcp:use` scope was rejected. The vocabulary is deliberately uniform -
|
||||
every resource has exactly `:read` and `:write` - and one special-cased action
|
||||
verb would be the first exception in a table whose value is having none.
|
||||
|
||||
The meanings:
|
||||
|
||||
- **`mcp:read`** — the token may reach `/api/mcp` at all. A token without it is
|
||||
- **`mcp:read`** - the token may reach `/api/mcp` at all. A token without it is
|
||||
not an agent token, whatever else it holds. Read tools are listed and callable
|
||||
subject to their own resource scopes.
|
||||
- **`mcp:write`** — write tools are listed and callable, again subject to their
|
||||
- **`mcp:write`** - write tools are listed and callable, again subject to their
|
||||
own resource scopes. Implied by the existing rule when a token holds
|
||||
`mcp:write`, so `mcp:read` need not be requested separately.
|
||||
|
||||
@@ -120,7 +120,7 @@ TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,
|
||||
|
||||
Validated on creation by the existing `services.ValidateTags`, so a token
|
||||
selector cannot express a tag a server could never carry. A caller may only
|
||||
create a token whose selector is at least as narrow as their own — the same
|
||||
create a token whose selector is at least as narrow as their own - the same
|
||||
rule `ScopeSatisfied` already enforces for scopes, applied to tags.
|
||||
|
||||
`auth.Session` carries `TagSelector`, populated in `sessionFromToken` and always
|
||||
@@ -166,13 +166,13 @@ console and OIDC being opt-in per customer.
|
||||
|
||||
Enforced in three places:
|
||||
|
||||
1. **Route** — `RequireFeature(license.FeatureMCP)` on the `/api/mcp` group,
|
||||
1. **Route** - `RequireFeature(license.FeatureMCP)` on the `/api/mcp` group,
|
||||
answering the standard `feature_unavailable` 403.
|
||||
2. **Token minting** — creating a token with `mcp:read` or `mcp:write` is
|
||||
2. **Token minting** - creating a token with `mcp:read` or `mcp:write` is
|
||||
refused without the feature. A licence downgrade should not leave live agent
|
||||
credentials that fail confusingly mid-conversation, and the same
|
||||
guard-at-source thinking is already in `services/packages.go`.
|
||||
3. **UI** — the token form's MCP scopes and the MCP connection panel are hidden
|
||||
3. **UI** - the token form's MCP scopes and the MCP connection panel are hidden
|
||||
when the licence does not grant it, as console is today.
|
||||
|
||||
Existing tokens are unaffected: absent the new scopes, no token can reach the
|
||||
@@ -185,7 +185,7 @@ returns either a JSON response or an SSE stream. The transport is stateless
|
||||
rather than session-resuming precisely so each request can stand alone and
|
||||
sit behind ordinary request middleware with no special-casing, and that
|
||||
stateless mode leaves no session for a server-to-client stream to resume
|
||||
against — so `GET /api/mcp` is registered but answers the protocol's 405
|
||||
against - so `GET /api/mcp` is registered but answers the protocol's 405
|
||||
rather than opening a stream. A client probing the endpoint therefore learns
|
||||
"POST-only here" rather than seeing a bare 404, which is what the MCP spec
|
||||
expects from a server that does not offer the GET/SSE leg.
|
||||
@@ -197,7 +197,7 @@ duplicate auth and double every request's cost for no benefit.
|
||||
|
||||
`routeScopes` gains `POST /api/mcp` and `GET /api/mcp`, both mapped to
|
||||
`mcp:read`, satisfying `AssertScopeMapComplete`. Per-tool scope enforcement
|
||||
happens inside the handler, because one route serves many operations — this is
|
||||
happens inside the handler, because one route serves many operations - this is
|
||||
the first route where the route-level scope is a floor rather than the whole
|
||||
answer, and the map entry's comment says so.
|
||||
|
||||
@@ -275,7 +275,7 @@ someone, so they carry extra rules on top of the ordinary write gates:
|
||||
tell at a glance what a model wrote. Workflows and monitors get the same
|
||||
treatment through their audit event rather than a new field.
|
||||
- **Script validation.** `create_step` runs the same parse and scan the existing
|
||||
step-create route runs (`services.CreateStep` already does this) — an agent
|
||||
step-create route runs (`services.CreateStep` already does this) - an agent
|
||||
gets no laxer a path than the UI.
|
||||
|
||||
## Audit
|
||||
@@ -292,14 +292,14 @@ instance".
|
||||
Event type `mcp.tool_call`; actor is the token name, as REST token actions
|
||||
already record; detail is the tool name, a compact argument summary, and the
|
||||
number of servers affected. Failures record `mcp.tool_denied` with the gate that
|
||||
refused — licence, MCP scope, resource scope, or tag selector — which is what
|
||||
refused - licence, MCP scope, resource scope, or tag selector - which is what
|
||||
turns "the agent said it couldn't" into a diagnosable event.
|
||||
|
||||
Arguments are summarised, never dumped verbatim: an argument could carry
|
||||
arbitrary text from a model, and the audit log is read by humans in a UI.
|
||||
|
||||
A chatty agent can produce many events. If that becomes a problem the throttle
|
||||
pattern already used for `token.expired_use` applies, but v1 records everything —
|
||||
pattern already used for `token.expired_use` applies, but v1 records everything -
|
||||
under-recording a new and sensitive surface is the worse failure.
|
||||
|
||||
## Errors
|
||||
@@ -310,7 +310,7 @@ workflows:write". The agent must be able to read the refusal and adapt or tell
|
||||
its user, and a transport-level failure is invisible to the model.
|
||||
|
||||
Out-of-scope hosts are not-found, matching the REST rule. Upstream service
|
||||
errors are summarised — a raw Mongo error is neither useful to a model nor safe
|
||||
errors are summarised - a raw Mongo error is neither useful to a model nor safe
|
||||
to expose.
|
||||
|
||||
## HQ, catalogue and Paddle
|
||||
@@ -321,7 +321,7 @@ to expose.
|
||||
more `KindFeature` row at `ScopeShared`, sold by every paid plan at one price.
|
||||
`SeedCatalogue` is `$setOnInsert` only, so the row appears empty on deploy and
|
||||
staff-entered price IDs are never blanked. The comment naming the row count
|
||||
("nine rows") is updated — the file explicitly asks the next person to keep that
|
||||
("nine rows") is updated - the file explicitly asks the next person to keep that
|
||||
number deliberate.
|
||||
|
||||
`catalogue.LineItems` needs no change: a `KindFeature` row the customer selected
|
||||
@@ -349,7 +349,7 @@ One product, two prices, created in the sandbox environment first:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Product name | Vantage — Agent Access (MCP) |
|
||||
| Product name | Vantage - Agent Access (MCP) |
|
||||
| Description | AI agent access to a Vantage instance over the Model Context Protocol |
|
||||
| Tax category | `standard` |
|
||||
| Currency | GBP |
|
||||
@@ -362,7 +362,7 @@ rows use.
|
||||
Creation runs through the connected `paddle-sandbox` MCP server during
|
||||
implementation, with the exact payload confirmed before each call. The resulting
|
||||
price IDs are recorded in the catalogue row's `price_ids.sandbox` map through
|
||||
the existing staff pricing page — not by a migration, because that page is the
|
||||
the existing staff pricing page - not by a migration, because that page is the
|
||||
only place price IDs are meant to be entered and a migration writing them would
|
||||
be a second source of truth.
|
||||
|
||||
@@ -379,8 +379,8 @@ the licence grants the feature:
|
||||
- A short client configuration snippet, again copyable.
|
||||
- A link to the docs page.
|
||||
|
||||
The token creation form gains the two MCP scopes in its scope list — no special
|
||||
UI, they are ordinary scopes — and a **tag restriction** field, which is shown
|
||||
The token creation form gains the two MCP scopes in its scope list - no special
|
||||
UI, they are ordinary scopes - and a **tag restriction** field, which is shown
|
||||
for every token regardless of licence because tag scoping is not gated. The
|
||||
field offers the tag keys and values already in use on servers, as the workflow
|
||||
target selector does.
|
||||
@@ -418,7 +418,7 @@ is the thing that must stay correct as tools are added:
|
||||
- **Audit.** A successful call and a refused call each write exactly one event
|
||||
of the expected type.
|
||||
- **Response size.** `list_servers` over a seeded fleet stays under a stated
|
||||
byte budget — a regression here degrades every agent interaction and is
|
||||
byte budget - a regression here degrades every agent interaction and is
|
||||
otherwise invisible.
|
||||
|
||||
`services/statuspages_test.go` is the style model.
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
|
||||
# itself — scratch has no shell — and os.MkdirTemp fails outright without it.
|
||||
# itself - scratch has no shell - and os.MkdirTemp fails outright without it.
|
||||
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
|
||||
|
||||
# Runtime stage
|
||||
|
||||
+8
-8
@@ -39,7 +39,7 @@ import (
|
||||
// comment group, separated by a real blank line rather than a bare "//": Go's
|
||||
// parser only splits ast.CommentGroups on an actual blank line, and
|
||||
// swag v2.0.0-rc5's parseSecAttributesV3 resolves a scheme's map key by
|
||||
// scanning from the start of whatever comment group it was handed — so three
|
||||
// scanning from the start of whatever comment group it was handed - so three
|
||||
// stacked blocks sharing one group all collapse onto the first block's name.
|
||||
// Three groups means three independent scans, each finding its own name.
|
||||
|
||||
@@ -63,7 +63,7 @@ func main() {
|
||||
//
|
||||
// Under Docker Compose neither is set and nothing changes: one process
|
||||
// migrates and then serves. Under Kubernetes with more than one replica
|
||||
// that is unsafe — every pod would run MigrateOrgToInstance at once, and
|
||||
// that is unsafe - every pod would run MigrateOrgToInstance at once, and
|
||||
// renaming collections while a sibling reads them is not a race anyone
|
||||
// wins. The chart therefore runs a pre-upgrade Job with MIGRATE_ONLY and
|
||||
// starts the Deployment with SKIP_MIGRATIONS.
|
||||
@@ -198,7 +198,7 @@ func runSchemaSetup() {
|
||||
}
|
||||
|
||||
// apiVersion mirrors the @version annotation on the swagger block above,
|
||||
// which is the only version string this server already establishes — there is
|
||||
// which is the only version string this server already establishes - there is
|
||||
// no separate runtime build-version constant to reuse instead. Nothing ties
|
||||
// the two together mechanically, so change them in the same commit: this is
|
||||
// the value mcp.SetVersion reports to MCP clients, and it must keep agreeing
|
||||
@@ -225,8 +225,8 @@ func serve() {
|
||||
}
|
||||
log.Printf("message bus ready as node %s", bus.NodeID())
|
||||
|
||||
// Cancelled on SIGTERM/SIGINT. Everything below that takes a context — the
|
||||
// housekeeping jobs, the leader lock — stops when the pod is asked to.
|
||||
// Cancelled on SIGTERM/SIGINT. Everything below that takes a context - the
|
||||
// housekeeping jobs, the leader lock - stops when the pod is asked to.
|
||||
ctx, shutdown := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer shutdown()
|
||||
|
||||
@@ -241,7 +241,7 @@ func serve() {
|
||||
// every monitor check firing N times, every incident notification delivered
|
||||
// to the customer N times, every retention sweep deleting concurrently, and
|
||||
// N reapers racing to purge the same instance. They share one lock rather
|
||||
// than holding four, because they are one role — housekeeping — and
|
||||
// than holding four, because they are one role - housekeeping - and
|
||||
// splitting them would only spread that role across pods for no benefit.
|
||||
bus.RunAsLeader(ctx, "housekeeping", func(jobCtx context.Context) {
|
||||
services.StartLogSweeper(jobCtx)
|
||||
@@ -278,7 +278,7 @@ func serve() {
|
||||
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
|
||||
// only produced audit strings; the public status limiter makes it load
|
||||
// bearing. Empty means trust nobody, which is correct for a direct
|
||||
// exposure and wrong behind a proxy — hence the explicit setting.
|
||||
// exposure and wrong behind a proxy - hence the explicit setting.
|
||||
if err := r.SetTrustedProxies(api.TrustedProxies()); err != nil {
|
||||
log.Fatalf("trusted proxies: %v", err)
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func boolEnv(key string) bool {
|
||||
// It replaces a substring filter that fed in only routes whose path contained
|
||||
// "server", ":serverId", "console" or "assign". That filter could only ever
|
||||
// catch a route whose *path* named a server, and a route can act on one named
|
||||
// in its body, in a query parameter, or derived by the handler — it caught one
|
||||
// in its body, in a query parameter, or derived by the handler - it caught one
|
||||
// of the leaks found in the final review of the MCP feature, and none of the
|
||||
// eleven found during implementation. Declaring every route is more typing
|
||||
// once and no maintenance after: a new route fails boot until somebody answers
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// consoleConnect godoc
|
||||
//
|
||||
// @Summary Open a browser console session
|
||||
// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise.
|
||||
// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent - answers 409 agent_offline otherwise.
|
||||
// @Tags console
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -109,7 +109,7 @@ func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
// Every branch here logs. That is deliberate and worth keeping: this handler
|
||||
// spans four hops (session store, agent dispatch, relay announcement, guacd),
|
||||
// any of which can fail, and the client is told the same near-useless thing by
|
||||
// most of them — a 500 that guacamole then reports as an *upstream* error,
|
||||
// most of them - a 500 that guacamole then reports as an *upstream* error,
|
||||
// naming the wrong hop entirely. Without a line per branch the only evidence a
|
||||
// failure leaves is a GIN status code, and with several replicas you cannot
|
||||
// even tell which process produced it.
|
||||
@@ -215,7 +215,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// The client is deliberately told nothing specific, so this is the only
|
||||
// place the real reason exists — a failed dispatch and a relay that was
|
||||
// place the real reason exists - a failed dispatch and a relay that was
|
||||
// never announced are the same generic 500 to the browser.
|
||||
tlog("reject: open relay: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open relay"})
|
||||
@@ -286,7 +286,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// The handshake is where guacd connects onward to the relay, so a
|
||||
// failure here is guacd reporting it could not reach %s:%d — the hop
|
||||
// failure here is guacd reporting it could not reach %s:%d - the hop
|
||||
// that has been hardest to see from either end.
|
||||
stream := guac.NewStream(conn, guac.SocketTimeout)
|
||||
if err := stream.Handshake(config); err != nil {
|
||||
|
||||
@@ -1631,14 +1631,14 @@
|
||||
"$ref": "#/components/schemas/models.AlertSettings"
|
||||
},
|
||||
"api_token_max_days": {
|
||||
"description": "APITokenMaxDays caps how long a newly created API token may live.\n\nA pointer for the same reason the retention fields are: absent must mean\nthe default, and the default here is no cap at all — never-expire tokens\nare allowed until an instance decides otherwise, so an upgrade changes\nnothing. Nil or 0 is no cap. A positive value refuses both a longer\nexpiry and a token with no expiry.\n\nIt is a policy on issuance, not on use: raising or lowering it never\ninvalidates a token that already exists.",
|
||||
"description": "APITokenMaxDays caps how long a newly created API token may live.\n\nA pointer for the same reason the retention fields are: absent must mean\nthe default, and the default here is no cap at all - never-expire tokens\nare allowed until an instance decides otherwise, so an upgrade changes\nnothing. Nil or 0 is no cap. A positive value refuses both a longer\nexpiry and a token with no expiry.\n\nIt is a policy on issuance, not on use: raising or lowering it never\ninvalidates a token that already exists.",
|
||||
"type": "integer"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"local_login_enabled": {
|
||||
"description": "LocalLoginEnabled is a pointer because it is absent on every settings\ndocument written before this feature existed, and a plain bool would read\nabsent as disabled — turning off password login for the entire fleet at\nupgrade. Nil means enabled.",
|
||||
"description": "LocalLoginEnabled is a pointer because it is absent on every settings\ndocument written before this feature existed, and a plain bool would read\nabsent as disabled - turning off password login for the entire fleet at\nupgrade. Nil means enabled.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
@@ -1970,7 +1970,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"fixed_in": {
|
||||
"description": "FixedIn empty means no vendor fix has been published. That is a real and\ncommon state and must never be conflated with \"not vulnerable\" — it is\nthe finding most in need of acceptance, since there is nothing to patch.",
|
||||
"description": "FixedIn empty means no vendor fix has been published. That is a real and\ncommon state and must never be conflated with \"not vulnerable\" - it is\nthe finding most in need of acceptance, since there is nothing to patch.",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
@@ -3319,7 +3319,7 @@
|
||||
},
|
||||
"/console/connect": {
|
||||
"post": {
|
||||
"description": "Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise.",
|
||||
"description": "Mints a one-time session token for the /console/tunnel websocket. Requires a live agent - answers 409 agent_offline otherwise.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -5645,7 +5645,7 @@
|
||||
},
|
||||
"/secrets/{group}/values": {
|
||||
"get": {
|
||||
"description": "Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.",
|
||||
"description": "Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings - a different credential from an API token, never substitutable for one.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Secret group name",
|
||||
@@ -6278,7 +6278,7 @@
|
||||
},
|
||||
"/servers/{id}/packages": {
|
||||
"get": {
|
||||
"description": "A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.",
|
||||
"description": "A server that has not reported yet answers reported=false rather than 404 - that is the normal state for the first hour after install.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Server ID",
|
||||
@@ -8627,7 +8627,7 @@
|
||||
},
|
||||
"/vulnerabilities": {
|
||||
"get": {
|
||||
"description": "Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.",
|
||||
"description": "Groups findings by CVE, most severe first - the same CVE on forty servers is one decision, not forty rows.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Filter by severity",
|
||||
@@ -8848,7 +8848,7 @@
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.",
|
||||
"description": "Requires a reason and a future expiry. Reopens automatically at expiry - permanent dismissal is never allowed.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Finding ID",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -70,7 +70,7 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
apiGroup.GET("/servers", listServers)
|
||||
// Static segment, registered alongside /servers/:id exactly as
|
||||
// /servers/new already is — gin resolves statics ahead of wildcards.
|
||||
// /servers/new already is - gin resolves statics ahead of wildcards.
|
||||
apiGroup.GET("/servers/tags", listKnownTags)
|
||||
apiGroup.POST("/servers", createServer)
|
||||
apiGroup.GET("/servers/new", newServer)
|
||||
@@ -132,7 +132,7 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
// handler answers every GET with a hardcoded 405, because a stateless
|
||||
// server has no session to open the server-to-client SSE stream against.
|
||||
// That 405 is the protocol-correct response for an MCP server that offers
|
||||
// no SSE leg — an unregistered GET would 404 instead, which a client reads
|
||||
// no SSE leg - an unregistered GET would 404 instead, which a client reads
|
||||
// as "no MCP endpoint here at all" rather than "this one is POST-only".
|
||||
// This route is not a working GET; it exists solely to produce that 405.
|
||||
mcpGroup := apiGroup.Group("/mcp", RequireFeature(license.FeatureMCP))
|
||||
@@ -576,11 +576,11 @@ func getKey(c *gin.Context) {
|
||||
all, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
|
||||
|
||||
// A tag-restricted token may legitimately hold a key that is also
|
||||
// assigned to a server outside its restriction — the key itself is
|
||||
// assigned to a server outside its restriction - the key itself is
|
||||
// still returned above. Only the assignment list is filtered, and
|
||||
// silently: an assignment whose Server is nil or out of scope is
|
||||
// dropped rather than kept with the hostname redacted, so the response
|
||||
// gives no signal — not even a count — of what was removed.
|
||||
// gives no signal - not even a count - of what was removed.
|
||||
scope := auth.ServerScope(c)
|
||||
assignments := make([]services.AssignmentWithServer, 0, len(all))
|
||||
for _, a := range all {
|
||||
@@ -778,7 +778,7 @@ func applyUpdates(c *gin.Context) {
|
||||
// downloads and installs the latest agent. Deliberately not in the generated
|
||||
// OpenAPI document: it is registered on the bare engine, not under the /api
|
||||
// group the document's BasePath assumes, so a @Router annotation here would
|
||||
// publish /api/update — a path that 404s — rather than the real top-level
|
||||
// publish /api/update - a path that 404s - rather than the real top-level
|
||||
// /update. It serves a shell script, not JSON, so there is nothing lost by
|
||||
// leaving it out of a JSON API reference.
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
//
|
||||
// /healthz is liveness: the process is up and serving. It touches nothing
|
||||
// external, because a Mongo outage must not make Kubernetes restart every
|
||||
// server pod — a restart loop cannot fix someone else's database, and it
|
||||
// server pod - a restart loop cannot fix someone else's database, and it
|
||||
// destroys every open command stream and console session on the way.
|
||||
//
|
||||
// /readyz is readiness: this pod can serve a request end to end, which needs
|
||||
|
||||
@@ -48,7 +48,7 @@ func licenceExempt(c *gin.Context) bool {
|
||||
// RequireActiveLicense blocks mutating requests when the licence is not valid.
|
||||
//
|
||||
// Mounted on the /api group, so a route added tomorrow is gated because of where
|
||||
// it lives rather than because someone remembered. GET and HEAD always pass —
|
||||
// it lives rather than because someone remembered. GET and HEAD always pass -
|
||||
// reading is never blocked.
|
||||
func RequireActiveLicense() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
const publicStatusRateLimit = 120
|
||||
|
||||
// RateLimitPublicStatus counts requests per client address in a one-minute
|
||||
// fixed window, exactly as RateLimitTokens does — including the part that
|
||||
// fixed window, exactly as RateLimitTokens does - including the part that
|
||||
// matters most: when Redis is unavailable it allows rather than denies. A
|
||||
// status page must survive the outage it exists to report.
|
||||
func RateLimitPublicStatus() gin.HandlerFunc {
|
||||
@@ -65,7 +65,7 @@ func RateLimitPublicStatus() gin.HandlerFunc {
|
||||
//
|
||||
// It carries no @Router annotation deliberately. openapi.json declares a
|
||||
// single server of "/api", so a @Router of /public/status/{pageId} would be
|
||||
// published as /api/public/status/{pageId} — a path that does not exist, and
|
||||
// published as /api/public/status/{pageId} - a path that does not exist, and
|
||||
// which would sit behind auth.Middleware if it did. The real address is:
|
||||
//
|
||||
// GET {scheme}://{instance-host}/public/status/{pageId}
|
||||
@@ -116,8 +116,8 @@ func getPublicStatusPage(c *gin.Context) {
|
||||
// so it is honoured only when the machine that opened the connection is one of
|
||||
// the configured trusted proxies.
|
||||
//
|
||||
// When the resulting host names no slug at all — vantage.acme.com,
|
||||
// status.acme.com, a bare IP — and the deployment is not cloud, the single
|
||||
// When the resulting host names no slug at all - vantage.acme.com,
|
||||
// status.acme.com, a bare IP - and the deployment is not cloud, the single
|
||||
// instance of that install is used. A self-hosted install has exactly one, and
|
||||
// without this every self-hosted status page 404s forever. More than one is a
|
||||
// refusal rather than a guess.
|
||||
|
||||
@@ -18,7 +18,7 @@ func runFixture() *models.WorkflowRun {
|
||||
|
||||
// A run document names every host it touched, hostname included. A restricted
|
||||
// caller must see only its own, and must be told some entries are missing
|
||||
// without being told how many — the targets_restricted precedent.
|
||||
// without being told how many - the targets_restricted precedent.
|
||||
func TestScopeRunHidesOutOfScopeServerRuns(t *testing.T) {
|
||||
got := scopeRun(runFixture(), map[string]bool{"stg-1": true}, true)
|
||||
if len(got.ServerRuns) != 1 || got.ServerRuns[0].ServerID != "stg-1" {
|
||||
@@ -45,7 +45,7 @@ func TestScopeRunLeavesUnrestrictedCallerWhole(t *testing.T) {
|
||||
}
|
||||
|
||||
// A restricted caller whose scope happens to cover the whole run must not be
|
||||
// told anything was hidden — the flag is about disclosure, not about being
|
||||
// told anything was hidden - the flag is about disclosure, not about being
|
||||
// restricted in general.
|
||||
func TestScopeRunNoFlagWhenNothingDropped(t *testing.T) {
|
||||
got := scopeRun(runFixture(), map[string]bool{"stg-1": true, "prod-1": true}, true)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// routeScopes maps a registered gin route — "<METHOD> <full path pattern>" — to
|
||||
// routeScopes maps a registered gin route - "<METHOD> <full path pattern>" - to
|
||||
// the scope an API token must hold to reach it.
|
||||
//
|
||||
// It is keyed on the route pattern rather than declared per route with a
|
||||
|
||||
@@ -40,7 +40,7 @@ func secretsReadAuth() gin.HandlerFunc {
|
||||
// esoGetGroup godoc
|
||||
//
|
||||
// @Summary Read a secret group's values (ESO)
|
||||
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.
|
||||
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings - a different credential from an API token, never substitutable for one.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
|
||||
@@ -10,7 +10,7 @@ const (
|
||||
scoped scopeDecl = iota
|
||||
// fleetWide: the route deliberately reaches the whole fleet. Every
|
||||
// fleetWide entry carries a comment giving the reason. It must never mean
|
||||
// "not scoped yet" — an unresolved gap belongs on a fix list, not here,
|
||||
// "not scoped yet" - an unresolved gap belongs on a fix list, not here,
|
||||
// because this value is read as a considered decision.
|
||||
fleetWide
|
||||
// exempt: the route touches no server-derived data at all. Every exempt
|
||||
@@ -38,14 +38,14 @@ const (
|
||||
// 1. It can only ever check that a DECLARATION EXISTS, never that the handler
|
||||
// honours it. "POST /api/workflows/:id/run" was declared scoped here while
|
||||
// services.TriggerWorkflow resolved its targets through the unscoped
|
||||
// ResolveTargets — a true entry that lied, boot-enforced, for the whole
|
||||
// ResolveTargets - a true entry that lied, boot-enforced, for the whole
|
||||
// life of the feature. A declaration is a claim a reviewer must verify,
|
||||
// not a property this file establishes.
|
||||
//
|
||||
// 2. /api/mcp is exempt at route level, and that is the honest answer rather
|
||||
// than an omission. One route serves roughly twenty tools of very
|
||||
// different shapes — some read no server data at all, some resolve one
|
||||
// host, some enumerate the fleet — so no single route-level value could
|
||||
// different shapes - some read no server data at all, some resolve one
|
||||
// host, some enumerate the fleet - so no single route-level value could
|
||||
// be true of all of them. The decision genuinely lives per tool, where
|
||||
// each tool that touches server data applies auth.ServerScope's selector
|
||||
// itself, and the registry's own tests are where that is enforced.
|
||||
@@ -68,8 +68,8 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"GET /api/servers/new": fleetWide,
|
||||
"POST /api/servers/new": fleetWide,
|
||||
|
||||
// KnownTags aggregates the tag *vocabulary* in use across the fleet — keys
|
||||
// and the values seen for them — never a server identifier or any other
|
||||
// KnownTags aggregates the tag *vocabulary* in use across the fleet - keys
|
||||
// and the values seen for them - never a server identifier or any other
|
||||
// server attribute, so it does not let a restricted token enumerate which
|
||||
// hosts exist. Filtering it would mean plumbing a selector through an
|
||||
// aggregation query for a leak that carries no server identity; ruled
|
||||
@@ -124,7 +124,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"POST /api/vulnerabilities/rescan": fleetWide,
|
||||
|
||||
// Accepting or reopening a finding names the finding, not a server, but a
|
||||
// finding does belong to one — so a restricted token can accept a finding
|
||||
// finding does belong to one - so a restricted token can accept a finding
|
||||
// on a host outside its scope if it learns the finding ID. It cannot learn
|
||||
// one through this API any more (every listing is now scoped), so this is
|
||||
// left fleet-wide rather than given a lookup of its own. Owner|admin only.
|
||||
@@ -143,7 +143,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// assignments whose server passes services.ServerInTokenScope before
|
||||
// returning it, so a restricted token cannot learn the hostname of an
|
||||
// out-of-scope server through a key it happens to also hold there. The
|
||||
// key document itself is still returned unfiltered — a token restricted
|
||||
// key document itself is still returned unfiltered - a token restricted
|
||||
// to staging may legitimately hold a key that is also assigned in prod,
|
||||
// and only the assignment list, not the key's existence, is the leak
|
||||
// this closes.
|
||||
@@ -152,7 +152,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// listKeys' services.ListKeys narrows each key's AssignedCount to
|
||||
// assignments on servers ServerInTokenScope admits, for the same reason
|
||||
// as getKey above: a nonzero count on a key a restricted token sees
|
||||
// nothing assigned to in its own scope is itself the leak — it tells the
|
||||
// nothing assigned to in its own scope is itself the leak - it tells the
|
||||
// token an assignment exists on a host it must not know about, without
|
||||
// naming the host.
|
||||
"GET /api/keys": scoped,
|
||||
@@ -169,7 +169,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"GET /api/keys/:id/private-key": exempt,
|
||||
|
||||
// Deleting a key removes it everywhere it is assigned, including on hosts
|
||||
// outside a restricted token's scope — the delete is of the key, not of a
|
||||
// outside a restricted token's scope - the delete is of the key, not of a
|
||||
// server, and there is no partial delete that leaves a key half-revoked.
|
||||
// Nothing about which hosts held it is disclosed by the call.
|
||||
"DELETE /api/keys/:id": fleetWide,
|
||||
@@ -179,7 +179,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// listWorkflows/getWorkflow narrow Workflow.TargetServerIDs to what the
|
||||
// caller's scope admits via services.VisibleServerIDs +
|
||||
// FilterVisibleServerIDs, wrapped in WorkflowResponse so the JSON field
|
||||
// name is unchanged. TargetTags is left untouched — the tag vocabulary
|
||||
// name is unchanged. TargetTags is left untouched - the tag vocabulary
|
||||
// itself is ruled acceptable to expose, unlike a resolved server ID.
|
||||
// TargetsRestricted is set (with no count) whenever at least one target
|
||||
// was dropped.
|
||||
@@ -191,7 +191,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// validate the ID-union-tags target set as a whole through
|
||||
// services.validateWorkflowTargetScope, which resolves the workflow's
|
||||
// targets both unscoped and scoped and refuses to save unless they match
|
||||
// — the same all-or-nothing rule the MCP create_workflow tool applies.
|
||||
// - the same all-or-nothing rule the MCP create_workflow tool applies.
|
||||
// Together these mean a restricted token can neither save a workflow
|
||||
// targeting a host or tag outside its scope (which the scheduler, firing
|
||||
// as the system, would otherwise run there) nor learn which IDs or tags
|
||||
@@ -203,12 +203,12 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// runWorkflow passes auth.ServerScope into services.TriggerWorkflow, which
|
||||
// resolves through ResolveTargetsScoped. Note the history: this entry read
|
||||
// scoped for the whole life of the feature while TriggerWorkflow called
|
||||
// the UNSCOPED ResolveTargets — see this file's header on what this
|
||||
// the UNSCOPED ResolveTargets - see this file's header on what this
|
||||
// assertion can and cannot prove.
|
||||
"POST /api/workflows/:id/run": scoped,
|
||||
|
||||
// getRun and listWorkflowRuns narrow WorkflowRun.ServerRuns — each entry
|
||||
// of which carries a ServerID and a Hostname — to what the caller's scope
|
||||
// getRun and listWorkflowRuns narrow WorkflowRun.ServerRuns - each entry
|
||||
// of which carries a ServerID and a Hostname - to what the caller's scope
|
||||
// admits, setting servers_restricted (a boolean, never a count) when any
|
||||
// entry was dropped.
|
||||
"GET /api/runs/:runId": scoped,
|
||||
@@ -224,7 +224,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// Deleting a workflow and cancelling a run both act on a definition rather
|
||||
// than on a server, and neither returns server data. Each can
|
||||
// nevertheless reach a definition whose targets a restricted token cannot
|
||||
// see — a cancel stops work on out-of-scope hosts. That reach is real but
|
||||
// see - a cancel stops work on out-of-scope hosts. That reach is real but
|
||||
// bounded: the caller learns nothing about which hosts are involved (both
|
||||
// /workflows listings are scoped), and a scope-narrowed variant of
|
||||
// "cancel this run" would have to either half-cancel a run or refuse one
|
||||
@@ -236,17 +236,17 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// Arming a schedule applies no scope check of its own, and that is safe
|
||||
// only because it has nothing left to check: CreateWorkflow and
|
||||
// UpdateWorkflow (internal/services/workflows.go) already refuse to save
|
||||
// a workflow whose resolved targets — TargetServerIDs union TargetTags —
|
||||
// a workflow whose resolved targets - TargetServerIDs union TargetTags -
|
||||
// reach outside the acting credential's scope, the same all-or-nothing
|
||||
// rule the MCP create_workflow tool applies. So a workflow written after
|
||||
// this check existed had its targets constrained to whichever scope wrote
|
||||
// it, and the scheduler firing it later with a nil token scope — acting
|
||||
// as the system, not as any caller — reaches nothing that write didn't
|
||||
// it, and the scheduler firing it later with a nil token scope - acting
|
||||
// as the system, not as any caller - reaches nothing that write didn't
|
||||
// already allow.
|
||||
//
|
||||
// This holds only for workflows written after the check was added. Rows
|
||||
// already in the database were saved under the old, unvalidated rule and
|
||||
// are never re-validated — neither this route nor the writers re-check an
|
||||
// are never re-validated - neither this route nor the writers re-check an
|
||||
// existing row's targets after the fact. A workflow saved before this fix
|
||||
// with an out-of-scope tag selector still schedules and fires exactly as
|
||||
// it did before.
|
||||
@@ -270,17 +270,17 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// listMonitors/getMonitor redact models.Monitor.Runner to
|
||||
// models.RunnerRestricted via services.RedactMonitorRunner when it names
|
||||
// a server outside the caller's scope — Runner is literally a server ID
|
||||
// a server outside the caller's scope - Runner is literally a server ID
|
||||
// for an agent-pushed monitor, so left unfiltered it discloses one
|
||||
// directly. The monitor itself is still returned: a restricted operator
|
||||
// may legitimately need to see that it exists and is up or down, so only
|
||||
// the runner field goes neutral. Runner "server" (control-plane-run) is
|
||||
// never touched — it names no server.
|
||||
// never touched - it names no server.
|
||||
"GET /api/monitors": scoped,
|
||||
"GET /api/monitors/:id": scoped,
|
||||
|
||||
// createMonitor/updateMonitor validate the runner — which is a server ID
|
||||
// for an agent-pushed monitor — through services.validateRunner, resolving
|
||||
// createMonitor/updateMonitor validate the runner - which is a server ID
|
||||
// for an agent-pushed monitor - through services.validateRunner, resolving
|
||||
// with GetServerScoped so a restricted token can neither point a check at
|
||||
// an out-of-scope agent nor use the not-found answer as an oracle.
|
||||
"POST /api/monitors": scoped,
|
||||
@@ -293,7 +293,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"DELETE /api/monitors/:id": fleetWide,
|
||||
|
||||
// A monitor's incidents, uptime rollups and recent samples are all about
|
||||
// the monitored endpoint — status, latency, timestamps — and carry no
|
||||
// the monitored endpoint - status, latency, timestamps - and carry no
|
||||
// server identifier at all; the runner is a field of the monitor
|
||||
// document, which these do not return.
|
||||
"GET /api/monitors/:id/incidents": exempt,
|
||||
@@ -302,7 +302,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// ---- notification channels ----
|
||||
|
||||
// A channel is an outbound destination — a webhook URL, an SMTP account.
|
||||
// A channel is an outbound destination - a webhook URL, an SMTP account.
|
||||
// Nothing about a server reaches these routes.
|
||||
"GET /api/channels": exempt,
|
||||
"POST /api/channels": exempt,
|
||||
@@ -328,7 +328,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// A status page pairs monitor IDs with per-page display names, and every
|
||||
// public read goes through services.assembleSnapshot, which is the
|
||||
// redaction boundary — its PublicComponent vocabulary has no field for a
|
||||
// redaction boundary - its PublicComponent vocabulary has no field for a
|
||||
// host, URL or runner. These authoring routes handle the page document
|
||||
// itself and never a server.
|
||||
"GET /api/status-pages": exempt,
|
||||
@@ -347,7 +347,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// Audit rows are a record of what people and tokens did, and a row's free
|
||||
// text detail can name a host in passing ("run <id> triggered", "key
|
||||
// assigned to web-01"). Filtering the log by tag would mean parsing those
|
||||
// strings, or dropping every row whose target this token cannot resolve —
|
||||
// strings, or dropping every row whose target this token cannot resolve -
|
||||
// which would hide a restricted token's own actions from itself the
|
||||
// moment a server is renamed or deleted. The log is left whole and
|
||||
// deliberately so: an audit trail with holes in it is worth less than the
|
||||
@@ -402,7 +402,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// AssertServerScopeMapComplete refuses to boot when any registered /api route
|
||||
// is missing from serverScopedRoutes. routes is every /api route the engine
|
||||
// registered — not a filtered subset — which is the whole point of the
|
||||
// registered - not a filtered subset - which is the whole point of the
|
||||
// inversion: a new route is checked by default rather than only when its path
|
||||
// happens to match a pattern somebody remembered to add.
|
||||
func AssertServerScopeMapComplete(routes []string) error {
|
||||
|
||||
@@ -32,7 +32,7 @@ func registerStatusPageRoutes(g *gin.RouterGroup) {
|
||||
|
||||
// statusPageError maps the service errors onto codes once, so ten handlers do
|
||||
// not each invent their own. services.ErrPageInvalid covers every validation
|
||||
// failure in the status page and incident services — a missing title or an
|
||||
// failure in the status page and incident services - a missing title or an
|
||||
// invalid incident status is a 400, not a 500.
|
||||
func statusPageError(c *gin.Context, err error) {
|
||||
switch {
|
||||
|
||||
@@ -87,7 +87,7 @@ func createToken(c *gin.Context) {
|
||||
|
||||
// A token-authenticated request may only mint a token whose scopes are a
|
||||
// subset of its own. Role is capped against the creating *user* below (in
|
||||
// services.CreateAPIToken), but a role cap alone does not confine scopes —
|
||||
// services.CreateAPIToken), but a role cap alone does not confine scopes -
|
||||
// without this, a CI token holding only settings:write could mint a token
|
||||
// holding keys:write and secrets:write, since minting only ever required
|
||||
// settings:write and never checked what the caller itself could reach. A
|
||||
|
||||
@@ -24,7 +24,7 @@ type LimitExceededResponse struct {
|
||||
}
|
||||
|
||||
// LicenceErrorResponse pairs an error with a machine-readable reason rather
|
||||
// than a code — used only on the two licence rejection paths that predate the
|
||||
// than a code - used only on the two licence rejection paths that predate the
|
||||
// error/code convention used everywhere else.
|
||||
type LicenceErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
@@ -115,7 +115,7 @@ type AgentVersionResponse struct {
|
||||
}
|
||||
|
||||
// WorkflowResponse is a workflow with its TargetServerIDs narrowed to what
|
||||
// the acting token's scope admits — the explicit field shadows the embedded
|
||||
// the acting token's scope admits - the explicit field shadows the embedded
|
||||
// one for JSON marshalling, matching the pattern KeyDetailResponse already
|
||||
// uses. TargetTags is not filtered: the tag vocabulary itself is ruled
|
||||
// acceptable to expose, and only the resolved ID list can name a specific
|
||||
@@ -134,7 +134,7 @@ type WorkflowResponse struct {
|
||||
// RunResponse is a workflow run with its ServerRuns narrowed to the servers
|
||||
// the acting token's scope admits. Each models.ServerRun carries both a
|
||||
// ServerID and a Hostname, so an unfiltered run document names every host it
|
||||
// touched — the same disclosure WorkflowResponse.TargetServerIDs closes one
|
||||
// touched - the same disclosure WorkflowResponse.TargetServerIDs closes one
|
||||
// level up, and the parent of the per-server log routes that were already
|
||||
// scoped.
|
||||
//
|
||||
|
||||
@@ -29,7 +29,7 @@ type vulnGroup struct {
|
||||
// listVulnerabilities godoc
|
||||
//
|
||||
// @Summary List vulnerabilities
|
||||
// @Description Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.
|
||||
// @Description Groups findings by CVE, most severe first - the same CVE on forty servers is one decision, not forty rows.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param severity query string false "Filter by severity"
|
||||
@@ -102,7 +102,7 @@ func groupByCVE(findings []models.VulnFinding) []vulnGroup {
|
||||
}
|
||||
|
||||
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
|
||||
// or malformed value, is no filter — a filter nobody asked for must never hide
|
||||
// or malformed value, is no filter - a filter nobody asked for must never hide
|
||||
// findings, and the wrong direction here hides the unfixable ones.
|
||||
func hasFixFromQuery(c *gin.Context) *bool {
|
||||
switch c.Query("has_fix") {
|
||||
@@ -199,7 +199,7 @@ type acceptFindingRequest struct {
|
||||
// acceptFinding godoc
|
||||
//
|
||||
// @Summary Accept a finding
|
||||
// @Description Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.
|
||||
// @Description Requires a reason and a future expiry. Reopens automatically at expiry - permanent dismissal is never allowed.
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -221,7 +221,7 @@ func acceptFinding(c *gin.Context) {
|
||||
|
||||
// Both rejected deliberately. An acceptance with no reason is a dismissal
|
||||
// nobody can audit, and one already expired is a permanent dismissal
|
||||
// wearing an expiry — the graveyard the expiry exists to prevent.
|
||||
// wearing an expiry - the graveyard the expiry exists to prevent.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a reason is required"})
|
||||
return
|
||||
@@ -313,7 +313,7 @@ func listServerVulnerabilities(c *gin.Context) {
|
||||
// getServerPackages godoc
|
||||
//
|
||||
// @Summary Get a server's package inventory
|
||||
// @Description A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.
|
||||
// @Description A server that has not reported yet answers reported=false rather than 404 - that is the normal state for the first hour after install.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
|
||||
@@ -151,7 +151,7 @@ func controlWorkload(c *gin.Context) {
|
||||
case errors.Is(err, services.ErrAgentNotConnected):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
case services.IsWorkloadProtected(err):
|
||||
// Nothing failed — the agent refused, which is the design. 409, not
|
||||
// Nothing failed - the agent refused, which is the design. 409, not
|
||||
// 500, and the reason is carried through.
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
|
||||
@@ -44,7 +44,7 @@ func githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oaut
|
||||
//
|
||||
// Verified alone is not enough: a non-primary address is one the person happens
|
||||
// to have proved, not the one they present as themselves. Primary alone is far
|
||||
// worse — an unverified address is not proof of control at all, and accepting
|
||||
// worse - an unverified address is not proof of control at all, and accepting
|
||||
// one would let anyone with a GitHub account claim any address in the instance.
|
||||
func selectGitHubEmail(emails []githubEmail) (string, error) {
|
||||
for _, e := range emails {
|
||||
|
||||
@@ -61,8 +61,8 @@ func hostSlug(host string) string {
|
||||
func HostSlug(host string) string { return hostSlug(host) }
|
||||
|
||||
// InstanceFromHost resolves the instance named by the request's own Host
|
||||
// header. Callers that must resolve a host from somewhere else — the public
|
||||
// status page reads a trusted X-Forwarded-Host — use InstanceForHost so the
|
||||
// header. Callers that must resolve a host from somewhere else - the public
|
||||
// status page reads a trusted X-Forwarded-Host - use InstanceForHost so the
|
||||
// slug rules and the 60s cache stay single-implementation.
|
||||
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
|
||||
return InstanceForHost(c.Request.Host)
|
||||
@@ -82,7 +82,7 @@ func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
if err != nil || inst == nil {
|
||||
// Negative entries are cached too. Without them an unknown but
|
||||
// well-formed host costs a Mongo query per anonymous request, which
|
||||
// the public status page exposes to the open internet — and the
|
||||
// the public status page exposes to the open internet - and the
|
||||
// round trip is itself a timing oracle separating "no such instance"
|
||||
// from "instance exists, page does not".
|
||||
storeInstance(slug, nil)
|
||||
@@ -93,7 +93,7 @@ func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
}
|
||||
|
||||
// SoleInstance resolves the one instance of a deployment that has exactly one.
|
||||
// It is how a self-hosted install serves a host that names no slug at all —
|
||||
// It is how a self-hosted install serves a host that names no slug at all -
|
||||
// vantage.acme.com, status.acme.com, or a bare address. It reuses the same
|
||||
// count-then-read that bootstrap uses, and refuses rather than guessing when
|
||||
// more than one instance exists.
|
||||
|
||||
@@ -89,7 +89,7 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
// HandleListPublicProviders is unauthenticated: it is what the login page reads
|
||||
// to decide what to draw. It carries no issuer, no client ID and no secret —
|
||||
// to decide what to draw. It carries no issuer, no client ID and no secret -
|
||||
// only what a button needs, because anyone who can reach the login page can
|
||||
// read this.
|
||||
func HandleListPublicProviders(c *gin.Context) {
|
||||
|
||||
@@ -104,7 +104,7 @@ func sessionFromCookie(c *gin.Context) (*Session, bool) {
|
||||
}
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
// A stale cookie plus a valid bearer token is a real combination —
|
||||
// A stale cookie plus a valid bearer token is a real combination -
|
||||
// a browser tab left open beside a curl. Fall through rather than
|
||||
// refusing a credential that would have worked.
|
||||
if bearerToken(c) != "" {
|
||||
@@ -203,7 +203,7 @@ func IsToken(c *gin.Context) bool { return TokenID(c) != "" }
|
||||
|
||||
// ServerScope is the tag restriction the acting credential carries, or nil for
|
||||
// an unrestricted token and for every cookie session. Callers pass it to
|
||||
// services.ServerInTokenScope or services.IntersectSelectors — nil means the
|
||||
// services.ServerInTokenScope or services.IntersectSelectors - nil means the
|
||||
// whole fleet, never nothing.
|
||||
func ServerScope(c *gin.Context) map[string]string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
|
||||
@@ -44,7 +44,7 @@ var presets = []Preset{
|
||||
Kind: models.KindOIDC,
|
||||
IssuerFormat: "https://%s/oauth2/default",
|
||||
InputLabel: "Okta org domain",
|
||||
InputHint: "e.g. acme.okta.com — no scheme, no trailing slash.",
|
||||
InputHint: "e.g. acme.okta.com - no scheme, no trailing slash.",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ type Session struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
// The four fields below are set only when the request authenticated with
|
||||
// an API token. They are never persisted to Redis — a token authenticates
|
||||
// an API token. They are never persisted to Redis - a token authenticates
|
||||
// per request and mints no session, so a revoked token stops working
|
||||
// immediately rather than at the end of a session TTL.
|
||||
TokenID string `json:"-"`
|
||||
@@ -38,7 +38,7 @@ var rdb *redis.Client
|
||||
// InitRedis connects the session store.
|
||||
//
|
||||
// Username and password may both be empty for an unauthenticated instance. For
|
||||
// a legacy `requirepass` Redis, pass the password with an empty username —
|
||||
// a legacy `requirepass` Redis, pass the password with an empty username -
|
||||
// go-redis then sends AUTH with one argument instead of two.
|
||||
func InitRedis(addr, username, password string) error {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
//
|
||||
// Everything here is deliberately best-effort delivery with an explicit ack
|
||||
// rather than a queue. A command whose owner pod died between the presence
|
||||
// check and the publish must fail loudly and immediately — the caller answers
|
||||
// 503 and the operator retries — not sit in a queue waiting for a stream that
|
||||
// check and the publish must fail loudly and immediately - the caller answers
|
||||
// 503 and the operator retries - not sit in a queue waiting for a stream that
|
||||
// no longer exists.
|
||||
package bus
|
||||
|
||||
@@ -97,7 +97,7 @@ const (
|
||||
//
|
||||
// The listener cannot be bound in advance on any particular pod. An agent's
|
||||
// ProxyStream is a separate HTTP/2 request from its CommandStream, and an
|
||||
// L7 proxy (Traefik) balances requests, not connections — so it may land on
|
||||
// L7 proxy (Traefik) balances requests, not connections - so it may land on
|
||||
// any replica, not the one holding the command stream. The pod it does land
|
||||
// on binds the listener and announces it here.
|
||||
ProxyAddrChannel = prefix + "proxyaddr:"
|
||||
@@ -295,7 +295,7 @@ func SetPendingProxy(ctx context.Context, proxyID, instanceID, serverID string,
|
||||
// round trips: single use is the whole security property, and two agents
|
||||
// racing the same proxy_id must not both be served.
|
||||
//
|
||||
// A missing record is reported as "", "" rather than an error — an unknown
|
||||
// A missing record is reported as "", "" rather than an error - an unknown
|
||||
// proxy_id, an expired one and a second claim are all the same refusal.
|
||||
func ClaimPendingProxy(ctx context.Context, proxyID string) (instanceID, serverID string) {
|
||||
v, err := claimPending.Run(ctx, rdb, []string{ProxyPendingKey + proxyID}).Text()
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
// a race anyone wins.
|
||||
//
|
||||
// Redis rather than a Kubernetes Lease so that Docker Compose, which has no
|
||||
// API server, takes the identical code path — one implementation to reason
|
||||
// API server, takes the identical code path - one implementation to reason
|
||||
// about, not two.
|
||||
//
|
||||
// job is given a context cancelled the moment leadership is lost, and must
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error {
|
||||
// testable without a real stream.
|
||||
//
|
||||
// The listener is bound here, on whichever replica the stream reached, rather
|
||||
// than in advance on the pod holding the agent's command stream — those are not
|
||||
// than in advance on the pod holding the agent's command stream - those are not
|
||||
// the same pod, because an L7 proxy balances HTTP/2 requests independently.
|
||||
func serveProxy(open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error {
|
||||
sess, err := services.ClaimProxyStream(instanceID, open.ServerId, open.ProxyId)
|
||||
|
||||
@@ -168,7 +168,7 @@ func (s *vantageServer) ReportPackages(ctx context.Context, req *pb.ReportPackag
|
||||
//
|
||||
// It is not gated by licence: the workload registry reads as core fleet
|
||||
// management rather than a premium add-on. If that ever changes, the check
|
||||
// belongs here — gating collection, not display — for the same reason it does
|
||||
// belongs here - gating collection, not display - for the same reason it does
|
||||
// in ReportPackages.
|
||||
func (s *vantageServer) ReportWorkloads(ctx context.Context, req *pb.ReportWorkloadsRequest) (*pb.ReportWorkloadsResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
@@ -304,7 +304,7 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
|
||||
// Serve claims this agent's presence on the bus and subscribes this pod to
|
||||
// its command channel, so a dispatch issued by any other replica arrives
|
||||
// here. The teardown releases both — an agent that reconnects to a
|
||||
// here. The teardown releases both - an agent that reconnects to a
|
||||
// different pod must not leave this one advertising a stream it no longer
|
||||
// has.
|
||||
ch, release := services.Dispatcher.Serve(stream.Context(), srv.ServerID)
|
||||
@@ -422,7 +422,7 @@ const pingSummaryInterval = 5 * time.Minute
|
||||
// presence claim, released by a deferred call that a killed process never runs.
|
||||
// The claim then outlives its owner for the remainder of its 30s TTL, during
|
||||
// which dispatch believes the agent is reachable, publishes to a channel with
|
||||
// no subscriber, and fails as "agent offline" — a pod that has already exited
|
||||
// no subscriber, and fails as "agent offline" - a pod that has already exited
|
||||
// still answering for an agent it can no longer reach.
|
||||
func StartGRPC(port int) (stop func(), err error) {
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
@@ -458,7 +458,7 @@ func StartGRPC(port int) (stop func(), err error) {
|
||||
//
|
||||
// It is bounded: an idle CommandStream returns as soon as its context is
|
||||
// cancelled, but a console relay mid-transfer would otherwise hold the
|
||||
// process past the pod's grace period and earn a SIGKILL — which is the
|
||||
// process past the pod's grace period and earn a SIGKILL - which is the
|
||||
// abrupt exit this exists to avoid.
|
||||
return func() {
|
||||
done := make(chan struct{})
|
||||
|
||||
@@ -35,7 +35,7 @@ var ErrConfirmRequired = errors.New("confirmation required")
|
||||
var ErrOutOfScope = errors.New("targets outside token scope")
|
||||
|
||||
// logEvent is services.LogEvent behind a package variable so tests can
|
||||
// observe what would have been audited without a live database connection —
|
||||
// observe what would have been audited without a live database connection -
|
||||
// services.LogEvent talks straight to Mongo via db.Col, which panics on a nil
|
||||
// client outside a real boot.
|
||||
var logEvent = services.LogEvent
|
||||
@@ -117,7 +117,7 @@ func LogDenied(c Caller, toolName, gate string) {
|
||||
}
|
||||
|
||||
// LogFailure records a write tool call that reached a service and that
|
||||
// service returned an error — as opposed to LogDenied, which records a
|
||||
// service returned an error - as opposed to LogDenied, which records a
|
||||
// policy refusal that never reached one. Distinguishing the two in
|
||||
// audit_logs is what lets a human reading it tell "the agent was stopped"
|
||||
// from "the agent tried and the machine failed".
|
||||
@@ -130,7 +130,7 @@ func LogFailure(c Caller, t Tool, args map[string]any, err error) {
|
||||
//
|
||||
// It is a distinct event type rather than another mcp.tool_call row because of
|
||||
// the question a human will actually ask, which is "what has this agent added
|
||||
// to my instance" — an answer buried among hundreds of read rows is not an
|
||||
// to my instance" - an answer buried among hundreds of read rows is not an
|
||||
// answer.
|
||||
func LogCreated(c Caller, kind, id, name string) {
|
||||
logEvent(c.InstanceID, "mcp.created", c.TokenName, "", "",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// It is a presentation layer over the service layer and introduces no authority
|
||||
// of its own: every tool calls the same service functions the REST handlers
|
||||
// call, and every decision about who may do what is made by machinery that
|
||||
// already exists. Three gates apply to every call — the licence feature, the
|
||||
// mcp:* scope, and the tool's own resource scope — and all three must pass.
|
||||
// already exists. Three gates apply to every call - the licence feature, the
|
||||
// mcp:* scope, and the tool's own resource scope - and all three must pass.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -50,7 +50,7 @@ const (
|
||||
// what tagArg decodes.
|
||||
ArgTagMap ArgType = "tag_map"
|
||||
// ArgObject is a free-form object whose inner shape the tool documents in
|
||||
// the argument description — create_monitor's target, whose fields differ
|
||||
// the argument description - create_monitor's target, whose fields differ
|
||||
// per monitor type.
|
||||
ArgObject ArgType = "object"
|
||||
)
|
||||
@@ -83,7 +83,7 @@ type Tool struct {
|
||||
Write bool
|
||||
// Args declares every argument the handler reads, in the order a client
|
||||
// should see them. A tool taking none declares an empty slice, which is
|
||||
// distinct from "nobody has written the schema yet" — see the registry
|
||||
// distinct from "nobody has written the schema yet" - see the registry
|
||||
// tests, which require the declaration to be deliberate.
|
||||
Args []ToolArg
|
||||
// TouchesServers marks a tool that returns or acts on server-derived data:
|
||||
@@ -92,7 +92,7 @@ type Tool struct {
|
||||
// ResolveTargetsScoped, ListServersFiltered or VisibleServerIDs.
|
||||
//
|
||||
// Like serverScopedRoutes in the api package, this can only ever assert
|
||||
// that a declaration exists, never that the handler honours it — get_run_logs
|
||||
// that a declaration exists, never that the handler honours it - get_run_logs
|
||||
// proved the run's instance and the server's membership in the run and then
|
||||
// read production stdout for a staging token. What it does buy is that
|
||||
// adding a tool forces an answer to "does this touch server data?", and the
|
||||
|
||||
@@ -72,8 +72,8 @@ func TestInputSchemaMarshals(t *testing.T) {
|
||||
// serverTouchingTools names every tool that returns or acts on server-derived
|
||||
// data. The test below pins the registry against it, so a tool added that
|
||||
// reads a hostname, a server ID, a package list or a run's per-server output
|
||||
// fails until somebody declares TouchesServers and — the point of the exercise
|
||||
// — decides how it applies Caller.TokenScope.
|
||||
// fails until somebody declares TouchesServers and - the point of the exercise
|
||||
// - decides how it applies Caller.TokenScope.
|
||||
//
|
||||
// This is the assertion that would have caught get_run_logs, which proved the
|
||||
// run's instance and the named server's membership in the run and then read
|
||||
|
||||
@@ -141,7 +141,7 @@ func buildMonitor(args map[string]any) (models.Monitor, error) {
|
||||
// named agent, and a silently ignored argument would leave a model
|
||||
// believing it had. services.CreateMonitor now validates a runner
|
||||
// through GetServerScoped as well, so this is a second line rather than
|
||||
// the only one — but the clearer answer belongs here.
|
||||
// the only one - but the clearer answer belongs here.
|
||||
if _, present := args["runner"]; present {
|
||||
return models.Monitor{}, fmt.Errorf("runner cannot be set from here; monitors created this way always run on the control plane")
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func init() {
|
||||
Write: true,
|
||||
Scope: "workflows:write",
|
||||
Description: "Create a reusable workflow step: a named script with an interpreter. " +
|
||||
"The step is SAVED to this Vantage instance but is not run by creating it — " +
|
||||
"The step is SAVED to this Vantage instance but is not run by creating it - " +
|
||||
"add it to a workflow with create_workflow, then run that with run_workflow. " +
|
||||
"Steps created this way cannot reference secrets.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// Runner in particular is not merely omitted as noise: for an agent-pushed
|
||||
// monitor it is literally a server ID, and REST's listMonitors/getMonitor
|
||||
// redact it to models.RunnerRestricted when that server is outside the
|
||||
// caller's scope. This projection never had a runner field to redact — the
|
||||
// caller's scope. This projection never had a runner field to redact - the
|
||||
// same outcome, reached by never including it rather than by filtering it
|
||||
// out, so this tool and get_monitor_status cannot disagree with the REST
|
||||
// surface about what a restricted token learns.
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestServerSummaryStaysSmall(t *testing.T) {
|
||||
}
|
||||
|
||||
// The real status vocabulary is "pending" / "active" / "offline" (see
|
||||
// internal/services/servers.go) — "online" is never assigned anywhere. A
|
||||
// internal/services/servers.go) - "online" is never assigned anywhere. A
|
||||
// server carrying the live status ("active") must project as Online: true,
|
||||
// or list_servers/get_server misreport the entire fleet as down.
|
||||
func TestSummariseServerReportsActiveAsOnline(t *testing.T) {
|
||||
|
||||
@@ -39,7 +39,7 @@ type workflowDetail struct {
|
||||
Tags map[string]string `json:"target_tags,omitempty"`
|
||||
Schedule string `json:"schedule,omitempty"`
|
||||
// TargetsRestricted is set, with no count, when Targets omits at least
|
||||
// one server ID outside this token's scope — mirroring
|
||||
// one server ID outside this token's scope - mirroring
|
||||
// WorkflowResponse's REST field, so a model reading this alongside a
|
||||
// run_workflow refusal for the same workflow is not left to conclude the
|
||||
// refusal invented a problem this tool never mentioned.
|
||||
@@ -341,7 +341,7 @@ func init() {
|
||||
//
|
||||
// The refusal reuses the membership message verbatim so that
|
||||
// "in the run but out of your scope" and "not in the run at all"
|
||||
// are indistinguishable — otherwise the difference between the
|
||||
// are indistinguishable - otherwise the difference between the
|
||||
// two answers enumerates hosts the token cannot see.
|
||||
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
|
||||
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
|
||||
@@ -461,7 +461,7 @@ func init() {
|
||||
}
|
||||
|
||||
// Go randomises map iteration order, so truncating a ranged map
|
||||
// to a page made two identical calls return different CVEs — a
|
||||
// to a page made two identical calls return different CVEs - a
|
||||
// model comparing its own two answers would see the fleet change
|
||||
// under it. Sorting by CVE ID (then package, since the key is a
|
||||
// pair) makes the page deterministic.
|
||||
@@ -541,7 +541,7 @@ func init() {
|
||||
},
|
||||
TouchesServers: true,
|
||||
Scope: "vulns:read",
|
||||
Description: "Search every server's installed packages by name across the whole fleet — " +
|
||||
Description: "Search every server's installed packages by name across the whole fleet - " +
|
||||
"answers questions like \"which hosts still run OpenSSL 1.1\". version_below is not " +
|
||||
"currently supported: filtering package versions correctly requires knowing each " +
|
||||
"distribution's own version-ordering scheme (dpkg/rpm/apk), which this tool cannot " +
|
||||
@@ -555,7 +555,7 @@ func init() {
|
||||
if stringArg(args, "version_below") != "" {
|
||||
return nil, fmt.Errorf("version_below is not supported: correct version ordering " +
|
||||
"depends on each host's distribution (dpkg/rpm/apk each order differently), " +
|
||||
"which this tool cannot resolve here — omit version_below and every matching " +
|
||||
"which this tool cannot resolve here - omit version_below and every matching " +
|
||||
"install is returned instead")
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ func init() {
|
||||
// This tool reads no arguments at all.
|
||||
Args: []ToolArg{},
|
||||
Scope: "secrets:read",
|
||||
Description: "List secret group and key names on this instance. Metadata only — no " +
|
||||
Description: "List secret group and key names on this instance. Metadata only - no " +
|
||||
"tool ever returns a secret's plaintext value to a model.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
groups, err := services.ListSecretGroups(c.InstanceID)
|
||||
|
||||
@@ -43,8 +43,8 @@ type runStartedResult struct {
|
||||
// workflow's own configured target_server_ids/target_tags via
|
||||
// services.ResolveTargets (unscoped) and runs against exactly that set. There
|
||||
// is no per-call server_ids/tags override to plumb through, so this tool takes
|
||||
// only workflow_id. To keep the token's scope meaningful — TriggerWorkflow
|
||||
// itself does not consult it — this handler first loads the workflow and
|
||||
// only workflow_id. To keep the token's scope meaningful - TriggerWorkflow
|
||||
// itself does not consult it - this handler first loads the workflow and
|
||||
// resolves its configured targets through ResolveTargetsScoped with the
|
||||
// caller's TokenScope, and refuses the run outright if that scoped view does
|
||||
// not cover every server the unscoped resolution would touch. That is the
|
||||
@@ -61,7 +61,7 @@ func init() {
|
||||
Write: true,
|
||||
Scope: "workflows:write",
|
||||
Description: "Run a workflow against the servers it is already configured to target " +
|
||||
"(its saved server list and tags — this call does not let you pick different " +
|
||||
"(its saved server list and tags - this call does not let you pick different " +
|
||||
"targets). This EXECUTES COMMANDS on real machines and cannot be undone from " +
|
||||
"here. Returns a run ID immediately; poll get_run for progress and get_run_logs " +
|
||||
"for output. Refused if the workflow's targets reach outside this token's own " +
|
||||
@@ -121,7 +121,7 @@ type cancelledResult struct {
|
||||
// cancel_run. The REST cancel route (workflows.go's cancelRun) calls
|
||||
// services.CancelRun(instanceID, runID) directly; that call is already scoped
|
||||
// to the caller's instance by instanceID, which is what "verifies the run
|
||||
// belongs to the caller's instance" reduces to here — there is no separate
|
||||
// belongs to the caller's instance" reduces to here - there is no separate
|
||||
// per-server scope to check, since cancelling touches the run record, not a
|
||||
// server.
|
||||
func init() {
|
||||
@@ -164,7 +164,7 @@ type updateBatchResult struct {
|
||||
// There is no fleet-wide variant of that service call to invoke once, so this
|
||||
// tool resolves the requested targets through ResolveTargetsScoped exactly as
|
||||
// the brief describes, then calls the same DispatchApplyUpdates the REST route
|
||||
// calls, once per resolved server — the identical dispatch, just looped
|
||||
// calls, once per resolved server - the identical dispatch, just looped
|
||||
// instead of hardcoded to one server_id from the URL.
|
||||
func init() {
|
||||
All().Register(Tool{
|
||||
@@ -275,12 +275,12 @@ type assignKeyResult struct {
|
||||
|
||||
// assign_key. The REST route (handlers.go's assignKey) takes one server_id in
|
||||
// the body and calls services.AssignKey(instanceID, keyID, serverID) directly
|
||||
// — AssignKey itself resolves the server with the unscoped services.GetServer,
|
||||
// - AssignKey itself resolves the server with the unscoped services.GetServer,
|
||||
// not GetServerScoped, so the REST route carries no token-scope check of its
|
||||
// own (session auth has no server-scope restriction; only API tokens do). For
|
||||
// the MCP surface, this tool resolves every named target through
|
||||
// ResolveTargetsScoped first — the same chokepoint every other target-
|
||||
// resolving write tool goes through — so a token whose scope excludes a server
|
||||
// ResolveTargetsScoped first - the same chokepoint every other target-
|
||||
// resolving write tool goes through - so a token whose scope excludes a server
|
||||
// cannot reach it here even though the REST handler's own server lookup would
|
||||
// not have stopped it. Then it calls the identical AssignKey once per resolved
|
||||
// server.
|
||||
@@ -298,7 +298,7 @@ func init() {
|
||||
Scope: "keys:write",
|
||||
Description: "Assign an SSH key to real servers, selected by server_ids and/or " +
|
||||
"tags. The agent rewrites /root/.ssh/authorized_keys on each targeted machine " +
|
||||
"and this cannot be undone from here — use revoke to remove it afterward.",
|
||||
"and this cannot be undone from here - use revoke to remove it afterward.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
keyID := stringArg(args, "key_id")
|
||||
if keyID == "" {
|
||||
|
||||
@@ -31,7 +31,7 @@ func callerFromContext(c *gin.Context) Caller {
|
||||
// because a stateless server has no session to open the server-to-client SSE
|
||||
// stream against. The GET route is still registered deliberately (see
|
||||
// handlers.go) so a client probing for the endpoint sees a protocol-correct
|
||||
// 405 rather than gin's 404 — the MCP spec expects exactly that response from
|
||||
// 405 rather than gin's 404 - the MCP spec expects exactly that response from
|
||||
// a server that does not offer the GET/SSE leg. Nothing here should route GET
|
||||
// requests differently or try to make them do anything else.
|
||||
func Handler() gin.HandlerFunc {
|
||||
@@ -69,7 +69,7 @@ func Handler() gin.HandlerFunc {
|
||||
func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) {
|
||||
// InputSchema is set explicitly rather than inferred from the handler's
|
||||
// argument type. The SDK can infer one from a typed In parameter, which is
|
||||
// cleaner where it fits — but every ToolFunc here takes map[string]any, and
|
||||
// cleaner where it fits - but every ToolFunc here takes map[string]any, and
|
||||
// inference over that yields a bare open object saying nothing. Giving each
|
||||
// tool its own Go argument struct would mean twenty-odd structs and a
|
||||
// generic registry that could no longer hold them in one map, losing the
|
||||
@@ -86,8 +86,8 @@ func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) {
|
||||
}
|
||||
|
||||
// callTool is the gate check, dispatch and audit write registerSDKTool wraps
|
||||
// onto the SDK's call signature. It is a separate function — rather than the
|
||||
// closure body inline — so it can be exercised directly in tests without
|
||||
// onto the SDK's call signature. It is a separate function - rather than the
|
||||
// closure body inline - so it can be exercised directly in tests without
|
||||
// standing up an sdk.Server and driving a real MCP request through it.
|
||||
func callTool(ctx context.Context, tool Tool, caller Caller, args map[string]any) (*sdk.CallToolResult, any, error) {
|
||||
if ok, gate := Allowed(tool, caller); !ok {
|
||||
@@ -98,14 +98,14 @@ func callTool(ctx context.Context, tool Tool, caller Caller, args map[string]any
|
||||
out, err := tool.Handler(ctx, caller, args)
|
||||
if err != nil {
|
||||
// A write tool's own handler never gets a chance to audit its own
|
||||
// refusal or failure — it returns before reaching its LogCall, and
|
||||
// refusal or failure - it returns before reaching its LogCall, and
|
||||
// unlike a successful write, this layer does not know a resolved
|
||||
// server count to pass along anyway. So every write failure is
|
||||
// audited here instead: a policy refusal (fan-out or tag scope) as
|
||||
// mcp.tool_denied naming the gate, everything else as
|
||||
// mcp.tool_failed, so a human reading audit_logs can tell "the agent
|
||||
// was stopped" from "the agent tried and the machine failed". Read
|
||||
// tools are unaffected — a failed read was never going to change
|
||||
// tools are unaffected - a failed read was never going to change
|
||||
// anything and carries no gate to name.
|
||||
if tool.Write {
|
||||
switch {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// TestRefusedWriteIsAudited exercises the real dispatch path (callTool, which
|
||||
// registerSDKTool wraps) for a write tool whose handler refuses the call
|
||||
// before it ever reaches its own LogCall — a fan-out refusal, in this case,
|
||||
// before it ever reaches its own LogCall - a fan-out refusal, in this case,
|
||||
// which run_workflow, apply_updates, update_agent and assign_key all reach
|
||||
// the same way via CheckFanOut. The refusal must still produce an audit row:
|
||||
// a blocked mutation attempt is the single most audit-worthy event a write
|
||||
@@ -77,8 +77,8 @@ func TestOutOfScopeWriteIsAudited(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestServiceFailureIsAuditedDistinctly makes sure a write tool failing for a
|
||||
// reason that is not a policy refusal — the underlying service call itself
|
||||
// erroring — is still audited, but as mcp.tool_failed rather than
|
||||
// reason that is not a policy refusal - the underlying service call itself
|
||||
// erroring - is still audited, but as mcp.tool_failed rather than
|
||||
// mcp.tool_denied, so a human reading audit_logs can tell the two apart.
|
||||
func TestServiceFailureIsAuditedDistinctly(t *testing.T) {
|
||||
var events []string
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
//
|
||||
// The plaintext is shown once at creation and never stored: only TokenHash,
|
||||
// which is sha256 hex of the value, exactly as servers.agent_token_hash and the
|
||||
// ESO read token already are. bcrypt is deliberately not used — the value is
|
||||
// ESO read token already are. bcrypt is deliberately not used - the value is
|
||||
// full-entropy random rather than a chosen password, and a per-token salt would
|
||||
// force a collection scan where an indexed lookup is wanted.
|
||||
//
|
||||
|
||||
@@ -22,7 +22,7 @@ const RedactedSecret = "••••••••"
|
||||
// channelSecretKeys names, per channel type, the config entries that are
|
||||
// credentials rather than settings. A Slack or Discord webhook URL is on this
|
||||
// list because possession of the URL *is* the authorisation to post to that
|
||||
// channel — there is nothing else to steal.
|
||||
// channel - there is nothing else to steal.
|
||||
var channelSecretKeys = map[string][]string{
|
||||
ChannelWebhook: {"url"},
|
||||
ChannelSlack: {"url"},
|
||||
|
||||
@@ -23,8 +23,8 @@ const RunnerServer = "server"
|
||||
|
||||
// RunnerRestricted replaces a monitor's runner in an API response when the
|
||||
// real value is a server ID the acting token's scope does not admit. The
|
||||
// monitor itself is still returned — a restricted operator may legitimately
|
||||
// need to see its name and state — only where it runs is hidden, the same
|
||||
// monitor itself is still returned - a restricted operator may legitimately
|
||||
// need to see its name and state - only where it runs is hidden, the same
|
||||
// way a workflow's target list can omit an ID without the whole workflow
|
||||
// disappearing from a list.
|
||||
const RunnerRestricted = "restricted"
|
||||
|
||||
@@ -63,7 +63,7 @@ type VulnFinding struct {
|
||||
PackageName string `bson:"package_name" json:"package_name"`
|
||||
Installed string `bson:"installed_version" json:"installed_version"`
|
||||
// FixedIn empty means no vendor fix has been published. That is a real and
|
||||
// common state and must never be conflated with "not vulnerable" — it is
|
||||
// common state and must never be conflated with "not vulnerable" - it is
|
||||
// the finding most in need of acceptance, since there is nothing to patch.
|
||||
FixedIn string `bson:"fixed_in,omitempty" json:"fixed_in,omitempty"`
|
||||
Severity string `bson:"severity" json:"severity"`
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// dispatchSMTP delivers a state change over one channel's own SMTP settings.
|
||||
//
|
||||
// The transport, the envelope and the look of the message all live in
|
||||
// shared/mail, which admin and sitesvc use too — a Vantage alert and a Vantage
|
||||
// shared/mail, which admin and sitesvc use too - a Vantage alert and a Vantage
|
||||
// licence email should not look like they came from different products. This
|
||||
// function only turns a channel document into a Sender.
|
||||
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
|
||||
|
||||
@@ -16,7 +16,7 @@ const TypeVuln = "vulnerability"
|
||||
//
|
||||
// One per rule per scan, never one per finding: a database refresh can open
|
||||
// several hundred at once, and a message each would rate-limit the webhook or
|
||||
// get the channel muted — either way the alerts stop being read.
|
||||
// get the channel muted - either way the alerts stop being read.
|
||||
type VulnDigest struct {
|
||||
InstanceName string
|
||||
RuleName string
|
||||
@@ -74,7 +74,7 @@ func vulnLines(d VulnDigest) string {
|
||||
if r.FixedIn != "" {
|
||||
fix = "fixed in " + r.FixedIn
|
||||
}
|
||||
s += fmt.Sprintf("\n• %s (%s) — %s on %s, %s", r.CVEID, r.Severity, r.PackageName, r.ServerName, fix)
|
||||
s += fmt.Sprintf("\n• %s (%s) - %s on %s, %s", r.CVEID, r.Severity, r.PackageName, r.ServerName, fix)
|
||||
}
|
||||
if d.More > 0 {
|
||||
s += fmt.Sprintf("\n…and %d more.", d.More)
|
||||
|
||||
@@ -34,4 +34,4 @@ func NewID() (string, error) {
|
||||
//
|
||||
// The pending record lives in Redis instead (bus.SetPendingProxy /
|
||||
// ClaimPendingProxy), and the listener is bound by whichever pod the stream
|
||||
// actually reaches — see services.ClaimProxyStream.
|
||||
// actually reaches - see services.ClaimProxyStream.
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *Session) Reason() string {
|
||||
// a no-op once a deliberate teardown (Close) has begun: a local Close closing
|
||||
// the conn out from under the relay goroutines produces exactly the kind of
|
||||
// error (net.ErrClosed, a broken pipe on write, ...) that looks like a remote
|
||||
// failure but is not one, and must not overwrite — or race to set — the real
|
||||
// failure but is not one, and must not overwrite - or race to set - the real
|
||||
// reason, or invent one where a clean local close has none.
|
||||
func (s *Session) setReason(r string) {
|
||||
s.mu.Lock()
|
||||
@@ -103,7 +103,7 @@ func (s *Session) setReason(r string) {
|
||||
// Close tears the session down once. A non-empty reason is recorded only if no
|
||||
// reason has been recorded already, and only before teardown begins. It closes
|
||||
// both the listener and, if a connection has already been accepted, that
|
||||
// connection too — an unconditional kill for the whole relay chain regardless
|
||||
// connection too - an unconditional kill for the whole relay chain regardless
|
||||
// of which stage it is in.
|
||||
func (s *Session) Close(reason string) {
|
||||
if reason != "" {
|
||||
|
||||
@@ -35,15 +35,15 @@ func LogEvent(instanceID, eventType, actor, serverID, keyID, details string) {
|
||||
// AuditFilter narrows a page of the audit log.
|
||||
//
|
||||
// Filtering is done here rather than in the browser because the audit log is
|
||||
// the one collection deliberately kept for months — audit_retention_days is a
|
||||
// licensed entitlement — and it is read to answer questions about the past
|
||||
// the one collection deliberately kept for months - audit_retention_days is a
|
||||
// licensed entitlement - and it is read to answer questions about the past
|
||||
// ("who removed that key in March"). A browser filtering the most recent 200
|
||||
// rows would answer "no results" for an event that exists, which is worse than
|
||||
// having no search at all.
|
||||
type AuditFilter struct {
|
||||
// Search matches actor, details or event type, case-insensitively.
|
||||
Search string
|
||||
// Category matches the segment before the first dot in an event type —
|
||||
// Category matches the segment before the first dot in an event type -
|
||||
// "workflow", "key", "server". Event types are named consistently enough
|
||||
// that the prefix is a real grouping rather than a guess.
|
||||
Category string
|
||||
|
||||
@@ -44,7 +44,7 @@ func StartAuditSweeper(ctx context.Context) {
|
||||
// process restarts.
|
||||
//
|
||||
// It skips an instance whose licence is not valid. A lapsed instance must not
|
||||
// have its history trimmed on the expired term's allowance — expiry degrades to
|
||||
// have its history trimmed on the expired term's allowance - expiry degrades to
|
||||
// read-only, and deleting more of somebody's audit trail is not read-only.
|
||||
//
|
||||
// It skips Unlimited and any non-positive value. A licence that decodes as zero
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// ErrLockout is returned when a change would leave an instance with neither
|
||||
// local password login nor an enabled provider — nobody could sign in, and no
|
||||
// local password login nor an enabled provider - nobody could sign in, and no
|
||||
// endpoint exists to undo it without database access.
|
||||
var ErrLockout = errors.New("that would leave nobody able to sign in")
|
||||
|
||||
@@ -36,8 +36,8 @@ func authProviderCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// CheckLockout is pure so the two endpoints that can reach this condition —
|
||||
// saving settings and changing a provider — share one answer.
|
||||
// CheckLockout is pure so the two endpoints that can reach this condition -
|
||||
// saving settings and changing a provider - share one answer.
|
||||
func CheckLockout(localEnabled bool, enabledProviders int) error {
|
||||
if localEnabled || enabledProviders > 0 {
|
||||
return nil
|
||||
@@ -244,8 +244,8 @@ func IsLocalLoginEnabled(instanceID string) bool {
|
||||
|
||||
// LocalLoginPermitted answers whether password sign-in must be accepted for
|
||||
// this instance, which is not the same question as whether an administrator
|
||||
// turned it on. An instance whose only providers have become unusable — a
|
||||
// lapsed licence, or every provider disabled — has to keep its password form,
|
||||
// turned it on. An instance whose only providers have become unusable - a
|
||||
// lapsed licence, or every provider disabled - has to keep its password form,
|
||||
// or nobody can sign in and there is no endpoint left to fix it with.
|
||||
func LocalLoginPermitted(instanceID string) bool {
|
||||
if IsLocalLoginEnabled(instanceID) {
|
||||
|
||||
@@ -110,7 +110,7 @@ func UpdateChannel(instanceID, channelID string, upd bson.M) error {
|
||||
// save arrives carrying the sentinel in place of the password. Writing it
|
||||
// through would replace the credential with eight bullet characters and break
|
||||
// delivery on the next alert. A value that is not the sentinel is written
|
||||
// verbatim — including the empty string, which is how a credential is cleared.
|
||||
// verbatim - including the empty string, which is how a credential is cleared.
|
||||
func mergeChannelSecrets(instanceID, channelID string, upd bson.M, cfg map[string]string) (map[string]string, error) {
|
||||
stored, err := GetChannel(instanceID, channelID)
|
||||
if err != nil {
|
||||
|
||||
@@ -30,7 +30,7 @@ var ErrAgentOffline = errors.New("agent is not connected")
|
||||
//
|
||||
// That third line is the one that is easy to get wrong. A ProxyStream is a
|
||||
// separate HTTP/2 request, and an L7 proxy (Traefik, which the chart's gRPC
|
||||
// ingress uses) balances requests rather than connections — so it does not
|
||||
// ingress uses) balances requests rather than connections - so it does not
|
||||
// follow the command stream. Binding the relay listener on the command
|
||||
// stream's pod therefore fails roughly (n-1)/n of the time with "proxy session
|
||||
// not found": the stream arrives at a pod whose registry is empty.
|
||||
@@ -48,7 +48,7 @@ var ErrAgentOffline = errors.New("agent is not connected")
|
||||
//
|
||||
// Teardown needs no message of its own. When the browser goes away guac closes
|
||||
// its connection to the relay, the relay sees the read end, and the session
|
||||
// closes itself — the same path a single-process deployment always took. Only
|
||||
// closes itself - the same path a single-process deployment always took. Only
|
||||
// the *reason* has to cross back, because the pod that writes the audit event
|
||||
// is not the pod that observed the failure.
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
//
|
||||
// An agent's CommandStream terminates on exactly one process, and with several
|
||||
// replicas that is almost never the process handling the REST request that
|
||||
// wants to talk to it. Publishing unconditionally — rather than checking for a
|
||||
// local stream first and falling back — means one code path, exercised on every
|
||||
// wants to talk to it. Publishing unconditionally - rather than checking for a
|
||||
// local stream first and falling back - means one code path, exercised on every
|
||||
// deployment including the single-replica ones, instead of a rare cross-pod
|
||||
// path that only fails in production.
|
||||
const (
|
||||
@@ -48,7 +48,7 @@ type CommandEnvelope struct {
|
||||
|
||||
// Node names the pod this envelope is for: the presence holder at the time
|
||||
// it was published. The command channel is a fan-out, so during a reconnect
|
||||
// two pods can be subscribed for one agent — the pod with the live stream,
|
||||
// two pods can be subscribed for one agent - the pod with the live stream,
|
||||
// and a pod whose stream is half-open and has not yet noticed. Both would
|
||||
// receive the envelope, and the first to ack wins the request. If that is
|
||||
// the stale one, the command is queued onto a dead stream and acked OK: the
|
||||
@@ -161,7 +161,7 @@ func (d *commandDispatcher) Serve(ctx context.Context, serverID string) (<-chan
|
||||
//
|
||||
// A Redis failure returns true. It is tempting to read an error as loss and
|
||||
// give up, but nothing is known in that moment about who holds the claim, and
|
||||
// the stream this pod is serving is demonstrably alive — the caller is either a
|
||||
// the stream this pod is serving is demonstrably alive - the caller is either a
|
||||
// ticker on that stream or a beat that just succeeded on it. Standing down on a
|
||||
// blip is precisely how an agent ends up connected, beating, and unreachable
|
||||
// until it happens to reconnect.
|
||||
@@ -174,7 +174,7 @@ func renewPresence(ctx context.Context, serverID string) bool {
|
||||
log.Printf("dispatch: presence for %s is held elsewhere, standing down", serverID)
|
||||
return false
|
||||
case bus.RenewedClaim:
|
||||
// Nobody held the key — Redis restarted, failed over, evicted it, or was
|
||||
// Nobody held the key - Redis restarted, failed over, evicted it, or was
|
||||
// unreachable for longer than the TTL. Worth a line: it is the only
|
||||
// evidence that presence was lost and recovered rather than never lost.
|
||||
log.Printf("dispatch: reclaimed presence for %s", serverID)
|
||||
|
||||
@@ -30,7 +30,7 @@ func findingKey(cveID, pkg string) string { return cveID + "\x00" + pkg }
|
||||
// DiffFindings computes the state changes for one server's scan.
|
||||
//
|
||||
// Pure by design: no database, no clock of its own. The ordering below is
|
||||
// load-bearing — see the comment above the second loop.
|
||||
// load-bearing - see the comment above the second loop.
|
||||
func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now time.Time) FindingDiff {
|
||||
var d FindingDiff
|
||||
|
||||
@@ -104,7 +104,7 @@ func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now ti
|
||||
}
|
||||
|
||||
// ErrFindingNotFound is returned for a finding that does not exist in this
|
||||
// instance. Callers turn it into a 404 — never a 403, which would confirm the
|
||||
// instance. Callers turn it into a 404 - never a 403, which would confirm the
|
||||
// finding exists in someone else's instance.
|
||||
var ErrFindingNotFound = errors.New("finding not found")
|
||||
|
||||
@@ -116,7 +116,7 @@ type FindingFilter struct {
|
||||
ServerID string
|
||||
Tags map[string]string
|
||||
// HasFix nil is no filter. true is "a vendor fix exists, this is
|
||||
// patchable"; false is the unfixable set — remove the package, disable the
|
||||
// patchable"; false is the unfixable set - remove the package, disable the
|
||||
// service, or accept it, but do not wait for an update.
|
||||
HasFix *bool
|
||||
// TokenScope is the acting credential's tag restriction, nil meaning
|
||||
@@ -172,7 +172,7 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
|
||||
}
|
||||
|
||||
// The token restriction is applied the same way the Tags selector above
|
||||
// is — by narrowing server_id — rather than by a post-pass, so the two
|
||||
// is - by narrowing server_id - rather than by a post-pass, so the two
|
||||
// cannot disagree and the query keeps one shape. IntersectSelectors is
|
||||
// not used here because Tags has already been resolved to IDs by this
|
||||
// point; intersecting the ID sets is the same operation one level down.
|
||||
@@ -370,7 +370,7 @@ func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.Vu
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ApplyFindingDiff writes a diff. Thin on purpose — the logic worth reading
|
||||
// ApplyFindingDiff writes a diff. Thin on purpose - the logic worth reading
|
||||
// twice is all in DiffFindings.
|
||||
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
|
||||
col := db.Col("vuln_findings")
|
||||
|
||||
@@ -122,7 +122,7 @@ type KeyWithCount struct {
|
||||
// count, narrowed by tokenScope: AssignedCount only counts assignments on
|
||||
// servers ServerInTokenScope admits. Without this, a restricted token reading
|
||||
// the key list would see a nonzero count for a key it cannot see a single
|
||||
// assignment of in its own scope — the same hostname-existence leak
|
||||
// assignment of in its own scope - the same hostname-existence leak
|
||||
// getKey's scope filter closes on the detail route, reachable here through a
|
||||
// count instead of a server object.
|
||||
//
|
||||
@@ -148,7 +148,7 @@ func ListKeys(instanceID string, tokenScope map[string]string) ([]KeyWithCount,
|
||||
|
||||
// Resolve the visible fleet once, outside the per-key loop, so a
|
||||
// restricted token's count costs one extra query total rather than one
|
||||
// per key — the same reasoning ResolveTargetsScoped already applies to
|
||||
// per key - the same reasoning ResolveTargetsScoped already applies to
|
||||
// target resolution.
|
||||
scoped := len(tokenScope) > 0
|
||||
var visibleIDs []string
|
||||
|
||||
@@ -22,7 +22,7 @@ type LicenseState struct {
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
Features map[string]bool `json:"features"`
|
||||
// Source is "stored", "env" or "none" — useful when a self-hosted operator
|
||||
// Source is "stored", "env" or "none" - useful when a self-hosted operator
|
||||
// asks why the licence they pasted is not the one in effect.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func limitCtx() (context.Context, context.CancelFunc) {
|
||||
// CheckServerLimit refuses a new server when the instance is at its cap.
|
||||
//
|
||||
// Counts live rows only. An instance already over its cap keeps every server it
|
||||
// has — nothing is truncated — it simply cannot add another.
|
||||
// has - nothing is truncated - it simply cannot add another.
|
||||
func CheckServerLimit(instanceID string) error {
|
||||
st := GetLicenseState(instanceID)
|
||||
ctx, cancel := limitCtx()
|
||||
@@ -92,7 +92,7 @@ func CheckChannelLimit(instanceID string) error {
|
||||
// CheckMonitorLimit refuses a new monitor when the instance is at its cap.
|
||||
//
|
||||
// Counts live rows only, like every other check here. An instance already over
|
||||
// its cap keeps every monitor it has and they keep executing — the licence
|
||||
// its cap keeps every monitor it has and they keep executing - the licence
|
||||
// expiry story is that monitoring never stops, so truncating here would
|
||||
// contradict it.
|
||||
func CheckMonitorLimit(instanceID string) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ type legacyInstanceOIDC struct {
|
||||
// migration that needs KEY_ENCRYPTION_KEY fails on an instance that has none
|
||||
// and strands the SSO configuration it was supposed to preserve.
|
||||
//
|
||||
// instance_oidc is left in place and no longer read. Nothing deletes it — a
|
||||
// instance_oidc is left in place and no longer read. Nothing deletes it - a
|
||||
// migration that drops the only copy of a client secret has no undo.
|
||||
func MigrateAuthProviders() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
//
|
||||
// Migration 0004 renames org_id to instance_id in each. A collection missing
|
||||
// from this list keeps the old field name and becomes invisible to every scoped
|
||||
// query — so this list is load-bearing, not documentation.
|
||||
// query - so this list is load-bearing, not documentation.
|
||||
//
|
||||
// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
|
||||
// holds an org_id.
|
||||
@@ -63,7 +63,7 @@ var collectionRenames = []struct{ from, to string }{
|
||||
// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
|
||||
// rather than by restoring a backup.
|
||||
//
|
||||
// The steps are not atomic across collections — multi-document transactions
|
||||
// The steps are not atomic across collections - multi-document transactions
|
||||
// would require a replica set, which self-hosted installs do not guarantee.
|
||||
// Instead every step is safely repeatable: a collection rename is skipped when
|
||||
// the source is already gone, and $rename matches nothing on a document that
|
||||
|
||||
@@ -55,8 +55,8 @@ func SpecFor(m *models.Monitor) checker.Spec {
|
||||
|
||||
// RedactMonitorRunner replaces m.Runner with models.RunnerRestricted when it
|
||||
// names a server outside the caller's scope, so GET /monitors and GET
|
||||
// /monitors/:id can keep listing the monitor itself — name, type, state,
|
||||
// whether it exists at all — as the first-class object it is, without
|
||||
// /monitors/:id can keep listing the monitor itself - name, type, state,
|
||||
// whether it exists at all - as the first-class object it is, without
|
||||
// disclosing which out-of-scope server it happens to run on. Omitting the
|
||||
// monitor entirely was considered and rejected: a restricted operator has a
|
||||
// legitimate reason to see that a monitor exists and is up or down even when
|
||||
@@ -293,7 +293,7 @@ const MaxMonitorSamples = 6000
|
||||
|
||||
// MonitorSamples returns individual check results since a point in time,
|
||||
// oldest first. Samples older than MonitorSampleTTL have expired, so an early
|
||||
// `since` silently returns a shorter window rather than an error — the caller
|
||||
// `since` silently returns a shorter window rather than an error - the caller
|
||||
// draws the gap.
|
||||
func MonitorSamples(instanceID, monitorID string, since time.Time) ([]models.MonitorSample, error) {
|
||||
ctx, cancel := monCtx()
|
||||
|
||||
@@ -21,7 +21,7 @@ const MonitorSampleTTL = 48 * time.Hour
|
||||
|
||||
// EnsureMonitorSampleIndexes declares the sample range index and its TTL.
|
||||
//
|
||||
// Warn rather than fatal, like the other history indexes — but note the TTL is
|
||||
// Warn rather than fatal, like the other history indexes - but note the TTL is
|
||||
// not an optimisation: without it nothing ever removes a sample, and the
|
||||
// collection grows at the fleet's total check rate forever. A boot that logs
|
||||
// this warning needs following up.
|
||||
|
||||
@@ -87,7 +87,7 @@ type PackageHit struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// SearchPackages answers "which servers run package X" across the fleet — the
|
||||
// SearchPackages answers "which servers run package X" across the fleet - the
|
||||
// question people actually ask during an incident.
|
||||
//
|
||||
// The Mongo filter narrows to documents containing the name; the second pass is
|
||||
@@ -96,8 +96,8 @@ type PackageHit struct {
|
||||
//
|
||||
// tokenScope is the acting credential's tag restriction, nil meaning
|
||||
// unrestricted; a hit on a server outside it is dropped before it is returned.
|
||||
// The filtering is done with VisibleServerIDs — one membership set resolved
|
||||
// once — rather than by resolving each hit's server individually the way
|
||||
// The filtering is done with VisibleServerIDs - one membership set resolved
|
||||
// once - rather than by resolving each hit's server individually the way
|
||||
// search_fleet does, because a package search can return one hit per host in
|
||||
// the fleet and the query shape must not depend on how many matched. The Mongo
|
||||
// query itself is unchanged: server_packages carries no tags to filter on, so
|
||||
|
||||
@@ -62,7 +62,7 @@ func reapAfter() time.Duration {
|
||||
//
|
||||
// Unexported and unguarded: it trusts its caller completely and performs an
|
||||
// irreversible delete on whatever instance ID it is handed. The tier and expiry
|
||||
// gate — Free tier, an expiry that exists, an expiry past the window — lives in
|
||||
// gate - Free tier, an expiry that exists, an expiry past the window - lives in
|
||||
// ReapFreeInstances, which is the only caller. Do not export this.
|
||||
//
|
||||
// Idempotent: re-running over a half-deleted instance completes it. The instance
|
||||
@@ -93,8 +93,8 @@ func purgeInstance(ctx context.Context, instanceID string) (map[string]int64, er
|
||||
// ago than the configured window.
|
||||
//
|
||||
// Eligibility requires ALL of:
|
||||
// - license_tier == "free" — a paid instance is never eligible
|
||||
// - license_expiry present — an instance that was never licensed, or whose
|
||||
// - license_tier == "free" - a paid instance is never eligible
|
||||
// - license_expiry present - an instance that was never licensed, or whose
|
||||
// issuance failed, has no expiry and is never eligible whatever its age
|
||||
// - license_expiry older than now minus the window
|
||||
//
|
||||
@@ -136,7 +136,7 @@ func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) {
|
||||
// audit entry written here would delete itself moments later. It is
|
||||
// written anyway, because an operator reading audit during the window
|
||||
// should see it coming.
|
||||
log.Printf("REAPING instance %s (%s, slug=%s) — Free licence expired %s, past the %s window",
|
||||
log.Printf("REAPING instance %s (%s, slug=%s) - Free licence expired %s, past the %s window",
|
||||
d.InstanceID, d.Name, d.Slug, d.Expiry.Format(time.RFC3339), window)
|
||||
LogEvent(d.InstanceID, "instance.reaped", "system", "", "",
|
||||
fmt.Sprintf("free licence expired %s, window %s", d.Expiry.Format(time.RFC3339), window))
|
||||
@@ -157,7 +157,7 @@ func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) {
|
||||
//
|
||||
// The pass at boot follows inject.StartReconciler's precedent and earns its keep
|
||||
// the same way: it makes a restart a supported way to force a sweep, which is
|
||||
// the only way this code can be exercised on demand — the ticker is hourly and
|
||||
// the only way this code can be exercised on demand - the ticker is hourly and
|
||||
// deletion is measured in days.
|
||||
func StartReaper(ctx context.Context) {
|
||||
window := reapAfter()
|
||||
@@ -165,7 +165,7 @@ func StartReaper(ctx context.Context) {
|
||||
log.Printf("reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)")
|
||||
return
|
||||
}
|
||||
log.Printf("reaper: ENABLED — Free instances are deleted %s after their licence expires", window)
|
||||
log.Printf("reaper: ENABLED - Free instances are deleted %s after their licence expires", window)
|
||||
|
||||
go func() {
|
||||
reapOnce(ctx)
|
||||
|
||||
@@ -76,7 +76,7 @@ func GetServer(instanceID, serverID string) (*models.Server, error) {
|
||||
// a restricted token must not be able to enumerate the fleet it cannot see by
|
||||
// noticing which IDs answer differently.
|
||||
//
|
||||
// mongo.ErrNoDocuments is GetServer's own not-found identifier — reused here
|
||||
// mongo.ErrNoDocuments is GetServer's own not-found identifier - reused here
|
||||
// rather than introducing a second one, so a caller checking for one keeps
|
||||
// working against a server that exists but is out of the token's scope.
|
||||
func GetServerScoped(instanceID, serverID string, tokenScope map[string]string) (*models.Server, error) {
|
||||
@@ -360,7 +360,7 @@ func markOfflineForFilter(scope bson.M, instanceID string) error {
|
||||
}
|
||||
|
||||
// notifyServerOffline delivers an agent-offline alert over the instance's
|
||||
// chosen notification channels — the same destinations monitors dispatch to,
|
||||
// chosen notification channels - the same destinations monitors dispatch to,
|
||||
// so a webhook or SMTP destination is configured and tested in exactly one
|
||||
// place. No channels selected means the alert is audited but not sent.
|
||||
func notifyServerOffline(instanceID string, channelIDs []string, s models.Server) {
|
||||
@@ -393,7 +393,7 @@ func notifyServerOffline(instanceID string, channelIDs []string, s models.Server
|
||||
}
|
||||
|
||||
// ListServersFiltered is ListServers with an optional tag selector. An empty
|
||||
// selector returns the whole fleet — unlike MatchesTags, where empty means
|
||||
// selector returns the whole fleet - unlike MatchesTags, where empty means
|
||||
// "nothing", because here the caller is a list view whose default is
|
||||
// "everything", not a run about to touch machines.
|
||||
func ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error) {
|
||||
|
||||
@@ -74,7 +74,7 @@ func validateIncident(inc *models.StatusIncident) error {
|
||||
// fleet's monitors: publishing "api-gateway is degraded" on a page that never
|
||||
// listed api-gateway names a machine to the public that the page deliberately
|
||||
// does not, which is the same leak assembleSnapshot's redaction boundary exists
|
||||
// to prevent — reached from the authoring side instead of the read side.
|
||||
// to prevent - reached from the authoring side instead of the read side.
|
||||
//
|
||||
// It is a separate pass rather than part of validateIncident because it reads
|
||||
// the database, and validateIncident is a pure function of the document. The
|
||||
@@ -289,7 +289,7 @@ func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author str
|
||||
set["resolved_at"] = upd.At
|
||||
} else {
|
||||
// Reopening via an appended update must clear a previously-set
|
||||
// resolved_at the same way UpdateStatusIncident does — otherwise a
|
||||
// resolved_at the same way UpdateStatusIncident does - otherwise a
|
||||
// resolved incident reopened to "monitoring" keeps a stale resolved_at
|
||||
// and silently drops off ListStatusIncidentsForPage once started_at
|
||||
// ages past the since cutoff, because none of its $or clauses match.
|
||||
|
||||
@@ -47,7 +47,7 @@ func spCtx() (context.Context, context.CancelFunc) {
|
||||
//
|
||||
// All three are attempted and the failures joined, rather than returning on
|
||||
// the first. The three are independent, and two of them are uniqueness
|
||||
// constraints — bailing out on the status_pages index meant a transient
|
||||
// constraints - bailing out on the status_pages index meant a transient
|
||||
// failure there silently left status_incidents with no unique
|
||||
// (instance_id, incident_id) index at all.
|
||||
func EnsureStatusPageIndexes() error {
|
||||
@@ -86,7 +86,7 @@ var (
|
||||
ErrPageIDTaken = errors.New("that page id is already in use")
|
||||
|
||||
// ErrPageInvalid is the sentinel for validation failures on a page or
|
||||
// incident body — anything the caller can fix by sending a different
|
||||
// incident body - anything the caller can fix by sending a different
|
||||
// request. statusPageError maps it to 400; wrap it rather than returning a
|
||||
// bare error, or a bad request answers 500.
|
||||
ErrPageInvalid = errors.New("status page request invalid")
|
||||
|
||||
@@ -58,7 +58,7 @@ type PublicIncidentUpdate struct {
|
||||
}
|
||||
|
||||
// PublicIncident covers both authored incidents and derived monitor outages.
|
||||
// A derived one carries no updates and no impact — and never a cause, which is
|
||||
// A derived one carries no updates and no impact - and never a cause, which is
|
||||
// where internal hostnames live.
|
||||
type PublicIncident struct {
|
||||
ID string `json:"id"`
|
||||
@@ -138,7 +138,7 @@ func assembleSnapshot(in snapshotInput) StatusSnapshot {
|
||||
names[entry.MonitorID] = name
|
||||
|
||||
// Uptime is computed from the days as reported by rollups, before
|
||||
// any maintenance repaint — a no_data day must never be counted as
|
||||
// any maintenance repaint - a no_data day must never be counted as
|
||||
// zero uptime just because it is later redrawn as "maintenance".
|
||||
days := buildDays(in.Rollups[entry.MonitorID], in.Now)
|
||||
comp := PublicComponent{
|
||||
@@ -294,7 +294,7 @@ func publicFromAuthored(inc models.StatusIncident, names map[string]string) Publ
|
||||
// already read the true no_data/up/down state of each day. Folding the
|
||||
// repaint in here would let a today cell with no rollups yet flip from
|
||||
// no_data to maintenance before its uptime contribution was decided, and
|
||||
// uptimeFromDays skips no_data days by their State — so that day would stop
|
||||
// uptimeFromDays skips no_data days by their State - so that day would stop
|
||||
// being skipped and start counting as a zero.
|
||||
func buildDays(rollups []models.Rollup, now time.Time) []PublicDay {
|
||||
type bucket struct{ checks, up int }
|
||||
@@ -335,7 +335,7 @@ func buildDays(rollups []models.Rollup, now time.Time) []PublicDay {
|
||||
// computation, not before: repainting first would turn a today cell with no
|
||||
// rollups yet from no_data (skipped) into maintenance (a 0% day counted in
|
||||
// the average), and repainting a day that DOES have rollups must still leave
|
||||
// that day's real up/down contribution in the average — maintenance changes
|
||||
// that day's real up/down contribution in the average - maintenance changes
|
||||
// how a day is drawn, never what the numbers say.
|
||||
func applyMaintenanceRepaint(days []PublicDay, inMaintenance bool) []PublicDay {
|
||||
if inMaintenance && len(days) > 0 {
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestAssembleSnapshotMaintenanceDoesNotChangeUptime(t *testing.T) {
|
||||
|
||||
// TestAssembleSnapshotMaintenanceRepaintDoesNotCountNoDataAsZero guards
|
||||
// against the maintenance repaint corrupting Uptime90d for a component whose
|
||||
// today rollup has not landed yet — an in-progress maintenance window on a
|
||||
// today rollup has not landed yet - an in-progress maintenance window on a
|
||||
// young component, or one that simply started before today's hourly rollup
|
||||
// was written. Repainting today's no_data cell to "maintenance" must never
|
||||
// make uptimeFromDays stop skipping it: doing so would turn a component with
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// process: step output arrives on whichever pod holds the agent's stream, the
|
||||
// run's markers are written by whichever pod started the run, and the browser
|
||||
// asks for the log through whichever pod the load balancer picked. Three pods,
|
||||
// one file, one local disk — two of them see an empty log.
|
||||
// one file, one local disk - two of them see an empty log.
|
||||
//
|
||||
// Mongo makes every pod an equal reader and writer, which is the property that
|
||||
// matters. It costs writes on the hot path, so the writer batches (see
|
||||
@@ -144,7 +144,7 @@ type stepLogRegistry struct {
|
||||
|
||||
// The registry stays process-local, and correctly so: a step's output arrives
|
||||
// on the pod holding that agent's stream, and that is the same pod the
|
||||
// dispatch envelope asked to open the writer. Nothing here crosses pods —
|
||||
// dispatch envelope asked to open the writer. Nothing here crosses pods -
|
||||
// only the lines it produces do, by virtue of landing in Mongo.
|
||||
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ type stepResultRegistry struct{}
|
||||
var StepResults = &stepResultRegistry{}
|
||||
|
||||
// Await subscribes to a command's result channel. The returned cancel function
|
||||
// must be called once the caller is done, whether a result arrived or not —
|
||||
// must be called once the caller is done, whether a result arrived or not -
|
||||
// it is what releases the Redis subscription.
|
||||
func (r *stepResultRegistry) Await(commandID string) (<-chan *pb.StepResult, func()) {
|
||||
out := make(chan *pb.StepResult, 1)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// ErrInvalidTag is returned for any tag the rules below reject. Handlers map
|
||||
// it to 400 — a malformed tag is the caller's mistake, not a server fault.
|
||||
// it to 400 - a malformed tag is the caller's mistake, not a server fault.
|
||||
var ErrInvalidTag = errors.New("invalid tag")
|
||||
|
||||
const (
|
||||
@@ -43,8 +43,8 @@ func validTagRunes(s string) bool {
|
||||
}
|
||||
|
||||
// ValidateTags enforces the shape of a whole tag map. It lives in the service
|
||||
// layer rather than a handler so that every write path — the tags endpoint,
|
||||
// server create, anything added later — agrees on what a valid tag is.
|
||||
// layer rather than a handler so that every write path - the tags endpoint,
|
||||
// server create, anything added later - agrees on what a valid tag is.
|
||||
func ValidateTags(tags map[string]string) error {
|
||||
if len(tags) > maxTagsPerHost {
|
||||
return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// report success over zero servers.
|
||||
var ErrNoTargets = errors.New("workflow has no target servers")
|
||||
|
||||
// MatchesTags reports whether srv carries every pair in sel — AND across keys.
|
||||
// MatchesTags reports whether srv carries every pair in sel - AND across keys.
|
||||
// An empty selector matches nothing. That is deliberate: the alternative,
|
||||
// "matches everything", turns a cleared field in the workflow designer into a
|
||||
// fleet-wide run.
|
||||
|
||||
@@ -129,7 +129,7 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagS
|
||||
InstanceID: instanceID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
// Hint is "vt_" plus 5 hex characters of the secret (20 bits) — enough
|
||||
// Hint is "vt_" plus 5 hex characters of the secret (20 bits) - enough
|
||||
// for a user to recognise their own token in a list, not enough to be
|
||||
// useful to anyone who only has the hint. Considered and accepted.
|
||||
Hint: plaintext[:8],
|
||||
@@ -197,7 +197,7 @@ var (
|
||||
)
|
||||
|
||||
// ShouldLogExpiredTokenUse reports whether an expired token's use is worth a
|
||||
// fresh audit row, throttled to once per token per minute — the same window
|
||||
// fresh audit row, throttled to once per token per minute - the same window
|
||||
// TouchAPIToken uses for last-used, kept here rather than in the auth package
|
||||
// because the storage concern (what counts as "recent") belongs beside the
|
||||
// token's other storage-backed state, not scattered into the request layer.
|
||||
|
||||
@@ -25,13 +25,13 @@ func ServerInTokenScope(srv models.Server, sel map[string]string) bool {
|
||||
|
||||
// VisibleServerIDs resolves the servers tokenScope admits into a membership
|
||||
// set, for a caller that needs to test many IDs against the caller's scope in
|
||||
// one pass — redacting a monitor's runner, filtering a workflow's target list
|
||||
// — rather than resolving one server at a time the way GetServerScoped does.
|
||||
// one pass - redacting a monitor's runner, filtering a workflow's target list
|
||||
// - rather than resolving one server at a time the way GetServerScoped does.
|
||||
//
|
||||
// restricted is false for an empty tokenScope, matching ServerInTokenScope's
|
||||
// own rule that an empty selector is unrestricted rather than "sees nothing".
|
||||
// ids is then nil, and callers must treat (nil, false) as "everything
|
||||
// visible", never as "nothing visible" — the zero value of a map read is
|
||||
// visible", never as "nothing visible" - the zero value of a map read is
|
||||
// false, which would silently invert the rule for every unrestricted caller
|
||||
// if this contract were not honoured.
|
||||
func VisibleServerIDs(instanceID string, tokenScope map[string]string) (ids map[string]bool, restricted bool, err error) {
|
||||
@@ -55,7 +55,7 @@ func VisibleServerIDs(instanceID string, tokenScope map[string]string) (ids map[
|
||||
// (ids, restricted) pair VisibleServerIDs returns. An unrestricted caller
|
||||
// (restricted false) gets ids back unchanged and hidden is always false.
|
||||
//
|
||||
// hidden reports only whether at least one id was dropped — never how many —
|
||||
// hidden reports only whether at least one id was dropped - never how many -
|
||||
// because the point of surfacing it at all is to let a caller say "some
|
||||
// targets are not visible to you" without the count itself becoming the leak
|
||||
// this exists to close. A workflow that targets both an in-scope and an
|
||||
|
||||
@@ -136,7 +136,7 @@ func TestScopedRunOfOutOfScopeWorkflowReachesNothing(t *testing.T) {
|
||||
t.Errorf("staging-scoped run resolved %v, want nothing", got)
|
||||
}
|
||||
// ResolveTargetsScoped turns that empty set into ErrNoTargets, which is
|
||||
// the same answer a workflow targeting no servers at all gives — so the
|
||||
// the same answer a workflow targeting no servers at all gives - so the
|
||||
// refusal does not tell the caller that production hosts exist.
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ var ErrLastOwner = errors.New("this is the organization's last owner promote ano
|
||||
// An hq-sourced row is projected from a Vantage HQ account: HQ owns its role,
|
||||
// its password and its existence. A role editable in two places is a role with
|
||||
// two answers, and the loser is whichever writer ran first. Refusing here
|
||||
// rather than merely hiding the control in web/ is the point — the API is the
|
||||
// rather than merely hiding the control in web/ is the point - the API is the
|
||||
// boundary, the UI is a courtesy.
|
||||
var ErrHQManaged = errors.New("this member is managed in Vantage HQ; change their role or remove them from the HQ portal")
|
||||
|
||||
@@ -77,7 +77,7 @@ func CreateUser(instanceID, email, password, role, authSource string) (*models.U
|
||||
//
|
||||
// There is deliberately no unscoped lookup by email. users is unique on
|
||||
// (instance_id, email), not on email alone, so an unscoped FindOne would return
|
||||
// an arbitrary one of several matching users — which on the login path means
|
||||
// an arbitrary one of several matching users - which on the login path means
|
||||
// signing someone into a tenant that is not theirs.
|
||||
func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
|
||||
@@ -131,7 +131,7 @@ func validateVulnRule(instanceID string, r *models.VulnAlertRule) error {
|
||||
return validateChannelIDs(instanceID, r.ChannelIDs)
|
||||
}
|
||||
|
||||
// SendVulnDigest delivers one message per rule per tick — never one per
|
||||
// SendVulnDigest delivers one message per rule per tick - never one per
|
||||
// finding. See vulnsched for why the tick is the batch boundary.
|
||||
func SendVulnDigest(instanceID string, newly []models.VulnFinding) {
|
||||
rules, err := ListVulnRules(instanceID)
|
||||
|
||||
@@ -26,7 +26,7 @@ const stepDispatchGrace = 15 * time.Second
|
||||
// trigger would leave the dispatch reaching further than the readout.
|
||||
//
|
||||
// A run whose configured targets fall entirely outside the caller's scope
|
||||
// resolves to nothing and returns ErrNoTargets — the same answer a workflow
|
||||
// resolves to nothing and returns ErrNoTargets - the same answer a workflow
|
||||
// targeting no servers at all gives, so an out-of-scope host stays
|
||||
// indistinguishable from one that does not exist.
|
||||
func TriggerWorkflow(instanceID, workflowID, actor string, tokenScope map[string]string) (string, error) {
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestSaveTargetsRestrictedCallerCannotReachOutsideScope(t *testing.T) {
|
||||
func TestSaveTargetsUnrestrictedCallerUnaffected(t *testing.T) {
|
||||
// validateWorkflowTargetScope short-circuits before ever resolving the
|
||||
// fleet when tokenScope is nil, so an unrestricted caller keeps today's
|
||||
// behaviour exactly — including saving a tag selector matching nothing.
|
||||
// behaviour exactly - including saving a tag selector matching nothing.
|
||||
// listServersForScope is stubbed to fail the test if called at all, so
|
||||
// this proves the short-circuit, not just that the decision would allow
|
||||
// it.
|
||||
@@ -66,7 +66,7 @@ func TestSaveTargetsEqualOrNarrowerSelectorAllowed(t *testing.T) {
|
||||
}
|
||||
|
||||
// The time-of-write/time-of-fire gap: a restricted caller naming IDs or tags
|
||||
// that match no server at all today must be refused, not passed through —
|
||||
// that match no server at all today must be refused, not passed through -
|
||||
// otherwise the caller could save a selector for an environment that does
|
||||
// not exist yet, arm the schedule, and have it fire the moment a server picks
|
||||
// up the tag. This is distinct from "no targets at all" below.
|
||||
@@ -98,7 +98,7 @@ func TestSaveTargetsMatchingNothingIsRefusedForRestrictedCaller(t *testing.T) {
|
||||
// ...
|
||||
// FAIL .../internal/services 0.006s
|
||||
//
|
||||
// rather than a clean assertion failure, which is still a failure — the test
|
||||
// rather than a clean assertion failure, which is still a failure - the test
|
||||
// no longer passes silently once the enforcement is removed.
|
||||
func TestCreateAndUpdateWorkflowBindToTargetScopeCheck(t *testing.T) {
|
||||
restore := listServersForScope
|
||||
@@ -126,7 +126,7 @@ func TestCreateAndUpdateWorkflowBindToTargetScopeCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
// A workflow with no targets at all (no IDs, no tags) must stay creatable
|
||||
// for a restricted caller — there is nothing to escalate through, and this
|
||||
// for a restricted caller - there is nothing to escalate through, and this
|
||||
// must not become collateral damage from the fix above.
|
||||
func TestSaveTargetsNoTargetsAtAllIsUnaffected(t *testing.T) {
|
||||
restore := listServersForScope
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// ErrDefaultStep is returned when a caller tries to edit or delete a step that
|
||||
// came from the image's default library. Those rows are re-seeded from disk on
|
||||
// every boot, so an edit would be silently reverted and a delete would come
|
||||
// back — refusing is honest about who owns them.
|
||||
// back - refusing is honest about who owns them.
|
||||
var ErrDefaultStep = errors.New("this step ships with Vantage and cannot be edited or deleted; duplicate it to make your own copy")
|
||||
|
||||
func isDefaultStep(ctx context.Context, instanceID, stepID string) (bool, error) {
|
||||
@@ -304,7 +304,7 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow, tokenScope map[str
|
||||
// The first is escalation: without it a token restricted to staging could save
|
||||
// a workflow targeting production and then reach those hosts through the
|
||||
// scheduler, which fires as the system with no restriction of its own. The
|
||||
// second is enumeration — "target server X not found" versus a successful save
|
||||
// second is enumeration - "target server X not found" versus a successful save
|
||||
// is a yes/no oracle over the whole fleet, and the design forbids a restricted
|
||||
// token learning which IDs exist outside its scope.
|
||||
//
|
||||
@@ -347,15 +347,15 @@ var listServersForScope = ListServers
|
||||
// A nil tokenScope is unrestricted and always passes: an unrestricted caller
|
||||
// may save any selector, including one matching nothing today, exactly as
|
||||
// before this fix. A workflow with no targets at all (empty IDs and empty
|
||||
// tags) is also left alone regardless of scope — there is nothing for it to
|
||||
// tags) is also left alone regardless of scope - there is nothing for it to
|
||||
// fire on, and refusing it would break the existing, unrelated ability to
|
||||
// save a workflow before wiring up its targets.
|
||||
//
|
||||
// What IS refused, for a restricted caller only, is a workflow that names IDs
|
||||
// or tags which resolve to no server at all. Without this, a token restricted
|
||||
// to env=staging could save target_tags {env: production} while no server yet
|
||||
// carries that pair — a not-yet-provisioned environment, a tag rollout in
|
||||
// progress, a guessed value — pass validation on an empty set, arm the
|
||||
// carries that pair - a not-yet-provisioned environment, a tag rollout in
|
||||
// progress, a guessed value - pass validation on an empty set, arm the
|
||||
// schedule, and have the scheduler execute on those hosts the moment someone
|
||||
// tags them. That is the same escalation as the out-of-scope case, just
|
||||
// deferred to whenever the fleet catches up to the selector, so it is
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user