Compare commits

...
86 Commits
Author SHA1 Message Date
mrhid6 fbcf436ef6 feat: show Ubuntu phased updates apart and leave them out of pending counts
Chart Release / chart (push) Successful in 29s
Server Deploy / deploy (push) Successful in 5m9s
apt lists phased updates as upgradable while an upgrade defers them until
Ubuntu selects the host, so a freshly patched server kept reporting pending
updates. The agent now flags them; the server stores the flag and leaves them
out of patch run counts, and the server page shows them in their own section.
2026-09-15 14:55:30 +00:00
mrhid6 0e464c4bb8 fix(web): wrap long patch output lines instead of widening the run table
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 1m14s
2026-09-15 14:27:42 +00:00
mrhid6 fc10575b08 fix: require a 2 minute boot time change before counting a patch reboot
Chart Release / chart (push) Successful in 36s
Server Deploy / deploy (push) Successful in 4m19s
Windows reports boot time as now minus uptime, which drifts by a second or so
between reports. A static report sent in the grace period before the reboot
could read as a changed boot and mark a server still owing a reboot as failed.
2026-09-15 14:09:34 +00:00
mrhid6 3f2d20868e fix(patching): final review fixes
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 6m12s
- no dispatch in the last 15 minutes of a window; no-result timeout from dispatch time
- per-server output moves to patch_run_outputs (16MB document limit)
- reboot proven by a changed boot time; RebootTimeout 45m, ResultGrace 20m
- window update and delete are server-scoped against the policies using them
- scheduler puts the claim back on an error after it, so the next tick retries
- cancelled runs with failures alert; MCP apply_updates audits per server
- apply-updates 503 body documented; openapi regenerated
- web: cleared numeric fields no longer save as 0; Run now asks for confirmation
2026-09-15 13:49:28 +00:00
mrhid6 3ecea7c39f feat(web): show patch policy coverage on servers and open the run after Apply updates 2026-09-15 13:23:19 +00:00
mrhid6 68c613fd40 feat(web): patch run detail page with per-server output 2026-09-15 12:24:17 +00:00
mrhid6 70c239021d feat(web): patching page with policies, windows and runs 2026-09-15 11:19:01 +00:00
mrhid6 3a7618f82f feat(web): maintenance window and patch policy editors 2026-09-15 10:59:06 +00:00
mrhid6 7809419202 fix(web): anchor patch agent version regex to mirror server parseVersion 2026-09-15 09:43:56 +00:00
mrhid6 59e7ef63fe feat(web): patching API client, status vocabulary and navigation 2026-09-15 09:41:07 +00:00
mrhid6 b5bbf28c63 feat: patching REST API, patching scope, run IDs from apply-updates and MCP 2026-09-15 09:31:51 +00:00
mrhid6 17c9f813fc fix: patchsched hasActiveRun must not treat a real DB error as no active run 2026-09-15 09:19:51 +00:00
mrhid6 139658864b feat: patch scheduler loop; record patch results and verify reboots from the agent stream 2026-09-15 09:17:27 +00:00
mrhid6 1bb2ba7f2b fix: patch run dispatch - per-server contexts, cancel race, result command guard
Fix round 1 review findings on the patch run service:
- advanceRun no longer runs every server's dispatch claim and failed-send
  reset on the caller's shared short context; each gets its own fresh
  patchCtx(), and a failed reset write is logged instead of discarded.
- The dispatch claim (queued/waiting_offline -> patching) now also requires
  the run to still be status running with no cancelled_at, closing a race
  where a tick that loaded the run just before CancelPatchRun wrote
  cancelled_at could still dispatch.
- RecordPatchResult's write is now guarded on command_id too, so a late
  result for a superseded command cannot land on a re-dispatched attempt.
2026-09-15 09:09:12 +00:00
mrhid6 fef886c93b feat: patch run service - dispatch, results, reboot verification, cancel, alerts, retention 2026-09-15 09:04:23 +00:00
mrhid6 3a1614066e feat: maintenance window and patch policy services
Named patch_window.go (not patch_windows.go) since the _windows.go
suffix is Go's implicit GOOS build constraint and would silently
exclude the file on non-Windows builds.
2026-09-15 08:50:36 +00:00
mrhid6 c0e26d0493 feat: patchsched fire/skip decision and window arithmetic 2026-09-15 08:44:52 +00:00
mrhid6 b60daf0461 feat: patchrun - pure state machine for patch runs
Implements the patchrun package with a pure functional state machine for managing
patch runs. Contains no database dependencies - the services layer loads a run,
asks this package what should change, and writes changes guarded by expected status.

All 14 test cases pass, covering:
- Agent version parsing and support detection
- Concurrency limits and queueing
- Window deadlines and offline handling
- Result timeouts (ResultGrace, ManualTimeout, RebootTimeout)
- Reboot verification with boot time proof
- Run finalization logic
- Summary generation for alerts
2026-09-15 08:39:21 +00:00
mrhid6 8f1ea6d5a0 feat: patch models, scoped collections and indexes; pin vantage-shared v0.5.0 2026-09-15 08:27:04 +00:00
mrhid6 e63e773cda docs: implementation plan for scheduled patching; align spec audit names, alert payload and run source 2026-09-14 15:32:47 +00:00
mrhid6 48116bf737 docs: design for maintenance windows and scheduled patching 2026-09-14 14:41:24 +00:00
mrhid6 2b4b630ae0 chore: pin vantage-shared v0.3.3
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 3m24s
2026-09-11 11:02:10 +00:00
mrhid6 ad0f583d41 chore: pin vantage-shared v0.3.2
Chart Release / chart (push) Successful in 21s
Server Deploy / deploy (push) Successful in 3m34s
2026-09-11 09:58:51 +00:00
mrhid6 5ca705c88c feat: login page says a locked instance is suspended instead of drawing the form
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 4m4s
2026-09-11 09:05:44 +00:00
mrhid6 2d96cb4224 chore: pin vantage-shared v0.3.0
Chart Release / chart (push) Successful in 23s
Server Deploy / deploy (push) Successful in 3m31s
2026-09-11 08:36:27 +00:00
mrhid6 d2672fc022 docs: HQ dispute lock and purge contract 2026-09-10 13:25:47 +00:00
mrhid6 ec22696402 feat: purge instances whose HQ dispute failed; Free reaper skips locked instances 2026-09-10 13:25:33 +00:00
mrhid6 913d3da386 feat: refuse instances Vantage HQ has locked under a dispute 2026-09-10 13:24:47 +00:00
mrhid6 8bd0d790dd fix: Fixed modal overflow issue
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 42s
2026-09-10 12:51:37 +00:00
mrhid6 3e4db573bc chore: Updated go deps
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 2m37s
2026-09-10 12:08:53 +00:00
mrhid6 5925717ca8 fix: Fixed env variable
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m6s
2026-09-10 11:19:53 +00:00
mrhid6 f3e7357735 fix: Fixed license page scrollbar overflow
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 46s
2026-09-10 09:25:24 +00:00
mrhid6 6ee203f5e9 chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m52s
2026-09-10 09:18:55 +00:00
mrhid6 b36a696d0e feat: Added nginx container to docker compose file
Server Deploy / deploy (push) Successful in 9s
Chart Release / chart (push) Successful in 10s
2026-09-10 08:43:25 +00:00
mrhid6 1538ae9bd1 fix: Fixed docker compose images
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 11s
2026-09-10 08:16:21 +00:00
mrhid6 5377a1e585 fix: bind scope tests to real code, close time-of-write gap, fail closed
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 3m41s
The previous tests reimplemented the target-scope rule instead of calling
validateWorkflowTargetScope, so they proved nothing about CreateWorkflow
or UpdateWorkflow's actual enforcement. Split the check into a pure
decideWorkflowTargetScope (tested directly, no database) and a thin
wrapper behind an overridable listServersForScope seam, so tests can
invoke the real CreateWorkflow/UpdateWorkflow without a live database and
fail if the call sites are removed.

Close the time-of-write/time-of-fire gap: a restricted caller could
previously save target_tags matching no server today (a selector aimed
at hosts not yet provisioned or not yet tagged), pass validation on an
empty set, and have the scheduler fire on those hosts the moment they
appeared. Now a restricted caller specifying targets that resolve to
nothing is refused with the same message as an out-of-scope match; a
workflow with no targets at all, and an unrestricted caller, are
unaffected.

A database error while resolving the fleet now surfaces as an error
instead of folding into a pass.

Correct three comments that overstated what the code does: the
create/update route comment now mentions the tag-scope check, not only
validateTargetServers; the schedule route comment explains its safety
holds only for workflows written after this check existed, not for rows
already in the database under the old rule.
2026-09-09 11:56:15 +00:00
mrhid6 2e3d2a33f9 fix: close privilege escalation via workflow TargetTags on REST create/update
CreateWorkflow and UpdateWorkflow validated TargetServerIDs against the
caller's scope but never checked TargetTags, letting a restricted token
save a workflow with an empty ID list and an out-of-scope tag selector,
then reach those hosts once the scheduler fires it as the system with no
restriction of its own. Add validateWorkflowTargetScope, applying the
same all-or-nothing rule the MCP create_workflow tool already enforces:
resolve the full target set unscoped and scoped, refuse unless they
match. Update the PUT /api/workflows/:id/schedule fleetWide comment to
say why it is safe now (targets are constrained at write time) rather
than repeating the falsified claim that scheduling reaches nothing new.
2026-09-09 11:45:39 +00:00
mrhid6 52ac966cba fix(api): allow serverScopedRoutes entries for routes outside /api group
TestServerScopeMapCoversEveryScopedRoute wrongly required serverScopedRoutes
to be a subset of routeScopes, which only covers the authenticated /api
group. The ESO route is registered on the root router and is exempt from
routeScopes by design, so it failed the test despite being correctly
declared. Lift the hardcoded exception in AssertScopeMapComplete into a
named routesOutsideAPIGroup set that both scopes.go and the test read.
2026-09-09 11:32:52 +00:00
mrhid6 bfe58c4cd2 fix: Fixed serverscope for secret eso endpoint
Chart Release / chart (push) Successful in 23s
Server Deploy / deploy (push) Successful in 2m13s
2026-09-09 08:57:21 +00:00
mrhid6 9d7c4b72aa fix: Fixed openapi doc
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Canceled after 2m8s
2026-09-09 08:46:25 +00:00
mrhid6 a2351c75dd feat:runscope test
Chart Release / chart (push) Successful in 23s
Server Deploy / deploy (push) Failing after 58s
2026-09-09 08:43:55 +00:00
mrhid6 3695bc9e1a feat(mcp): declare an input schema and a server-data flag for every tool 2026-09-09 08:41:16 +00:00
mrhid6 9bcec168b9 refactor(api): require every /api route to declare its server-scope status 2026-09-09 08:38:53 +00:00
mrhid6 d95f299562 feat(tokens): refuse minting mcp scopes without the MCP licence feature 2026-09-09 08:36:26 +00:00
mrhid6 ac8e957859 fix(security): scope package search, run documents, workloads and vulnerability reads to the token's tags 2026-09-09 08:36:04 +00:00
mrhid6 6a48dd5d73 fix(security): scope workflow target and monitor runner validation to the caller's tags 2026-09-09 08:33:38 +00:00
mrhid6 5fcfb40084 fix(security): scope workflow run dispatch and MCP run logs to the token's tags 2026-09-09 08:32:32 +00:00
mrhid6 705085d3c7 feat: Fixed spacing 2026-09-09 08:16:06 +00:00
mrhid6 67520c677b feat: show mcp feature row on the licence page 2026-09-09 08:09:46 +00:00
mrhid6 bb698eba8a feat: let an agent create steps, workflows and monitors, inert until a human arms them 2026-09-09 08:07:26 +00:00
mrhid6 3bdbf33f90 fix: scope monitor runner and workflow targets to the caller's fleet
GET /api/monitors and GET /api/monitors/:id returned Monitor.Runner
unfiltered; for an agent-pushed monitor that field is literally a server
ID, so a restricted token learned which out-of-scope server a monitor
runs on directly, not merely that one exists. services.RedactMonitorRunner
replaces Runner with models.RunnerRestricted when it names a server
outside the caller's scope, resolved once via the new
services.VisibleServerIDs rather than per monitor. The monitor itself is
still returned — a restricted operator may legitimately need to see that
it exists and is up or down — only the runner field goes neutral; omitting
the monitor entirely was considered and rejected as more surprising than
one field changing. Runner "server" (control-plane-run) is never
touched. The MCP list_monitors/get_monitor_status projections never had a
Runner field to begin with, so REST and the tool surface already agreed;
a comment now records why.

GET /api/workflows and GET /api/workflows/:id returned
Workflow.TargetServerIDs unfiltered — directly naming out-of-scope
servers, worse than a count. services.FilterVisibleServerIDs narrows the
list to what VisibleServerIDs admits and reports hidden (no count) when
at least one target was dropped; WorkflowResponse wraps *models.Workflow
with a scoped TargetServerIDs and a TargetsRestricted flag. TargetTags is
left untouched — the tag vocabulary is already ruled acceptable to
expose. The MCP list_workflows/get_workflow tools get the identical
treatment: list_workflows' target count is now based on the filtered ID
list, and get_workflow's workflowDetail carries the same
TargetsRestricted flag, so a model that sees a filtered target list and
then has run_workflow refuse the same workflow for out-of-scope targets
is not left concluding the refusal invented a problem the list never
mentioned.

All four routes recorded in serverScopedRoutes as true; none is
boot-enforced, for the same substring-filter reason as the key routes
added in the previous round.
2026-09-09 08:03:26 +00:00
mrhid6 e06f9d5670 fix: scope the assignment count leaked by the key list
GET /api/keys returned each key's AssignedCount as a raw
CountDocuments over every non-revoked assignment, with no scope filter —
a tag-restricted token reading the list saw a nonzero count for a key it
can see nothing assigned to in its own scope, which is enough to tell it
an assignment exists on a host it must not know about. Same class of leak
getKey's assignment-list filter closed on the detail route, surviving on
the list route through a count instead of a server object.

services.ListKeys now takes the caller's tokenScope. An unrestricted
caller (empty scope) takes the original unfiltered per-key
CountDocuments with no extra work, so the common case is not slower. A
restricted caller resolves the visible fleet once via ListServers before
the per-key loop, then counts each key's assignments with an added
server_id $in filter — one extra query total, not one per key.
ListKeys had exactly one caller (listKeys), so the parameter went there
rather than adding a second entry point.

Recorded GET /api/keys in serverScopedRoutes as true; its path, like GET
/api/keys/:id, matches none of serverTouchingRoutes' substrings, so the
entry is not boot-enforced. Deliberately did not widen the filter to
catch "keys" — that would sweep in create/delete/private-key routes with
no server data at all. The real fix for this shape of gap is the
declare-by-default inversion already recorded as a follow-up.
2026-09-09 07:54:42 +00:00
mrhid6 dc6e1b3c29 fix: widen server-scope boot check, filter out-of-scope key assignments
serverTouchingRoutes in cmd/main.go filtered on "server"/"console"/an exact
workflows-run match, which is how POST /api/keys/:id/assign reached
production with no scope check and no boot-time signal at all: its path
names neither. Widen the filter to also match ":serverId" and "assign",
and document at the filter why a substring match is the weak part of this
design — a route that acts on a server without saying so in its path stays
invisible to it — noting that inverting the model (every /api route
declares itself, with an exemption list) would be the stronger fix and is
left as a follow-up. Re-running the mechanical check against the widened
filter swept in no route beyond what serverScopedRoutes already declared.

GET /api/keys/:id also leaked out-of-scope hostnames: it returned every
assignment for a key, server attached, unfiltered by the caller's tag
restriction. getKey now drops any assignment whose server fails
services.ServerInTokenScope before returning the list — silently, so the
response carries no count of what was removed — while still returning the
key itself, since a restricted token may legitimately hold a key also
assigned outside its scope. GetAssignmentsWithServers has exactly one
caller (getKey), so the filtering is done in the handler rather than
threaded into the service. Recorded in serverScopedRoutes; its path
matches none of the filter's substrings either, so it is not boot-enforced
and is kept as a considered decision, same as the assign/revoke entries.
2026-09-09 07:51:04 +00:00
mrhid6 cbf929fe2d fix: audit refused and failed mcp write calls, scope key assignment to token
Every early return from a write-tool handler skipped both the tool's own
LogCall and transport.go's gated LogCall (which only fires for reads), so a
blocked mutation attempt left no audit trail. registerSDKTool now routes
every write-tool error through LogDenied (fan-out and tag-scope refusals,
by gate name) or LogFailure (everything else), keeping the successful-write
path logging its own resolved server count exactly as before.

Also close a live scope gap surfaced while reviewing this: POST
/api/keys/:id/assign called services.AssignKey with an unscoped GetServer
lookup, so a tag-restricted token could assign a key to a server outside
its restriction. The handler now resolves the target through
GetServerScoped first, matching its sibling revoke route, and the route is
recorded in serverScopedRoutes.
2026-09-09 07:46:45 +00:00
mrhid6 b3651ab58c feat: add mcp write tools with a fan-out guard 2026-09-09 07:28:48 +00:00
mrhid6 191a8e9074 fix(mcp): correct fleet online status, unsafe version filter, audit totals
summariseServer compared Status against "online", a value never assigned
anywhere (the real vocabulary is pending/active/offline), so every server
misreported as offline. search_fleet's version_below used a lexicographic
comparison across dpkg/rpm/apk version schemes with no common ordering, so
it refuses that filter now and returns all matches instead of a wrong
answer. listAuditResult's Total carried the "shown" JSON tag and the
capped count; it now reports the real total alongside shown.
2026-09-09 07:24:09 +00:00
mrhid6 ed79df4270 fix: restore the vantage-shared go.sum entries dropped by go mod tidy 2026-09-09 07:17:53 +00:00
mrhid6 7ec97ae8c2 feat: Removed go.mod replace
Chart Release / chart (push) Successful in 17s
Server Deploy / deploy (push) Successful in 3m9s
2026-09-08 15:10:05 +00:00
mrhid6 b96cd85e43 feat: go mod tidy
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Failing after 1m16s
2026-09-08 15:00:54 +00:00
mrhid6 971bcece44 feat: Updated openapi doc 2026-09-08 14:51:43 +00:00
mrhid6 333d729026 feat: Updated go deps
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Failing after 1m20s
2026-09-08 14:44:45 +00:00
mrhid6 8dd68e34c1 docs: mark the api keys redesign plan implemented
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m11s
2026-09-08 14:14:57 +00:00
mrhid6 e5b9894384 feat: surface the mcp endpoint and its scopes on the api keys page 2026-09-08 14:14:44 +00:00
mrhid6 14b947f791 feat: add mcp read tools for fleet, health and workflow data 2026-09-08 14:12:44 +00:00
mrhid6 98233b620c docs: fold the mcp task 12 token-form steps into the api keys redesign 2026-09-08 14:12:26 +00:00
mrhid6 2d6b5bd8a3 feat: restrict an api key to tagged servers from the create dialog 2026-09-08 14:12:10 +00:00
mrhid6 ac9cc57e7e feat: rebuild the create key dialog around a scope matrix and a preview 2026-09-08 14:10:37 +00:00
mrhid6 a0641e8ecb feat: summarise key posture above the ledger 2026-09-08 14:09:00 +00:00
mrhid6 67ac029354 feat: redesign the api key list as a ledger with lifetime bars 2026-09-08 14:07:56 +00:00
mrhid6 a0b5565a63 docs: explain why GET /api/mcp deliberately answers 405 in stateless mode 2026-09-08 14:07:54 +00:00
mrhid6 aedc388535 refactor: split the api keys panel into ledger, chips, lifetime and dialog 2026-09-08 14:06:02 +00:00
mrhid6 5e4c8afdd1 feat: model an api key's remaining lifetime as a single value 2026-09-08 14:03:49 +00:00
mrhid6 0166b17299 feat: serve the mcp endpoint behind the licence feature 2026-09-08 13:57:21 +00:00
mrhid6 674236bb76 feat: audit mcp tool calls and guard against fleet-wide fan-out 2026-09-08 13:50:28 +00:00
mrhid6 5943d98681 feat: add the mcp tool registry and its scope gates 2026-09-08 13:50:01 +00:00
mrhid6 7ea8e2fff0 fix: close out-of-scope server access in vulns, packages, run logs, and key revoke 2026-09-08 13:48:11 +00:00
mrhid6 f87986b4f7 feat: enforce token tag restrictions at the server resolution chokepoints 2026-09-08 13:43:38 +00:00
mrhid6 2481974b3a feat: carry the token tag restriction on the session 2026-09-08 13:37:05 +00:00
mrhid6 f9df426e6c feat: allow an API token to be restricted to servers by tag 2026-09-08 13:34:58 +00:00
mrhid6 e8e41f197a build: temporarily replace vantage-shared with local checkout 2026-09-08 13:31:56 +00:00
mrhid6 1f2b56ea29 Revert "chore: pick up the mcp licence feature from vantage-shared"
This reverts commit a2fe478c82.
2026-09-08 13:31:20 +00:00
mrhid6 8234bdf9f3 feat: add the mcp scope resource 2026-09-08 13:29:41 +00:00
mrhid6 a2fe478c82 chore: pick up the mcp licence feature from vantage-shared 2026-09-08 13:28:25 +00:00
mrhid6 a2eee958f0 docs: implementation plan for the mcp server, and creation tools in the spec 2026-09-08 13:24:03 +00:00
mrhid6 998e1c419d docs: design spec for the MCP server feature 2026-09-08 13:12:57 +00:00
mrhid6 d92ca7591f Rephrased secrets page
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m3s
2026-09-08 10:25:01 +00:00
229 changed files with 22709 additions and 1234 deletions
+3 -3
View File
@@ -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')
+2 -2
View File
@@ -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
+251 -182
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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 ---
@@ -17,4 +17,7 @@ KEY_ENCRYPTION_KEY=
# MongoDB is bundled in this compose file. Override only to use an external DB.
MONGO_URI=mongodb://mongo:27017/vantage
# Host port the bundled nginx reverse proxy listens on (plain HTTP).
NGINX_HTTP_PORT=80
# Where workflow run logs are written inside the server container.
+17 -7
View File
@@ -13,7 +13,7 @@ services:
timeout: 5s
retries: 5
mongo:
image: mongo:7
image: mongo:8
restart: unless-stopped
volumes:
- mongo_data:/data/db
@@ -33,7 +33,7 @@ services:
ports:
- 4822:4822
server:
image: gitea.hostxtra.co.uk/mrhid6/vantage/server:latest
image: gitea.hostxtra.co.uk/vantage/vantage-app/server:latest
restart: unless-stopped
ports:
- 8080:8080
@@ -56,16 +56,26 @@ services:
volumes:
- ./data:/data
web:
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
image: gitea.hostxtra.co.uk/vantage/vantage-app/web:latest
restart: unless-stopped
ports:
- 3000:3000
# No API_URL: web proxies nothing. The reverse proxy in front of this
# deployment must route /api, /auth, /public, /install*, /update* to
# server:8080 and everything else to web:3000. Reaching web:3000
# directly serves the UI and every API call 404s.
# No API_URL: web proxies nothing. The nginx service below routes
# /api, /auth, /public, /install*, /update* to server:8080 and
# everything else to web:3000. Reaching web:3000 directly serves the
# UI and every API call 404s.
depends_on:
- server
nginx:
image: nginx:1.29-alpine
restart: unless-stopped
ports:
- ${NGINX_HTTP_PORT:-80}:80
volumes:
- ./nginx/vantage.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- server
- web
volumes:
mongo_data: null
redis_data: null
+72
View File
@@ -0,0 +1,72 @@
# Reverse proxy for a self-hosted Vantage install.
#
# One hostname, two backends: the control plane's HTTP routes go to
# server:8080, everything else to web:3000. web proxies nothing itself, so
# without the server locations the UI loads and every request it makes 404s.
#
# Plain HTTP only. Terminate TLS in front of this container, or see the
# self-hosted install docs for a variant that terminates TLS here.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream vantage_server {
server server:8080;
keepalive 16;
}
upstream vantage_web {
server web:3000;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name _;
# Step imports and licence pastes are the largest request bodies.
client_max_body_size 10m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Control plane: REST API, auth, public status pages.
location ^~ /api/ {
proxy_pass http://vantage_server;
# The browser console is a WebSocket at /api/console/tunnel and
# workflow logs stream as server-sent events; neither may be buffered
# or cut off by a short read timeout.
proxy_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location ^~ /auth/ {
proxy_pass http://vantage_server;
}
location ^~ /public/ {
proxy_pass http://vantage_server;
}
# Agent install and update scripts: /install, /install.ps1, /update,
# /update.ps1.
location ~ ^/(install|update)(\.ps1)?$ {
proxy_pass http://vantage_server;
}
# Everything else is the web UI.
location / {
proxy_pass http://vantage_web;
}
}
@@ -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
@@ -0,0 +1,439 @@
<title>Vantage Key Ledger</title>
<style>
:root{
color-scheme: dark;
--ground:#071628;
--panel:#0d2138;
--panel-2:#102842;
--well:#04101f;
--ink:#e4ecf6;
--ink-2:#9fb3ca;
--ink-3:#71879f;
--rule:#1e3855;
--rule-soft:#172c44;
--accent:#5b9be8;
--accent-hover:#7fb2f0;
--accent-ink:#04101f;
--up:#4fb484;
--pend:#d6a63f;
--down:#e2705a;
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--mono:ui-monospace,"Cascadia Mono","SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--r:4px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--ground);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;font-size:14px;line-height:1.5}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 96px}
/* ---------- masthead ---------- */
.masthead{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid var(--rule)}
h1,h2,h3{font-weight:800;letter-spacing:-.03em;text-wrap:balance}
h1{font-size:26px}
.btn{font:inherit;font-weight:500;border-radius:var(--r);border:1px solid transparent;padding:9px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:8px}
.btn-primary{background:var(--accent);color:var(--accent-ink);font-weight:600}
.btn-primary:hover{background:var(--accent-hover)}
.btn-ghost{background:transparent;color:var(--ink-2);border-color:var(--rule)}
.btn-ghost:hover{color:var(--ink);border-color:var(--ink-3)}
.btn-danger{background:transparent;color:var(--down);border-color:transparent;padding:5px 8px;font-size:13px}
.btn-danger:hover{background:rgba(226,112,90,.12)}
/* ---------- posture strip: the summary before the detail ---------- */
.posture{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--rule-soft);border:1px solid var(--rule);border-radius:var(--r);margin:20px 0 24px;overflow:hidden}
.posture div{background:var(--panel);padding:12px 14px;display:flex;flex-direction:column;gap:2px}
.posture .n{font-family:var(--mono);font-size:20px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
.posture .l{font-size:12px;color:var(--ink-3)}
.n.warn{color:var(--pend)} .n.bad{color:var(--down)} .n.dim{color:var(--ink-2)}
/* ---------- filter ---------- */
.filters{display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.seg{display:inline-flex;border:1px solid var(--rule);border-radius:var(--r);overflow:hidden}
.seg button{font:inherit;font-size:13px;background:transparent;color:var(--ink-2);border:0;padding:6px 12px;cursor:pointer;white-space:nowrap}
.seg button+button{border-left:1px solid var(--rule)}
.seg button[aria-pressed="true"]{background:var(--panel-2);color:var(--ink);box-shadow:inset 0 -2px 0 var(--accent)}
.filters .spacer{flex:1}
.hint{font-size:12px;color:var(--ink-3)}
/* ---------- ledger ---------- */
.ledger{border:1px solid var(--rule);border-radius:var(--r);background:var(--panel);overflow:hidden}
.lrow{display:grid;grid-template-columns:minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto;gap:20px;align-items:center;padding:14px 18px}
.lhead{padding:9px 18px;background:var(--panel-2);border-bottom:1px solid var(--rule);font-size:11px;color:var(--ink-3);letter-spacing:.06em;font-family:var(--mono)}
.lrow+.lrow{border-top:1px solid var(--rule-soft)}
.lrow:hover{background:var(--panel-2)}
.name{display:flex;flex-direction:column;gap:3px;min-width:0}
.name b{font-weight:600}
.fingerprint{font-family:var(--mono);font-size:12px;color:var(--ink-3)}
.who{font-size:12px;color:var(--ink-3)}
.pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-family:var(--mono);border:1px solid var(--rule);border-radius:999px;padding:1px 8px;color:var(--ink-2)}
.pill.owner{border-color:rgba(91,155,232,.5);color:var(--accent)}
.pill.admin{border-color:rgba(214,166,63,.5);color:var(--pend)}
.dot{width:5px;height:5px;border-radius:50%;background:currentColor}
/* scope matrix: resource + r/w encoded as filled halves */
.scopes{display:flex;flex-wrap:wrap;gap:5px}
.scope{display:inline-flex;align-items:stretch;border:1px solid var(--rule);border-radius:3px;overflow:hidden;font-family:var(--mono);font-size:11px}
.scope span{padding:1px 6px;color:var(--ink-2)}
.scope i{font-style:normal;padding:1px 5px;border-left:1px solid var(--rule);color:var(--ink-3)}
.scope.rw i{background:rgba(91,155,232,.18);color:var(--accent)}
.scope.r i{background:rgba(159,179,202,.1)}
.scope.none{color:var(--ink-3);border-style:dashed}
/* lifetime bar: the redesign's one visual idea */
.life{display:flex;flex-direction:column;gap:5px}
.track{height:4px;border-radius:2px;background:var(--rule-soft);overflow:hidden}
.track b{display:block;height:100%;background:var(--up)}
.track b.warn{background:var(--pend)} .track b.bad{background:var(--down)} .track b.flat{background:var(--ink-3)}
.life small{font-family:var(--mono);font-size:11.5px;color:var(--ink-2);font-variant-numeric:tabular-nums}
.life small.warn{color:var(--pend)} .life small.bad{color:var(--down)}
.used{font-family:var(--mono);font-size:12px;color:var(--ink-2);font-variant-numeric:tabular-nums}
.used.never{color:var(--ink-3)}
.right{text-align:right}
/* ---------- modal ---------- */
.stage{margin-top:44px;padding-top:28px;border-top:1px dashed var(--rule)}
.stage h2{font-size:16px}
.stage p.note{color:var(--ink-3);font-size:13px;margin-top:4px;max-width:60ch}
.scrim{margin-top:18px;background:rgba(4,16,31,.72);border:1px solid var(--rule-soft);border-radius:var(--r);padding:28px 16px;display:flex;justify-content:center}
.modal{width:100%;max-width:640px;background:var(--panel);border:1px solid var(--rule);border-radius:var(--r);box-shadow:0 1px 0 rgba(0,0,0,.35),0 20px 44px -26px rgba(0,0,0,.85);overflow:hidden}
.mhead{padding:16px 20px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center}
.mhead h3{font-size:15px}
.mhead .hint{margin-top:2px}
.mbody{padding:20px;display:flex;flex-direction:column;gap:20px}
.mfoot{padding:14px 20px;border-top:1px solid var(--rule);display:flex;justify-content:flex-end;gap:8px;background:var(--panel-2)}
label.f{display:flex;flex-direction:column;gap:6px}
label.f>span{font-size:13px;font-weight:500}
label.f em{font-style:normal;font-size:12px;color:var(--ink-3);font-weight:400}
input[type=text],select{font:inherit;background:var(--well);border:1px solid var(--rule);color:var(--ink);border-radius:var(--r);padding:9px 11px;width:100%}
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 */
.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)}
.mx>*{padding:7px 12px}
.mx.head>*:not(:first-child),.mx>label{text-align:center}
.mx+.mx{border-top:1px solid var(--rule-soft)}
.mx>b{font-weight:500;font-size:13px}
.mx>b small{display:block;color:var(--ink-3);font-size:11.5px;font-weight:400}
.mx label{display:flex;justify-content:center;cursor:pointer}
input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);background:var(--well);cursor:pointer}
.mxfoot{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;background:var(--panel-2);border-top:1px solid var(--rule);font-size:12px;color:var(--ink-3)}
.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 */
.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)}
/* reveal panel */
.reveal{display:flex;flex-direction:column;gap:14px}
.warnbar{border:1px solid rgba(214,166,63,.35);background:rgba(214,166,63,.1);color:var(--pend);border-radius:var(--r);padding:9px 12px;font-size:13px}
.secret{display:flex;gap:0;align-items:stretch;border:1px solid var(--rule);border-radius:var(--r);overflow:hidden;background:var(--well)}
.secret code{flex:1;font-family:var(--mono);font-size:13px;padding:11px 12px;overflow-x:auto;white-space:nowrap;color:var(--ink)}
.secret button{border:0;border-left:1px solid var(--rule);background:var(--panel-2);color:var(--ink);font:inherit;font-size:13px;padding:0 16px;cursor:pointer}
.secret button:hover{background:var(--rule)}
dl.meta{display:grid;grid-template-columns:88px 1fr;gap:8px 14px;font-size:13px;align-items:baseline}
dl.meta dt{color:var(--ink-3)}
/* ---------- narrow: the ledger stops being a table ---------- */
@media (max-width:900px){
.wrap{padding:24px 16px 72px}
.masthead{align-items:flex-start;gap:16px}
.masthead>.btn{width:100%;justify-content:center}
.posture{grid-template-columns:1fr 1fr}
.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
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}
.lrow>[data-label]::before{content:attr(data-label);display:block;font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);margin-bottom:6px}
.name{padding-right:88px}
.scopes{overflow-x:auto;flex-wrap:nowrap;padding-bottom:2px;-webkit-overflow-scrolling:touch}
.scope{flex:0 0 auto}
.right{position:absolute;top:12px;right:12px;text-align:right}
.btn-danger{border-color:var(--rule);padding:7px 12px}
}
@media (max-width:520px){
h1{font-size:22px}
.posture{grid-template-columns:1fr}
.posture div{flex-direction:row;align-items:baseline;gap:10px}
.posture .n{font-size:16px;min-width:2ch}
/* The spacer and the hint were competing with the segment for one row,
squeezing the buttons below their own labels. Stack instead. */
.filters{flex-direction:column;align-items:stretch}
.filters .spacer{display:none}
.filters .seg{width:100%}
.filters .seg button{flex:1;white-space:nowrap;padding:9px 8px}
.scrim{padding:16px 10px}
.mbody{padding:16px;gap:16px}
.mhead,.mfoot{padding:14px 16px}
.mfoot{flex-direction:column-reverse}
.mfoot .btn{width:100%;justify-content:center}
.mx{grid-template-columns:1fr 54px 54px}
.mx>*{padding:9px 10px}
.mxfoot{flex-direction:column;align-items:flex-start;gap:6px}
/* Copy has to stay reachable without scrolling the secret sideways first. */
.secret{flex-direction:column}
.secret button{border-left:0;border-top:1px solid var(--rule);padding:11px 16px}
dl.meta{grid-template-columns:1fr;gap:3px}
dl.meta dt{margin-top:8px}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
<div class="wrap">
<header class="masthead">
<div>
<h1>API keys</h1>
</div>
<button class="btn btn-primary" onclick="document.getElementById('create').scrollIntoView({behavior:'smooth'})">Create key</button>
</header>
<section class="posture" aria-label="Key posture">
<div><span class="n">6</span><span class="l">keys in this instance</span></div>
<div><span class="n warn">2</span><span class="l">expire within 7 days</span></div>
<div><span class="n bad">1</span><span class="l">never expires</span></div>
<div><span class="n dim">2</span><span class="l">unused since issue</span></div>
</section>
<div class="filters">
<div class="seg" role="group" aria-label="Whose keys">
<button type="button" aria-pressed="false">My keys</button>
<button type="button" aria-pressed="true">All keys</button>
</div>
<div class="spacer"></div>
<span class="hint">Instance policy caps new keys at 90 days.</span>
</div>
<section class="ledger" aria-label="API keys">
<div class="lrow lhead">
<span>key / holder</span><span>scopes</span><span>lifetime</span><span>last call</span><span></span>
</div>
<div class="lrow">
<div class="name">
<b>gitea-ci-deploy</b>
<span class="fingerprint">vt_9f2c…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill admin"><span class="dot"></span>admin</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope rw"><span>workflows</span><i>rw</i></span>
<span class="scope r"><span>secrets</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b style="width:71%"></b></div>
<small>64 days left · 12 Nov</small>
</div>
<span class="used" data-label="last call">4 minutes ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>eso-cluster-prod</b>
<span class="fingerprint">vt_41ab…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>secrets</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="warn" style="width:6%"></b></div>
<small class="warn">5 days left · 13 Sep</small>
</div>
<span class="used" data-label="last call">22 minutes ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>status-page-embed</b>
<span class="fingerprint">vt_c70e…</span>
<span class="who">priya@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>status</span><i>r</i></span>
<span class="scope r"><span>monitors</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b style="width:88%"></b></div>
<small>318 days left · 23 Jul 2027</small>
</div>
<span class="used" data-label="last call">3 days ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>patch-tuesday-runner</b>
<span class="fingerprint">vt_2d55…</span>
<span class="who">ops@hostxtra.co.uk · <span class="pill owner"><span class="dot"></span>owner</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope rw"><span>vulns</span><i>rw</i></span>
<span class="scope rw"><span>workloads</span><i>rw</i></span>
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope r"><span>keys</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="flat" style="width:100%"></b></div>
<small>No expiry · issued before the 90-day cap</small>
</div>
<span class="used" data-label="last call">Yesterday</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>laptop-scratch</b>
<span class="fingerprint">vt_86f1…</span>
<span class="who">priya@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes"><span class="scope none"><span>no scopes granted</span></span></div>
<div class="life" data-label="lifetime">
<div class="track"><b class="bad" style="width:0%"></b></div>
<small class="bad">Expired 2 Sep</small>
</div>
<span class="used never" data-label="last call">Never used</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>terraform-plan-readonly</b>
<span class="fingerprint">vt_0b3d…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>servers</span><i>r</i></span>
<span class="scope r"><span>keys</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="warn" style="width:2%"></b></div>
<small class="warn">2 days left · 10 Sep</small>
</div>
<span class="used never" data-label="last call">Never used</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
</section>
<!-- ============ create modal, state 1 ============ -->
<section class="stage" id="create">
<h2>Create key</h2>
<p class="note">One dialog, three decisions in the order that matters: who the key is, what it may call, how long it lives. The preview line is the key read back as a sentence before it exists.</p>
<div class="scrim">
<div class="modal" role="dialog" aria-label="Create key">
<div class="mhead">
<div>
<h3>Create key</h3>
<p class="hint">Shown once. Copy it before you close.</p>
</div>
<span class="pill"><span class="dot"></span>your role: admin</span>
</div>
<div class="mbody">
<div class="two">
<label class="f">
<span>Name <em>what will use it</em></span>
<input type="text" value="gitea-ci-deploy">
</label>
<label class="f">
<span>Role <em>capped at yours</em></span>
<select><option>admin</option><option>member</option></select>
</label>
</div>
<div>
<label class="f" style="margin-bottom:8px"><span>Scopes <em>write already covers read</em></span></label>
<div class="matrix">
<div class="mx head"><span>resource</span><span>read</span><span>write</span></div>
<div class="mx"><b>servers<small>fleet list, inventory, agent updates</small></b><label><input type="checkbox" checked></label><label><input type="checkbox" checked></label></div>
<div class="mx"><b>workflows<small>steps, runs, logs</small></b><label><input type="checkbox" checked></label><label><input type="checkbox" checked></label></div>
<div class="mx"><b>secrets<small>vault groups and values</small></b><label><input type="checkbox" checked></label><label><input type="checkbox"></label></div>
<div class="mx"><b>keys<small>SSH keys and assignments</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mx"><b>monitors<small>checks, incidents, uptime</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mx"><b>vulns<small>findings and rescans</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mxfoot">
<span>3 of 9 resources · 5 scopes</span>
<span><button class="linky" type="button">Read-only everywhere</button> · <button class="linky" type="button">Clear all</button></span>
</div>
</div>
</div>
<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>
</label>
<p class="preview">
<b>gitea-ci-deploy</b> acts as <b>admin</b>, may <b>read and write</b> servers and workflows,
<b>read</b> secrets, and stops working on <b class="cap">7 December 2026</b>.
</p>
</div>
<div class="mfoot">
<button class="btn btn-ghost">Cancel</button>
<button class="btn btn-primary">Create key</button>
</div>
</div>
</div>
</section>
<!-- ============ create modal, state 2 ============ -->
<section class="stage">
<h2>After it is created</h2>
<p class="note">The secret is unrecoverable once this closes, so Copy is the primary action and the summary confirms what was granted without a second trip to the ledger.</p>
<div class="scrim">
<div class="modal" role="dialog" aria-label="Key created">
<div class="mhead">
<div>
<h3>gitea-ci-deploy is ready</h3>
<p class="hint">Vantage stores only a hash of this value.</p>
</div>
</div>
<div class="mbody reveal">
<div class="warnbar">This is the only time the key is shown. Copy it into your CI secret store now.</div>
<div class="secret">
<code>vt_9f2c4b71ae03d85f6c19bb27e4a0d3f58c62719ad4be05f3c8a1d7602b94ef11</code>
<button type="button">Copy</button>
</div>
<dl class="meta">
<dt>Role</dt><dd>admin</dd>
<dt>Scopes</dt>
<dd class="scopes">
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope rw"><span>workflows</span><i>rw</i></span>
<span class="scope r"><span>secrets</span><i>r</i></span>
</dd>
<dt>Expires</dt><dd>7 December 2026 · 90 days</dd>
<dt>Use it</dt><dd><code style="font-family:var(--mono);font-size:12px;color:var(--ink-2)">curl -H "Authorization: Bearer vt_…" https://acme.vantage.example/api/servers</code></dd>
</dl>
</div>
<div class="mfoot">
<button class="btn btn-ghost">Done</button>
<button class="btn btn-primary">Copy key</button>
</div>
</div>
</div>
</section>
<!-- ============ empty state ============ -->
<section class="stage">
<h2>When there are no keys</h2>
<div class="scrim" style="padding:40px 16px">
<div style="max-width:420px;text-align:center;display:flex;flex-direction:column;gap:10px;align-items:center">
<span style="font-family:var(--mono);font-size:13px;color:var(--ink-3);border:1px dashed var(--rule);border-radius:var(--r);padding:6px 12px">vt_ · nothing issued yet</span>
<b style="font-size:15px">You have no API keys.</b>
<p style="color:var(--ink-2);font-size:13px">Create one to call the REST API from a script or a CI job. It is scoped to what you grant it and never outranks your own role.</p>
<button class="btn btn-primary">Create your first key</button>
</div>
</div>
</section>
</div>
@@ -0,0 +1,386 @@
# API Keys Page Redesign Implementation Plan
> **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.
**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.
**Existing code:** the whole page is one 487-line file, `web/components/apikeys/ApiKeysPanel.tsx`, rendered by `web/app/(app)/tokens/page.tsx`.
## 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`.
- **`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".
- **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.
## Relationship to the MCP server plan
`docs/superpowers/plans/2026-09-08-mcp-server.md` is in progress on branch `feat/mcp-server`. Tasks 15 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 615 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.
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.
Sequencing:
- Run this plan **on `feat/mcp-server`, after MCP Task 11**, so `license.features.mcp` and the `mcp:*` scopes exist when Tasks 6 and 7 below need them; or
- run it on its own branch **stopping after Task 5**, and land Tasks 6 and 7 later once MCP merges.
Tasks 6 and 7 are written to be skippable and are marked so. Nothing in Tasks 15 depends on MCP.
> **Implemented 8 September 2026, all eight tasks, on `feat/mcp-server`.** MCP
> Tasks 69 landed on the same branch while this ran, which unblocked Task 7, so
> 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
> instance with fixture keys.
Whichever route is taken, **strike steps 25 from MCP Task 12 and leave a pointer to this plan**, so the next worker through does not build the tag picker twice.
---
### 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.
**Files:**
- Create: `web/lib/keyLifetime.ts`
- Modify: none yet
**Interfaces:**
- Produces: `keyLifetime(token, now)` returning `{ state, remainingPct, label, outsidePolicy }`, consumed by Tasks 2 and 3.
- [x] **Step 1: Write the module**
Create `web/lib/keyLifetime.ts`. It takes an `ApiToken` and returns everything a lifetime cell needs, with no JSX and no date formatting spread across components:
```ts
import type { ApiToken } from "@/lib/api";
export type LifetimeState = "healthy" | "soon" | "expired" | "eternal";
export type Lifetime = {
state: LifetimeState;
/** 0100, the share of the token's issued life still to run. `eternal` is 100. */
remainingPct: number;
/** e.g. "64 days left · 12 Nov", "Expired 2 Sep", "No expiry". */
label: string;
/** True when the instance cap has tightened since this token was issued. */
outsidePolicy: boolean;
};
```
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.
- `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 0100, 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.
- 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**
From `vantage-app/web`: `npx tsc --noEmit`
Expected: no output.
- [x] **Step 3: Commit**
```bash
git add web/lib/keyLifetime.ts
git commit -m "feat: model an api key's remaining lifetime as a single value"
```
---
### Task 2: Split the panel into components
`ApiKeysPanel.tsx` is 487 lines holding a page, a table, two dialogs and a form. Every later task in this plan edits it. Split first, or each of them edits the same file and the diffs stop being reviewable.
**Files:**
- Create: `web/components/apikeys/ScopeChips.tsx`, `web/components/apikeys/LifetimeBar.tsx`, `web/components/apikeys/KeyLedger.tsx`, `web/components/apikeys/CreateKeyDialog.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`
**Interfaces:**
- Produces: the four components above. `ApiKeysPanel` keeps the queries, the mutations and the dialog open/closed state; the children stay presentational, taking props and calling handlers.
- [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`.
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.
`ApiKeysPanel` keeps: both `useQuery` calls, both `useMutation` calls, `showAll`, `createOpen`, `revoking`, the form state, `closeCreate`, `copyToken`, `resetForm` and the expiry-default effect.
- [x] **Step 2: Verify nothing moved on screen**
```bash
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.
- [x] **Step 3: Commit**
```bash
git add web/components/apikeys/
git commit -m "refactor: split the api keys panel into ledger, chips, lifetime and dialog"
```
---
### Task 3: The ledger
Replace the seven-column table with the record layout from the mockup: identity (name, `vt_` hint, holder, role) in one column, then scopes, lifetime and last call.
**Files:**
- Modify: `web/components/apikeys/KeyLedger.tsx`, `web/components/apikeys/LifetimeBar.tsx`, `web/components/apikeys/ScopeChips.tsx`
**Interfaces:**
- Consumes: `keyLifetime` (Task 1).
- [x] **Step 1: Rewrite `LifetimeBar`**
It renders a 4px track with a filled portion at `remainingPct`, the label beneath it in mono with `tabular-nums`, and the policy note when `outsidePolicy` is set. Colour by state: `bg-success`, `bg-warning`, `bg-danger`, and `bg-text-tertiary` for `eternal`. The label takes the matching text colour.
Give the track `role="img"` with an `aria-label` carrying the same text as the visible label. A bar with no accessible name is decoration to a screen reader, and this one is the primary signal in the row.
- [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.
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.
- [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 `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.
Copy these breakpoints from the mockup rather than re-deriving them; they were tuned against a real narrow viewport.
- [x] **Step 5: Keep `AsyncBoundary`, the skeleton and the empty state working**
`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.
- [x] **Step 6: Verify**
```bash
npm run lint && npm run build
```
In a browser at `/tokens`: a healthy key, a key expiring inside seven days, an expired key, a never-expiring key and a key with no scopes all render distinctly. Resize to 375px wide and confirm every cell is labelled and nothing clips. Tab through the page and confirm Revoke is reachable and its focus ring is visible.
- [x] **Step 7: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: redesign the api key list as a ledger with lifetime bars"
```
---
### Task 4: The posture strip
Four counts above the list, answering "is anything wrong here" before the operator reads a single row.
**Files:**
- Create: `web/components/apikeys/KeyPosture.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`
- [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.**
"Never used" is `last_used_at == null`. It is muted rather than coloured: an unused key is a cleanup candidate, not an incident.
The counts describe the list as filtered, so the strip sits below the `My keys` / `All keys` toggle in the DOM order the mockup shows, and re-renders with it.
- [x] **Step 2: Delete the old subtitle**
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.
- [x] **Step 3: Verify**
Browser check: with the fixtures from Task 3 present, the four counts are correct, and switching `My keys` / `All keys` changes them.
- [x] **Step 4: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: summarise key posture above the ledger"
```
---
### Task 5: The create dialog
Name and role side by side, scopes as one matrix instead of nine mini-cards, an expiry select that names the resolved date, and a preview line that reads the key back as a sentence before it exists.
**Files:**
- Modify: `web/components/apikeys/CreateKeyDialog.tsx`
- Create: `web/components/apikeys/ScopeMatrix.tsx`
- [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.
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.
- [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.
- [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.
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.
- [x] **Step 5: Verify**
```bash
npm run lint && npm run build
```
In a browser: create a key with two resources ticked; the preview names them and the resolved date; the created key's summary matches what the preview said. Confirm the cap still disables the long options, and that closing the dialog after a reveal still invalidates the list.
- [x] **Step 6: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: rebuild the create key dialog around a scope matrix and a preview"
```
---
### Task 6: Tag restriction - absorbs MCP Task 12 steps 2 and 4
**Requires MCP Tasks 35, 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.
**Files:**
- Modify: `web/lib/api.ts` (the `ApiToken` type and `createApiToken`)
- Modify: `web/components/apikeys/CreateKeyDialog.tsx`, `web/components/apikeys/KeyLedger.tsx`
- [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.
- [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.
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.
- [x] **Step 4: Verify**
Create a restricted key, confirm the chip appears, and confirm the audit detail on the server records the restriction (`api/tokens.go` already appends "restricted to …").
- [x] **Step 5: Commit**
```bash
git add web/lib/api.ts web/components/apikeys/
git commit -m "feat: restrict an api key to tagged servers from the create dialog"
```
---
### Task 7: Agent access - absorbs MCP Task 12 steps 3 and 5
**Requires MCP Tasks 611 (the endpoint itself) and the `mcp` licence feature.** Skip on a branch without them.
**Files:**
- Create: `web/components/apikeys/AgentAccessPanel.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`, `web/components/apikeys/ScopeMatrix.tsx`
- [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.
- [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`.
- [x] **Step 3: Verify**
With the feature off: no panel, no MCP row in the matrix. With it on: both appear, and the copied JSON pastes into a client and connects.
- [x] **Step 4: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: surface the mcp endpoint and its scopes on the api keys page"
```
- [x] **Step 5: Amend the MCP plan**
In `docs/superpowers/plans/2026-09-08-mcp-server.md`, strike steps 25 of Task 12, correct its stale file list to `web/app/(app)/tokens/` plus `web/components/apikeys/`, and point the remaining step 1 at this plan for the rest. Commit as `docs:`.
---
### Task 8: Documentation
**Files:**
- Modify: `vantage-docs/docs/reference/api-tokens.md`
- [x] **Step 1: Update the screenshots and the walkthrough**
The reference page describes the old form field by field. Rewrite the creation walkthrough around the matrix and the preview line, and document the lifetime bar's four states so the colours mean the same thing to a reader as to an operator. If Task 6 landed, document the tag restriction and both halves of its asymmetry.
- [x] **Step 2: Commit**
```bash
cd vantage-docs
git add docs/reference/api-tokens.md
git commit -m "docs: describe the redesigned api keys page"
```
---
## Verification checklist
Run before calling the work done:
- [x] `npm run lint` and `npm run build` clean from `vantage-app/web`.
- [x] `grep -rn "#[0-9a-fA-F]\{6\}" web/components/apikeys/` returns nothing.
- [ ] `/tokens` renders correctly at 1440px, 900px and 375px, with nothing clipped and every mobile cell labelled.
- [ ] Every state has a fixture that was actually looked at: healthy, expiring, expired, never-expiring, unscoped, never-used, outside-policy.
- [ ] Keyboard: every control reachable, focus rings visible on the dark ground, the dialog still traps focus and restores it on close.
- [ ] A member (not owner or admin) sees the page, sees only their own keys, and sees no `All keys` toggle.
- [ ] Revoke still works, still names the key in its confirmation and its toast, and still shows the server's error inline on failure.
- [ ] The plaintext key is still shown exactly once, and closing the dialog after a reveal still invalidates the list.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -0,0 +1,444 @@
# MCP server
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
as every other API caller.
Concretely: a user mints a Vantage API token, points Claude (or any MCP client)
at `https://<instance>/api/mcp`, and asks "which hosts are still on OpenSSL
1.1?" or "run the patch workflow on staging". Nothing an agent can do is
something the token's owner could not already do through the UI.
Out of scope, deliberately:
- **OAuth 2.1 authorization server.** A first-class remote connector on
claude.ai would need one. Bearer tokens work today in every client that
matters, and the token model already exists. Its own sub-project.
- **MCP resources and prompts.** Client support is uneven, and tools alone are
the whole value. Both can be added later without a protocol break.
- **Secret reveal.** Secret names and metadata are exposed; plaintext never is,
at any scope. An LLM context window is the wrong place for a credential, and
the human path through the UI still exists.
- **Console and exec.** Interactive terminal access is a streaming, stateful
problem that does not fit a tool call, and an agent with a shell is a
different security conversation.
- **An approval queue.** Writes are gated by an explicit scope, not by a
human-in-the-loop workflow. A pending-action subsystem is a real feature and
would roughly double this one.
- **Editing or deleting existing workflows, steps and monitors.** Creation is
in scope; changing or removing something a human already made is not. An agent
that can only add leaves every existing definition intact, and an unwanted new
one is deleted in a click.
- **Secret-referencing steps.** A created step may not declare `secret_refs`.
Composing a script around a secret reference is how a credential ends up
echoed into a log, and the human step editor already does this safely.
## Current state
Most of the hard parts already exist, which is the reason this is worth doing
now rather than as a large project later.
| Capability | Where |
| --- | --- |
| Bearer token auth, role recomputation, expiry auditing | `auth.sessionFromToken` |
| Token model, hash-only storage, immutable role and scopes | `models.APIToken` |
| Coarse scope vocabulary, write implies read | `services.ScopeResources` |
| Route-to-scope map, boot-time completeness assertion | `api.routeScopes`, `AssertScopeMapComplete` |
| Licence feature gate as middleware | `api.RequireFeature`, `license.HasFeature` |
| Feature catalogue and Paddle pricing | `models.CatalogueRow`, `catalogue.LineItems` |
| Tag selectors over servers | `services.MatchesTags`, `ListServersFiltered`, `ResolveTargets` |
| Audit event write | `services.LogEvent` |
Three things do not exist: any MCP protocol handling, any notion of a
credential restricted to part of the fleet, and any licence feature for either.
## Approach
A new package `server/internal/mcp` registers a tool set against the existing
service layer and serves it over Streamable HTTP at `/api/mcp`, mounted inside
the existing `/api` group so that every middleware already on that group applies
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
feature.
Three independent gates gate every tool call, and all three must pass:
1. The licence grants `license.FeatureMCP`.
2. The token holds `mcp:read` (any tool) or `mcp:write` (write tools).
3. The token holds the per-tool resource scope, e.g. `workflows:write`.
## Scope vocabulary
`services.ScopeResources` gains one entry, `"mcp"`. That is the whole change:
`AllScopes()` derives `mcp:read` and `mcp:write` from it, `validScope` accepts
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
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
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
own resource scopes. Implied by the existing rule when a token holds
`mcp:write`, so `mcp:read` need not be requested separately.
Write tools are **omitted from `tools/list`** for a token without `mcp:write`,
not merely refused on call. An agent cannot be talked into using a tool it has
never been told exists, and a read-only agent that cannot see destructive tools
produces better behaviour than one that keeps trying them and reading errors.
## Tag-scoped API tokens
This is a general API token capability, not an MCP one, and it ships ungated by
licence. Restricting what a credential can touch is a security control, and
putting a security control behind a paywall is the wrong instinct.
`models.APIToken` gains:
```go
// TagSelector restricts this token to servers carrying every tag in the map.
// Empty or nil means the whole fleet. Immutable after creation, like Role and
// Scopes: narrowing or widening what a deployed credential reaches, with no
// record of what it reached before, is worse than requiring a rotation.
TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,omitempty"`
```
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
rule `ScopeSatisfied` already enforces for scopes, applied to tags.
`auth.Session` carries `TagSelector`, populated in `sessionFromToken` and always
empty for a cookie session. `auth.ServerScope(c)` returns it.
### Enforcement
The selector is intersected at the points where servers are resolved, not at
each handler:
| Path | Change |
| --- | --- |
| `services.ListServers` | Handlers call `ListServersFiltered` with the session selector merged into any request selector |
| `services.GetServer` | Returns not-found when `!MatchesTags(srv, sel)` |
| `services.ResolveTargets` | Intersects the caller's selector with the requested one; a request naming an out-of-scope ID resolves to nothing |
`ResolveTargets` is the chokepoint that matters most: workflow runs, console
connections and update application all pass through it, so a correct
intersection there covers the mutating surface.
**Out-of-scope hosts read as 404, never 403.** A scoped token must not be able
to enumerate the fleet it cannot see by observing which IDs answer differently.
### Completeness assertion
Mirroring `AssertScopeMapComplete`, a boot-time assertion in `api` lists every
route that returns or acts on server-derived data and asserts each is declared
either tag-filtered or explicitly fleet-wide. A route added tomorrow that reads
server data without honouring the selector fails at deploy rather than leaking
silently. The precedent is deliberate: this codebase already prefers a
maintained map that fails boot over a decorator someone can forget.
## Licence feature
`vantage-shared/license` gains:
```go
FeatureMCP = "mcp" // agent access over the Model Context Protocol
```
No plan bundles it. Every tier's `Features` stays `[]string{}`, consistent with
console and OIDC being opt-in per customer.
Enforced in three places:
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
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
when the licence does not grant it, as console is today.
Existing tokens are unaffected: absent the new scopes, no token can reach the
endpoint, so enabling the feature grants nothing by itself.
## Transport and protocol
Streamable HTTP, stateless. `POST /api/mcp` carries the JSON-RPC request and
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
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.
Protocol framing comes from `github.com/modelcontextprotocol/go-sdk`. Everything
below the framing is the existing service layer, called directly in-process.
The MCP layer never issues HTTP requests to Vantage's own API: doing so would
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
the first route where the route-level scope is a floor rather than the whole
answer, and the map entry's comment says so.
Server metadata advertises the instance name and Vantage version, so a user with
several instances connected can tell them apart in a client.
## Tool set
Roughly twenty tools, written to how an agent asks questions rather than to how
the REST API is shaped. Each declares its resource scope and whether it is a
write.
| Tool | Scope | Write |
| --- | --- | --- |
| `list_servers` | `servers:read` | |
| `get_server` | `servers:read` | |
| `search_fleet` | `vulns:read` | |
| `list_monitors` | `monitors:read` | |
| `get_monitor_status` | `monitors:read` | |
| `list_incidents` | `monitors:read` | |
| `get_monitor_samples` | `monitors:read` | |
| `list_pending_updates` | `servers:read` | |
| `list_vulnerabilities` | `vulns:read` | |
| `get_server_packages` | `vulns:read` | |
| `list_workflows` | `workflows:read` | |
| `get_workflow` | `workflows:read` | |
| `get_run` | `workflows:read` | |
| `get_run_logs` | `workflows:read` | |
| `list_audit_events` | `settings:read` | |
| `list_secret_names` | `secrets:read` | |
| `run_workflow` | `workflows:write` | yes |
| `cancel_run` | `workflows:write` | yes |
| `apply_updates` | `servers:write` | yes |
| `update_agent` | `servers:write` | yes |
| `assign_key` | `keys:write` | yes |
| `create_step` | `workflows:write` | yes |
| `create_workflow` | `workflows:write` | yes |
| `create_monitor` | `monitors:write` | yes |
Rules every tool follows:
- **Trimmed projections, not API JSON.** `list_servers` over thirty hosts must
cost a few hundred tokens, not several thousand. Each tool defines its own
response struct containing what an agent needs to decide what to do next, and
a `get_*` tool exists for the detail.
- **Pagination with a hard cap.** Every list takes `limit` and `cursor`, caps
`limit`, and states the total so an agent knows it is seeing a page.
- **Descriptions state blast radius in plain words.** A tool description is
prompt text; `run_workflow` says that it executes commands on real servers.
- **No blocking.** `run_workflow` returns a run ID immediately. The agent polls
`get_run`. A tool call must never hold a connection open for a long job.
- **Fan-out guard.** Any write tool resolving more than a configurable number of
servers (default 25) refuses unless called with `confirm: true`, and says how
many it would have touched. Cheap insurance against a mis-parsed selector
reaching the whole fleet.
### Creation tools
`create_step`, `create_workflow` and `create_monitor` let an agent build the
thing it is about to propose, rather than describing a script in prose that a
human then retypes. They are the tools that make the surface generative instead
of merely observational, and they are also the ones most able to surprise
someone, so they carry extra rules on top of the ordinary write gates:
- **Creation only.** No update and no delete tool exists. An agent may add a
definition; it may never alter or remove one a human wrote.
- **Nothing is armed on creation.** `create_workflow` refuses a `schedule`, and
`create_monitor` sets `enabled` false. A created definition sits inert until a
human enables it, so creating and acting stay two decisions. An agent that
wants to run what it just made calls `run_workflow`, which is separately
gated, separately audited, and subject to the fan-out guard.
- **No secret references.** `create_step` rejects a non-empty `secret_refs`.
- **Marked as agent-authored.** `models.WorkflowStep` already carries a `Source`
field; created steps set it to `mcp`, so the UI can badge them and a human can
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
gets no laxer a path than the UI.
## Audit
Every tool call writes an audit event through `services.LogEvent`, reads
included. The point of an agent-facing surface is being able to reconstruct
afterwards what the agent looked at, not only what it changed.
Creation tools log a distinct event type, `mcp.created`, naming what was made
and its ID. A generic tool-call row buried among reads is not enough for the
question a human will actually ask, which is "what has this agent added to my
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
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 -
under-recording a new and sensitive surface is the worse failure.
## Errors
Scope, licence and selector failures return **MCP tool errors**, not transport
errors, carrying a plain-language remedy: "this token does not hold
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
to expose.
## HQ, catalogue and Paddle
### Catalogue
`models.seedRows()` gains `license.FeatureMCP` to its shared feature list, one
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
number deliberate.
`catalogue.LineItems` needs no change: a `KindFeature` row the customer selected
becomes a line item, and one with no price ID in the running environment is
granted free. That is what makes the pre-pricing window safe.
### HQ UI
`vantage-admin/web/lib/features.ts` gains the label "Agent access (MCP)" and the
description "Let AI agents query and act on your fleet through the Model Context
Protocol, under a scoped token you control." It then appears automatically in
the purchase form, the staff pricing page and the account detail view, all of
which render from that map.
### App licence page
`vantage-app/web/app/(app)/settings/license/page.tsx` gains
`<Feature label="Agent Access (MCP)" included={Boolean(license.features.mcp)} />`
alongside the existing four. `licenceResponse.Features` is already a
`map[string]bool` built from the licence, so no server change is needed.
### Paddle
One product, two prices, created in the sandbox environment first:
| Field | Value |
| --- | --- |
| Product name | Vantage - Agent Access (MCP) |
| Description | AI agent access to a Vantage instance over the Model Context Protocol |
| Tax category | `standard` |
| Currency | GBP |
| Monthly price | £9.00, billing interval `month` × 1 |
| Annual price | £90.00, billing interval `year` × 1 |
Annual is ten months' money for twelve, matching the convention the other add-on
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
only place price IDs are meant to be entered and a migration writing them would
be a second source of truth.
Production prices are created by hand in the Paddle dashboard when the feature
ships, and pasted into `price_ids.production` the same way. Nothing in this spec
writes to a production billing account.
## Frontend
A new **Agent access** panel on the API tokens settings page, visible only when
the licence grants the feature:
- The endpoint URL for this instance, with a copy button.
- 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
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.
The token list shows a token's tag restriction as a chip beside its scopes, so
that "what can this credential reach" is answerable at a glance.
## Documentation
`vantage-docs` gains `docs/vantage/mcp.md`: what MCP is in two sentences, how to
mint a suitable token, how to connect Claude and other clients, the full tool
list with what each one does, and an explicit section on what an agent cannot do
(reveal secrets, open a console, exceed its tags, act without `mcp:write`).
`docs/reference/api-tokens.md` gains the tag restriction field.
## Testing
Table-driven, over the tool registry rather than per tool, because the registry
is the thing that must stay correct as tools are added:
- **Gate matrix.** Each tool × token shape (no MCP scope, `mcp:read`,
`mcp:write`, missing resource scope, missing licence feature): assert listed
or not listed, and allowed or refused. This is the security test of the
feature.
- **Registry completeness.** Every registered tool declares a resource scope
from `ScopeResources` and a write flag. Same spirit as
`AssertScopeMapComplete`; a tool added without a scope fails the build.
- **Tag scoping at the chokepoints.** `GetServer` on an out-of-scope host is
not-found; `ResolveTargets` intersects rather than unions; a run naming
out-of-scope IDs targets nothing. Service-level tests, since the property is a
service-level one.
- **Token creation.** A caller cannot mint a token with scopes or a tag
selector broader than their own; MCP scopes are refused without the licence.
- **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
otherwise invisible.
`services/statuspages_test.go` is the style model.
## Migration and rollout
No data migration. `TagSelector` absent on existing tokens means fleet-wide,
which is what those tokens do today. `SeedCatalogue` adds the row on the next
admin deploy. No licence gains the feature until staff grant it.
Order of work:
1. `vantage-shared`: `FeatureMCP` constant.
2. `vantage-app` server: `mcp` scope resource, tag selector on tokens plus
enforcement and the completeness assertion, then the MCP package and tools.
3. `vantage-app` web: token form fields, agent access panel, licence page row.
4. `vantage-admin`: catalogue seed row, feature label.
5. Paddle sandbox product and prices; price IDs entered through the staff page.
6. `vantage-docs`: the MCP page.
Steps 13 are independently useful: tag-scoped tokens are a security improvement
whether or not MCP ever ships, which is the argument for building them as a
general capability rather than folding them into the MCP package.
@@ -0,0 +1,539 @@
# Maintenance windows and scheduled patching
Date: 2026-09-14
## Goal
Let an operator say "install security updates on every `env:prod` server,
Sundays 02:00 to 04:00 Europe/London, and reboot them if the OS says a reboot
is owed", and have Vantage do it, report exactly what happened per server, and
say so loudly when it did not.
Today the only patching path is **Apply updates**, which runs immediately on one
server, installs everything, never reboots, and reports nothing back: the agent
logs a failure locally and the control plane never hears of it.
Out of scope, deliberately:
- **Update rings / staged rollout.** A policy runs all its targets within one
window, bounded only by `max_concurrent`. Rings are their own feature and
build on this one.
- **Package holds and per-package allow-lists.** Scope is `all` or `security`.
- **Live-streamed patch output.** A capped output tail is stored per server;
`workflow_log_lines` is not reused.
- **Alert muting during windows.** The window is a standalone object so this can
reference it later, but v1 does not mute anything.
- **Licence gating.** Patching is free on every tier, consistent with
`applyUpdates` already being exempt from the licence gate.
## Current state
- `POST /api/servers/:id/apply-updates` calls `services.DispatchApplyUpdates`,
which sends an empty `ApplyUpdatesCmd` and returns. No result is awaited or
recorded.
- Agent `handleApplyUpdates` runs `updates.ApplyAll()` and, on success only,
sends an empty `ReportUpdates`. Output is discarded (`exec.Cmd.Run()`).
- The apt path wraps `apt-get update` **and** `apt-get upgrade` in one 5-minute
context, so a large upgrade can be killed partway. dnf, yum, zypper, pacman
and apk run with no timeout at all.
- The agent never reboots. `inventory.reboot_required` is set on the 15-minute
static snapshot, and at agent start.
- `workflowsched` already solves scheduling: 5-field cron, IANA timezone,
`next_run_at` persisted and claimed atomically, recorded skips, run inside
`bus.RunAsLeader("housekeeping", ...)`.
- `default_steps/` ships `apply_package_updates` and `reboot_server` bash steps.
They stay as they are; this feature does not use them.
## Approach
A native patch path, not a workflow convention. The agent gains a result-bearing
update command with a scope and an opt-in reboot. Three new collections hold
windows, policies and runs. A new `patchsched` loop fires policies and advances
runs from state held in MongoDB, so a leader handover mid-window loses nothing.
The manual button and the vulnerability page move onto the same run model, so
every patch Vantage performs has a record.
Rejected alternatives:
- **Build on the workflow engine** (script steps per package manager). The patch
logic would exist twice, as scripts and as the agent's `updates` package; the
engine sends one step list to every target, so a mixed Linux/Windows policy
needs per-OS branching it does not have; and the manual button would stay
fire-and-forget.
- **Native command, recorded as a `WorkflowRun`.** Saves one page, but binds
patching to `steps_snapshot` and log sequencing that do not describe it, and
breaks the rule that a run always shows the script that ran.
## Data model
All three collections carry `instance_id` and are added to
`services.ScopedCollections`, so instance purge covers them.
### `maintenance_windows`
```go
type MaintenanceWindow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
WindowID string `bson:"window_id" json:"window_id"`
Name string `bson:"name" json:"name"`
Cron string `bson:"cron" json:"cron"` // 5-field, window start
TZ string `bson:"tz" json:"tz"` // IANA name
DurationMinutes int `bson:"duration_minutes" json:"duration_minutes"` // 15..720
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
```
A window answers "when" and nothing else: no targets, no behaviour. Cron and TZ
are validated through `workflowsched.NextOccurrence`, exactly as workflow
schedules are. Deleting a window referenced by any policy is refused with 409
`window_in_use`. Editing a window recomputes `next_run_at` on every policy that
references it, in the same service call.
### `patch_policies`
```go
type PatchPolicy struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
PolicyID string `bson:"policy_id" json:"policy_id"`
Name string `bson:"name" json:"name"`
Enabled bool `bson:"enabled" json:"enabled"`
WindowID string `bson:"window_id" json:"window_id"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"`
Scope string `bson:"scope" json:"scope"` // "all" | "security"
Reboot string `bson:"reboot" json:"reboot"` // "never" | "if_required"
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // 0 = no cap
NotifyChannelIDs []string `bson:"notify_channel_ids,omitempty" json:"notify_channel_ids,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"`
LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"`
DisabledReason string `bson:"disabled_reason,omitempty" json:"disabled_reason,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
```
Targets use the workflow selector semantics unchanged: the distinct union of
named servers and tag matches, resolved through `services.ResolveTargets` at
fire time, and an empty selector matches nothing (`ErrNoTargets` on save).
`Skip` is the existing `models.Skip`.
Index: `{instance_id: 1, enabled: 1, next_run_at: 1}`.
### `patch_runs`
```go
type PatchRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
RunID string `bson:"run_id" json:"run_id"`
PolicyID string `bson:"policy_id,omitempty" json:"policy_id,omitempty"` // empty for manual
PolicyName string `bson:"policy_name,omitempty" json:"policy_name,omitempty"`
TriggeredBy string `bson:"triggered_by" json:"triggered_by"` // "schedule" | actor
Source string `bson:"source" json:"source"` // schedule | server | vulnerabilities | mcp | run_now
CancelledAt *time.Time `bson:"cancelled_at,omitempty" json:"cancelled_at,omitempty"`
Scope string `bson:"scope" json:"scope"` // snapshot
Reboot string `bson:"reboot" json:"reboot"` // snapshot
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // snapshot
WindowEnd *time.Time `bson:"window_end,omitempty" json:"window_end,omitempty"` // nil for manual
Status string `bson:"status" json:"status"` // running | succeeded | partial | failed | cancelled
StartedAt time.Time `bson:"started_at" json:"started_at"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
Servers []PatchServerRun `bson:"servers" json:"servers"`
}
type PatchServerRun struct {
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
Status string `bson:"status" json:"status"`
CommandID string `bson:"command_id,omitempty" json:"-"`
PendingBefore int `bson:"pending_before" json:"pending_before"`
PendingAfter *int `bson:"pending_after,omitempty" json:"pending_after,omitempty"`
RebootedAt *time.Time `bson:"rebooted_at,omitempty" json:"rebooted_at,omitempty"`
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
Output string `bson:"output,omitempty" json:"output,omitempty"` // tail, max 64KB
Error string `bson:"error,omitempty" json:"error,omitempty"`
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
}
```
Server run statuses:
| Status | Terminal | Meaning |
| ----------------- | -------- | -------------------------------------------------------------------- |
| `queued` | no | Waiting for a concurrency slot |
| `waiting_offline` | no | Agent not connected; retried each tick while the window is open |
| `patching` | no | Command dispatched, no result yet |
| `rebooting` | no | Agent announced a reboot; awaiting a post-boot inventory report |
| `succeeded` | yes | Patched, and rebooted and verified if a reboot was owed and allowed |
| `failed` | yes | Package manager failed, no result, or reboot not verified |
| `unsupported` | yes | Security-only requested on a host with no security metadata |
| `agent_too_old` | yes | Agent predates patch results; not dispatched |
| `missed_offline` | yes | Offline for the whole window |
| `window_closed` | yes | Still queued when the window ended |
| `cancelled` | yes | Run cancelled before this server was dispatched |
Scope, reboot and concurrency are snapshotted onto the run so editing a policy
never changes what a past run shows. `PendingBefore` is the server's
`available_updates` count at dispatch; `PendingAfter` comes from the result.
Indexes: `{instance_id: 1, policy_id: 1, started_at: -1}`,
`{instance_id: 1, "servers.server_id": 1, started_at: -1}`,
`{status: 1}` (the tick's scan), and `{"servers.command_id": 1}` (result lookup).
Runs are swept under `workflow_log_retention_days` by the existing log sweeper.
## Wire contract
Changed in `vantage-shared` (`grpc/pb` and `proto/vantage/v1/vantage.proto` in
the same commit):
```proto
message ApplyUpdatesCmd {
string scope = 1; // "" or "all" | "security"
bool reboot_if_required = 2;
int64 deadline_unix = 3; // 0 = none; the agent caps at 2h
}
message PatchResult { // new AgentMessage oneof variant: PatchResult patch_result = 8;
string command_id = 1;
string status = 2; // ok | failed | unsupported | busy
string message = 3;
string output_tail = 4; // at most 64KB, newest bytes kept
int32 pending_after = 5;
bool reboot_required = 6;
bool rebooting = 7;
}
message InventoryReport {
// existing fields ...
int64 boot_time_unix = 11; // every report
}
```
The existing comment on `InventoryReport.reboot_required` ("The agent never
reboots") is reworded in the same commit to match the new reboot rule.
An empty `ApplyUpdatesCmd` means `scope: all`, no reboot, no deadline: exactly
today's behaviour, so a new agent under an old server is unaffected.
### Old agents
An old agent ignores the new fields. Under `scope: security` it would install
**everything**, and it sends no `PatchResult`. The server therefore gates on
`servers.agent_version`:
- `agentSupportsPatchResults(version)` is a semver comparison against the first
agent release carrying this feature. Empty, unparseable and dev versions are
treated as too old.
- A policy run marks an older server `agent_too_old` without dispatching.
- A manual Apply updates on an older server still dispatches the empty command,
records the server run as `succeeded` immediately with
`error: "no result reported: agent predates patch results"`, and the UI shows
it as unverified. The button keeps working through the transition.
## Agent
`internal/updates`:
- `Apply(opts ApplyOptions) (Result, error)` replaces `ApplyAll()`.
`ApplyOptions{Scope string; Deadline time.Time}`. `Result{Output []byte;
Unsupported bool}`.
- A package-level mutex: a second `Apply` while one runs returns `ErrBusy`.
- Index refresh (`apt-get update`, and nothing else) keeps its own 5-minute
timeout. The upgrade runs under a context ending at `Deadline`, or 2h from
start when no deadline is given. The existing single 5-minute context is
removed.
- Combined stdout and stderr go to a 64KB ring buffer that keeps the newest
bytes.
- Non-interactive everywhere: `DEBIAN_FRONTEND=noninteractive` and
`-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold` for
apt, `--non-interactive` for zypper, `--noconfirm` stays for pacman.
Security-only, per package manager:
| Manager | Command | No security metadata |
| --------- | ---------------------------------------------------------------------------------------------------- | -------------------- |
| apt | temp sources list of entries whose suite ends in `-security`, from `/etc/apt/sources.list`, `*.list` and deb822 `*.sources`; `apt-get update` and `apt-get upgrade -y` with `-o Dir::Etc::SourceList=<tmp> -o Dir::Etc::SourceParts=-` | no such entries: `unsupported` |
| dnf / yum | `<pm> upgrade --security -y` | n/a |
| zypper | `zypper --non-interactive patch --category security` | n/a |
| pacman | none | always `unsupported` |
| apk | none | always `unsupported` |
| Windows | existing COM search, keeping updates whose `Categories` include Security Updates (`0FA1201D-4330-4FA8-8AE9-B877473B6441`) or Critical Updates (`E6CF1350-C01B-414D-A61F-263D14D133B4`) | n/a |
`unsupported` installs nothing. It never falls back to `all`.
apt security source filtering is a pure function over file contents
(`securitySources(files map[string]string) (list string, ok bool)`) so it is
testable on any platform.
`internal/sync` `handleApplyUpdates`:
1. `updates.Apply(...)` with the command's scope and deadline.
2. Re-run `CheckAvailable`, `ReportUpdates` the result (on success **and**
failure, so counts are fresh either way), read `RebootRequired()`.
3. If `reboot_if_required`, a reboot is owed, and at least 5 minutes remain
before the deadline: send `PatchResult{status: ok, rebooting: true, ...}` on
the command stream, then run `shutdown -r +1` (Linux) or
`shutdown /r /t 60 /c "Vantage patch policy"` (Windows). The one-minute
grace lets the result leave before the host goes down.
4. Otherwise send `PatchResult` with the outcome.
`internal/inventory` reports `boot_time_unix` on every report (Linux
`/proc/stat` `btime`, Windows via the existing collection).
The agent's documented promise changes from "never reboots a host" to "never
reboots a host unless the command explicitly asks and the OS reports a reboot
is owed". `vantage-agent/CLAUDE.md` and this repository's CLAUDE.md are updated
in the same change.
## Scheduler and run lifecycle
### `patchsched`
Runs inside the existing `RunAsLeader("housekeeping", ...)`, ticking every 30s.
It must not import `services` (the same cycle `workflowsched` avoids); its
dependencies are injected from `main.go` as `patchsched.Deps`.
Each tick does two things.
**Fire due policies.** For each enabled policy with `next_run_at <= now`, claim
it with the `workflowsched` pattern: `UpdateOne` matching `policy_id` **and**
the current `next_run_at`, setting the next occurrence. A zero match means
another process claimed it. Then `Decide(due, windowEnd, now, running, nTargets)`:
| Condition | Result |
| ----------------------------------------------------- | -------------------------------------------------- |
| window missing or no longer parses | disable policy, set `disabled_reason`, audit |
| `now >= windowEnd` or `now > due + 1h` | skip `missed` |
| a run of this policy has `status: running` | skip `already_running` |
| targets resolve to zero servers | skip `no_targets` |
| otherwise | fire |
Skips set `last_skipped` and write `patch.skipped`, as workflow skips do. Firing
creates a `patch_run` with every target `queued` (or `agent_too_old`),
`WindowEnd = due + duration`, sets `last_run_at`, and writes `patch.run_started`
with actor `schedule`.
**Advance running runs.** For each `patch_run` with `status: running`, load
connection state for its servers and apply `Advance(run, now, connected)`, a
pure function returning the transitions:
- While `now < WindowEnd` (or always, for a manual run): move `queued` and
`waiting_offline` servers to `patching` up to `MaxConcurrent` in flight
(`patching` plus `rebooting`), dispatching `ApplyUpdatesCmd` with
`deadline_unix = WindowEnd`. A dispatch that fails (503, agent offline) moves
the server to `waiting_offline`.
- When `now >= WindowEnd`: `queued` to `window_closed`, `waiting_offline` to
`missed_offline`.
- `patching` with no result past `deadline + 10m` (manual: start + 2h + 10m):
`failed`, `"no result from agent"`.
- `rebooting` with no verifying report past `RebootedAt + 20m`: `failed`,
`"did not come back within 20 minutes"`.
- When every server is terminal, `Finalize` sets the run status: `succeeded` if
all servers succeeded, `failed` if none did, otherwise `partial`.
`unsupported`, `agent_too_old`, `missed_offline` and `window_closed` are not
successes. Then notify (below) and write `patch.run_finished`.
Every transition is written with a filter on the server's current status, so a
result arriving concurrently is never overwritten by a stale tick.
### Results
`CommandStream`, on whichever pod holds the agent's stream, handles
`PatchResult` by updating the server run found by `servers.command_id`, via
`ApplyResult(serverRun, result, now)`:
- `ok` without `rebooting`: `succeeded`, `PendingAfter`, output.
- `ok` with `rebooting`: `rebooting`, `RebootedAt = now`, write `patch.reboot`.
- `failed` or `busy`: `failed` with the message.
- `unsupported`: `unsupported`.
Nothing awaits the result on the bus. The run document is the only state, so a
pod or leader change mid-window loses nothing. A result for an unknown command
ID is dropped and logged.
### Reboot verification
`ReportInventory` checks, for this server, any server run in `rebooting`, and
only on reports with `include_static` set: `reboot_required` is only computed
on static snapshots, so a metrics-only report would read as "no reboot owed".
The agent sends a static snapshot at start, so the first report after a reboot
qualifies.
`VerifyReboot(serverRun, bootTime, rebootRequired, now)`:
- `bootTime <= RebootedAt`: not yet rebooted, no change. This is why boot time
is used rather than "a report arrived": a static snapshot sent during the
one-minute grace must not count.
- `bootTime > RebootedAt` and `reboot_required` false: `succeeded`,
`VerifiedAt = now`.
- `bootTime > RebootedAt` and `reboot_required` still true: `failed`,
`"still requires a reboot after restarting"`.
### Manual runs
`POST /servers/:id/apply-updates` and the vulnerability page's Apply updates
create a one-server `patch_run` (`scope: all`, `reboot: never`, no window,
`TriggeredBy` the actor, `Source` one of `server`, `vulnerabilities` or `mcp`)
and dispatch immediately in
the handler rather than waiting for the next tick. A dispatch failure still
answers 503, and the run is recorded as `failed` so the attempt is not lost.
Timeouts and finalisation go through the same tick.
### Cancel
`POST /patch-runs/:runId/cancel` moves `queued` and `waiting_offline` servers to
`cancelled`. Servers already `patching` or `rebooting` continue: interrupting a
package manager mid-transaction is worse than letting it finish. The run
finalises as `cancelled` once the in-flight servers settle, unless none were in
flight, in which case immediately.
### Notifications
When a run finalises as `partial` or `failed`, one summary goes to each channel
in `NotifyChannelIDs` through the existing `notify` dispatch, as a new event
type `patch`: `[Vantage] Patch policy "Sunday prod" partial: 38 succeeded,
2 failed, 1 missed offline (run 3f2a...)`. The webhook payload is the existing
event shape with that summary as its message; no new payload fields. A clean
run sends nothing.
## REST API
All under `/api`, each route registered in `routeScopes` (so
`AssertScopeMapComplete` passes) and annotated for `swag`; `openapi.json` is
regenerated and committed.
```
maintenance-windows GET,POST /maintenance-windows · GET,PUT,DELETE /maintenance-windows/:id
POST /maintenance-windows/preview {cron,tz,duration_minutes} -> next 3 {start,end}
patch-policies GET,POST /patch-policies · GET,PUT,DELETE /patch-policies/:id
POST /patch-policies/:id/run-now window = now .. now + window duration
patch-runs GET /patch-runs?policy_id=&server_id=&limit= · GET /patch-runs/:runId
POST /patch-runs/:runId/cancel
servers POST /servers/:id/apply-updates 202 {run_id, message}
```
- New scope resource **`patching`** (`:read`, `:write`). Windows, policies and
runs use it. `apply-updates` stays on `servers:write`.
- Creating, editing and deleting windows and policies, and run-now, are
owner or admin (`RequireRole`). Viewing and cancelling runs are open to every
role. Apply updates keeps its current access.
- Saving a policy through a tag-restricted token is refused if its selector
reaches outside the restriction, reusing `validateWorkflowTargetScope`
unchanged. Scheduled firing passes a nil token scope, as workflow schedules do.
- `apply-updates` keeps `message` in its response for existing scripts and adds
`run_id`.
- MCP `apply_updates` returns the `run_id`. No new MCP tools.
Errors: `400` validation (`invalid_cron`, `invalid_tz`, `invalid_duration`,
`invalid_scope`, `invalid_reboot`, `no_targets`), `404` unknown window, policy
or run, `409 window_in_use`, `503` agent offline on apply-updates.
## Audit
Every event uses the `patch.` prefix so the audit page groups them under one
category: `patch.window_created|window_updated|window_deleted`,
`patch.policy_created|policy_updated|policy_deleted|policy_disabled`,
`patch.run_started`, `patch.run_finished`, `patch.skipped`, `patch.cancelled`,
`patch.reboot` (one per server Vantage reboots, naming the policy),
and the existing `updates.applied` for manual runs, now carrying the run ID.
## Frontend
`web/`, dark tokens only, no hex, pills carry shape and label.
- Sidebar: **Patching** in the Fleet group.
- `/patching`, three tabs:
- **Policies**: name, next window in its own timezone, resolved target count
via `web/lib/targets.ts`, scope and reboot chips, last-run pill, enabled
toggle. A disabled policy shows its `disabled_reason`.
- **Windows**: name, schedule in words, duration, policies using it.
- **Runs**: newest first, filter by policy.
- **Policy editor** (modal): name; window picker with inline "New window";
targets with `DualListBox` plus tag rows; scope radio, noting apk and pacman
hosts report unsupported for security-only; reboot radio, where "If required"
shows "Up to N servers may reboot during this window"; max concurrent;
notification channels. Shows "N of M targets need an agent update" linking to
the servers when any target is too old.
- **Window editor** (modal): name, presets writing cron (Nightly 02:00,
Sunday 02:00, Saturday 22:00, Monthly 1st 02:00), cron field, timezone,
duration; next 3 occurrences from `/maintenance-windows/preview`.
- `/patching/runs/[runId]`: header with status, trigger, window end and counts
per status; table of servers with status pill, updates installed
(`PendingBefore - PendingAfter`), rebooted and verified times, error; each row
expands to the output tail on the `--well` surface. Cancel while running. The
page polls while the run is `running`.
- **Server detail, OS updates panel**: "Covered by *Sunday prod*, next window
Sun 21 Sep 02:00 BST" or "Not covered by any patch policy"; last patch run
with link. Apply updates navigates to the new run.
- **Vulnerabilities**: Apply updates navigates to the new run.
## Documentation
`vantage-docs`:
- New `docs/vantage/patching.md`: windows, policies, scope per package manager
(including unsupported), reboot rule and verification, statuses table,
offline and window-close behaviour, agent version requirement.
- `vantage/servers.md`: remove the "Applying updates is not scheduled or staged"
warning, describe the run record, update the reboot sentence.
- `vantage/vulnerabilities.md`: Apply updates creates a run; link to patching.
- `hq/licensing-and-entitlements.md`: patching is available on every tier.
- `reference/api-tokens.md`: the `patching` scope.
`vantage-app/CLAUDE.md`: new "Scheduled patching" subsystem section, updated
"Inventory and OS updates", collection list, REST list, `ServerCommand` and
`AgentMessage` variants. `vantage-agent/CLAUDE.md`: the reboot promise.
## Testing
The repository tests pure functions without a database, so the logic is shaped
for that and the Mongo layer is a thin shell.
Server:
- `patchsched.Decide`: on time, late within grace, past grace, past window
end, already running, zero targets, and a DST case (`Europe/London`, last
Sunday of October, 01:30 start occurring twice; `NextOccurrence` behaviour is
asserted, not assumed).
- `patchrun.Advance`: concurrency cap never exceeded counting `rebooting`;
offline then online inside the window dispatches; offline to window end is
`missed_offline`; queued at window end is `window_closed`; nothing dispatched
after window end; no-result timeout; reboot timeout; cancelled runs dispatch
nothing; manual runs ignore window rules.
- `patchrun.ApplyResult` for each result status.
- `patchrun.VerifyReboot`: boot time before, equal to and after `RebootedAt`,
with reboot still owed and cleared.
- `patchrun.Finalize`: every mix of terminal statuses.
- `agentSupportsPatchResults`: older, equal, newer, pre-release, empty,
unparseable.
- Validation of windows and policies.
- `scopes_test` covers the new routes; a token tag-restriction case in the
style of `workflow_target_scope_test`.
Agent:
- `securitySources`: Debian and Ubuntu `.list`, Ubuntu 24.04 deb822 `.sources`,
commented lines, a file set with no security suites (`ok == false`).
- Ring buffer keeps the newest 64KB.
- Windows security category filtering, as a parser test beside `winparse_test`.
- Manual verification on Debian 12, Ubuntu 24.04, Rocky 9 and Windows Server
2022: security-only and all, reboot and verification, window close
mid-queue, agent busy.
## Rollout
1. `vantage-shared`: `ApplyUpdatesCmd` fields, `PatchResult`,
`InventoryReport.boot_time_unix`, proto and `pb` together. Release a tag.
2. `vantage-app`: bump the pin; ship collections, indexes, `patchsched`,
results handling, API, UI, MCP change and docs. With no new agents yet,
policies show every target as `agent_too_old` and the manual button works as
before, now with a run record.
3. `vantage-agent`: bump the pin, ship the agent changes, tag `agent/v*`. Set
that version as the gate constant in the server in step 2 (the gate names a
version that does not exist yet until step 3 ships, which is harmless: every
agent reads as too old until it does).
4. `vantage-docs`: publish the patching page.
+1 -1
View File
@@ -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
+60 -6
View File
@@ -22,7 +22,10 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
@@ -38,7 +41,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.
@@ -62,7 +65,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.
@@ -178,6 +181,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure status page indexes: %v", err)
}
if err := services.EnsurePatchIndexes(); err != nil {
log.Printf("warning: patch indexes: %v", err)
}
if err := services.EnsureAuditIndexes(); err != nil {
log.Printf("warning: failed to ensure audit indexes: %v", err)
}
@@ -196,7 +203,17 @@ func runSchemaSetup() {
}
// apiVersion mirrors the @version annotation on the swagger block above,
// 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
// with "// @version" above or the two will read as two different servers.
const apiVersion = "1.0"
func serve() {
mcp.SetVersion(apiVersion)
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
redisUser := os.Getenv("REDIS_USERNAME")
redisPass := os.Getenv("REDIS_PASSWORD")
@@ -214,8 +231,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()
@@ -230,7 +247,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)
@@ -242,6 +259,17 @@ func serve() {
LogEvent: services.LogEvent,
})
patchsched.Start(jobCtx, patchsched.Deps{
LookupWindow: services.LookupWindow,
CountTargets: services.CountPolicyTargets,
StartPolicyRun: func(p models.PatchPolicy, windowEnd time.Time) error {
_, err := services.StartPolicyRun(p, windowEnd, models.PatchSourceSchedule, "schedule")
return err
},
AdvanceRuns: services.AdvancePatchRuns,
LogEvent: services.LogEvent,
})
vulnsched.Start(jobCtx, vulnsched.Deps{
LogEvent: services.LogEvent,
SendDigest: services.SendVulnDigest,
@@ -267,7 +295,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)
}
@@ -282,6 +310,10 @@ func serve() {
log.Fatalf("api scope map: %v", err)
}
if err := api.AssertServerScopeMapComplete(apiRoutes(r)); err != nil {
log.Fatalf("api server scope map: %v", err)
}
srv := &http.Server{Addr: ":8080", Handler: r}
go func() {
log.Println("REST server listening on :8080")
@@ -343,3 +375,25 @@ func boolEnv(key string) bool {
}
return false
}
// apiRoutes lists every registered /api route as "METHOD /path", which is the
// whole input AssertServerScopeMapComplete now takes.
//
// 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
// 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
// "does this touch server data?" for it.
func apiRoutes(r *gin.Engine) []string {
var out []string
for _, route := range r.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
continue
}
out = append(out, route.Method+" "+route.Path)
}
return out
}
+54 -47
View File
@@ -1,79 +1,86 @@
module gitea.hostxtra.co.uk/mrhid6/vantage/server
go 1.26
go 1.26.0
require (
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gin-gonic/gin v1.10.0
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57
github.com/coreos/go-oidc/v3 v3.21.0
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.20.1
github.com/redis/go-redis/v9 v9.22.0
github.com/robfig/cron/v3 v3.0.1
github.com/wwt/guac v1.3.2
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
go.mongodb.org/mongo-driver/v2 v2.9.0
golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.37.0
google.golang.org/grpc v1.83.2
oras.land/oras-go/v2 v2.6.2
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/samber/lo v1.50.0 // indirect
github.com/samber/oops v1.18.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.62.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/samber/oops v1.23.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/time v0.16.0 // indirect
)
require (
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0
github.com/bytedance/sonic v1.15.3 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/websocket v1.4.1 // indirect
github.com/go-playground/validator/v10 v10.30.4 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
golang.org/x/arch v0.31.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+133 -103
View File
@@ -1,129 +1,144 @@
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 h1:EtojZ1d3cN9foHpc/CAI3KzBewYGn4sKWdkWs2MV78Q=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f h1:GvCU5GXhHq+7LeOzx/haG7HSIZokl3/0GkoUFzsRJjg=
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8=
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 h1:dWzdsqjh1p2gNtRKqNwuBvKqMNwnLOPLzVZT1n6DK7s=
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23/go.mod h1:lUaIXCWzf7BRKTY5iEcrYy1TfgbYLYVIS/B2vPkJzOc=
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f h1:xt29M2T6STgldg+WEP51gGePQCsQvklmP2eIhPIBK3g=
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f/go.mod h1:i4sF0l1fFnY1aiw08QQSwVAFxHEm311Me3WsU/X7nL0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790 h1:2R0QOkcV/csVHDigcH1sMNP3fQLw4Wi1ZxG0oC+29Ts=
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790/go.mod h1:i4sF0l1fFnY1aiw08QQSwVAFxHEm311Me3WsU/X7nL0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/oops v1.23.1 h1:QKkdrmSTr46B2u+FoQnUJ8dtp0an/yHKvytWHCTEba8=
github.com/samber/oops v1.23.1/go.mod h1:LO+VjrupgloQZ3CrXnhWqOWYARz8lY4EM+RHGzb3UJI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
@@ -132,75 +147,90 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+6 -6
View File
@@ -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
@@ -46,7 +46,7 @@ func consoleConnect(c *gin.Context) {
return
}
srv, err := services.GetServer(auth.InstanceID(c), body.ServerID)
srv, err := services.GetServerScoped(auth.InstanceID(c), body.ServerID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -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.
@@ -168,7 +168,7 @@ func consoleTunnel(c *gin.Context) {
return
}
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
srv, err := services.GetServerScoped(auth.InstanceID(c), sess.ServerID, auth.ServerScope(c))
if err != nil {
tlog("reject: server %s not found: %v", sess.ServerID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
@@ -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 {
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+94 -28
View File
@@ -8,8 +8,10 @@ import (
"strconv"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
"github.com/gin-gonic/gin"
)
@@ -68,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)
@@ -120,6 +122,23 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
// MCP is mounted inside /api so that bearer auth, rate limiting, licence
// activity and RequireScopes all apply from where it lives rather than
// because someone remembered. The route-level scope is a floor: one route
// serves many tools, so per-tool scopes are enforced inside the handler.
//
// GET is registered deliberately even though the transport runs stateless
// and therefore never serves it usefully: mcp.Handler's underlying SDK
// 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
// 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))
mcpGroup.POST("", mcp.Handler())
mcpGroup.GET("", mcp.Handler())
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
@@ -168,6 +187,7 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
registerStatusPageRoutes(apiGroup)
registerPatchingRoutes(apiGroup)
}
}
@@ -185,11 +205,18 @@ func RegisterRoutes(r *gin.Engine) {
// @Security bearerAuth
// @Router /servers [get]
func listServers(c *gin.Context) {
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
requested, err := services.ParseTagFilters(c.QueryArray("tag"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sel, ok := services.IntersectSelectors(auth.ServerScope(c), requested)
if !ok {
// The token's own restriction and the requested filter can never both
// hold, so this resolves to nothing rather than an error.
c.JSON(http.StatusOK, []models.Server{})
return
}
servers, err := services.ListServersFiltered(auth.InstanceID(c), sel)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -246,7 +273,7 @@ func putServerTags(c *gin.Context) {
instanceID := auth.InstanceID(c)
serverID := c.Param("id")
before, err := services.GetServer(instanceID, serverID)
before, err := services.GetServerScoped(instanceID, serverID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -352,7 +379,7 @@ func newServer(c *gin.Context) {
// @Router /servers/{id} [get]
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -379,7 +406,11 @@ func getServer(c *gin.Context) {
// @Router /servers/{id} [delete]
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -422,7 +453,7 @@ func generateKey(c *gin.Context) {
body.Label = "generated"
}
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -459,7 +490,7 @@ func generateKey(c *gin.Context) {
// @Security bearerAuth
// @Router /keys [get]
func listKeys(c *gin.Context) {
keys, err := services.ListKeys(auth.InstanceID(c))
keys, err := services.ListKeys(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -543,7 +574,22 @@ func getKey(c *gin.Context) {
return
}
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
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
// 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.
scope := auth.ServerScope(c)
assignments := make([]services.AssignmentWithServer, 0, len(all))
for _, a := range all {
if a.Server != nil && !services.ServerInTokenScope(*a.Server, scope) {
continue
}
assignments = append(assignments, a)
}
c.JSON(http.StatusOK, KeyDetailResponse{
Key: key,
@@ -593,6 +639,7 @@ func deleteKey(c *gin.Context) {
// @Router /keys/{id}/assign [post]
func assignKey(c *gin.Context) {
keyID := c.Param("id")
instanceID := auth.InstanceID(c)
var body struct {
ServerID string `json:"server_id" binding:"required"`
}
@@ -601,7 +648,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
if _, err := services.GetServerScoped(instanceID, body.ServerID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
a, err := services.AssignKey(instanceID, keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -626,8 +678,14 @@ func assignKey(c *gin.Context) {
func revokeAssignment(c *gin.Context) {
keyID := c.Param("id")
serverID := c.Param("serverId")
instanceID := auth.InstanceID(c)
if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil {
if _, err := services.GetServerScoped(instanceID, serverID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.RevokeAssignment(instanceID, keyID, serverID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -670,7 +728,7 @@ func getLatestAgentVersion(c *gin.Context) {
// @Router /servers/{id}/update-agent [post]
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -691,37 +749,48 @@ func updateAgent(c *gin.Context) {
// applyUpdates godoc
//
// @Summary Apply pending OS updates on a server
// @Description Dispatches ApplyUpdatesCmd. Exempt from the licence gate: security patching is never paywalled.
// @Description Starts a manual patch run (all updates, no reboot) and returns its ID. Exempt from the licence gate: security patching is never paywalled.
// @Tags servers
// @Produce json
// @Param id path string true "Server ID"
// @Success 202 {object} MessageResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Param id path string true "Server ID"
// @Param source query string false "vulnerabilities when started from the vulnerabilities page"
// @Success 202 {object} ApplyUpdatesResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ApplyUpdatesErrorResponse "agent offline; the attempt is recorded as run_id"
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/apply-updates [post]
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
instanceID := auth.InstanceID(c)
s, err := services.GetServerScoped(instanceID, c.Param("id"), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DispatchApplyUpdates(s.ServerID); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
source := models.PatchSourceServer
if c.Query("source") == models.PatchSourceVulnerabilities {
source = models.PatchSourceVulnerabilities
}
run, err := services.StartManualRun(instanceID, s, actorFromCtx(c), source)
if errors.Is(err, services.ErrAgentOffline) {
// The attempt is still recorded, so it has a run ID to show.
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error(), "run_id": run.RunID})
return
}
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "updates.applied", actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("package update run %s started on %s", run.RunID, s.Hostname))
c.JSON(http.StatusAccepted, ApplyUpdatesResponse{Message: "apply updates command sent to agent", RunID: run.RunID})
}
// handleUpdateScript serves a dynamically generated shell script that
// 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) {
@@ -893,10 +962,7 @@ func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
giteaHost := "gitea.hostxtra.co.uk"
grpcHost := os.Getenv("GRPC_HOST")
script := fmt.Sprintf(`#!/usr/bin/env bash
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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) {
+21 -3
View File
@@ -39,6 +39,16 @@ func listMonitors(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range monitors {
monitors[i] = services.RedactMonitorRunner(monitors[i], visible, restricted)
}
c.JSON(http.StatusOK, monitors)
}
@@ -73,7 +83,7 @@ func createMonitor(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
created, err := services.CreateMonitor(auth.InstanceID(c), &m, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -103,7 +113,15 @@ func getMonitor(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
c.JSON(http.StatusOK, m)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
redacted := services.RedactMonitorRunner(*m, visible, restricted)
c.JSON(http.StatusOK, &redacted)
}
// updateMonitor godoc
@@ -168,7 +186,7 @@ func updateMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil {
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+455
View File
@@ -0,0 +1,455 @@
package api
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// registerPatchingRoutes mounts maintenance windows, patch policies and patch
// runs. Free on every tier: security patching is never paywalled, so no
// RequireFeature here. Writes that decide what reboots and when are owner or
// admin; watching and cancelling a run is open to every role.
func registerPatchingRoutes(g *gin.RouterGroup) {
admin := auth.RequireRole("owner", "admin")
g.GET("/maintenance-windows", listWindows)
g.POST("/maintenance-windows", admin, createWindow)
g.POST("/maintenance-windows/preview", previewWindow)
g.GET("/maintenance-windows/:id", getWindow)
g.PUT("/maintenance-windows/:id", admin, updateWindow)
g.DELETE("/maintenance-windows/:id", admin, deleteWindow)
g.GET("/patch-policies", listPolicies)
g.POST("/patch-policies", admin, createPolicy)
g.GET("/patch-policies/:id", getPolicy)
g.PUT("/patch-policies/:id", admin, updatePolicy)
g.DELETE("/patch-policies/:id", admin, deletePolicy)
g.POST("/patch-policies/:id/run-now", admin, runPolicyNow)
g.GET("/patch-runs", listPatchRuns)
g.GET("/patch-runs/:runId", getPatchRun)
g.POST("/patch-runs/:runId/cancel", cancelPatchRun)
}
// patchError maps every patching service error once.
func patchError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrWindowNotFound), errors.Is(err, services.ErrPolicyNotFound), errors.Is(err, services.ErrPatchRunNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWindowInUse), errors.Is(err, services.ErrPatchRunActive), errors.Is(err, services.ErrPatchRunFinished):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWindowInvalid), errors.Is(err, services.ErrPolicyInvalid),
errors.Is(err, services.ErrNoTargets), errors.Is(err, services.ErrInvalidTag),
errors.Is(err, services.ErrWorkflowTargetOutOfScope):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
// listWindows godoc
//
// @Summary List maintenance windows
// @Tags patching
// @Produce json
// @Success 200 {array} models.MaintenanceWindow
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows [get]
func listWindows(c *gin.Context) {
ws, err := services.ListWindows(auth.InstanceID(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, ws)
}
// createWindow godoc
//
// @Summary Create a maintenance window
// @Tags patching
// @Accept json
// @Produce json
// @Param body body models.MaintenanceWindow true "Window"
// @Success 201 {object} models.MaintenanceWindow
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows [post]
func createWindow(c *gin.Context) {
var body models.MaintenanceWindow
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := services.CreateWindow(auth.InstanceID(c), body)
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_created", actorFromCtx(c), "", "",
fmt.Sprintf("maintenance window %s: %s %s for %d minutes", w.Name, w.Cron, w.TZ, w.DurationMinutes))
c.JSON(http.StatusCreated, w)
}
// previewWindow godoc
//
// @Summary Preview the next three maintenance windows
// @Description Computed by the scheduler's own code, so the editor and the scheduler agree.
// @Tags patching
// @Accept json
// @Produce json
// @Param body body WindowPreviewRequest true "Schedule"
// @Success 200 {array} services.WindowSpan
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/preview [post]
func previewWindow(c *gin.Context) {
var body WindowPreviewRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
spans, err := services.PreviewWindow(body.Cron, body.TZ, body.DurationMinutes, time.Now(), 3)
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, spans)
}
// getWindow godoc
//
// @Summary Get a maintenance window
// @Tags patching
// @Produce json
// @Param id path string true "Window ID"
// @Success 200 {object} models.MaintenanceWindow
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [get]
func getWindow(c *gin.Context) {
w, err := services.GetWindow(auth.InstanceID(c), c.Param("id"))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, w)
}
// updateWindow godoc
//
// @Summary Update a maintenance window
// @Description Moves the next run of every enabled policy using it.
// @Tags patching
// @Accept json
// @Produce json
// @Param id path string true "Window ID"
// @Param body body models.MaintenanceWindow true "Window"
// @Success 200 {object} models.MaintenanceWindow
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [put]
func updateWindow(c *gin.Context) {
var body models.MaintenanceWindow
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.CheckWindowScope(auth.InstanceID(c), c.Param("id"), auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
w, err := services.UpdateWindow(auth.InstanceID(c), c.Param("id"), body)
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_updated", actorFromCtx(c), "", "",
fmt.Sprintf("maintenance window %s: %s %s for %d minutes", w.Name, w.Cron, w.TZ, w.DurationMinutes))
c.JSON(http.StatusOK, w)
}
// deleteWindow godoc
//
// @Summary Delete a maintenance window
// @Tags patching
// @Param id path string true "Window ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse "window_in_use"
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [delete]
func deleteWindow(c *gin.Context) {
if err := services.CheckWindowScope(auth.InstanceID(c), c.Param("id"), auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if err := services.DeleteWindow(auth.InstanceID(c), c.Param("id")); err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_deleted", actorFromCtx(c), "", "", "maintenance window "+c.Param("id")+" deleted")
c.Status(http.StatusNoContent)
}
// listPolicies godoc
//
// @Summary List patch policies
// @Tags patching
// @Produce json
// @Success 200 {array} models.PatchPolicy
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies [get]
func listPolicies(c *gin.Context) {
ps, err := services.ListPolicies(auth.InstanceID(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, ps)
}
// createPolicy godoc
//
// @Summary Create a patch policy
// @Tags patching
// @Accept json
// @Produce json
// @Param body body models.PatchPolicy true "Policy"
// @Success 201 {object} models.PatchPolicy
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies [post]
func createPolicy(c *gin.Context) {
var body models.PatchPolicy
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p, err := services.CreatePolicy(auth.InstanceID(c), body, auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.policy_created", actorFromCtx(c), "", "",
fmt.Sprintf("patch policy %s: scope %s, reboot %s, enabled %v", p.Name, p.Scope, p.Reboot, p.Enabled))
c.JSON(http.StatusCreated, p)
}
// getPolicy godoc
//
// @Summary Get a patch policy
// @Tags patching
// @Produce json
// @Param id path string true "Policy ID"
// @Success 200 {object} models.PatchPolicy
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [get]
func getPolicy(c *gin.Context) {
p, err := services.GetPolicy(auth.InstanceID(c), c.Param("id"))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, p)
}
// updatePolicy godoc
//
// @Summary Update a patch policy
// @Tags patching
// @Accept json
// @Produce json
// @Param id path string true "Policy ID"
// @Param body body models.PatchPolicy true "Policy"
// @Success 200 {object} models.PatchPolicy
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [put]
func updatePolicy(c *gin.Context) {
var body models.PatchPolicy
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p, err := services.UpdatePolicy(auth.InstanceID(c), c.Param("id"), body, auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.policy_updated", actorFromCtx(c), "", "",
fmt.Sprintf("patch policy %s: scope %s, reboot %s, enabled %v", p.Name, p.Scope, p.Reboot, p.Enabled))
c.JSON(http.StatusOK, p)
}
// deletePolicy godoc
//
// @Summary Delete a patch policy
// @Tags patching
// @Param id path string true "Policy ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [delete]
func deletePolicy(c *gin.Context) {
instanceID := auth.InstanceID(c)
p, err := services.GetPolicy(instanceID, c.Param("id"))
if err != nil {
patchError(c, err)
return
}
if err := services.CheckPolicyScope(instanceID, *p, auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if err := services.DeletePolicy(instanceID, p.PolicyID); err != nil {
patchError(c, err)
return
}
services.LogEvent(instanceID, "patch.policy_deleted", actorFromCtx(c), "", "", "patch policy "+p.Name+" deleted")
c.Status(http.StatusNoContent)
}
// runPolicyNow godoc
//
// @Summary Run a patch policy now
// @Description Opens a window of the policy's usual length starting now.
// @Tags patching
// @Produce json
// @Param id path string true "Policy ID"
// @Success 202 {object} models.PatchRun
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id}/run-now [post]
func runPolicyNow(c *gin.Context) {
run, err := services.StartRunNow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c), auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusAccepted, run)
}
// listPatchRuns godoc
//
// @Summary List patch runs
// @Tags patching
// @Produce json
// @Param policy_id query string false "Filter by policy"
// @Param server_id query string false "Filter by server"
// @Param limit query int false "At most 200, default 50"
// @Success 200 {array} models.PatchRun
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs [get]
func listPatchRuns(c *gin.Context) {
instanceID := auth.InstanceID(c)
limit, _ := strconv.ParseInt(c.Query("limit"), 10, 64)
runs, err := services.ListPatchRuns(instanceID, c.Query("policy_id"), c.Query("server_id"), limit)
if err != nil {
patchError(c, err)
return
}
out := runs[:0]
for i := range runs {
before := len(runs[i].Servers)
if err := services.ScopePatchRun(instanceID, &runs[i], auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if before == 0 || len(runs[i].Servers) > 0 {
out = append(out, runs[i])
}
}
c.JSON(http.StatusOK, out)
}
// getPatchRun godoc
//
// @Summary Get a patch run, with per-server output
// @Tags patching
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.PatchRun
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs/{runId} [get]
func getPatchRun(c *gin.Context) {
run, ok := scopedRun(c)
if ok {
c.JSON(http.StatusOK, run)
}
}
// cancelPatchRun godoc
//
// @Summary Cancel a patch run
// @Description Stops further dispatch. Servers already patching finish.
// @Tags patching
// @Param runId path string true "Run ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs/{runId}/cancel [post]
func cancelPatchRun(c *gin.Context) {
run, ok := scopedRun(c)
if !ok {
return
}
if err := services.CancelPatchRun(auth.InstanceID(c), run.RunID); err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.cancelled", actorFromCtx(c), "", "", "patch run "+run.RunID+" cancelled")
c.Status(http.StatusNoContent)
}
// scopedRun loads a run for a caller. A tag-restricted token that cannot see
// every server in the run gets a 404, the same answer as a run that does not
// exist, rather than a partial record it could act on.
func scopedRun(c *gin.Context) (*models.PatchRun, bool) {
instanceID := auth.InstanceID(c)
run, err := services.GetPatchRun(instanceID, c.Param("runId"))
if err != nil {
patchError(c, err)
return nil, false
}
before := len(run.Servers)
if err := services.ScopePatchRun(instanceID, run, auth.ServerScope(c)); err != nil {
patchError(c, err)
return nil, false
}
if len(run.Servers) != before {
patchError(c, services.ErrPatchRunNotFound)
return nil, false
}
return run, true
}
@@ -0,0 +1,34 @@
package api
import "testing"
// Every patching route must carry a scope and a server-scope declaration, or
// boot fails. Asserting the exact scope here keeps a copy-paste of
// "patching:read" onto a write route from slipping through.
func TestPatchingRouteScopes(t *testing.T) {
want := map[string]string{
"GET /api/maintenance-windows": "patching:read",
"POST /api/maintenance-windows": "patching:write",
"POST /api/maintenance-windows/preview": "patching:read",
"GET /api/maintenance-windows/:id": "patching:read",
"PUT /api/maintenance-windows/:id": "patching:write",
"DELETE /api/maintenance-windows/:id": "patching:write",
"GET /api/patch-policies": "patching:read",
"POST /api/patch-policies": "patching:write",
"GET /api/patch-policies/:id": "patching:read",
"PUT /api/patch-policies/:id": "patching:write",
"DELETE /api/patch-policies/:id": "patching:write",
"POST /api/patch-policies/:id/run-now": "patching:write",
"GET /api/patch-runs": "patching:read",
"GET /api/patch-runs/:runId": "patching:read",
"POST /api/patch-runs/:runId/cancel": "patching:write",
}
for route, scope := range want {
if got := routeScopes[route]; got != scope {
t.Errorf("%s: scope %q, want %q", route, got, scope)
}
if _, ok := serverScopedRoutes[route]; !ok {
t.Errorf("%s: missing from serverScopedRoutes", route)
}
}
}
+4 -4
View File
@@ -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.
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func runFixture() *models.WorkflowRun {
return &models.WorkflowRun{
RunID: "r1",
ServerRuns: []models.ServerRun{
{ServerID: "stg-1", Hostname: "staging-web"},
{ServerID: "prod-1", Hostname: "prod-db"},
},
}
}
// 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.
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" {
t.Fatalf("server_runs = %v, want only stg-1", got.ServerRuns)
}
for _, sr := range got.ServerRuns {
if sr.Hostname == "prod-db" {
t.Error("out-of-scope hostname survived filtering")
}
}
if !got.ServersRestricted {
t.Error("servers_restricted = false with an entry dropped")
}
}
func TestScopeRunLeavesUnrestrictedCallerWhole(t *testing.T) {
got := scopeRun(runFixture(), nil, false)
if len(got.ServerRuns) != 2 {
t.Fatalf("server_runs = %v, want both", got.ServerRuns)
}
if got.ServersRestricted {
t.Error("unrestricted caller told entries were restricted")
}
}
// 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
// restricted in general.
func TestScopeRunNoFlagWhenNothingDropped(t *testing.T) {
got := scopeRun(runFixture(), map[string]bool{"stg-1": true, "prod-1": true}, true)
if got.ServersRestricted {
t.Error("servers_restricted set with nothing dropped")
}
}
+38 -4
View File
@@ -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
@@ -43,6 +43,12 @@ var routeScopes = map[string]string{
"GET /api/agent/latest-version": "servers:read",
"GET /api/audit": "settings:read",
// Both MCP routes require mcp:read as a floor. Individual tools require
// their own resource scope, and write tools additionally require mcp:write,
// enforced inside the handler because one route serves many operations.
"POST /api/mcp": "mcp:read",
"GET /api/mcp": "mcp:read",
"GET /api/settings": "settings:read",
"PUT /api/settings": "settings:write",
"POST /api/settings/secrets-token": "settings:write",
@@ -169,6 +175,23 @@ var routeScopes = map[string]string{
"PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write",
// Scheduled patching: maintenance windows, patch policies and patch runs.
"GET /api/maintenance-windows": "patching:read",
"POST /api/maintenance-windows": "patching:write",
"POST /api/maintenance-windows/preview": "patching:read",
"GET /api/maintenance-windows/:id": "patching:read",
"PUT /api/maintenance-windows/:id": "patching:write",
"DELETE /api/maintenance-windows/:id": "patching:write",
"GET /api/patch-policies": "patching:read",
"POST /api/patch-policies": "patching:write",
"GET /api/patch-policies/:id": "patching:read",
"PUT /api/patch-policies/:id": "patching:write",
"DELETE /api/patch-policies/:id": "patching:write",
"POST /api/patch-policies/:id/run-now": "patching:write",
"GET /api/patch-runs": "patching:read",
"GET /api/patch-runs/:runId": "patching:read",
"POST /api/patch-runs/:runId/cancel": "patching:write",
}
// RequireScopes enforces routeScopes for token-authenticated requests and does
@@ -204,6 +227,17 @@ func RequireScopes() gin.HandlerFunc {
}
}
// routesOutsideAPIGroup lists routes matching the "/api/" path prefix that
// are nonetheless registered on the root router rather than the authenticated
// /api group, so they carry no session or API-token auth and are exempt from
// routeScopes. scopes.go and serverscope_test.go both read this set so the
// exception is stated once.
var routesOutsideAPIGroup = map[string]bool{
// The ESO endpoint keeps its own bearer scheme and is deliberately
// outside the token vocabulary.
"GET /api/secrets/:group/values": true,
}
// AssertScopeMapComplete fails boot when a registered /api route has no scope.
//
// Without it, adding a route silently makes it unreachable by every token, and
@@ -214,9 +248,9 @@ func AssertScopeMapComplete(r *gin.Engine) error {
if !strings.HasPrefix(route.Path, "/api/") {
continue
}
// The ESO endpoint keeps its own bearer scheme and is deliberately
// outside the token vocabulary.
if route.Path == "/api/secrets/:group/values" {
// Routes registered outside the authenticated /api group (their own
// bearer scheme, not the token vocabulary) are deliberately excluded.
if routesOutsideAPIGroup[route.Method+" "+route.Path] {
continue
}
if _, ok := routeScopes[route.Method+" "+route.Path]; !ok {
+1 -1
View File
@@ -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"
+442
View File
@@ -0,0 +1,442 @@
package api
import "fmt"
// scopeDecl is one route's declaration about server-derived data.
type scopeDecl int
const (
// scoped: the handler honours the acting token's tag restriction.
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,
// because this value is read as a considered decision.
fleetWide
// exempt: the route touches no server-derived data at all. Every exempt
// entry carries a comment saying why, because "this reads no server data"
// is exactly the claim that turns out to be wrong when a handler later
// grows a server lookup.
exempt
)
// serverScopedRoutes declares, for EVERY registered /api route, whether it
// honours the acting token's tag restriction.
//
// The declaration is inverted from what it used to be. It was once a partial
// map checked only against routes whose path contained "server", "console" or
// "assign"; that filter caught one of the routes found leaking in the final
// review of this feature, and none of the eleven found during implementation,
// because a route can act on a server named in its body, in a query parameter
// or derived by the handler, with a path saying nothing about it. Every route
// must now appear here with an explicit value and boot fails on an undeclared
// one, so the question "does this touch server data?" is asked once per route
// by construction rather than when someone thinks to widen a pattern.
//
// Two things this assertion cannot do, and one of them has already bitten:
//
// 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
// 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
// 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.
var serverScopedRoutes = map[string]scopeDecl{
// ---- servers ----
"GET /api/servers": scoped,
"GET /api/servers/:id": scoped,
"DELETE /api/servers/:id": scoped,
"POST /api/servers/:id/apply-updates": scoped,
"POST /api/servers/:id/update-agent": scoped,
"PUT /api/servers/:id/tags": scoped,
"POST /api/servers/:id/generate-key": scoped,
// Creating a server has no server to filter yet.
"POST /api/servers": fleetWide,
// The agent's own enrolment routes authenticate as the agent, not as a
// user token, so no session selector exists to apply.
"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
// 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
// acceptable to leave fleet-wide rather than take that on for this.
"GET /api/servers/tags": fleetWide,
// ---- console ----
"POST /api/console/connect": scoped,
"GET /api/console/tunnel": scoped,
// ---- workloads ----
// Workload routes all resolve the server through GetServerScoped before
// touching anything.
"GET /api/servers/:id/workloads": scoped,
"POST /api/servers/:id/workloads/refresh": scoped,
"POST /api/servers/:id/workloads/:wid/action": scoped,
"GET /api/servers/:id/workloads/:wid/logs": scoped,
// listWorkloads passes the caller's selector into services.SearchWorkloads,
// which drops hits on servers outside it. A WorkloadHit names a server ID,
// so the fleet-wide form enumerated hosts directly.
"GET /api/workloads": scoped,
// ---- vulnerabilities and packages ----
// listServerVulnerabilities and getServerPackages resolve the server
// through GetServerScoped before calling ListFindings/ListPackages, so an
// out-of-scope server ID reads as not-found before either function runs.
"GET /api/servers/:id/vulnerabilities": scoped,
"GET /api/servers/:id/packages": scoped,
// listVulnerabilities passes the selector as FindingFilter.TokenScope,
// narrowing server_id in the same query the Tags selector already narrows,
// and vulnerabilitySummary passes it to CountOpenFindingsBySeverity so the
// summary tiles count only visible servers.
"GET /api/vulnerabilities": scoped,
"GET /api/vulnerabilities/summary": scoped,
// searchPackages passes the caller's selector into services.SearchPackages,
// which drops hits on servers outside it using one VisibleServerIDs
// membership set. The MCP search_fleet tool answers the same question and
// was already scoped; this makes the REST twin agree.
"GET /api/packages/search": scoped,
// Rescan flags the whole fleet and returns a count of servers flagged, not
// their identities. Scanning is a control-plane background job with no
// caller-visible per-server effect, and a partial rescan would leave the
// findings a restricted token *can* see computed against a stale database.
// Owner|admin only in any case.
"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
// 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.
"POST /api/vulnerabilities/:id/accept": fleetWide,
"DELETE /api/vulnerabilities/:id/accept": fleetWide,
// Vuln alert rules carry severities and tag selectors, never server IDs.
"GET /api/vuln-rules": exempt,
"POST /api/vuln-rules": exempt,
"PUT /api/vuln-rules/:id": exempt,
"DELETE /api/vuln-rules/:id": exempt,
// ---- keys ----
// getKey filters services.GetAssignmentsWithServers' result down to
// 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
// 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.
"GET /api/keys/:id": scoped,
// 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
// token an assignment exists on a host it must not know about, without
// naming the host.
"GET /api/keys": scoped,
// assignKey resolves body.ServerID through GetServerScoped before calling
// services.AssignKey, and revokeAssignment resolves :serverId the same way
// before calling RevokeAssignment.
"POST /api/keys/:id/assign": scoped,
"DELETE /api/keys/:id/assign/:serverId": scoped,
// Uploading a key and reading its stored private half touch no server:
// a key exists in the library before it is assigned anywhere.
"POST /api/keys": exempt,
"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
// 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,
// ---- workflows, steps and runs ----
// 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
// itself is ruled acceptable to expose, unlike a resolved server ID.
// TargetsRestricted is set (with no count) whenever at least one target
// was dropped.
"GET /api/workflows": scoped,
"GET /api/workflows/:id": scoped,
// createWorkflow/updateWorkflow validate target_server_ids through
// services.validateTargetServers (GetServerScoped per ID) and separately
// 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.
// 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
// resolve to something by the difference between a refusal and a
// successful save.
"POST /api/workflows": scoped,
"PUT /api/workflows/:id": scoped,
// 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
// 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
// admits, setting servers_restricted (a boolean, never a count) when any
// entry was dropped.
"GET /api/runs/:runId": scoped,
"GET /api/workflows/:id/runs": scoped,
// getServerRunLog/streamServerRunLog resolve :serverId through
// GetServerScoped before reading anything from the log store, so a
// restricted token holding a valid runId still cannot read output from a
// server outside its scope.
"GET /api/runs/:runId/servers/:serverId/logs": scoped,
"GET /api/runs/:runId/servers/:serverId/logs/stream": scoped,
// 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
// 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
// whose targets are mixed, neither of which is a better answer than the
// current one. Recorded as a deliberate choice, not an oversight.
"DELETE /api/workflows/:id": fleetWide,
"POST /api/runs/:runId/cancel": fleetWide,
// 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 -
// 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
// 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
// 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.
"PUT /api/workflows/:id/schedule": fleetWide,
"GET /api/workflows/:id/schedule/preview": exempt,
// A step is a script with declared inputs and outputs. It names no server
// and is not bound to one; targeting happens at the workflow level.
"GET /api/steps": exempt,
"POST /api/steps": exempt,
"PUT /api/steps/:id": exempt,
"DELETE /api/steps/:id": exempt,
"GET /api/steps/:id/export": exempt,
"POST /api/steps/import": exempt,
"POST /api/steps/parse": exempt,
"POST /api/steps/seed-defaults": exempt,
// StepUsageCounts counts workflows per step, never servers.
"GET /api/steps/usage": exempt,
// ---- monitors ----
// 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
// 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.
"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
// 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,
"PUT /api/monitors/:id": scoped,
// Deleting a monitor removes the check, not a server, and returns nothing
// about where it ran. The runner field it might have named is already
// redacted on every read path, so a restricted token cannot learn one to
// then act on.
"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
// server identifier at all; the runner is a field of the monitor
// document, which these do not return.
"GET /api/monitors/:id/incidents": exempt,
"GET /api/monitors/:id/uptime": exempt,
"GET /api/monitors/:id/samples": exempt,
// ---- notification channels ----
// 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,
"PUT /api/channels/:id": exempt,
"DELETE /api/channels/:id": exempt,
"POST /api/channels/:id/test": exempt,
// ---- secrets ----
// Vault secrets are key/value pairs grouped by name, consumed by workflow
// steps at execution time. No secret is bound to a server, and no server
// attribute is returned by any of these.
"GET /api/secrets": exempt,
"POST /api/secrets": exempt,
"GET /api/secrets/:group": exempt,
"PUT /api/secrets/:group": exempt,
"DELETE /api/secrets/:group": exempt,
"DELETE /api/secrets/:group/:key": exempt,
"POST /api/secrets/:group/reveal": exempt,
"GET /api/secrets/:group/values": exempt,
// ---- status pages ----
// 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
// host, URL or runner. These authoring routes handle the page document
// itself and never a server.
"GET /api/status-pages": exempt,
"POST /api/status-pages": exempt,
"GET /api/status-pages/:pageId": exempt,
"PUT /api/status-pages/:pageId": exempt,
"DELETE /api/status-pages/:pageId": exempt,
"GET /api/status-pages/:pageId/incidents": exempt,
"POST /api/status-pages/:pageId/incidents": exempt,
"PUT /api/status-pages/:pageId/incidents/:incidentId": exempt,
"DELETE /api/status-pages/:pageId/incidents/:incidentId": exempt,
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": exempt,
// ---- audit ----
// 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 -
// 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
// disclosure is worth avoiding, and the route is settings:read.
"GET /api/audit": fleetWide,
// ---- instance administration ----
// Members, roles, single sign-on, settings and the licence are all
// instance-level configuration. None reads the servers collection.
"GET /api/instance/users": exempt,
"POST /api/instance/users": exempt,
"PUT /api/instance/users/:id/role": exempt,
"DELETE /api/instance/users/:id": exempt,
"GET /api/auth/providers": exempt,
"POST /api/auth/providers": exempt,
"PUT /api/auth/providers/:id": exempt,
"DELETE /api/auth/providers/:id": exempt,
"POST /api/auth/providers/:id/test": exempt,
"POST /api/auth/providers/:id/ack-notice": exempt,
"GET /api/auth/presets": exempt,
"GET /api/settings": exempt,
"PUT /api/settings": exempt,
"POST /api/settings/secrets-token": exempt,
"GET /api/license": exempt,
"POST /api/license": exempt,
// A token document carries a tag selector but no server: minting one
// checks the selector is no wider than the caller's own
// (services.SelectorNarrowerOrEqual), which reads the caller's session,
// not the fleet.
"GET /api/tokens": exempt,
"GET /api/tokens/scopes": exempt,
"POST /api/tokens": exempt,
"DELETE /api/tokens/:id": exempt,
// Reference documentation and the agent version lookup are static or read
// from a release feed.
"GET /api/openapi.json": exempt,
"GET /api/docs": exempt,
"GET /api/docs/scalar.js": exempt,
"GET /api/agent/latest-version": exempt,
// ---- MCP ----
// Exempt at route level, for the reason set out in this file's header:
// one route serves many tools, so the answer genuinely lives per tool.
// Each tool touching server data applies the caller's selector itself.
"POST /api/mcp": exempt,
"GET /api/mcp": exempt,
// Maintenance windows are a cron expression, a zone and a duration. They
// name no server and return no server data, so reading and creating one
// is exempt. Changing or deleting one moves or stops the patching of
// every policy using it, so each is refused when any of those policies
// targets servers outside the token's tag restriction.
"GET /api/maintenance-windows": exempt,
"POST /api/maintenance-windows": exempt,
"POST /api/maintenance-windows/preview": exempt,
"GET /api/maintenance-windows/:id": exempt,
"PUT /api/maintenance-windows/:id": scoped,
"DELETE /api/maintenance-windows/:id": scoped,
// Reading a policy returns its selector (server IDs and tag pairs) and no
// hostname, inventory or state, the same data a workflow's targets carry.
"GET /api/patch-policies": exempt,
"GET /api/patch-policies/:id": exempt,
// Writes and run-now act on the policy's targets, so each is refused when
// those targets reach outside the token's tag restriction.
"POST /api/patch-policies": scoped,
"PUT /api/patch-policies/:id": scoped,
"DELETE /api/patch-policies/:id": scoped,
"POST /api/patch-policies/:id/run-now": scoped,
// Runs name hostnames; ScopePatchRun removes servers the token cannot see.
"GET /api/patch-runs": scoped,
"GET /api/patch-runs/:runId": scoped,
"POST /api/patch-runs/:runId/cancel": scoped,
}
// 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
// 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 {
for _, r := range routes {
if _, ok := serverScopedRoutes[r]; !ok {
return fmt.Errorf("route %q is not declared in serverScopedRoutes "+
"(declare it scoped, fleetWide with a reason, or exempt with a reason)", r)
}
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
package api
import "testing"
// The two maps must name exactly the same routes. AssertScopeMapComplete
// already fails boot on an /api route missing from routeScopes, so making
// serverScopedRoutes agree with routeScopes is what makes the inverted
// server-scope assertion total without needing a running engine to check it.
func TestServerScopeMapCoversEveryScopedRoute(t *testing.T) {
for r := range routeScopes {
if _, ok := serverScopedRoutes[r]; !ok {
t.Errorf("route %q is in routeScopes but not declared in serverScopedRoutes", r)
}
}
for r := range serverScopedRoutes {
if _, ok := routeScopes[r]; ok {
continue
}
if routesOutsideAPIGroup[r] {
continue
}
t.Errorf("route %q is declared in serverScopedRoutes but is not a registered route", r)
}
}
// A route that vanished from the engine but stayed here would make the
// assertion pass while declaring nothing real, so the assertion itself is
// tested for the one thing it does promise: an undeclared route fails.
func TestAssertServerScopeMapCompleteRejectsUndeclaredRoute(t *testing.T) {
if err := AssertServerScopeMapComplete([]string{"GET /api/servers"}); err != nil {
t.Fatalf("declared route rejected: %v", err)
}
if err := AssertServerScopeMapComplete([]string{"GET /api/brand-new"}); err == nil {
t.Fatal("undeclared route accepted; boot would not fail on it")
}
}
+1 -1
View File
@@ -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 {
+46 -8
View File
@@ -4,10 +4,12 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
"github.com/gin-gonic/gin"
)
@@ -72,10 +74,11 @@ func listTokenScopes(c *gin.Context) {
// @Router /tokens [post]
func createToken(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Role string `json:"role" binding:"required"`
Scopes []string `json:"scopes" binding:"required"`
ExpiresInDays *int `json:"expires_in_days"`
Name string `json:"name" binding:"required"`
Role string `json:"role" binding:"required"`
Scopes []string `json:"scopes" binding:"required"`
ExpiresInDays *int `json:"expires_in_days"`
TagSelector map[string]string `json:"tag_selector"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -84,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
@@ -108,9 +111,40 @@ func createToken(c *gin.Context) {
}
}
// The MCP scopes are refused without the licence feature, matching the
// guard-at-source thinking in services/packages.go rather than relying on
// RequireFeature at the /api/mcp group alone. That gate is a runtime one:
// without this, a licence downgrade leaves live agent credentials that
// authenticate, list as MCP tokens in the UI, and then fail mid-
// conversation with a 403 the model cannot explain. Refusing at minting
// means a token carrying mcp:* only ever existed while the feature did.
//
// Existing tokens are deliberately untouched by a downgrade: the route
// gate already stops them reaching the endpoint, and silently revoking
// credentials on a billing change is worse than refusing new ones.
if !services.GetLicenseState(auth.InstanceID(c)).Feature(license.FeatureMCP) {
for _, s := range body.Scopes {
if strings.HasPrefix(s, "mcp:") {
c.JSON(http.StatusForbidden, gin.H{
"error": "feature_unavailable",
"feature": license.FeatureMCP,
"code": "feature_unavailable",
})
return
}
}
}
if !services.SelectorNarrowerOrEqual(body.TagSelector, auth.ServerScope(c)) {
c.JSON(http.StatusForbidden, gin.H{
"error": "a token cannot reach servers its creator cannot reach",
})
return
}
tok, plaintext, err := services.CreateAPIToken(
auth.InstanceID(c), auth.UserID(c),
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
body.Name, body.Role, body.Scopes, body.TagSelector, body.ExpiresInDays, c.ClientIP(),
)
switch {
case errors.Is(err, services.ErrTokenNameTaken):
@@ -137,8 +171,12 @@ func createToken(c *gin.Context) {
if tok.ExpiresAt != nil {
expiry = "expires " + tok.ExpiresAt.Format("2006-01-02")
}
services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "",
fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry))
detail := fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s",
tok.Name, tok.Role, tok.Scopes, expiry)
if len(tok.TagSelector) > 0 {
detail += fmt.Sprintf(", restricted to %v", tok.TagSelector)
}
services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "", detail)
// The plaintext is returned exactly once and is not stored anywhere.
c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok})
+79 -5
View File
@@ -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"`
@@ -114,6 +114,57 @@ type AgentVersionResponse struct {
Version string `json:"version"`
}
// WorkflowResponse is a workflow with its TargetServerIDs narrowed to what
// 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
// out-of-scope server.
//
// TargetsRestricted is set, with no count, when at least one target was
// dropped, so a caller reading this alongside run_workflow's all-or-nothing
// out-of-scope refusal sees why: the refusal is not inventing a problem the
// list never mentioned.
type WorkflowResponse struct {
*models.Workflow
TargetServerIDs []string `json:"target_server_ids"`
TargetsRestricted bool `json:"targets_restricted,omitempty"`
}
// 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
// level up, and the parent of the per-server log routes that were already
// scoped.
//
// ServersRestricted follows the targets_restricted precedent exactly: a
// boolean and no count, because how many entries were dropped is itself
// information about a fleet the caller must not be able to size.
type RunResponse struct {
*models.WorkflowRun
ServerRuns []models.ServerRun `json:"server_runs"`
ServersRestricted bool `json:"servers_restricted,omitempty"`
}
// scopeRun narrows one run's ServerRuns using the (visible, restricted) pair
// services.VisibleServerIDs returns.
func scopeRun(r *models.WorkflowRun, visible map[string]bool, restricted bool) RunResponse {
if !restricted {
return RunResponse{WorkflowRun: r, ServerRuns: r.ServerRuns}
}
out := make([]models.ServerRun, 0, len(r.ServerRuns))
hidden := false
for _, sr := range r.ServerRuns {
if visible[sr.ServerID] {
out = append(out, sr)
} else {
hidden = true
}
}
return RunResponse{WorkflowRun: r, ServerRuns: out, ServersRestricted: hidden}
}
type UpdateAgentResponse struct {
Message string `json:"message"`
Version string `json:"version"`
@@ -121,14 +172,14 @@ type UpdateAgentResponse struct {
type AuditEventsResponse struct {
Events []models.AuditEvent `json:"events"`
Total int64 `json:"total"`
Total int64 `json:"total"`
}
// --- tokens ---
type ListTokensResponse struct {
Tokens []models.APIToken `json:"tokens"`
All bool `json:"all"`
All bool `json:"all"`
}
type TokenScopesResponse struct {
@@ -215,8 +266,8 @@ type RunWorkflowResponse struct {
}
type ScheduleResponse struct {
Schedule models.Schedule `json:"schedule"`
NextRunAt *time.Time `json:"next_run_at"`
Schedule models.Schedule `json:"schedule"`
NextRunAt *time.Time `json:"next_run_at"`
}
type OccurrencesResponse struct {
@@ -246,3 +297,26 @@ type StatusIncidentUpdateRequest struct {
Status string `json:"status" binding:"required"`
Body string `json:"body" binding:"required"`
}
// --- patching ---
// ApplyUpdatesResponse keeps the message existing scripts read and adds the
// run that records what happened.
type ApplyUpdatesResponse struct {
Message string `json:"message"`
RunID string `json:"run_id,omitempty"`
}
// ApplyUpdatesErrorResponse is the 503 body of apply-updates: the agent is
// offline, and the attempt is still recorded as a run.
type ApplyUpdatesErrorResponse struct {
Error string `json:"error"`
RunID string `json:"run_id,omitempty"`
}
// WindowPreviewRequest is the body of POST /maintenance-windows/preview.
type WindowPreviewRequest struct {
Cron string `json:"cron"`
TZ string `json:"tz"`
DurationMinutes int `json:"duration_minutes"`
}
+25 -9
View File
@@ -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"
@@ -49,6 +49,8 @@ func listVulnerabilities(c *gin.Context) {
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
HasFix: hasFixFromQuery(c),
TokenScope: auth.ServerScope(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -100,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") {
@@ -143,7 +145,7 @@ func tagsFromQuery(c *gin.Context) map[string]string {
// @Security bearerAuth
// @Router /vulnerabilities/summary [get]
func vulnerabilitySummary(c *gin.Context) {
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -197,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
@@ -219,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
@@ -290,7 +292,14 @@ func writeFindingError(c *gin.Context, err error) {
// @Security bearerAuth
// @Router /servers/{id}/vulnerabilities [get]
func listServerVulnerabilities(c *gin.Context) {
findings, err := services.ListFindings(c.Request.Context(), auth.InstanceID(c), c.Param("id"))
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
findings, err := services.ListFindings(c.Request.Context(), instanceID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -304,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"
@@ -314,7 +323,14 @@ func listServerVulnerabilities(c *gin.Context) {
// @Security bearerAuth
// @Router /servers/{id}/packages [get]
func getServerPackages(c *gin.Context) {
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sp, err := services.ListPackages(instanceID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -347,7 +363,7 @@ func searchPackages(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
hits, err := services.SearchPackages(auth.InstanceID(c), name)
hits, err := services.SearchPackages(auth.InstanceID(c), name, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+52 -8
View File
@@ -67,6 +67,10 @@ func getServerRunLog(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if _, err := services.GetServerScoped(auth.InstanceID(c), serverID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if !services.HasServerRunLog(runID, serverID) {
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
return
@@ -117,6 +121,10 @@ func streamServerRunLog(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if _, err := services.GetServerScoped(auth.InstanceID(c), serverID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
@@ -434,7 +442,20 @@ func listWorkflows(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, wfs)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]WorkflowResponse, 0, len(wfs))
for i := range wfs {
w := wfs[i]
ids, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
out = append(out, WorkflowResponse{Workflow: &w, TargetServerIDs: ids, TargetsRestricted: hidden})
}
c.JSON(http.StatusOK, out)
}
// createWorkflow godoc
@@ -456,7 +477,7 @@ func createWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateWorkflow(auth.InstanceID(c), w)
out, err := services.CreateWorkflow(auth.InstanceID(c), w, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -482,7 +503,15 @@ func getWorkflow(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, w)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ids, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
c.JSON(http.StatusOK, WorkflowResponse{Workflow: w, TargetServerIDs: ids, TargetsRestricted: hidden})
}
// updateWorkflow godoc
@@ -505,7 +534,7 @@ func updateWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil {
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -552,7 +581,7 @@ func deleteWorkflow(c *gin.Context) {
// @Security bearerAuth
// @Router /workflows/{id}/run [post]
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c), auth.ServerScope(c))
if err != nil {
if errors.Is(err, services.ErrNoTargets) {
c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
@@ -589,7 +618,17 @@ func listWorkflowRuns(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, runs)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]RunResponse, 0, len(runs))
for i := range runs {
out = append(out, scopeRun(&runs[i], visible, restricted))
}
c.JSON(http.StatusOK, out)
}
// getRun godoc
@@ -598,7 +637,7 @@ func listWorkflowRuns(c *gin.Context) {
// @Tags workflows
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.WorkflowRun
// @Success 200 {object} RunResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
@@ -609,7 +648,12 @@ func getRun(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, r)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, scopeRun(r, visible, restricted))
}
// cancelRun godoc
+6 -6
View File
@@ -35,7 +35,7 @@ func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServer(instanceID, id); err != nil {
if _, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
@@ -77,7 +77,7 @@ func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -139,7 +139,7 @@ func controlWorkload(c *gin.Context) {
return
}
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -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:
@@ -205,7 +205,7 @@ func getWorkloadLogs(c *gin.Context) {
tail = services.MaxWorkloadLogLines
}
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -245,7 +245,7 @@ func getWorkloadLogs(c *gin.Context) {
// @Router /workloads [get]
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))
c.Query("image"), c.Query("stack"), c.Query("state"), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+1 -1
View File
@@ -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 {
+47 -23
View File
@@ -61,45 +61,68 @@ 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)
}
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
func InstanceForHost(host string) (*models.Instance, bool) {
slug := hostSlug(host)
if slug == "" {
return nil, false
}
if inst, hit := cachedInstanceFor(slug); hit {
return inst, inst != nil
}
// instanceBySlug is a variable so tests can stub the Mongo read.
var instanceBySlug = services.GetInstanceBySlugIncludingLocked
inst, err := services.GetInstanceBySlug(slug)
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
// round trip is itself a timing oracle separating "no such instance"
// from "instance exists, page does not".
storeInstance(slug, nil)
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
//
// An instance Vantage HQ has locked under a dispute resolves to nothing, so
// every caller refuses it without knowing locks exist. HostLocked is the one
// place that may tell the two apart, for the login page's message.
func InstanceForHost(host string) (*models.Instance, bool) {
inst := slugInstance(hostSlug(host))
if inst == nil || inst.LockedAt != nil {
return nil, false
}
storeInstance(slug, inst)
return inst, true
}
// HostLocked reports whether the host names an instance Vantage HQ has locked.
// It shares InstanceForHost's cache, so a lock and an unlock are seen within
// the same 60 seconds by both.
func HostLocked(host string) bool {
inst := slugInstance(hostSlug(host))
return inst != nil && inst.LockedAt != nil
}
// slugInstance reads the instance a slug names, locked or not, through the
// cache.
func slugInstance(slug string) *models.Instance {
if slug == "" {
return nil
}
if inst, hit := cachedInstanceFor(slug); hit {
return inst
}
inst, err := instanceBySlug(slug)
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
// round trip is itself a timing oracle separating "no such instance"
// from "instance exists, page does not".
storeInstance(slug, nil)
return nil
}
storeInstance(slug, inst)
return inst
}
// 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.
func SoleInstance() (*models.Instance, bool) {
if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit {
return inst, inst != nil
return inst, inst != nil && inst.LockedAt == nil
}
n, err := services.CountInstances()
if err != nil || n != 1 {
@@ -112,7 +135,8 @@ func SoleInstance() (*models.Instance, bool) {
return nil, false
}
storeInstance(soleInstanceCacheKey, inst)
return inst, true
// A locked sole instance is refused like any other locked instance.
return inst, inst.LockedAt == nil
}
func cachedInstanceFor(key string) (*models.Instance, bool) {
@@ -0,0 +1,56 @@
package auth
import (
"errors"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// stubSlugs replaces the slug lookup and empties the host cache for one test.
func stubSlugs(t *testing.T, bySlug map[string]*models.Instance) {
t.Helper()
prev := instanceBySlug
instanceBySlug = func(slug string) (*models.Instance, error) {
if inst, ok := bySlug[slug]; ok {
return inst, nil
}
return nil, errors.New("not found")
}
instanceCacheMu.Lock()
instanceCache = map[string]cachedInstance{}
instanceCacheMu.Unlock()
t.Cleanup(func() {
instanceBySlug = prev
instanceCacheMu.Lock()
instanceCache = map[string]cachedInstance{}
instanceCacheMu.Unlock()
})
}
func TestLockedHostResolvesToNothingButReportsLocked(t *testing.T) {
at := time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC)
stubSlugs(t, map[string]*models.Instance{
"acme": {InstanceID: "i-acme", Slug: "acme", LockedAt: &at},
"open": {InstanceID: "i-open", Slug: "open"},
})
if _, ok := InstanceForHost("acme.vantage.example.com"); ok {
t.Fatal("a locked instance must not resolve")
}
if !HostLocked("acme.vantage.example.com") {
t.Fatal("a locked instance's host must report locked")
}
if inst, ok := InstanceForHost("open.vantage.example.com"); !ok || inst.InstanceID != "i-open" {
t.Fatalf("unlocked instance: got %v, %v", inst, ok)
}
if HostLocked("open.vantage.example.com") {
t.Fatal("an unlocked instance is not locked")
}
if HostLocked("missing.vantage.example.com") || HostLocked("example.com") {
t.Fatal("a host naming no instance is not locked")
}
}
+37 -1
View File
@@ -1,6 +1,7 @@
package auth
import (
"errors"
"fmt"
"net/http"
@@ -31,10 +32,18 @@ func SetSessionCookie(c *gin.Context, sessionID string) {
//
// Anything else is refused rather than guessed. Picking an instance on someone's
// behalf is how you sign them into the wrong tenant.
//
// An instance Vantage HQ has locked answers ErrInstanceLocked on both paths.
// Without the check on the fallback, a single-instance deployment whose host
// resolver hides the locked instance fell through to "the sole instance" and
// served its sign-in page anyway.
func resolveLoginInstance(c *gin.Context) (string, error) {
if inst, ok := InstanceFromHost(c); ok {
return inst.InstanceID, nil
}
if HostLocked(c.Request.Host) {
return "", ErrInstanceLocked
}
n, err := services.CountInstances()
if err != nil {
return "", err
@@ -48,9 +57,22 @@ func resolveLoginInstance(c *gin.Context) (string, error) {
if err != nil {
return "", err
}
if inst.LockedAt != nil {
return "", ErrInstanceLocked
}
return inst.InstanceID, nil
}
// ErrInstanceLocked is a sign-in to an instance Vantage HQ has locked under an
// account dispute. The login page shows it as a message in place of the form.
var ErrInstanceLocked = errors.New("access to this instance is suspended")
// loginLocked reports whether this request's sign-in target is locked.
func loginLocked(c *gin.Context) bool {
_, err := resolveLoginInstance(c)
return errors.Is(err, ErrInstanceLocked)
}
func HandleLocalLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
@@ -61,6 +83,10 @@ func HandleLocalLogin(c *gin.Context) {
return
}
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -89,7 +115,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) {
@@ -101,6 +127,10 @@ func HandleListPublicProviders(c *gin.Context) {
out := []publicProvider{}
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusOK, gin.H{"local_enabled": false, "providers": out, "locked": true})
return
}
if err != nil {
// An unresolvable instance is not an error the login page can act on:
// it still has to render a password form. Answer the safe shape.
@@ -127,6 +157,12 @@ func HandleBootstrapStatus(c *gin.Context) {
err error
instName string
)
// Checked first: a locked instance must not fall through to the global
// user count, which answers needs_setup false and draws the sign-in form.
if loginLocked(c) {
c.JSON(http.StatusOK, gin.H{"needs_setup": false, "locked": true})
return
}
if inst, ok := InstanceFromHost(c); ok {
n, err = services.CountInstanceUsers(inst.InstanceID)
instName = inst.Name
+34
View File
@@ -0,0 +1,34 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRefuseLocked(t *testing.T) {
gin.SetMode(gin.TestMode)
prev := instanceLocked
t.Cleanup(func() { instanceLocked = prev })
instanceLocked = func(id string) bool { return id == "locked" }
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
if !refuseLocked(c, "locked") {
t.Fatal("a locked instance must be refused")
}
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
if refuseLocked(c, "open") {
t.Fatal("an unlocked instance must pass")
}
if c.IsAborted() {
t.Fatal("an unlocked instance must not abort")
}
}
+37 -4
View File
@@ -12,6 +12,20 @@ import (
const ctxSessionKey = "km_session"
// instanceLocked is a variable so tests can stub the Mongo-backed check.
var instanceLocked = services.InstanceLocked
// refuseLocked answers 401 for a session or token on an instance Vantage HQ has
// locked under a dispute. Same body as an expired session: a locked instance
// is not announced as locked to whoever holds a credential for it.
func refuseLocked(c *gin.Context, instanceID string) bool {
if !instanceLocked(instanceID) {
return false
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return true
}
func GetSessionFromContext(c *gin.Context) *Session {
v, _ := c.Get(ctxSessionKey)
sess, _ := v.(*Session)
@@ -81,6 +95,13 @@ func Middleware() gin.HandlerFunc {
return
}
// Explicit, because the host guard below cannot do this: the resolver
// hides a locked instance, so its host resolves to nothing and that
// guard is skipped rather than tripped.
if refuseLocked(c, sess.InstanceID) {
return
}
c.Set(ctxSessionKey, sess)
// The host guard applies to both credential kinds. A token carries an
@@ -104,7 +125,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) != "" {
@@ -167,9 +188,10 @@ func sessionFromToken(c *gin.Context) (*Session, bool) {
Role: services.LowerRole(user.Role, tok.Role),
Email: user.Email,
Name: user.Email,
TokenID: tok.TokenID,
TokenName: tok.Name,
Scopes: tok.Scopes,
TokenID: tok.TokenID,
TokenName: tok.Name,
Scopes: tok.Scopes,
TokenScope: tok.TagSelector,
}, true
}
@@ -199,3 +221,14 @@ func Scopes(c *gin.Context) []string {
// IsToken reports whether this request authenticated with an API token rather
// than a browser session.
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
// whole fleet, never nothing.
func ServerScope(c *gin.Context) map[string]string {
if s := GetSessionFromContext(c); s != nil {
return s.TokenScope
}
return nil
}
+4
View File
@@ -88,6 +88,10 @@ func loadProvider(instanceID, providerID string) (*models.AuthProvider, string,
func HandleSSOStart(c *gin.Context) {
inst, ok := InstanceFromHost(c)
if !ok {
if HostLocked(c.Request.Host) {
c.Redirect(http.StatusFound, "/login?error=instance_locked")
return
}
c.Redirect(http.StatusFound, "/login?error=unknown_host")
return
}
+1 -1
View File
@@ -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"},
},
{
+7 -6
View File
@@ -23,13 +23,14 @@ type Session struct {
Email string `json:"email"`
Name string `json:"name"`
// The three fields below are set only when the request authenticated with
// an API token. They are never persisted to Redis a token authenticates
// The four fields below are set only when the request authenticated with
// 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:"-"`
TokenName string `json:"-"`
Scopes []string `json:"-"`
TokenID string `json:"-"`
TokenName string `json:"-"`
Scopes []string `json:"-"`
TokenScope map[string]string `json:"-"`
}
var rdb *redis.Client
@@ -37,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{
+4 -4
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+14 -4
View File
@@ -103,6 +103,7 @@ func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdates
Name: u.Name,
CurrentVersion: u.CurrentVersion,
NewVersion: u.NewVersion,
Phased: u.Phased,
}
}
if err := services.StoreAvailableUpdates(srv.ServerID, pkgs); err != nil {
@@ -168,7 +169,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)
@@ -236,6 +237,12 @@ func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryRe
if err := services.StoreInventory(srv.ServerID, req); err != nil {
log.Printf("store inventory for %s: %v", srv.ServerID, err)
}
// Only static snapshots compute reboot_required, so only they can settle a
// reboot. The agent sends one at start, so the first report after a
// reboot qualifies.
if req.IncludeStatic && req.BootTimeUnix > 0 {
services.VerifyPatchReboots(srv.InstanceID, srv.ServerID, time.Unix(req.BootTimeUnix, 0), req.RebootRequired)
}
return &pb.InventoryReportResponse{}, nil
}
@@ -304,7 +311,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)
@@ -330,6 +337,9 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
if m.WorkloadLogsResult != nil {
services.WorkloadResults.Deliver(m.WorkloadLogsResult)
}
if m.PatchResult != nil {
services.RecordPatchResult(srv.InstanceID, srv.ServerID, m.PatchResult)
}
if m.StepResult != nil {
services.StepResults.Deliver(m.StepResult)
}
@@ -422,7 +432,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 +468,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{})
+138
View File
@@ -0,0 +1,138 @@
package mcp
import (
"errors"
"fmt"
"sort"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// FanOutLimit is how many servers a write tool may touch before it demands
// explicit confirmation. Cheap insurance against a mis-parsed selector reaching
// the whole fleet on one badly phrased instruction.
const FanOutLimit = 25
// Gate names for a write tool's own policy refusals, on top of GateMCPScope
// and GateResourceScope in registry.go. These name a decision this package
// made deliberately, so a human reading audit_logs can tell "the agent was
// stopped by policy" from "the agent tried and the machine failed".
const (
GateFanOut = "fan_out"
GateTagScope = "tag_selector"
)
// ErrConfirmRequired is returned to the model as a tool error it can act on:
// it says what would have happened and how to proceed deliberately.
var ErrConfirmRequired = errors.New("confirmation required")
// ErrOutOfScope wraps a write tool's refusal to act because the resolved (or,
// for run_workflow, the workflow's configured) targets are not entirely
// within the calling token's tag restriction. Handlers wrap this rather than
// returning a bare error so registerSDKTool can tell a scope refusal apart
// from an ordinary service failure and audit it as GateTagScope.
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 -
// services.LogEvent talks straight to Mongo via db.Col, which panics on a nil
// client outside a real boot.
var logEvent = services.LogEvent
// CheckFanOut refuses a write that would touch more servers than FanOutLimit
// unless the call passed confirm:true.
func CheckFanOut(count int, args map[string]any) error {
if count <= FanOutLimit {
return nil
}
if confirm, ok := args["confirm"].(bool); ok && confirm {
return nil
}
return fmt.Errorf("%w: this would affect %d servers, above the limit of %d; "+
"call again with confirm:true if that is intended",
ErrConfirmRequired, count, FanOutLimit)
}
// SummariseArgs renders an argument object as a short, deterministic,
// bounded string for the audit log. Values are described rather than
// reproduced: an argument may carry arbitrary text a model generated.
func SummariseArgs(args map[string]any) string {
if len(args) == 0 {
return "no arguments"
}
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+summariseValue(args[k]))
}
out := strings.Join(parts, " ")
if len(out) > 200 {
out = out[:197] + "..."
}
return out
}
func summariseValue(v any) string {
switch t := v.(type) {
case string:
if len(t) > 40 {
return fmt.Sprintf("<%d chars>", len(t))
}
return t
case bool, float64, int:
return fmt.Sprint(t)
case []any:
return fmt.Sprintf("<%d items>", len(t))
case map[string]any:
return fmt.Sprintf("<%d fields>", len(t))
default:
return "<value>"
}
}
// LogCall records a successful tool call. Reads are recorded as well as writes:
// the point of an agent-facing surface is being able to reconstruct afterwards
// what the agent looked at, not only what it changed.
func LogCall(c Caller, t Tool, args map[string]any, servers int) {
detail := fmt.Sprintf("tool %s (%s)", t.Name, SummariseArgs(args))
if servers > 0 {
detail += fmt.Sprintf(", %d server(s) affected", servers)
}
logEvent(c.InstanceID, "mcp.tool_call", c.TokenName, "", "", detail)
}
// LogDenied records a refusal and which gate refused, which is what turns "the
// agent said it could not" into a diagnosable event.
func LogDenied(c Caller, toolName, gate string) {
logEvent(c.InstanceID, "mcp.tool_denied", c.TokenName, "", "",
fmt.Sprintf("tool %s refused by %s", toolName, gate))
}
// LogFailure records a write tool call that reached a service and that
// 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".
func LogFailure(c Caller, t Tool, args map[string]any, err error) {
logEvent(c.InstanceID, "mcp.tool_failed", c.TokenName, "", "",
fmt.Sprintf("tool %s (%s) failed: %v", t.Name, SummariseArgs(args), err))
}
// LogCreated records a definition an agent added.
//
// 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
// answer.
func LogCreated(c Caller, kind, id, name string) {
logEvent(c.InstanceID, "mcp.created", c.TokenName, "", "",
fmt.Sprintf("created %s %q (%s)", kind, name, id))
}
+45
View File
@@ -0,0 +1,45 @@
package mcp
import (
"strings"
"testing"
)
// Arguments can carry arbitrary model output and the audit log is read by
// humans in a UI, so they are summarised rather than dumped.
func TestSummariseArgsIsBoundedAndOrdered(t *testing.T) {
got := SummariseArgs(map[string]any{
"workflow_id": "wf-1",
"note": strings.Repeat("x", 500),
})
if len(got) > 200 {
t.Errorf("summary is %d chars, want at most 200", len(got))
}
if !strings.Contains(got, "workflow_id") {
t.Errorf("summary %q omits an argument name", got)
}
// Deterministic ordering, or two identical calls produce different audit
// rows and nothing can be compared.
if SummariseArgs(map[string]any{"b": 1, "a": 2}) != SummariseArgs(map[string]any{"a": 2, "b": 1}) {
t.Error("SummariseArgs is not deterministic")
}
}
func TestCheckFanOutRequiresConfirmation(t *testing.T) {
if err := CheckFanOut(5, nil); err != nil {
t.Errorf("CheckFanOut(5) = %v, want nil", err)
}
err := CheckFanOut(200, nil)
if err == nil {
t.Fatal("CheckFanOut(200) = nil, want a refusal")
}
if !strings.Contains(err.Error(), "200") {
t.Errorf("refusal %q does not say how many servers", err)
}
if err := CheckFanOut(200, map[string]any{"confirm": true}); err != nil {
t.Errorf("CheckFanOut(200, confirm) = %v, want nil", err)
}
}
+224
View File
@@ -0,0 +1,224 @@
// Package mcp exposes Vantage to LLM agents over the Model Context Protocol.
//
// 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.
package mcp
import (
"context"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// Gate names, returned by Allowed so a refusal can be audited and explained to
// the model in words it can act on.
const (
GateMCPScope = "mcp_scope"
GateResourceScope = "resource_scope"
)
// ToolFunc is one tool's implementation. args is the decoded argument object;
// the returned value is marshalled as the tool result.
type ToolFunc func(ctx context.Context, c Caller, args map[string]any) (any, error)
// Caller is the acting credential, built from the gin session by the transport
// layer. The mcp package never reads a request or a cookie itself.
type Caller struct {
InstanceID string
Scopes []string
TokenScope map[string]string
TokenName string
}
// ArgType is the JSON type of one declared tool argument. The set is closed
// deliberately: these are the only shapes the argument helpers in this package
// (stringArg, stringSliceArg, tagArg, pageLimit) can actually decode, so a
// schema promising anything else would advertise an argument no handler could
// read.
type ArgType string
const (
ArgString ArgType = "string"
ArgInteger ArgType = "integer"
ArgBoolean ArgType = "boolean"
ArgStringArray ArgType = "string_array"
// ArgTagMap is a flat object of string tag keys to string values, which is
// 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
// per monitor type.
ArgObject ArgType = "object"
)
// ToolArg declares one argument a tool actually reads.
//
// Without this, a tool's arguments existed only in prose inside its
// Description and in the handler's args[...] lookups: no client could discover
// limit, tags, cursor, confirm, server_ids or any of the rest, so a model had
// to guess them from the description or not use them at all. Declaring them
// here also gets the SDK to validate and reject a malformed call before the
// handler runs, which is where a required-argument check belongs.
type ToolArg struct {
Name string
Type ArgType
Description string
Required bool
}
// Tool is one registered capability.
type Tool struct {
Name string
// Description is prompt text the model reads to choose a tool, so it states
// blast radius in plain words rather than describing an endpoint.
Description string
// Scope is the resource scope required, e.g. "servers:read".
Scope string
// Write marks a tool that changes something. A write tool is omitted from
// the listing for a caller without mcp:write.
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
// tests, which require the declaration to be deliberate.
Args []ToolArg
// TouchesServers marks a tool that returns or acts on server-derived data:
// a hostname, a server ID, a package list, a run's per-server output. Such
// a tool must apply Caller.TokenScope, through GetServerScoped,
// 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
// 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
// registry test names every tool that says yes, so the set cannot grow
// without a reviewer seeing it.
TouchesServers bool
Handler ToolFunc
}
// InputSchema renders the tool's declared arguments as a JSON Schema object,
// which is what a client reads from tools/list to know what to send.
//
// It returns a map rather than a typed schema so this file stays free of the
// MCP SDK; the transport hands it straight to the SDK, which remarshals it.
// additionalProperties is left open: several handlers accept confirm on top of
// their own arguments through CheckFanOut, and a strict object would refuse a
// call the fan-out guard is there to handle.
func (t Tool) InputSchema() map[string]any {
props := map[string]any{}
var required []string
for _, a := range t.Args {
p := map[string]any{"description": a.Description}
switch a.Type {
case ArgStringArray:
p["type"] = "array"
p["items"] = map[string]any{"type": "string"}
case ArgTagMap:
p["type"] = "object"
p["additionalProperties"] = map[string]any{"type": "string"}
case ArgObject:
p["type"] = "object"
default:
p["type"] = string(a.Type)
}
props[a.Name] = p
if a.Required {
required = append(required, a.Name)
}
}
schema := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
// Registry holds the tool set in registration order, which is the order a
// client sees.
type Registry struct {
order []string
tools map[string]Tool
}
func NewRegistry() *Registry {
return &Registry{tools: map[string]Tool{}}
}
func (r *Registry) Register(t Tool) {
if _, exists := r.tools[t.Name]; exists {
panic("mcp: duplicate tool " + t.Name)
}
r.order = append(r.order, t.Name)
r.tools[t.Name] = t
}
func (r *Registry) Lookup(name string) (Tool, bool) {
t, ok := r.tools[name]
return t, ok
}
// Tools returns every registered tool regardless of caller, for tests and
// documentation generation.
func (r *Registry) Tools() []Tool {
out := make([]Tool, 0, len(r.order))
for _, n := range r.order {
out = append(out, r.tools[n])
}
return out
}
// Visible is what this caller's tools/list returns.
func (r *Registry) Visible(c Caller) []Tool {
out := []Tool{}
for _, t := range r.Tools() {
if ok, _ := Allowed(t, c); ok {
out = append(out, t)
}
}
return out
}
// Allowed reports whether this caller may invoke this tool, and names the gate
// that refused when they may not.
//
// The licence gate is not checked here: it is route middleware, so a caller
// reaching this code has already passed it.
func Allowed(t Tool, c Caller) (bool, string) {
required := "mcp:read"
if t.Write {
required = "mcp:write"
}
if !services.ScopeSatisfied(c.Scopes, required) {
return false, GateMCPScope
}
if !services.ScopeSatisfied(c.Scopes, t.Scope) {
return false, GateResourceScope
}
return true, ""
}
func knownScope(s string) bool {
resource, action, ok := strings.Cut(s, ":")
if !ok || (action != services.ScopeRead && action != services.ScopeWrite) {
return false
}
for _, r := range services.ScopeResources {
if r == resource {
return true
}
}
return false
}
// all is the process-wide registry the tool files populate from their init
// functions, and the transport serves.
var all = NewRegistry()
// All returns the process-wide registry.
func All() *Registry { return all }
+148
View File
@@ -0,0 +1,148 @@
package mcp
import (
"encoding/json"
"strings"
"testing"
)
// Every tool must declare its arguments. A nil Args is "nobody wrote the
// schema", which is what the whole tool set looked like before: descriptions
// promised limit, tags, confirm, server_ids and the rest, and tools/list
// advertised none of them, so no client could discover an argument and a model
// had to guess. An empty (but non-nil) slice is the deliberate "takes none".
func TestEveryToolDeclaresArgs(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Args == nil {
t.Errorf("tool %q declares no Args; use []ToolArg{} if it truly takes none", tool.Name)
}
}
}
func TestToolArgsAreWellFormed(t *testing.T) {
valid := map[ArgType]bool{
ArgString: true, ArgInteger: true, ArgBoolean: true,
ArgStringArray: true, ArgTagMap: true, ArgObject: true,
}
for _, tool := range All().Tools() {
seen := map[string]bool{}
for _, a := range tool.Args {
if a.Name == "" {
t.Errorf("tool %q has an argument with no name", tool.Name)
}
if seen[a.Name] {
t.Errorf("tool %q declares argument %q twice", tool.Name, a.Name)
}
seen[a.Name] = true
if !valid[a.Type] {
t.Errorf("tool %q argument %q has unknown type %q", tool.Name, a.Name, a.Type)
}
if strings.TrimSpace(a.Description) == "" {
t.Errorf("tool %q argument %q has no description; the description is what a model reads", tool.Name, a.Name)
}
}
}
}
// The schema has to survive JSON marshalling, because that is the only form a
// client ever sees it in.
func TestInputSchemaMarshals(t *testing.T) {
for _, tool := range All().Tools() {
schema := tool.InputSchema()
if schema["type"] != "object" {
t.Errorf("tool %q schema is not an object", tool.Name)
}
b, err := json.Marshal(schema)
if err != nil {
t.Errorf("tool %q schema does not marshal: %v", tool.Name, err)
continue
}
props, _ := schema["properties"].(map[string]any)
for _, a := range tool.Args {
if _, ok := props[a.Name]; !ok {
t.Errorf("tool %q declares argument %q but the schema omits it", tool.Name, a.Name)
}
}
if len(tool.Args) > 0 && !strings.Contains(string(b), tool.Args[0].Name) {
t.Errorf("tool %q schema lost argument %q in marshalling", tool.Name, tool.Args[0].Name)
}
}
}
// 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.
//
// 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
// production stdout for a token restricted to staging. Declaring the flag does
// not prove the handler is scoped; it puts the question in front of a reviewer
// at the moment the tool is written, which is the same bargain
// api.serverScopedRoutes makes.
var serverTouchingTools = map[string]bool{
"list_servers": true,
"get_server": true,
"list_monitors": true, // Runner is a server ID; redacted out of scope.
"get_monitor_status": true, // same.
"list_workflows": true, // target server IDs.
"get_workflow": true, // same.
"get_run": true, // per-server run status.
"get_run_logs": true, // a named server's stdout.
"list_pending_updates": true,
"list_vulnerabilities": true, // affected-host counts.
"get_server_packages": true,
"search_fleet": true,
"run_workflow": true,
"apply_updates": true,
"update_agent": true,
"assign_key": true,
"create_workflow": true, // saves a target server list.
}
func TestServerTouchingToolsAreDeclared(t *testing.T) {
for _, tool := range All().Tools() {
want := serverTouchingTools[tool.Name]
if tool.TouchesServers != want {
if want {
t.Errorf("tool %q is listed as touching server data but does not declare TouchesServers", tool.Name)
} else {
t.Errorf("tool %q declares TouchesServers but is not in serverTouchingTools; "+
"add it there, having first checked it applies Caller.TokenScope", tool.Name)
}
}
}
registered := map[string]bool{}
for _, tool := range All().Tools() {
registered[tool.Name] = true
}
for name := range serverTouchingTools {
if !registered[name] {
t.Errorf("serverTouchingTools names %q, which is not a registered tool", name)
}
}
}
// A tool that touches server data and takes a server_ids or tags selector must
// also offer confirm, or the fan-out guard has no way to be satisfied and a
// legitimate fleet-wide call is unrefusable rather than merely confirmed.
func TestFanOutToolsOfferConfirm(t *testing.T) {
for _, tool := range All().Tools() {
if !tool.Write {
continue
}
selector, confirm := false, false
for _, a := range tool.Args {
switch a.Name {
case "server_ids", "tags":
selector = true
case "confirm":
confirm = true
}
}
if selector && !confirm {
t.Errorf("write tool %q takes a server selector but declares no confirm argument", tool.Name)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package mcp
import "testing"
func testRegistry() *Registry {
r := NewRegistry()
r.Register(Tool{Name: "list_servers", Scope: "servers:read", Write: false})
r.Register(Tool{Name: "run_workflow", Scope: "workflows:write", Write: true})
return r
}
// A token without mcp:read is not an agent token, whatever else it holds.
func TestNoMCPScopeSeesNothing(t *testing.T) {
c := Caller{Scopes: []string{"servers:read", "workflows:write"}}
if got := testRegistry().Visible(c); len(got) != 0 {
t.Errorf("Visible = %d tools, want 0", len(got))
}
}
// Write tools are OMITTED from the listing, not merely refused on call: an
// agent cannot be talked into using a tool it has never been told exists.
func TestReadOnlyCallerCannotSeeWriteTools(t *testing.T) {
c := Caller{Scopes: []string{"mcp:read", "servers:read", "workflows:write"}}
names := map[string]bool{}
for _, tool := range testRegistry().Visible(c) {
names[tool.Name] = true
}
if !names["list_servers"] {
t.Error("list_servers hidden from a read-capable caller")
}
if names["run_workflow"] {
t.Error("run_workflow listed without mcp:write")
}
}
func TestWriteCallerSeesBoth(t *testing.T) {
c := Caller{Scopes: []string{"mcp:write", "servers:read", "workflows:write"}}
if got := testRegistry().Visible(c); len(got) != 2 {
t.Errorf("Visible = %d tools, want 2", len(got))
}
}
// The resource scope is enforced independently of the MCP scope.
func TestResourceScopeStillRequired(t *testing.T) {
c := Caller{Scopes: []string{"mcp:write", "servers:read"}}
for _, tool := range testRegistry().Visible(c) {
if tool.Name == "run_workflow" {
t.Error("run_workflow listed without workflows:write")
}
}
run, _ := testRegistry().Lookup("run_workflow")
ok, gate := Allowed(run, c)
if ok {
t.Error("run_workflow allowed without workflows:write")
}
if gate != GateResourceScope {
t.Errorf("gate = %q, want %q", gate, GateResourceScope)
}
}
func TestAllowedNamesTheMCPGate(t *testing.T) {
c := Caller{Scopes: []string{"servers:read"}}
list, _ := testRegistry().Lookup("list_servers")
ok, gate := Allowed(list, c)
if ok {
t.Error("call allowed without mcp:read")
}
if gate != GateMCPScope {
t.Errorf("gate = %q, want %q", gate, GateMCPScope)
}
}
// Every tool must declare a scope from the real vocabulary, or a tool added
// tomorrow could be reachable with no resource scope at all.
func TestEveryRegisteredToolDeclaresAKnownScope(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Scope == "" {
t.Errorf("tool %q declares no scope", tool.Name)
continue
}
if !knownScope(tool.Scope) {
t.Errorf("tool %q declares unknown scope %q", tool.Name, tool.Scope)
}
if tool.Description == "" {
t.Errorf("tool %q has no description; descriptions are prompt text", tool.Name)
}
}
}
+288
View File
@@ -0,0 +1,288 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// SourceMCP marks a definition an agent wrote. models.WorkflowStep already
// carries a Source field for exactly this kind of provenance, so the UI can
// badge agent-authored steps without a schema change.
const SourceMCP = "mcp"
// buildStep validates the arguments and returns the step to create.
//
// It is pure so that every refusal below is testable without a database, and
// separate from the handler so the handler is only plumbing.
func buildStep(args map[string]any) (models.WorkflowStep, error) {
name := stringArg(args, "name")
interpreter := stringArg(args, "interpreter")
script := stringArg(args, "script")
if name == "" {
return models.WorkflowStep{}, fmt.Errorf("name is required")
}
if interpreter == "" {
return models.WorkflowStep{}, fmt.Errorf("interpreter is required, for example bash or powershell")
}
if script == "" {
return models.WorkflowStep{}, fmt.Errorf("script is required")
}
if len(stringSliceArg(args, "secret_refs")) > 0 {
return models.WorkflowStep{}, fmt.Errorf(
"a step created through MCP cannot reference secrets; " +
"add the secret reference in the Vantage UI after reviewing the script")
}
return models.WorkflowStep{
Name: name,
Description: stringArg(args, "description"),
Interpreter: interpreter,
Script: script,
Source: SourceMCP,
}, nil
}
// buildWorkflow validates the arguments and returns the workflow to create.
func buildWorkflow(args map[string]any) (models.Workflow, error) {
name := stringArg(args, "name")
if name == "" {
return models.Workflow{}, fmt.Errorf("name is required")
}
if _, scheduled := args["schedule"]; scheduled {
return models.Workflow{}, fmt.Errorf(
"a workflow created through MCP cannot be scheduled; " +
"create it, review it, then set a schedule in the Vantage UI")
}
stepIDs := stringSliceArg(args, "step_ids")
if len(stepIDs) == 0 {
return models.Workflow{}, fmt.Errorf("step_ids must name at least one existing step; create steps first with create_step")
}
// Order comes from the array order rather than from a field, because step
// order is the whole meaning of a workflow and is not worth asking a model
// to restate correctly. OnFailure defaults to "stop", the same default the
// workflow runner falls back to when a saved ref leaves it blank (see
// resolveInlineStep/resolveLibStep in workflow_runner.go).
steps := make([]models.WorkflowStepRef, 0, len(stepIDs))
for i, id := range stepIDs {
steps = append(steps, models.WorkflowStepRef{
StepID: id,
Order: i,
OnFailure: "stop",
})
}
return models.Workflow{
Name: name,
TargetServerIDs: stringSliceArg(args, "server_ids"),
TargetTags: tagArg(args),
Steps: steps,
}, nil
}
// buildMonitor validates the arguments and returns the monitor to create.
func buildMonitor(args map[string]any) (models.Monitor, error) {
name := stringArg(args, "name")
monitorType := stringArg(args, "type")
if name == "" {
return models.Monitor{}, fmt.Errorf("name is required")
}
if monitorType == "" {
return models.Monitor{}, fmt.Errorf("type is required")
}
rawTarget, ok := args["target"].(map[string]any)
if !ok || len(rawTarget) == 0 {
return models.Monitor{}, fmt.Errorf("target is required")
}
target := models.MonitorTarget{
URL: stringArg(rawTarget, "url"),
Host: stringArg(rawTarget, "host"),
Method: stringArg(rawTarget, "method"),
Keyword: stringArg(rawTarget, "keyword"),
}
if n, ok := rawTarget["port"].(float64); ok {
target.Port = int(n)
}
if n, ok := rawTarget["expected_status"].(float64); ok {
target.ExpectedStatus = int(n)
}
if n, ok := rawTarget["tls_warn_days"].(float64); ok {
target.TLSWarnDays = int(n)
}
if b, ok := rawTarget["insecure"].(bool); ok {
target.Insecure = b
}
switch monitorType {
case models.MonitorHTTP, models.MonitorTLS:
if target.URL == "" {
return models.Monitor{}, fmt.Errorf("target.url is required for a %s monitor", monitorType)
}
case models.MonitorTCP, models.MonitorICMP:
if target.Host == "" {
return models.Monitor{}, fmt.Errorf("target.host is required for a %s monitor", monitorType)
}
if monitorType == models.MonitorTCP && target.Port == 0 {
return models.Monitor{}, fmt.Errorf("target.port is required for a tcp monitor")
}
default:
return models.Monitor{}, fmt.Errorf("unknown monitor type %q", monitorType)
}
// Runner is deliberately not settable from a tool call, and this refusal
// makes that explicit rather than leaving it safe by omission. A runner
// is a server ID: accepting one would let an agent push a check onto a
// 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.
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")
}
interval := 60
if n, ok := args["interval_sec"].(float64); ok && int(n) > 0 {
interval = int(n)
}
return models.Monitor{
Name: name,
Group: stringArg(args, "group"),
Type: monitorType,
Target: target,
IntervalSec: interval,
// Never armed on creation. A monitor that started enabled would begin
// alerting real people the moment a model invented it, and creating
// must stay a separate decision from acting.
Enabled: false,
}, nil
}
func init() {
All().Register(Tool{
Name: "create_step",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the step.", Required: true},
{Name: "interpreter", Type: ArgString, Description: "Interpreter to run the script with, e.g. bash or powershell.", Required: true},
{Name: "script", Type: ArgString, Description: "The script body. It is parsed and scanned exactly as the UI does; secret_refs are refused.", Required: true},
{Name: "description", Type: ArgString, Description: "What the step does, for a human reading the library later."},
},
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 - " +
"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) {
step, err := buildStep(args)
if err != nil {
return nil, err
}
created, err := services.CreateStep(c.InstanceID, step)
if err != nil {
return nil, fmt.Errorf("could not create the step: %w", err)
}
LogCreated(c, "step", created.StepID, created.Name)
return map[string]any{
"step_id": created.StepID,
"name": created.Name,
"note": "Saved but not run. Reference this step_id from create_workflow.",
}, nil
},
})
All().Register(Tool{
Name: "create_workflow",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the workflow.", Required: true},
{Name: "step_ids", Type: ArgStringArray, Description: "IDs of existing steps, in the order they should run.", Required: true},
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "workflows:write",
Description: "Create a workflow from existing step IDs, in the order given, targeting " +
"servers by ID or by tags. The workflow is SAVED but not run and cannot be " +
"created with a schedule; run it explicitly with run_workflow.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
wf, err := buildWorkflow(args)
if err != nil {
return nil, err
}
// Targets are stored, not resolved at run time, so a workflow
// cannot be saved pointing at servers this token cannot reach.
// run_workflow later refuses to run a saved workflow unless the
// scoped view of its targets covers every server the unscoped
// resolution would touch; this mirrors that same all-or-nothing
// check at creation time so a workflow this token could not run
// is never created in the first place.
if len(wf.TargetServerIDs) > 0 || len(wf.TargetTags) > 0 {
allTargets, err := services.ResolveTargets(c.InstanceID, wf.TargetServerIDs, wf.TargetTags)
if err != nil {
return nil, fmt.Errorf("this workflow matches no servers")
}
scopedTargets, err := services.ResolveTargetsScoped(c.InstanceID, wf.TargetServerIDs, wf.TargetTags, c.TokenScope)
if err != nil || len(scopedTargets) != len(allTargets) {
return nil, fmt.Errorf("%w: no servers visible to this token matched the requested targets", ErrOutOfScope)
}
if err := CheckFanOut(len(scopedTargets), args); err != nil {
return nil, err
}
}
created, err := services.CreateWorkflow(c.InstanceID, wf, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not create the workflow: %w", err)
}
LogCreated(c, "workflow", created.WorkflowID, created.Name)
return map[string]any{
"workflow_id": created.WorkflowID,
"name": created.Name,
"steps": len(created.Steps),
"note": "Saved but not run and not scheduled. Call run_workflow to run it.",
}, nil
},
})
All().Register(Tool{
Name: "create_monitor",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the monitor.", Required: true},
{Name: "type", Type: ArgString, Description: "Check type: http, tcp, icmp or tls.", Required: true},
{Name: "target", Type: ArgObject, Description: "What to check. http/tls take url; tcp/icmp take host, and tcp also port. Optional: method, keyword, expected_status, tls_warn_days, insecure.", Required: true},
{Name: "group", Type: ArgString, Description: "Optional group name to file the monitor under."},
{Name: "interval_sec", Type: ArgInteger, Description: "Seconds between checks; defaults to 60."},
},
Write: true,
Scope: "monitors:write",
Description: "Create a monitor. It is SAVED DISABLED and will not check anything or " +
"send any alert until a human enables it in the Vantage UI, so proposing a " +
"monitor is safe.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
m, err := buildMonitor(args)
if err != nil {
return nil, err
}
created, err := services.CreateMonitor(c.InstanceID, &m, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not create the monitor: %w", err)
}
LogCreated(c, "monitor", created.MonitorID, created.Name)
return map[string]any{
"monitor_id": created.MonitorID,
"name": created.Name,
"enabled": false,
"note": "Created disabled. Enable it in Vantage to start checking.",
}, nil
},
})
}
+228
View File
@@ -0,0 +1,228 @@
package mcp
import (
"strings"
"testing"
)
func TestCreationToolsAreRegisteredAsWrites(t *testing.T) {
for _, name := range []string{"create_step", "create_workflow", "create_monitor"} {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if !tool.Write {
t.Errorf("tool %q is not marked as a write", name)
}
}
}
// An agent may add a definition. It may never alter or remove one a human
// wrote, and the cheapest guard against that is the tool simply not existing.
func TestNoUpdateOrDeleteTools(t *testing.T) {
for _, tool := range All().Tools() {
n := tool.Name
if strings.HasPrefix(n, "update_") && n != "update_agent" {
t.Errorf("tool %q edits an existing definition", n)
}
if strings.HasPrefix(n, "delete_") || strings.HasPrefix(n, "remove_") {
t.Errorf("tool %q deletes a definition", n)
}
}
}
// Composing a script around a secret reference is how a credential ends up
// echoed into a log.
func TestCreateStepRejectsSecretRefs(t *testing.T) {
step, err := buildStep(map[string]any{
"name": "leaky",
"interpreter": "bash",
"script": "echo hello",
"secret_refs": []any{"prod/db"},
})
if err == nil {
t.Fatalf("buildStep accepted secret_refs, got %+v", step)
}
if !strings.Contains(err.Error(), "secret") {
t.Errorf("error %q does not explain the refusal", err)
}
}
func TestCreateStepRequiresScriptAndInterpreter(t *testing.T) {
if _, err := buildStep(map[string]any{"name": "x", "script": "echo hi"}); err == nil {
t.Error("buildStep accepted a step with no interpreter")
}
if _, err := buildStep(map[string]any{"name": "x", "interpreter": "bash"}); err == nil {
t.Error("buildStep accepted a step with no script")
}
}
// Steps a model wrote are badged in the UI, so a human can tell at a glance
// what came from an agent.
func TestCreatedStepIsMarkedAgentAuthored(t *testing.T) {
step, err := buildStep(map[string]any{
"name": "patch", "interpreter": "bash", "script": "apt-get update",
})
if err != nil {
t.Fatal(err)
}
if step.Source != "mcp" {
t.Errorf("Source = %q, want %q", step.Source, "mcp")
}
}
// Creating and acting stay two decisions: a created workflow cannot arrive
// already scheduled.
func TestCreateWorkflowRefusesASchedule(t *testing.T) {
_, err := buildWorkflow(map[string]any{
"name": "nightly",
"step_ids": []any{"step-1"},
"schedule": map[string]any{"cron": "0 3 * * *"},
})
if err == nil {
t.Fatal("buildWorkflow accepted a schedule")
}
if !strings.Contains(err.Error(), "schedule") {
t.Errorf("error %q does not explain the refusal", err)
}
}
func TestCreateWorkflowRequiresSteps(t *testing.T) {
if _, err := buildWorkflow(map[string]any{"name": "empty"}); err == nil {
t.Error("buildWorkflow accepted a workflow with no steps")
}
}
// Step order is the whole meaning of a workflow, so it comes from the array
// order rather than from a field a model has to get right.
func TestBuildWorkflowNumbersStepsInOrder(t *testing.T) {
wf, err := buildWorkflow(map[string]any{
"name": "three",
"step_ids": []any{"a", "b", "c"},
})
if err != nil {
t.Fatal(err)
}
if len(wf.Steps) != 3 {
t.Fatalf("got %d steps, want 3", len(wf.Steps))
}
for i, ref := range wf.Steps {
if ref.Order != i {
t.Errorf("step %d has Order %d", i, ref.Order)
}
}
if wf.Steps[0].StepID != "a" || wf.Steps[2].StepID != "c" {
t.Errorf("step order does not follow the argument order: %+v", wf.Steps)
}
}
// A monitor that starts enabled would begin alerting real people the moment a
// model invented it.
func TestCreatedMonitorIsDisabled(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health", "type": "http", "target": map[string]any{"url": "https://example.com"},
})
if err != nil {
t.Fatal(err)
}
if m.Enabled {
t.Error("created monitor is enabled; it must wait for a human")
}
}
func TestCreateMonitorRequiresNameAndType(t *testing.T) {
if _, err := buildMonitor(map[string]any{"type": "http"}); err == nil {
t.Error("buildMonitor accepted a monitor with no name")
}
if _, err := buildMonitor(map[string]any{"name": "x"}); err == nil {
t.Error("buildMonitor accepted a monitor with no type")
}
}
// A monitor with no target argument at all cannot be checked, so it is
// refused the same way a missing name or type is.
func TestCreateMonitorRequiresTarget(t *testing.T) {
if _, err := buildMonitor(map[string]any{"name": "x", "type": "http"}); err == nil {
t.Error("buildMonitor accepted a monitor with no target")
}
}
// The target argument decodes into the real models.MonitorTarget shape, not
// a passthrough map, so an http monitor without a URL is rejected here rather
// than surfacing a confusing failure the first time it is checked.
func TestCreateMonitorHTTPRequiresURL(t *testing.T) {
if _, err := buildMonitor(map[string]any{
"name": "x", "type": "http", "target": map[string]any{"method": "GET"},
}); err == nil {
t.Error("buildMonitor accepted an http monitor with no target url")
}
}
func TestCreateMonitorTCPRequiresHostAndPort(t *testing.T) {
if _, err := buildMonitor(map[string]any{
"name": "x", "type": "tcp", "target": map[string]any{"host": "example.com"},
}); err == nil {
t.Error("buildMonitor accepted a tcp monitor with no port")
}
}
func TestCreateMonitorDecodesTargetFields(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health", "type": "http",
"target": map[string]any{
"url": "https://example.com/health",
"method": "GET",
"expected_status": float64(200),
"keyword": "ok",
},
})
if err != nil {
t.Fatal(err)
}
if m.Target.URL != "https://example.com/health" {
t.Errorf("Target.URL = %q", m.Target.URL)
}
if m.Target.Method != "GET" {
t.Errorf("Target.Method = %q", m.Target.Method)
}
if m.Target.ExpectedStatus != 200 {
t.Errorf("Target.ExpectedStatus = %d", m.Target.ExpectedStatus)
}
if m.Target.Keyword != "ok" {
t.Errorf("Target.Keyword = %q", m.Target.Keyword)
}
}
// A runner is a server ID. buildMonitor must refuse one outright rather than
// dropping it silently, or a model would believe it had pinned a check to an
// agent it never reached.
func TestBuildMonitorRefusesRunner(t *testing.T) {
args := map[string]any{
"name": "api health",
"type": "http",
"target": map[string]any{"url": "https://example.com"},
"runner": "some-server-id",
}
if _, err := buildMonitor(args); err == nil {
t.Fatal("buildMonitor accepted a runner argument")
}
}
// Without a runner it still builds, and never arms itself.
func TestBuildMonitorWithoutRunnerIsDisabled(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health",
"type": "http",
"target": map[string]any{"url": "https://example.com"},
})
if err != nil {
t.Fatalf("buildMonitor: %v", err)
}
if m.Runner != "" {
t.Errorf("Runner = %q, want empty so CreateMonitor defaults it to the control plane", m.Runner)
}
if m.Enabled {
t.Error("monitor created enabled")
}
}
+136
View File
@@ -0,0 +1,136 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// serverSummary is what a list returns: enough for a model to decide which
// server to ask about next, and nothing else. The full document is an order of
// magnitude larger and listing thirty of them would dominate a context window.
type serverSummary struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Online bool `json:"online"`
Tags map[string]string `json:"tags,omitempty"`
}
// models.Server has no Online bool: it stores Status as one of "pending",
// "active" or "offline" (see internal/services/servers.go). Online here
// mirrors that string the same way the REST layer treats it.
func summariseServer(s models.Server) serverSummary {
return serverSummary{
ID: s.ServerID,
Hostname: s.Hostname,
OS: s.OSInfo,
Online: s.Status == "active",
Tags: s.Tags,
}
}
// defaultLimit and maxLimit bound every listing. A model asking for everything
// gets a page and is told the total, which is more useful than a truncated blob
// it cannot tell is truncated.
const (
defaultLimit = 50
maxLimit = 200
)
func pageLimit(args map[string]any) int {
n, ok := args["limit"].(float64)
if !ok || int(n) <= 0 {
return defaultLimit
}
if int(n) > maxLimit {
return maxLimit
}
return int(n)
}
func stringArg(args map[string]any, key string) string {
s, _ := args[key].(string)
return s
}
func tagArg(args map[string]any) map[string]string {
raw, ok := args["tags"].(map[string]any)
if !ok {
return nil
}
out := map[string]string{}
for k, v := range raw {
if s, ok := v.(string); ok {
out[k] = s
}
}
return out
}
type listServersResult struct {
Servers []serverSummary `json:"servers"`
Total int `json:"total"`
Shown int `json:"shown"`
}
func init() {
All().Register(Tool{
Name: "list_servers",
Args: []ToolArg{
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
},
TouchesServers: true,
Scope: "servers:read",
Description: "List the servers in this Vantage fleet, optionally filtered by tags. " +
"Returns a compact summary per server; use get_server for full detail on one.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
if !ok {
// The requested tags and the token's restriction can never both
// hold, so the honest answer is an empty fleet.
return listServersResult{Servers: []serverSummary{}}, nil
}
servers, err := services.ListServersFiltered(c.InstanceID, sel)
if err != nil {
return nil, fmt.Errorf("could not list servers: %w", err)
}
limit := pageLimit(args)
out := make([]serverSummary, 0, limit)
for _, s := range servers {
if len(out) == limit {
break
}
out = append(out, summariseServer(s))
}
return listServersResult{Servers: out, Total: len(servers), Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_server",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "The server's ID.", Required: true},
},
TouchesServers: true,
Scope: "servers:read",
Description: "Get detail for one server by ID: OS, online state and tags. " +
"Use list_pending_updates for that server's outstanding package updates.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "server_id")
if id == "" {
return nil, fmt.Errorf("server_id is required")
}
srv, err := services.GetServerScoped(c.InstanceID, id, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", id)
}
return summariseServer(*srv), nil
},
})
}
+265
View File
@@ -0,0 +1,265 @@
package mcp
import (
"context"
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// monitorSummary carries state and identity. A model asking "what is broken"
// needs the state and the name; the target URL, expected status, keyword,
// runner and channel list are configuration it did not ask for.
//
// 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
// 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.
type monitorSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Interval int `json:"interval_sec"`
}
// models.Monitor.State is a MonitorState struct whose status field is
// Status (a plain string: models.StatusUp/StatusDown/StatusPending), not the
// ".Status" field-of-a-field the brief guessed at.
func summariseMonitor(m models.Monitor) monitorSummary {
return monitorSummary{
ID: m.MonitorID,
Name: m.Name,
Group: m.Group,
Type: m.Type,
Enabled: m.Enabled,
State: m.State.Status,
Interval: m.IntervalSec,
}
}
type listMonitorsResult struct {
Monitors []monitorSummary `json:"monitors"`
Total int `json:"total"`
Shown int `json:"shown"`
Down int `json:"down"`
}
// monitorStatusDetail is get_monitor_status's projection: enough to tell a
// model what a monitor is currently doing, without its target configuration.
type monitorStatusDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
State string `json:"state"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type incidentSummary struct {
ID string `json:"id"`
MonitorName string `json:"monitor_name"`
StartedAt time.Time `json:"started_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
Cause string `json:"cause,omitempty"`
}
type listIncidentsResult struct {
Incidents []incidentSummary `json:"incidents"`
Shown int `json:"shown"`
}
type monitorSample struct {
At time.Time `json:"at"`
Ok bool `json:"ok"`
LatencyMs int `json:"latency_ms"`
}
type listSamplesResult struct {
Samples []monitorSample `json:"samples"`
Shown int `json:"shown"`
}
const defaultSampleLimit = 100
const maxSampleLimit = 500
func init() {
All().Register(Tool{
Name: "list_monitors",
Args: []ToolArg{
{Name: "state", Type: ArgString, Description: "Only monitors in this state: up, down or pending."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "monitors:read",
Description: "List the monitors on this instance with their current state. " +
"Pass state:\"down\" to see only what is currently failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
monitors, err := services.ListMonitors(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list monitors: %w", err)
}
wantState := stringArg(args, "state")
limit := pageLimit(args)
out := make([]monitorSummary, 0, limit)
down, total := 0, 0
for _, m := range monitors {
summary := summariseMonitor(m)
if summary.State == "down" {
down++
}
if wantState != "" && summary.State != wantState {
continue
}
total++
if len(out) < limit {
out = append(out, summary)
}
}
return listMonitorsResult{Monitors: out, Total: total, Shown: len(out), Down: down}, nil
},
})
All().Register(Tool{
Name: "get_monitor_status",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "The monitor's ID.", Required: true},
},
TouchesServers: true,
Scope: "monitors:read",
Description: "Get one monitor's current state: up, down or pending, the last check " +
"time, and the last error message if it is failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "monitor_id")
if id == "" {
return nil, fmt.Errorf("monitor_id is required")
}
m, err := services.GetMonitor(c.InstanceID, id)
if err != nil || m == nil {
return nil, fmt.Errorf("no monitor %q found", id)
}
return monitorStatusDetail{
ID: m.MonitorID,
Name: m.Name,
Type: m.Type,
State: m.State.Status,
LastCheckAt: m.State.LastCheckAt,
LastError: m.State.Message,
}, nil
},
})
All().Register(Tool{
Name: "list_incidents",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "Only incidents for this monitor; omit for every monitor."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "monitors:read",
Description: "List monitor incidents (outages), most recent first. Pass monitor_id to " +
"scope to one monitor, or omit it to see incidents across every monitor.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := int64(pageLimit(args))
monitorID := stringArg(args, "monitor_id")
var monitorNames map[string]string
var monitorIDs []string
if monitorID != "" {
monitorIDs = []string{monitorID}
} else {
monitors, err := services.ListMonitors(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list monitors: %w", err)
}
monitorNames = make(map[string]string, len(monitors))
for _, m := range monitors {
monitorNames[m.MonitorID] = m.Name
monitorIDs = append(monitorIDs, m.MonitorID)
}
}
out := []incidentSummary{}
for _, mid := range monitorIDs {
if len(out) >= int(limit) {
break
}
incidents, err := services.ListIncidents(c.InstanceID, mid, limit)
if err != nil {
return nil, fmt.Errorf("could not list incidents: %w", err)
}
name := mid
if monitorNames != nil {
if n, ok := monitorNames[mid]; ok {
name = n
}
} else {
if m, err := services.GetMonitor(c.InstanceID, mid); err == nil && m != nil {
name = m.Name
}
}
for _, inc := range incidents {
if len(out) >= int(limit) {
break
}
out = append(out, incidentSummary{
ID: inc.IncidentID,
MonitorName: name,
StartedAt: inc.StartedAt,
ResolvedAt: inc.ResolvedAt,
Cause: inc.Cause,
})
}
}
return listIncidentsResult{Incidents: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_monitor_samples",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "The monitor's ID.", Required: true},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "monitors:read",
Description: "Get one monitor's recent raw check results (timestamp, ok/fail, latency). " +
"Samples are numerous and expire after 48 hours; use list_incidents for a longer view.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "monitor_id")
if id == "" {
return nil, fmt.Errorf("monitor_id is required")
}
limit := int(pageLimit(args))
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
limit = int(raw)
} else {
limit = defaultSampleLimit
}
if limit > maxSampleLimit {
limit = maxSampleLimit
}
samples, err := services.MonitorSamples(c.InstanceID, id, time.Now().Add(-services.MonitorSampleTTL))
if err != nil {
return nil, fmt.Errorf("could not get samples: %w", err)
}
out := make([]monitorSample, 0, limit)
for _, s := range samples {
if len(out) == limit {
break
}
out = append(out, monitorSample{At: s.At, Ok: s.Up, LatencyMs: s.LatencyMs})
}
return listSamplesResult{Samples: out, Shown: len(out)}, nil
},
})
}
+78
View File
@@ -0,0 +1,78 @@
package mcp
import (
"encoding/json"
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func TestReadToolsAreRegistered(t *testing.T) {
want := []string{
"list_servers", "get_server", "search_fleet",
"list_monitors", "get_monitor_status", "list_incidents", "get_monitor_samples",
"list_pending_updates", "list_vulnerabilities", "get_server_packages",
"list_workflows", "get_workflow", "get_run", "get_run_logs",
"list_audit_events", "list_secret_names",
}
for _, name := range want {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if tool.Write {
t.Errorf("tool %q is marked as a write", name)
}
}
}
// Secret plaintext must never be reachable, at any scope. This is the one
// deliberate refusal in the read set and it is worth a test of its own.
func TestNoSecretRevealTool(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Name == "reveal_secret" || tool.Name == "get_secret" {
t.Errorf("tool %q exposes secret plaintext to a model", tool.Name)
}
}
}
// A fleet listing that costs thousands of tokens degrades every interaction
// and is otherwise invisible until someone reads a bill.
func TestServerSummaryStaysSmall(t *testing.T) {
fleet := make([]serverSummary, 30)
for i := range fleet {
fleet[i] = summariseServer(models.Server{
ServerID: "srv-000000000000000000000000",
Hostname: "web-server-with-a-longish-name",
OSInfo: "Ubuntu 24.04.1 LTS",
Tags: map[string]string{"env": "prod", "team": "core"},
})
}
out, err := json.Marshal(fleet)
if err != nil {
t.Fatal(err)
}
if len(out) > 8000 {
t.Errorf("30 servers serialise to %d bytes, want at most 8000", len(out))
}
}
// The real status vocabulary is "pending" / "active" / "offline" (see
// 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) {
active := summariseServer(models.Server{ServerID: "srv-active", Status: "active"})
if !active.Online {
t.Errorf("server with status %q should be online, got Online=false", "active")
}
for _, status := range []string{"pending", "offline"} {
s := summariseServer(models.Server{ServerID: "srv-" + status, Status: status})
if s.Online {
t.Errorf("server with status %q should not be online, got Online=true", status)
}
}
}
+646
View File
@@ -0,0 +1,646 @@
package mcp
import (
"context"
"fmt"
"sort"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// ---- workflows ----
type workflowSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Steps int `json:"steps"`
Targets int `json:"targets"`
Scheduled bool `json:"scheduled"`
}
type listWorkflowsResult struct {
Workflows []workflowSummary `json:"workflows"`
Total int `json:"total"`
Shown int `json:"shown"`
}
type workflowStepRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
type workflowDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []workflowStepRef `json:"steps"`
Targets []string `json:"target_server_ids,omitempty"`
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
// 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.
TargetsRestricted bool `json:"targets_restricted,omitempty"`
}
// ---- runs ----
type runStatusCounts struct {
Pending int `json:"pending,omitempty"`
Running int `json:"running,omitempty"`
Success int `json:"success,omitempty"`
Failed int `json:"failed,omitempty"`
Skipped int `json:"skipped,omitempty"`
}
type runDetail struct {
ID string `json:"id"`
WorkflowName string `json:"workflow_name"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
ServerCounts runStatusCounts `json:"server_status_counts"`
}
type runLogsResult struct {
Lines []string `json:"lines"`
Shown int `json:"shown"`
}
const defaultLogLimit = 200
// ---- pending updates ----
type pendingUpdate struct {
ServerID string `json:"server_id"`
Hostname string `json:"hostname"`
Package string `json:"package"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
// Phased is an Ubuntu phased update apt defers until the host is selected.
Phased bool `json:"phased,omitempty"`
}
type listPendingUpdatesResult struct {
Updates []pendingUpdate `json:"updates"`
Shown int `json:"shown"`
}
// ---- vulnerabilities ----
type vulnSummary struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Package string `json:"package"`
AffectedNum int `json:"affected_servers"`
FixedIn string `json:"fixed_in,omitempty"`
}
type listVulnsResult struct {
Vulnerabilities []vulnSummary `json:"vulnerabilities"`
Shown int `json:"shown"`
}
// ---- packages ----
type packageEntry struct {
Name string `json:"name"`
Version string `json:"version"`
}
type serverPackagesResult struct {
ServerID string `json:"server_id"`
Packages []packageEntry `json:"packages"`
Total int `json:"total"`
Shown int `json:"shown"`
}
// ---- search_fleet ----
type packageMatch struct {
Hostname string `json:"hostname"`
Package string `json:"package"`
Version string `json:"version"`
}
type searchFleetResult struct {
Matches []packageMatch `json:"matches"`
Shown int `json:"shown"`
}
// ---- audit ----
type auditEventSummary struct {
At time.Time `json:"at"`
Type string `json:"type"`
Actor string `json:"actor"`
Detail string `json:"detail,omitempty"`
}
type listAuditResult struct {
Events []auditEventSummary `json:"events"`
Total int64 `json:"total"`
Shown int `json:"shown"`
}
// ---- secrets ----
type secretGroupNames struct {
Group string `json:"group"`
Keys []string `json:"keys"`
}
type listSecretNamesResult struct {
Groups []secretGroupNames `json:"groups"`
}
func init() {
All().Register(Tool{
Name: "list_workflows",
Args: []ToolArg{
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "List the workflows defined on this instance: step count, target count, " +
"and whether each is on a schedule. Use get_workflow for the ordered step list.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
workflows, err := services.ListWorkflows(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list workflows: %w", err)
}
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
}
limit := pageLimit(args)
out := make([]workflowSummary, 0, limit)
for _, w := range workflows {
if len(out) == limit {
break
}
// The tag count is exposed as-is (the tag vocabulary is not
// restricted); the ID count is narrowed to what this token
// can see so it cannot itself disclose that out-of-scope
// targets exist, the same leak the REST list closes.
ids, _ := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
targets := len(ids)
if len(w.TargetTags) > 0 {
targets = len(w.TargetTags)
}
out = append(out, workflowSummary{
ID: w.WorkflowID,
Name: w.Name,
Steps: len(w.Steps),
Targets: targets,
Scheduled: w.Schedule != nil && w.Schedule.Enabled,
})
}
return listWorkflowsResult{Workflows: out, Total: len(workflows), Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_workflow",
Args: []ToolArg{
{Name: "workflow_id", Type: ArgString, Description: "The workflow's ID.", Required: true},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get one workflow's full definition: ordered steps, targets and schedule. " +
"Use get_run for what happened the last time it ran.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "workflow_id")
if id == "" {
return nil, fmt.Errorf("workflow_id is required")
}
w, err := services.GetWorkflow(c.InstanceID, id)
if err != nil || w == nil {
return nil, fmt.Errorf("no workflow %q found", id)
}
steps := make([]workflowStepRef, 0, len(w.Steps))
for _, s := range w.Steps {
name := s.StepID
if s.Inline != nil {
name = s.Inline.Name
}
steps = append(steps, workflowStepRef{ID: s.StepID, Name: name})
}
schedule := ""
if w.Schedule != nil && w.Schedule.Enabled {
schedule = w.Schedule.Cron
}
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
}
targets, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
return workflowDetail{
ID: w.WorkflowID,
Name: w.Name,
Steps: steps,
Targets: targets,
Tags: w.TargetTags,
Schedule: schedule,
TargetsRestricted: hidden,
}, nil
},
})
All().Register(Tool{
Name: "get_run",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run's ID.", Required: true},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get one workflow run's status: overall state, start/finish time, and a " +
"count of servers by their per-server status. Use get_run_logs for the output of one server.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "run_id")
if id == "" {
return nil, fmt.Errorf("run_id is required")
}
r, err := services.GetRun(c.InstanceID, id)
if err != nil || r == nil {
return nil, fmt.Errorf("no run %q found", id)
}
var counts runStatusCounts
for _, sr := range r.ServerRuns {
switch sr.Status {
case "pending":
counts.Pending++
case "running":
counts.Running++
case "success":
counts.Success++
case "failed":
counts.Failed++
case "skipped":
counts.Skipped++
}
}
return runDetail{
ID: r.RunID,
WorkflowName: r.Name,
Status: r.Status,
StartedAt: r.StartedAt,
FinishedAt: r.FinishedAt,
ServerCounts: counts,
}, nil
},
})
All().Register(Tool{
Name: "get_run_logs",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run's ID.", Required: true},
{Name: "server_id", Type: ArgString, Description: "Which server within the run to read output for.", Required: true},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get the ordered log lines for one server within one workflow run. " +
"Capped at 200 lines by default; ask for a higher limit if you need more.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
runID := stringArg(args, "run_id")
serverID := stringArg(args, "server_id")
if runID == "" || serverID == "" {
return nil, fmt.Errorf("run_id and server_id are required")
}
// The run's own instance must be checked before any line is
// returned: ReadServerRunLog takes no instance ID and will read
// any run on the process, so GetRun is what proves this run
// belongs to the caller.
r, err := services.GetRun(c.InstanceID, runID)
if err != nil || r == nil {
return nil, fmt.Errorf("no run %q found", runID)
}
found := false
for _, sr := range r.ServerRuns {
if sr.ServerID == serverID {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
}
// Membership in the run is not scope: a run started before this
// token was restricted, or by an unrestricted credential, names
// servers this token must not read. The stdout of an
// out-of-scope host is exactly the data the tag restriction
// exists to withhold.
//
// 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
// 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)
}
limit := defaultLogLimit
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
limit = int(raw)
}
if limit > maxLimit {
limit = maxLimit
}
lines, _, err := services.ReadServerRunLog(runID, serverID, 0, limit)
if err != nil {
return nil, fmt.Errorf("could not read run log: %w", err)
}
return runLogsResult{Lines: lines, Shown: len(lines)}, nil
},
})
All().Register(Tool{
Name: "list_pending_updates",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "One server to report on; omit to report across the fleet."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "servers:read",
Description: "List outstanding package updates across the fleet, or for one server. " +
"Pass server_id for one server, or tags to filter by, respecting the token's own scope.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := pageLimit(args)
out := []pendingUpdate{}
serverID := stringArg(args, "server_id")
if serverID != "" {
srv, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
}
for _, u := range srv.AvailableUpdates {
if len(out) == limit {
break
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased,
})
}
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
}
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
if !ok {
return listPendingUpdatesResult{Updates: out}, nil
}
servers, err := services.ListServersFiltered(c.InstanceID, sel)
if err != nil {
return nil, fmt.Errorf("could not list servers: %w", err)
}
for _, srv := range servers {
for _, u := range srv.AvailableUpdates {
if len(out) == limit {
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased,
})
}
}
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_vulnerabilities",
Args: []ToolArg{
{Name: "severity", Type: ArgString, Description: "Only this severity: critical, high, medium or low."},
{Name: "status", Type: ArgString, Description: "Only findings in this state: open (default) or accepted."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "List known CVEs affecting this fleet, one row per CVE/package pair with " +
"how many servers are affected. Filter by severity or status (open/accepted).",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
f := services.FindingFilter{
Severity: stringArg(args, "severity"),
State: stringArg(args, "status"),
// The service layer drops findings on servers outside this
// token's scope, so the affected-host count below is over
// visible servers only. An unfiltered count is the same
// aggregate leak ListKeys.AssignedCount was fixed for: it
// says something exists on a machine the caller must not
// know about. A CVE affecting only out-of-scope hosts
// disappears from the list rather than showing a zero.
TokenScope: c.TokenScope,
}
findings, err := services.ListInstanceFindings(c.InstanceID, f)
if err != nil {
return nil, fmt.Errorf("could not list vulnerabilities: %w", err)
}
type key struct{ cve, pkg string }
counts := map[key]int{}
meta := map[key]vulnSummary{}
for _, fnd := range findings {
k := key{fnd.CVEID, fnd.PackageName}
counts[k]++
if _, seen := meta[k]; !seen {
meta[k] = vulnSummary{CVEID: fnd.CVEID, Severity: fnd.Severity, Package: fnd.PackageName, FixedIn: fnd.FixedIn}
}
}
// Go randomises map iteration order, so truncating a ranged map
// 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.
keys := make([]key, 0, len(meta))
for k := range meta {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].cve != keys[j].cve {
return keys[i].cve < keys[j].cve
}
return keys[i].pkg < keys[j].pkg
})
limit := pageLimit(args)
out := make([]vulnSummary, 0, limit)
for _, k := range keys {
if len(out) == limit {
break
}
v := meta[k]
v.AffectedNum = counts[k]
out = append(out, v)
}
return listVulnsResult{Vulnerabilities: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_server_packages",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "The server's ID.", Required: true},
{Name: "name", Type: ArgString, Description: "Substring match on the package name. A host can carry ~2000 packages, so pass this."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "List installed packages on one server, optionally filtered by name. " +
"A server can carry ~2000 packages, so pass name to search rather than listing them all.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
serverID := stringArg(args, "server_id")
if serverID == "" {
return nil, fmt.Errorf("server_id is required")
}
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
}
pkgs, err := services.ListPackages(c.InstanceID, serverID)
if err != nil || pkgs == nil {
return nil, fmt.Errorf("no package data for server %q", serverID)
}
nameFilter := strings.ToLower(stringArg(args, "name"))
limit := pageLimit(args)
out := make([]packageEntry, 0, limit)
total := 0
for _, p := range pkgs.Packages {
if nameFilter != "" && !strings.Contains(strings.ToLower(p.Name), nameFilter) {
continue
}
total++
if len(out) < limit {
out = append(out, packageEntry{Name: p.Name, Version: p.Version})
}
}
return serverPackagesResult{ServerID: serverID, Packages: out, Total: total, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "search_fleet",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Exact package name to search for across the fleet.", Required: true},
{Name: "version_below", Type: ArgString, Description: "Not supported and refused if supplied: version ordering is per-distribution and cannot be resolved here."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
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 " +
"determine, so it refuses rather than guess with a lexicographic comparison. Every " +
"matching install is returned; compare versions yourself.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
name := stringArg(args, "name")
if name == "" {
return nil, fmt.Errorf("name is required")
}
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 " +
"install is returned instead")
}
hits, err := services.SearchPackages(c.InstanceID, name, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not search packages: %w", err)
}
limit := pageLimit(args)
out := make([]packageMatch, 0, limit)
for _, h := range hits {
if len(out) == limit {
break
}
// SearchPackages now filters by the token's scope itself,
// so this resolve is how the hostname is obtained rather
// than the only scope check. It stays scoped anyway: this
// loop is what turns a server ID into a name the model
// sees, and a second check costs nothing.
srv, err := services.GetServerScoped(c.InstanceID, h.ServerID, c.TokenScope)
if err != nil {
continue
}
out = append(out, packageMatch{Hostname: srv.Hostname, Package: h.Name, Version: h.Version})
}
return searchFleetResult{Matches: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_audit_events",
Args: []ToolArg{
{Name: "event_type", Type: ArgString, Description: "Event type prefix to filter by, e.g. \"workflow\", \"key\", \"server\"."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "settings:read",
Description: "List recent audit log events on this instance: who did what, and when. " +
"Filter by event_type prefix (e.g. \"workflow\", \"key\", \"server\").",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := int64(pageLimit(args))
events, total, err := services.ListAuditEvents(c.InstanceID, services.AuditFilter{
Category: stringArg(args, "event_type"),
Limit: limit,
})
if err != nil {
return nil, fmt.Errorf("could not list audit events: %w", err)
}
out := make([]auditEventSummary, 0, len(events))
for _, e := range events {
out = append(out, auditEventSummary{At: e.CreatedAt, Type: e.EventType, Actor: e.Actor, Detail: e.Details})
}
return listAuditResult{Events: out, Total: total, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_secret_names",
// 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 " +
"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)
if err != nil {
return nil, fmt.Errorf("could not list secret groups: %w", err)
}
out := make([]secretGroupNames, 0, len(groups))
for _, g := range groups {
// GetSecretGroup returns key metadata only (models.Secret's
// EncryptedValue is json:"-"); the plaintext reveal path
// (services.RevealSecret) is never called from this tool.
secrets, err := services.GetSecretGroup(c.InstanceID, g.Group)
if err != nil {
return nil, fmt.Errorf("could not read secret group %q: %w", g.Group, err)
}
keys := make([]string, 0, len(secrets))
for _, s := range secrets {
keys = append(keys, s.Key)
}
out = append(out, secretGroupNames{Group: g.Group, Keys: keys})
}
return listSecretNamesResult{Groups: out}, nil
},
})
}
+343
View File
@@ -0,0 +1,343 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// stringSliceArg reads a JSON array-of-strings argument, ignoring any element
// that is not a string. Missing or wrongly-typed input decodes to nil, which
// every caller here treats as "no targets named this way".
func stringSliceArg(args map[string]any, key string) []string {
raw, ok := args[key].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func mustLookup(name string) Tool {
t, ok := All().Lookup(name)
if !ok {
panic("mcp: unknown tool " + name)
}
return t
}
type runStartedResult struct {
RunID string `json:"run_id"`
Note string `json:"note"`
}
// run_workflow. The real REST run route (internal/api/workflows.go's
// runWorkflow) does not take an ad-hoc target list at all: it calls
// services.TriggerWorkflow(instanceID, workflowID, actor), which resolves the
// 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
// 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
// fan-out and scope check; the actual dispatch is the same single call the
// REST route makes, so there is exactly one path that starts a run.
func init() {
All().Register(Tool{
Name: "run_workflow",
Args: []ToolArg{
{Name: "workflow_id", Type: ArgString, Description: "The workflow to run. Its saved targets are used; this call cannot pick different ones.", Required: true},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
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 " +
"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 " +
"server scope, or if it would affect more than the fan-out limit without confirm:true.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
workflowID := stringArg(args, "workflow_id")
if workflowID == "" {
return nil, fmt.Errorf("workflow_id is required")
}
wf, err := services.GetWorkflow(c.InstanceID, workflowID)
if err != nil {
return nil, fmt.Errorf("workflow not found")
}
allTargets, err := services.ResolveTargets(c.InstanceID, wf.TargetServerIDs, wf.TargetTags)
if err != nil {
return nil, fmt.Errorf("this workflow matches no servers")
}
// This all-or-nothing pre-check is no longer the only guard:
// services.TriggerWorkflow now resolves through
// ResolveTargetsScoped itself, so a run started with this token
// can never touch a server outside its scope regardless of what
// happens here. It is kept because its refusal is the clearer
// answer for a model: the service layer would silently run
// against the in-scope subset, while a partially out-of-scope
// workflow is documented here as refused outright, which is
// behaviour a caller relies on.
scopedTargets, err := services.ResolveTargetsScoped(c.InstanceID, wf.TargetServerIDs, wf.TargetTags, c.TokenScope)
if err != nil || len(scopedTargets) != len(allTargets) {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(scopedTargets), args); err != nil {
return nil, err
}
runID, err := services.TriggerWorkflow(c.InstanceID, workflowID, c.TokenName, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not start the run: %w", err)
}
LogCall(c, mustLookup("run_workflow"), args, len(scopedTargets))
return runStartedResult{
RunID: runID,
Note: "The run is in progress. Poll get_run with this run_id; do not assume it succeeded.",
}, nil
},
})
}
type cancelledResult struct {
Cancelled bool `json:"cancelled"`
}
// 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
// per-server scope to check, since cancelling touches the run record, not a
// server.
func init() {
All().Register(Tool{
Name: "cancel_run",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run to cancel.", Required: true},
},
Write: true,
Scope: "workflows:write",
Description: "Cancel an in-progress workflow run. This stops further steps from " +
"being dispatched to real machines but cannot undo steps that already ran, and " +
"cannot be undone from here.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
runID := stringArg(args, "run_id")
if runID == "" {
return nil, fmt.Errorf("run_id is required")
}
if err := services.CancelRun(c.InstanceID, runID); err != nil {
return nil, fmt.Errorf("could not cancel the run: %w", err)
}
LogCall(c, mustLookup("cancel_run"), args, 0)
return cancelledResult{Cancelled: true}, nil
},
})
}
type updateBatchResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
RunIDs map[string]string `json:"run_ids,omitempty"` // server ID -> patch run ID
}
// apply_updates. The REST route (internal/api/handlers.go's applyUpdates) is
// per-server: POST /servers/:id/apply-updates resolves one server with
// services.GetServerScoped and calls services.StartManualRun. There is no
// fleet-wide variant of that service call to invoke once, so this tool
// resolves the requested targets through ResolveTargetsScoped, then calls the
// same StartManualRun once per resolved server and writes the same
// updates.applied audit event per server with its run ID.
func init() {
All().Register(Tool{
Name: "apply_updates",
Args: []ToolArg{
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "servers:write",
Description: "Apply pending OS package updates on real servers, selected by " +
"server_ids and/or tags. Starts one manual patch run per server. This installs " +
"packages on real machines right now and cannot be undone from here. A server " +
"may need a reboot afterward, which this tool does not do.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := updateBatchResult{Servers: len(targets), Failed: map[string]string{}}
for i := range targets {
srv := targets[i]
run, err := services.StartManualRun(c.InstanceID, &srv, "mcp:"+c.TokenName, models.PatchSourceMCP)
if err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
services.LogEvent(c.InstanceID, "updates.applied", "mcp:"+c.TokenName, srv.ServerID, "",
fmt.Sprintf("package update run %s started on %s", run.RunID, srv.Hostname))
result.Succeeded = append(result.Succeeded, srv.ServerID)
if result.RunIDs == nil {
result.RunIDs = map[string]string{}
}
result.RunIDs[srv.ServerID] = run.RunID
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("apply_updates"), args, len(targets))
return result, nil
},
})
}
type agentUpdateResult struct {
Servers int `json:"servers"`
Succeeded map[string]string `json:"succeeded,omitempty"`
Failed map[string]string `json:"failed,omitempty"`
}
// update_agent. Same shape as apply_updates: the REST route
// (handlers.go's updateAgent) resolves one server and calls
// services.DispatchUpdateAgent(serverID), so this tool loops the same call
// over the resolved, scoped target set.
func init() {
All().Register(Tool{
Name: "update_agent",
Args: []ToolArg{
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "servers:write",
Description: "Trigger the Vantage agent on real servers, selected by server_ids " +
"and/or tags, to download and replace itself with the latest version. This " +
"restarts the agent process on real machines and cannot be undone from here.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := agentUpdateResult{Servers: len(targets), Succeeded: map[string]string{}, Failed: map[string]string{}}
for _, srv := range targets {
version, err := services.DispatchUpdateAgent(srv.ServerID)
if err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded[srv.ServerID] = version
}
if len(result.Succeeded) == 0 {
result.Succeeded = nil
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("update_agent"), args, len(targets))
return result, nil
},
})
}
type assignKeyResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
}
// 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,
// 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
// 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.
func init() {
All().Register(Tool{
Name: "assign_key",
Args: []ToolArg{
{Name: "key_id", Type: ArgString, Description: "The SSH key to assign.", Required: true},
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
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.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
keyID := stringArg(args, "key_id")
if keyID == "" {
return nil, fmt.Errorf("key_id is required")
}
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := assignKeyResult{Servers: len(targets), Failed: map[string]string{}}
for _, srv := range targets {
if _, err := services.AssignKey(c.InstanceID, keyID, srv.ServerID); err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded = append(result.Succeeded, srv.ServerID)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("assign_key"), args, len(targets))
return result, nil
},
})
}
+44
View File
@@ -0,0 +1,44 @@
package mcp
import "testing"
func TestWriteToolsAreMarkedAsWrites(t *testing.T) {
want := []string{"run_workflow", "cancel_run", "apply_updates", "update_agent", "assign_key"}
for _, name := range want {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if !tool.Write {
t.Errorf("tool %q is not marked as a write, so it would be listed to a read-only agent", name)
}
}
}
// A tool description is prompt text. A model choosing between tools must be
// told which ones touch real machines.
func TestWriteToolDescriptionsStateBlastRadius(t *testing.T) {
for _, tool := range All().Tools() {
if !tool.Write {
continue
}
if len(tool.Description) < 40 {
t.Errorf("tool %q has a %d-char description; write tools must state what they affect",
tool.Name, len(tool.Description))
}
}
}
// A caller holding every resource scope but not mcp:write must still see no
// write tools at all.
func TestWriteToolsHiddenWithoutMCPWrite(t *testing.T) {
c := Caller{Scopes: []string{
"mcp:read", "servers:write", "workflows:write", "keys:write",
}}
for _, tool := range All().Visible(c) {
if tool.Write {
t.Errorf("write tool %q visible without mcp:write", tool.Name)
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package mcp
import (
"context"
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"github.com/gin-gonic/gin"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// callerFromContext builds the acting credential from the session the auth
// middleware already resolved. The mcp package reads no cookie and no header of
// its own: identity is settled before a request reaches here.
func callerFromContext(c *gin.Context) Caller {
return Caller{
InstanceID: auth.InstanceID(c),
Scopes: auth.Scopes(c),
TokenScope: auth.ServerScope(c),
TokenName: auth.TokenName(c),
}
}
// Handler serves the MCP endpoint. It is stateless: no session resumption, each
// request self-contained, which is what lets it sit behind ordinary request
// middleware with no special casing.
//
// Stateless mode also means this handler is POST-only in practice: the SDK's
// StreamableHTTPHandler hardcodes a 405 for GET whenever Stateless is true,
// 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
// 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 {
return func(c *gin.Context) {
caller := callerFromContext(c)
// A cookie session is not an agent. MCP is a credential-shaped surface
// and browsing to it in a logged-in tab must not act as one.
if !auth.IsToken(c) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "the mcp endpoint requires an API token",
})
return
}
srv := sdk.NewServer(&sdk.Implementation{
Name: "vantage",
Version: buildVersion,
}, nil)
for _, tool := range All().Visible(caller) {
registerSDKTool(srv, tool, caller)
}
sdk.NewStreamableHTTPHandler(func(*http.Request) *sdk.Server {
return srv
}, &sdk.StreamableHTTPOptions{Stateless: true}).ServeHTTP(c.Writer, c.Request)
}
}
// registerSDKTool adapts one registered Tool onto the SDK, wrapping it in the
// gate check and the audit write. The gate is re-checked here rather than
// trusted from Visible, because listing and calling are separate requests and a
// token's scopes are re-read on each.
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
// 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
// gate logic and the audit wrapper this function exists to apply. The
// declared Args are the same information without that cost, and the SDK
// validates against the schema either way.
sdk.AddTool(srv, &sdk.Tool{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.InputSchema(),
}, func(ctx context.Context, req *sdk.CallToolRequest, args map[string]any) (*sdk.CallToolResult, any, error) {
return callTool(ctx, tool, caller, args)
})
}
// 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
// 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 {
LogDenied(caller, tool.Name, gate)
return nil, nil, toolError(gate, tool)
}
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
// 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
// anything and carries no gate to name.
if tool.Write {
switch {
case errors.Is(err, ErrConfirmRequired):
LogDenied(caller, tool.Name, GateFanOut)
case errors.Is(err, ErrOutOfScope):
LogDenied(caller, tool.Name, GateTagScope)
default:
LogFailure(caller, tool, args, err)
}
}
return nil, nil, err
}
if !tool.Write {
// Write tools log their own call with a resolved server count, which
// this layer cannot know.
LogCall(caller, tool, args, 0)
}
return nil, out, nil
}
// toolError explains a refusal in words the model can act on. A transport-level
// failure would be invisible to it; a tool error is something it can read and
// relay to its user.
func toolError(gate string, tool Tool) error {
switch gate {
case GateMCPScope:
if tool.Write {
return errors.New("this token does not hold mcp:write, so it cannot use tools that change anything")
}
return errors.New("this token does not hold mcp:read")
case GateResourceScope:
return errors.New("this token does not hold " + tool.Scope)
default:
return errors.New("refused")
}
}
// buildVersion is stamped so a user with several instances connected can tell
// them apart in a client. Wire it to whatever the server already uses for its
// version string.
var buildVersion = "dev"
// SetVersion is called once at boot from main.
func SetVersion(v string) { buildVersion = v }
@@ -0,0 +1,25 @@
package mcp
import (
"testing"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// sdk.AddTool panics on a schema it cannot resolve, and the only place that
// would otherwise happen is inside a live request. Registering every tool onto
// a real server here moves that failure to the test run.
func TestEveryToolRegistersWithTheSDK(t *testing.T) {
srv := sdk.NewServer(&sdk.Implementation{Name: "vantage", Version: "test"}, nil)
caller := Caller{InstanceID: "i", Scopes: []string{"mcp:write"}}
for _, tool := range All().Tools() {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("tool %q: SDK rejected its input schema: %v", tool.Name, r)
}
}()
registerSDKTool(srv, tool, caller)
}()
}
}
+115
View File
@@ -0,0 +1,115 @@
package mcp
import (
"context"
"testing"
)
// 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,
// 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
// tool produces, and until this test the only thing recording it was the
// tool's own success path.
func TestRefusedWriteIsAudited(t *testing.T) {
var got []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
got = append(got, eventType+": "+details)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_write_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, CheckFanOut(200, args)
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
_, _, err := callTool(context.Background(), tool, caller, nil)
if err == nil {
t.Fatal("callTool() = nil error, want the fan-out refusal")
}
if len(got) != 1 {
t.Fatalf("logEvent called %d times, want exactly 1 audit row for the refusal; got %v", len(got), got)
}
if want := "mcp.tool_denied: "; len(got[0]) < len(want) || got[0][:len(want)] != want {
t.Errorf("audit row %q does not record a denial", got[0])
}
}
// TestOutOfScopeWriteIsAudited covers the other write refusal shape: a
// service-layer ErrOutOfScope wrap, as apply_updates/update_agent/assign_key
// return when ResolveTargetsScoped finds nothing this token may touch.
func TestOutOfScopeWriteIsAudited(t *testing.T) {
var got []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
got = append(got, eventType)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_scoped_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, ErrOutOfScope
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
if _, _, err := callTool(context.Background(), tool, caller, nil); err == nil {
t.Fatal("callTool() = nil error, want the scope refusal")
}
if len(got) != 1 || got[0] != "mcp.tool_denied" {
t.Errorf("audit events = %v, want exactly one mcp.tool_denied row", got)
}
}
// 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
// mcp.tool_denied, so a human reading audit_logs can tell the two apart.
func TestServiceFailureIsAuditedDistinctly(t *testing.T) {
var events []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
events = append(events, eventType)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_failing_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, errFakeServiceFailure
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
if _, _, err := callTool(context.Background(), tool, caller, nil); err == nil {
t.Fatal("callTool() = nil error, want the service failure")
}
if len(events) != 1 || events[0] != "mcp.tool_failed" {
t.Errorf("audit events = %v, want exactly one mcp.tool_failed row", events)
}
}
var errFakeServiceFailure = &fakeError{"the dispatcher refused the command"}
type fakeError struct{ msg string }
func (e *fakeError) Error() string { return e.msg }
+13 -4
View File
@@ -10,13 +10,14 @@ 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.
//
// Role and Scopes are immutable after creation. There is no update endpoint:
// editing what a credential already deployed in CI can do, with no record of
// what it could do before, is worse than requiring a rotation.
// Role, Scopes and TagSelector are immutable after creation. There is no
// update endpoint: editing what a credential already deployed in CI can do,
// with no record of what it could do before, is worse than requiring a
// rotation.
type APIToken struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
TokenID string `bson:"token_id" json:"token_id"`
@@ -33,6 +34,14 @@ type APIToken struct {
Role string `bson:"role" json:"role"`
Scopes []string `bson:"scopes" json:"scopes"`
// TagSelector restricts this token to servers carrying every tag in the
// map. Empty or nil means the whole fleet.
//
// Immutable after creation for the same reason as Role and Scopes: changing
// what a credential already deployed in CI can reach, with no record of what
// it could reach before, is worse than requiring a rotation.
TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,omitempty"`
// ExpiresAt nil means the token never expires. Whether that is allowed is
// a per-instance policy, settings.api_token_max_days.
ExpiresAt *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"`
+1 -1
View File
@@ -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"},
+8
View File
@@ -21,6 +21,14 @@ const (
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
// way a workflow's target list can omit an ID without the whole workflow
// disappearing from a list.
const RunnerRestricted = "restricted"
type MonitorTarget struct {
URL string `bson:"url,omitempty" json:"url,omitempty"`
Host string `bson:"host,omitempty" json:"host,omitempty"`
+116
View File
@@ -0,0 +1,116 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
PatchScopeAll = "all"
PatchScopeSecurity = "security"
PatchRebootNever = "never"
PatchRebootIfRequired = "if_required"
PatchRunRunning = "running"
PatchRunSucceeded = "succeeded"
PatchRunPartial = "partial"
PatchRunFailed = "failed"
PatchRunCancelled = "cancelled"
PatchSrvQueued = "queued"
PatchSrvWaitingOffline = "waiting_offline"
PatchSrvPatching = "patching"
PatchSrvRebooting = "rebooting"
PatchSrvSucceeded = "succeeded"
PatchSrvFailed = "failed"
PatchSrvUnsupported = "unsupported"
PatchSrvAgentTooOld = "agent_too_old"
PatchSrvMissedOffline = "missed_offline"
PatchSrvWindowClosed = "window_closed"
PatchSrvCancelled = "cancelled"
PatchSourceSchedule = "schedule"
PatchSourceRunNow = "run_now"
PatchSourceServer = "server"
PatchSourceVulnerabilities = "vulnerabilities"
PatchSourceMCP = "mcp"
)
// MaintenanceWindow answers "when" and nothing else. Policies reference it by
// ID, so one window can later serve alert muting and status page maintenance
// without a second definition of the same Sunday morning.
type MaintenanceWindow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
WindowID string `bson:"window_id" json:"window_id"`
Name string `bson:"name" json:"name"`
Cron string `bson:"cron" json:"cron"` // 5-field, window start
TZ string `bson:"tz" json:"tz"` // IANA name
DurationMinutes int `bson:"duration_minutes" json:"duration_minutes"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type PatchPolicy struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
PolicyID string `bson:"policy_id" json:"policy_id"`
Name string `bson:"name" json:"name"`
Enabled bool `bson:"enabled" json:"enabled"`
WindowID string `bson:"window_id" json:"window_id"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"`
Scope string `bson:"scope" json:"scope"`
Reboot string `bson:"reboot" json:"reboot"`
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // 0 = no cap
NotifyChannelIDs []string `bson:"notify_channel_ids,omitempty" json:"notify_channel_ids,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"`
LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"`
DisabledReason string `bson:"disabled_reason,omitempty" json:"disabled_reason,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// PatchRun is one firing of a policy, or one manual Apply updates. Scope,
// reboot and concurrency are copied from the policy at fire time so editing
// the policy never rewrites what a past run shows.
type PatchRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
RunID string `bson:"run_id" json:"run_id"`
PolicyID string `bson:"policy_id,omitempty" json:"policy_id,omitempty"`
PolicyName string `bson:"policy_name,omitempty" json:"policy_name,omitempty"`
TriggeredBy string `bson:"triggered_by" json:"triggered_by"`
Source string `bson:"source" json:"source"`
Scope string `bson:"scope" json:"scope"`
Reboot string `bson:"reboot" json:"reboot"`
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"`
WindowEnd *time.Time `bson:"window_end,omitempty" json:"window_end,omitempty"`
Status string `bson:"status" json:"status"`
CancelledAt *time.Time `bson:"cancelled_at,omitempty" json:"cancelled_at,omitempty"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
Servers []PatchServerRun `bson:"servers" json:"servers"`
}
type PatchServerRun struct {
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
Status string `bson:"status" json:"status"`
CommandID string `bson:"command_id,omitempty" json:"-"`
PendingBefore int `bson:"pending_before" json:"pending_before"`
PendingAfter *int `bson:"pending_after,omitempty" json:"pending_after,omitempty"`
RebootedAt *time.Time `bson:"rebooted_at,omitempty" json:"rebooted_at,omitempty"`
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
// BootTimeBefore is the host's reported boot time when the reboot was
// announced. A later report with a different boot time proves the
// restart without comparing the host clock to the server clock.
BootTimeBefore *time.Time `bson:"boot_time_before,omitempty" json:"-"`
Output string `bson:"output,omitempty" json:"output,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
}
+6
View File
@@ -10,6 +10,9 @@ type PackageUpdate struct {
Name string `bson:"name" json:"name"`
CurrentVersion string `bson:"current_version,omitempty" json:"current_version,omitempty"`
NewVersion string `bson:"new_version" json:"new_version"`
// Phased is an Ubuntu phased update the host is not yet selected for: apt
// lists it but an upgrade defers it, so pending counts leave it out.
Phased bool `bson:"phased,omitempty" json:"phased,omitempty"`
}
type CPUInfo struct {
@@ -42,6 +45,9 @@ type Inventory struct {
RebootRequired bool `bson:"reboot_required,omitempty" json:"reboot_required,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
// BootTime is the host's last reported boot time, stored on every report
// that carries one so a patch reboot can be proven by a changed boot.
BootTime *time.Time `bson:"boot_time,omitempty" json:"boot_time,omitempty"`
}
type Server struct {
+1 -1
View File
@@ -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"`
+5 -1
View File
@@ -11,6 +11,10 @@ import (
// monitor check. MonitorName carries the hostname in that case.
const TypeServer = "server"
// TypePatch marks a patch run summary. Like a vulnerability digest it is a
// headline, not a transition, so title() adds no verb.
const TypePatch = "patch"
type Event struct {
MonitorName string
Type string
@@ -26,7 +30,7 @@ func (e Event) title() string {
verb = "is DOWN"
}
var s string
if e.Type == TypeVuln {
if e.Type == TypeVuln || e.Type == TypePatch {
// A digest is not a transition. MonitorName already carries the whole
// headline ("12 new critical across 4 servers"), so no verb applies.
s = fmt.Sprintf("[Vantage] %s", e.MonitorName)
+17
View File
@@ -0,0 +1,17 @@
package notify
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// A patch summary is not a transition: it must not read "is DOWN" or
// "recovered".
func TestPatchEventTitle(t *testing.T) {
ev := Event{MonitorName: `Patch policy "Sunday prod" partial`, Type: TypePatch, NewStatus: models.PatchRunPartial, Message: "38 succeeded, 2 failed"}
want := `[Vantage] Patch policy "Sunday prod" partial: 38 succeeded, 2 failed`
if got := ev.title(); got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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)
+275
View File
@@ -0,0 +1,275 @@
// Package patchrun is the patch run state machine as pure functions. Nothing
// here touches the database: the services layer loads a run, asks this
// package what should change, and writes that change guarded by the status it
// expected. That split is what makes the rules testable without MongoDB.
package patchrun
import (
"fmt"
"strconv"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
// MinAgentVersion is the first agent release that honours ApplyUpdatesCmd's
// scope and answers with a PatchResult. An older agent ignores the scope and
// installs everything, so a policy must never dispatch to one.
const MinAgentVersion = "1.4.0"
const (
// ResultGrace covers what the agent does after the upgrade and before it
// answers: a pending-update re-check of up to 10 minutes (Windows Update
// search) plus a 2 minute reboot check, with room to spare.
ResultGrace = 20 * time.Minute
// ManualTimeout is the agent's backstop for one started upgrade, counted
// from its own start. The window end never stops a running upgrade.
ManualTimeout = 2 * time.Hour
// RebootTimeout is how long a rebooting server has to send a post-boot
// inventory report. Windows cumulative updates routinely take over 20.
RebootTimeout = 45 * time.Minute
// LatestStartBeforeEnd is the tail of a window in which no server starts
// patching: a late start would run long past the window end.
LatestStartBeforeEnd = 15 * time.Minute
)
// AgentSupportsPatchResults compares major.minor.patch. Empty, "dev" and
// anything unparseable count as too old, and a pre-release of exactly the
// minimum version counts as older than it, as semver orders them.
func AgentSupportsPatchResults(version string) bool {
have, pre, ok := parseVersion(version)
if !ok {
return false
}
want, _, _ := parseVersion(MinAgentVersion)
for i := 0; i < 3; i++ {
if have[i] != want[i] {
return have[i] > want[i]
}
}
return !pre
}
func parseVersion(v string) ([3]int, bool, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
v, _, _ = strings.Cut(v, "+")
core, pre, hasPre := strings.Cut(v, "-")
parts := strings.Split(core, ".")
if len(parts) != 3 {
return out, false, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false, false
}
out[i] = n
}
return out, hasPre && pre != "", true
}
// IsTerminal reports whether a server run has finished.
func IsTerminal(status string) bool {
switch status {
case models.PatchSrvQueued, models.PatchSrvWaitingOffline, models.PatchSrvPatching, models.PatchSrvRebooting:
return false
}
return true
}
// Transition is one change Advance wants made. The caller writes it guarded
// by From, so a result that arrived meanwhile is never overwritten. Dispatch
// means: send ApplyUpdatesCmd, and set To only once the command is on its way.
type Transition struct {
ServerID string
From string
To string
Error string
Dispatch bool
}
// Advance returns what should change on this tick. connected says which
// agents hold a command stream right now.
func Advance(run models.PatchRun, now time.Time, connected map[string]bool) []Transition {
if run.Status != models.PatchRunRunning {
return nil
}
windowOpen := run.WindowEnd == nil || now.Before(*run.WindowEnd)
// In the last LatestStartBeforeEnd of a window nothing new starts: queued
// and waiting servers simply wait, and close at WindowEnd as usual.
mayStart := run.WindowEnd == nil || now.Before(run.WindowEnd.Add(-LatestStartBeforeEnd))
inFlight := 0
for _, s := range run.Servers {
if s.Status == models.PatchSrvPatching || s.Status == models.PatchSrvRebooting {
inFlight++
}
}
var out []Transition
for _, s := range run.Servers {
switch s.Status {
case models.PatchSrvQueued, models.PatchSrvWaitingOffline:
switch {
case run.CancelledAt != nil:
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvCancelled})
case !windowOpen:
to := models.PatchSrvWindowClosed
if s.Status == models.PatchSrvWaitingOffline {
to = models.PatchSrvMissedOffline
}
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: to})
case !mayStart:
// The window tail: no dispatch, no transition.
case run.MaxConcurrent > 0 && inFlight >= run.MaxConcurrent:
// No slot this tick.
case !connected[s.ServerID]:
if s.Status == models.PatchSrvQueued {
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvWaitingOffline})
}
default:
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvPatching, Dispatch: true})
inFlight++
}
case models.PatchSrvPatching:
if now.After(resultDeadline(run, s)) {
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvFailed, Error: "no result from agent"})
}
case models.PatchSrvRebooting:
if s.RebootedAt != nil && now.After(s.RebootedAt.Add(RebootTimeout)) {
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvFailed, Error: fmt.Sprintf("did not come back within %d minutes", int(RebootTimeout.Minutes()))})
}
}
}
return out
}
// resultDeadline is the same for windowed and manual runs: the agent lets a
// started upgrade finish past the window end, so the window end says nothing
// about when a result is due. The base is the server's dispatch time.
func resultDeadline(run models.PatchRun, s models.PatchServerRun) time.Time {
start := run.StartedAt
if s.StartedAt != nil {
start = *s.StartedAt
}
return start.Add(ManualTimeout + ResultGrace)
}
// ApplyResult folds an agent's PatchResult into the server run. It only acts
// on a server that is patching; anything else is a late or duplicate result.
func ApplyResult(s models.PatchServerRun, r *pb.PatchResult, now time.Time) (models.PatchServerRun, bool) {
if s.Status != models.PatchSrvPatching {
return s, false
}
s.Output = r.OutputTail
if r.PendingAfter >= 0 {
v := int(r.PendingAfter)
s.PendingAfter = &v
}
switch r.Status {
case pb.PatchStatusOK:
if r.Rebooting {
s.Status = models.PatchSrvRebooting
s.RebootedAt = &now
return s, true
}
s.Status = models.PatchSrvSucceeded
case pb.PatchStatusUnsupported:
s.Status = models.PatchSrvUnsupported
s.Error = r.Message
default:
s.Status = models.PatchSrvFailed
s.Error = r.Message
if s.Error == "" {
s.Error = "agent reported a failure"
}
}
s.FinishedAt = &now
return s, true
}
// VerifyReboot settles a rebooting server from a static inventory report.
// When the boot time reported before the reboot is known, a later boot time
// is the proof: both come from the host clock, so skew against the server
// clock does not matter. Otherwise only a boot time later than the reboot
// command counts. Either way a snapshot sent during the one-minute grace
// period, before the host went down, does not.
func VerifyReboot(s models.PatchServerRun, bootTime time.Time, rebootRequired bool, now time.Time) (models.PatchServerRun, bool) {
if s.Status != models.PatchSrvRebooting || s.RebootedAt == nil {
return s, false
}
proven := bootTime.After(*s.RebootedAt)
if s.BootTimeBefore != nil {
proven = bootTime.After(s.BootTimeBefore.Add(BootTimeTolerance))
}
if !proven {
return s, false
}
if rebootRequired {
s.Status = models.PatchSrvFailed
s.Error = "still requires a reboot after restarting"
} else {
s.Status = models.PatchSrvSucceeded
s.VerifiedAt = &now
}
s.FinishedAt = &now
return s, true
}
// BootTimeTolerance is how far a reported boot time must move before it counts
// as a reboot. Windows reports boot time as now minus uptime, which drifts by a
// second or so between reports; a real reboot moves it by far more than this.
const BootTimeTolerance = 2 * time.Minute
// Finalize says whether the run is over and how it ended. Only succeeded
// counts as success: unsupported, agent_too_old and the window outcomes did
// not patch anything.
func Finalize(run models.PatchRun) (string, bool) {
ok := 0
for _, s := range run.Servers {
if !IsTerminal(s.Status) {
return "", false
}
if s.Status == models.PatchSrvSucceeded {
ok++
}
}
switch {
case run.CancelledAt != nil:
return models.PatchRunCancelled, true
case len(run.Servers) > 0 && ok == len(run.Servers):
return models.PatchRunSucceeded, true
case ok == 0:
return models.PatchRunFailed, true
default:
return models.PatchRunPartial, true
}
}
var summaryOrder = []struct{ status, label string }{
{models.PatchSrvSucceeded, "succeeded"},
{models.PatchSrvFailed, "failed"},
{models.PatchSrvUnsupported, "unsupported"},
{models.PatchSrvAgentTooOld, "need an agent update"},
{models.PatchSrvMissedOffline, "missed offline"},
{models.PatchSrvWindowClosed, "window closed"},
{models.PatchSrvCancelled, "cancelled"},
}
// Summary is the one-line count used in alerts, e.g. "38 succeeded, 2 failed".
func Summary(run models.PatchRun) string {
counts := map[string]int{}
for _, s := range run.Servers {
counts[s.Status]++
}
var parts []string
for _, o := range summaryOrder {
if n := counts[o.status]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, o.label))
}
}
return strings.Join(parts, ", ")
}
+341
View File
@@ -0,0 +1,341 @@
package patchrun
import (
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
var t0 = time.Date(2026, 9, 20, 2, 0, 0, 0, time.UTC)
func tp(t time.Time) *time.Time { return &t }
func srv(id, status string) models.PatchServerRun {
return models.PatchServerRun{ServerID: id, Hostname: id, Status: status}
}
func windowRun(max int, servers ...models.PatchServerRun) models.PatchRun {
return models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, WindowEnd: tp(t0.Add(2 * time.Hour)), MaxConcurrent: max, Servers: servers}
}
func find(ts []Transition, id string) *Transition {
for i := range ts {
if ts[i].ServerID == id {
return &ts[i]
}
}
return nil
}
func TestAgentSupportsPatchResults(t *testing.T) {
cases := map[string]bool{
"1.4.0": true, "v1.4.0": true, "1.4.1": true, "1.10.0": true, "2.0.0": true,
"1.3.5": false, "1.4.0-rc1": false, "1.5.0-rc1": true,
"": false, "dev": false, "1.4": false, "x.y.z": false,
}
for v, want := range cases {
if got := AgentSupportsPatchResults(v); got != want {
t.Errorf("%q: got %v, want %v", v, got, want)
}
}
}
func TestAdvanceRespectsConcurrencyCountingRebooting(t *testing.T) {
run := windowRun(2,
srv("a", models.PatchSrvRebooting),
srv("b", models.PatchSrvQueued),
srv("c", models.PatchSrvQueued),
)
run.Servers[0].RebootedAt = tp(t0)
ts := Advance(run, t0.Add(time.Minute), map[string]bool{"b": true, "c": true})
if tr := find(ts, "b"); tr == nil || !tr.Dispatch || tr.To != models.PatchSrvPatching {
t.Fatalf("b should dispatch, got %+v", tr)
}
if tr := find(ts, "c"); tr != nil {
t.Fatalf("c must wait for a slot, got %+v", tr)
}
}
func TestAdvanceUnlimitedWhenZero(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued), srv("b", models.PatchSrvQueued))
ts := Advance(run, t0, map[string]bool{"a": true, "b": true})
if len(ts) != 2 || !ts[0].Dispatch || !ts[1].Dispatch {
t.Fatalf("both should dispatch: %+v", ts)
}
}
func TestAdvanceOfflineWaitsThenDispatches(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued))
ts := Advance(run, t0, map[string]bool{})
if len(ts) != 1 || ts[0].To != models.PatchSrvWaitingOffline || ts[0].Dispatch {
t.Fatalf("offline should wait: %+v", ts)
}
run.Servers[0].Status = models.PatchSrvWaitingOffline
if ts := Advance(run, t0.Add(time.Minute), map[string]bool{}); len(ts) != 0 {
t.Fatalf("still offline must be a no-op, got %+v", ts)
}
ts = Advance(run, t0.Add(2*time.Minute), map[string]bool{"a": true})
if len(ts) != 1 || !ts[0].Dispatch {
t.Fatalf("back online should dispatch: %+v", ts)
}
}
func TestAdvanceWindowCloses(t *testing.T) {
run := windowRun(1, srv("q", models.PatchSrvQueued), srv("w", models.PatchSrvWaitingOffline))
ts := Advance(run, t0.Add(2*time.Hour), map[string]bool{"q": true, "w": true})
if tr := find(ts, "q"); tr == nil || tr.To != models.PatchSrvWindowClosed || tr.Dispatch {
t.Fatalf("queued at window end: %+v", tr)
}
if tr := find(ts, "w"); tr == nil || tr.To != models.PatchSrvMissedOffline {
t.Fatalf("waiting at window end: %+v", tr)
}
}
// A windowed run times out from the server's own dispatch time, not from the
// window end: a server dispatched late in the window may finish past it.
func TestAdvanceNoResultTimeoutFromDispatch(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvPatching))
run.Servers[0].StartedAt = tp(t0.Add(90 * time.Minute))
deadline := t0.Add(90*time.Minute + ManualTimeout + ResultGrace)
if ts := Advance(run, run.WindowEnd.Add(ResultGrace+time.Minute), nil); len(ts) != 0 {
t.Fatalf("past WindowEnd+grace but inside the dispatch timeout: %+v", ts)
}
if ts := Advance(run, deadline.Add(-time.Minute), nil); len(ts) != 0 {
t.Fatalf("inside the dispatch timeout: %+v", ts)
}
ts := Advance(run, deadline.Add(time.Minute), nil)
if len(ts) != 1 || ts[0].To != models.PatchSrvFailed || ts[0].Error == "" {
t.Fatalf("past the dispatch timeout: %+v", ts)
}
}
// Without a server StartedAt the run's own start is the base.
func TestAdvanceNoResultTimeoutFallsBackToRunStart(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvPatching))
if ts := Advance(run, t0.Add(2*time.Hour+19*time.Minute), nil); len(ts) != 0 {
t.Fatalf("inside timeout: %+v", ts)
}
if ts := Advance(run, t0.Add(2*time.Hour+21*time.Minute), nil); len(ts) != 1 || ts[0].To != models.PatchSrvFailed {
t.Fatalf("past timeout: %+v", ts)
}
}
func TestAdvanceManualRunTimeout(t *testing.T) {
run := models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, Servers: []models.PatchServerRun{srv("a", models.PatchSrvPatching)}}
run.Servers[0].StartedAt = tp(t0)
if ts := Advance(run, t0.Add(2*time.Hour+19*time.Minute), nil); len(ts) != 0 {
t.Fatalf("manual inside timeout: %+v", ts)
}
if ts := Advance(run, t0.Add(2*time.Hour+21*time.Minute), nil); len(ts) != 1 || ts[0].To != models.PatchSrvFailed {
t.Fatalf("manual past timeout: %+v", ts)
}
}
func TestTimingConstants(t *testing.T) {
if ResultGrace != 20*time.Minute || RebootTimeout != 45*time.Minute || LatestStartBeforeEnd != 15*time.Minute {
t.Fatalf("ResultGrace=%v RebootTimeout=%v LatestStartBeforeEnd=%v", ResultGrace, RebootTimeout, LatestStartBeforeEnd)
}
}
// No server starts patching in the last 15 minutes of a window: it would
// either be cut short or run long past the window end.
func TestAdvanceNoDispatchInWindowTail(t *testing.T) {
run := windowRun(0, srv("q", models.PatchSrvQueued), srv("w", models.PatchSrvWaitingOffline), srv("o", models.PatchSrvQueued))
cutoff := run.WindowEnd.Add(-LatestStartBeforeEnd)
online := map[string]bool{"q": true, "w": true}
for _, at := range []time.Time{cutoff, cutoff.Add(time.Minute), run.WindowEnd.Add(-time.Second)} {
if ts := Advance(run, at, online); len(ts) != 0 {
t.Fatalf("at %v: nothing may change in the window tail, got %+v", at.Sub(t0), ts)
}
}
}
func TestAdvanceDispatchJustBeforeWindowTail(t *testing.T) {
run := windowRun(0, srv("q", models.PatchSrvQueued))
at := run.WindowEnd.Add(-LatestStartBeforeEnd - time.Second)
ts := Advance(run, at, map[string]bool{"q": true})
if len(ts) != 1 || !ts[0].Dispatch {
t.Fatalf("dispatch must be allowed just before the tail: %+v", ts)
}
}
// A manual run has no window and no tail.
func TestAdvanceManualRunHasNoTail(t *testing.T) {
run := models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, Servers: []models.PatchServerRun{srv("a", models.PatchSrvQueued)}}
if ts := Advance(run, t0.Add(10*time.Hour), map[string]bool{"a": true}); len(ts) != 1 || !ts[0].Dispatch {
t.Fatalf("manual run must dispatch: %+v", ts)
}
}
func TestAdvanceRebootTimeout(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvRebooting))
run.Servers[0].RebootedAt = tp(t0)
if ts := Advance(run, t0.Add(44*time.Minute), nil); len(ts) != 0 {
t.Fatalf("inside reboot timeout: %+v", ts)
}
ts := Advance(run, t0.Add(46*time.Minute), nil)
if len(ts) != 1 || ts[0].To != models.PatchSrvFailed || ts[0].Error != "did not come back within 45 minutes" {
t.Fatalf("past reboot timeout: %+v", ts)
}
}
func TestAdvanceCancelledDispatchesNothing(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued), srv("b", models.PatchSrvWaitingOffline), srv("c", models.PatchSrvPatching))
run.CancelledAt = tp(t0)
ts := Advance(run, t0.Add(time.Minute), map[string]bool{"a": true, "b": true})
for _, id := range []string{"a", "b"} {
if tr := find(ts, id); tr == nil || tr.To != models.PatchSrvCancelled || tr.Dispatch {
t.Errorf("%s: %+v", id, tr)
}
}
if find(ts, "c") != nil {
t.Error("an in-flight server must be left to finish")
}
}
func TestAdvanceIgnoresFinishedRun(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued))
run.Status = models.PatchRunSucceeded
if ts := Advance(run, t0, map[string]bool{"a": true}); ts != nil {
t.Fatalf("got %+v", ts)
}
}
func TestApplyResult(t *testing.T) {
now := t0.Add(10 * time.Minute)
cases := []struct {
r pb.PatchResult
want string
}{
{pb.PatchResult{Status: pb.PatchStatusOK, PendingAfter: 0}, models.PatchSrvSucceeded},
{pb.PatchResult{Status: pb.PatchStatusOK, Rebooting: true}, models.PatchSrvRebooting},
{pb.PatchResult{Status: pb.PatchStatusFailed, Message: "apt broke"}, models.PatchSrvFailed},
{pb.PatchResult{Status: pb.PatchStatusBusy, Message: "busy"}, models.PatchSrvFailed},
{pb.PatchResult{Status: pb.PatchStatusUnsupported, Message: "no metadata"}, models.PatchSrvUnsupported},
}
for _, c := range cases {
got, ok := ApplyResult(srv("a", models.PatchSrvPatching), &c.r, now)
if !ok || got.Status != c.want {
t.Errorf("%s: got %s ok=%v, want %s", c.r.Status, got.Status, ok, c.want)
}
if c.want == models.PatchSrvRebooting && (got.RebootedAt == nil || got.FinishedAt != nil) {
t.Errorf("rebooting must set RebootedAt and leave FinishedAt nil: %+v", got)
}
}
if _, ok := ApplyResult(srv("a", models.PatchSrvSucceeded), &pb.PatchResult{Status: pb.PatchStatusOK}, now); ok {
t.Error("a result for a server not patching must be ignored")
}
got, _ := ApplyResult(srv("a", models.PatchSrvPatching), &pb.PatchResult{Status: pb.PatchStatusOK, PendingAfter: -1}, now)
if got.PendingAfter != nil {
t.Error("PendingAfter -1 means unknown and must stay nil")
}
}
func TestVerifyReboot(t *testing.T) {
s := srv("a", models.PatchSrvRebooting)
s.RebootedAt = tp(t0)
now := t0.Add(5 * time.Minute)
if _, ok := VerifyReboot(s, t0.Add(-time.Hour), false, now); ok {
t.Error("boot before the reboot command is not proof")
}
if _, ok := VerifyReboot(s, t0, false, now); ok {
t.Error("boot equal to the reboot command is not proof")
}
got, ok := VerifyReboot(s, t0.Add(2*time.Minute), false, now)
if !ok || got.Status != models.PatchSrvSucceeded || got.VerifiedAt == nil {
t.Errorf("clean reboot: %+v", got)
}
got, ok = VerifyReboot(s, t0.Add(2*time.Minute), true, now)
if !ok || got.Status != models.PatchSrvFailed || got.Error == "" {
t.Errorf("still owed: %+v", got)
}
if _, ok := VerifyReboot(srv("a", models.PatchSrvPatching), t0.Add(time.Hour), false, now); ok {
t.Error("only rebooting servers verify")
}
}
// With the boot time recorded before the reboot, a changed boot time is the
// proof, whatever the skew between the host clock and the server clock.
func TestVerifyRebootChangedBoot(t *testing.T) {
s := srv("a", models.PatchSrvRebooting)
s.RebootedAt = tp(t0)
s.BootTimeBefore = tp(t0.Add(-10 * 24 * time.Hour))
now := t0.Add(5 * time.Minute)
// The host clock runs 10 minutes slow: its new boot time reads earlier
// than the server's RebootedAt, yet the boot did change.
got, ok := VerifyReboot(s, t0.Add(-8*time.Minute), false, now)
if !ok || got.Status != models.PatchSrvSucceeded {
t.Fatalf("changed boot behind a slow clock must be proven: %+v ok=%v", got, ok)
}
// A fast host clock with an unchanged boot is not proof.
if _, ok := VerifyReboot(s, *s.BootTimeBefore, false, now); ok {
t.Error("an unchanged boot time is not proof")
}
if _, ok := VerifyReboot(s, s.BootTimeBefore.Add(-time.Minute), false, now); ok {
t.Error("an earlier boot time is not proof")
}
}
// Windows derives boot time from now minus uptime, so it drifts by a second or
// so between reports. A report sent in the grace period before the reboot
// must not read as a reboot, or a server still owing one is marked failed.
func TestVerifyRebootIgnoresBootTimeDrift(t *testing.T) {
s := srv("a", models.PatchSrvRebooting)
s.RebootedAt = tp(t0)
s.BootTimeBefore = tp(t0.Add(-10 * 24 * time.Hour))
now := t0.Add(30 * time.Second)
for _, drift := range []time.Duration{time.Second, BootTimeTolerance} {
if _, ok := VerifyReboot(s, s.BootTimeBefore.Add(drift), true, now); ok {
t.Errorf("a boot time drifted by %s is not proof of a reboot", drift)
}
}
got, ok := VerifyReboot(s, s.BootTimeBefore.Add(BootTimeTolerance+time.Second), false, now)
if !ok || got.Status != models.PatchSrvSucceeded {
t.Fatalf("a boot time past the tolerance is a reboot: %+v ok=%v", got, ok)
}
}
func TestFinalize(t *testing.T) {
mk := func(statuses ...string) models.PatchRun {
r := windowRun(0)
for i, s := range statuses {
r.Servers = append(r.Servers, srv(string(rune('a'+i)), s))
}
return r
}
cases := []struct {
run models.PatchRun
want string
done bool
}{
{mk(models.PatchSrvSucceeded, models.PatchSrvSucceeded), models.PatchRunSucceeded, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvFailed), models.PatchRunPartial, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvUnsupported), models.PatchRunPartial, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvAgentTooOld), models.PatchRunPartial, true},
{mk(models.PatchSrvMissedOffline, models.PatchSrvWindowClosed), models.PatchRunFailed, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvPatching), "", false},
{mk(), models.PatchRunFailed, true},
}
for i, c := range cases {
got, done := Finalize(c.run)
if got != c.want || done != c.done {
t.Errorf("case %d: got %q/%v, want %q/%v", i, got, done, c.want, c.done)
}
}
cancelled := mk(models.PatchSrvSucceeded, models.PatchSrvCancelled)
cancelled.CancelledAt = tp(t0)
if got, done := Finalize(cancelled); got != models.PatchRunCancelled || !done {
t.Errorf("cancelled: %q/%v", got, done)
}
}
func TestSummary(t *testing.T) {
r := windowRun(0,
srv("a", models.PatchSrvSucceeded), srv("b", models.PatchSrvSucceeded),
srv("c", models.PatchSrvFailed), srv("d", models.PatchSrvMissedOffline))
if got := Summary(r); got != "2 succeeded, 1 failed, 1 missed offline" {
t.Fatalf("got %q", got)
}
}
+55
View File
@@ -0,0 +1,55 @@
// Package patchsched fires patch policies at the start of their maintenance
// window and advances running patch runs. Like workflowsched it runs under the
// housekeeping leader lock and must not import services: services imports
// this package for NextStart and WindowEnd.
package patchsched
import (
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
)
type Decision string
const (
Fire Decision = "fire"
SkipMissed Decision = "missed"
SkipRunning Decision = "already_running"
SkipNoTargets Decision = "no_targets"
)
// Decide is the whole fire or skip rule for one due policy. Missed comes
// first, as in workflowsched: a stale occurrence is recorded as missed
// whatever else is true. A window that has already closed is missed even
// inside the hour of grace, because there is no time left to patch in.
func Decide(due, windowEnd, now time.Time, runActive bool, targets int) Decision {
if !now.Before(windowEnd) || now.Sub(due) > workflowsched.GraceWindow {
return SkipMissed
}
if runActive {
return SkipRunning
}
if targets == 0 {
return SkipNoTargets
}
return Fire
}
func WindowEnd(start time.Time, durationMinutes int) time.Time {
return start.Add(time.Duration(durationMinutes) * time.Minute)
}
// NextStart is the first window start strictly after from. Callers pass
// Later(now, currentWindowEnd) so windows never overlap, including across a
// daylight-saving fall-back where the same wall-clock time occurs twice.
func NextStart(cron, tz string, from time.Time) (time.Time, error) {
return workflowsched.NextOccurrence(cron, tz, from)
}
func Later(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
+66
View File
@@ -0,0 +1,66 @@
package patchsched
import (
"testing"
"time"
)
var due = time.Date(2026, 9, 20, 2, 0, 0, 0, time.UTC)
func TestDecide(t *testing.T) {
end := due.Add(2 * time.Hour)
cases := []struct {
name string
now time.Time
end time.Time
running bool
targets int
want Decision
}{
{"on time", due, end, false, 3, Fire},
{"late within grace", due.Add(59 * time.Minute), end, false, 3, Fire},
{"past grace", due.Add(61 * time.Minute), end, false, 3, SkipMissed},
{"window already over", due.Add(20 * time.Minute), due.Add(15 * time.Minute), false, 3, SkipMissed},
{"missed wins over running", due.Add(2 * time.Hour), end, true, 3, SkipMissed},
{"previous run active", due, end, true, 3, SkipRunning},
{"no targets", due, end, false, 0, SkipNoTargets},
}
for _, c := range cases {
if got := Decide(due, c.end, c.now, c.running, c.targets); got != c.want {
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
}
}
}
// Europe/London falls back on 25 October 2026, so 01:30 happens twice. The
// next window is computed from the end of the current one, so a two-hour
// window starting at the first 01:30 cannot fire again at the second.
func TestNextStartAcrossFallBack(t *testing.T) {
loc, _ := time.LoadLocation("Europe/London")
from := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
first, err := NextStart("30 1 * * 0", "Europe/London", from)
if err != nil {
t.Fatal(err)
}
if d := first.In(loc); d.Day() != 25 || d.Month() != time.October || d.Hour() != 1 || d.Minute() != 30 {
t.Fatalf("first = %s", d)
}
end := WindowEnd(first, 120)
next, err := NextStart("30 1 * * 0", "Europe/London", Later(first.Add(time.Minute), end))
if err != nil {
t.Fatal(err)
}
if d := next.In(loc); d.Day() != 1 || d.Month() != time.November || d.Hour() != 1 || d.Minute() != 30 {
t.Fatalf("next = %s, want 2026-11-01 01:30 London", d)
}
}
func TestWindowEndAndLater(t *testing.T) {
if got := WindowEnd(due, 90); !got.Equal(due.Add(90 * time.Minute)) {
t.Fatalf("WindowEnd = %s", got)
}
a, b := due, due.Add(time.Second)
if !Later(a, b).Equal(b) || !Later(b, a).Equal(b) {
t.Fatal("Later must return the later time")
}
}
+159
View File
@@ -0,0 +1,159 @@
package patchsched
import (
"context"
"errors"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const tickInterval = 30 * time.Second
// Deps are injected from main.go: services imports this package, so this
// package cannot import services.
type Deps struct {
// LookupWindow returns nil, nil when the window no longer exists.
LookupWindow func(instanceID, windowID string) (*models.MaintenanceWindow, error)
CountTargets func(p models.PatchPolicy) (int, error)
StartPolicyRun func(p models.PatchPolicy, windowEnd time.Time) error
AdvanceRuns func(ctx context.Context)
LogEvent func(instanceID, eventType, actor, serverID, keyID, details string)
}
// Start runs until ctx is cancelled, inside bus.RunAsLeader("housekeeping").
// Each tick fires due policies, then advances every running run.
func Start(ctx context.Context, deps Deps) {
go func() {
t := time.NewTicker(tickInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
fireDue(ctx, deps, time.Now())
deps.AdvanceRuns(ctx)
}
}
}()
}
func fireDue(ctx context.Context, deps Deps, now time.Time) {
cur, err := db.Col("patch_policies").Find(ctx, bson.M{"enabled": true, "next_run_at": bson.M{"$lte": now}})
if err != nil {
log.Printf("patchsched: find due: %v", err)
return
}
var due []models.PatchPolicy
if err := cur.All(ctx, &due); err != nil {
log.Printf("patchsched: decode due: %v", err)
return
}
for _, p := range due {
if ctx.Err() != nil {
return
}
process(ctx, deps, p, now)
}
}
func process(ctx context.Context, deps Deps, p models.PatchPolicy, now time.Time) {
if p.NextRunAt == nil {
return
}
due := *p.NextRunAt
w, err := deps.LookupWindow(p.InstanceID, p.WindowID)
if err != nil {
log.Printf("patchsched: policy %s: load window: %v", p.PolicyID, err)
return // a database error is retried next tick, not treated as "gone"
}
if w == nil {
disable(ctx, deps, p, "its maintenance window no longer exists")
return
}
end := WindowEnd(due, w.DurationMinutes)
next, err := NextStart(w.Cron, w.TZ, Later(now, end))
if err != nil {
disable(ctx, deps, p, "its maintenance window schedule is no longer valid: "+err.Error())
return
}
// The claim, as in workflowsched: matching the current next_run_at means a
// second process reaching this policy matches nothing.
res, err := db.Col("patch_policies").UpdateOne(ctx,
bson.M{"policy_id": p.PolicyID, "next_run_at": due},
bson.M{"$set": bson.M{"next_run_at": next}})
if err != nil || res.MatchedCount == 0 {
return
}
n, err := deps.CountTargets(p)
if err != nil {
retryLater(ctx, deps, p, err, due, next, now)
return
}
active, err := hasActiveRun(ctx, p)
if err != nil {
retryLater(ctx, deps, p, err, due, next, now)
return
}
switch d := Decide(due, end, now, active, n); d {
case Fire:
if err := deps.StartPolicyRun(p, end); err != nil {
retryLater(ctx, deps, p, err, due, next, now)
return
}
_, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID},
bson.M{"$set": bson.M{"last_run_at": now}, "$unset": bson.M{"last_skipped": ""}})
default:
recordSkip(ctx, deps, p, string(d), due, now)
}
}
func hasActiveRun(ctx context.Context, p models.PatchPolicy) (bool, error) {
err := db.Col("patch_runs").FindOne(ctx,
bson.M{"instance_id": p.InstanceID, "policy_id": p.PolicyID, "status": models.PatchRunRunning},
options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
if err == nil {
return true, nil
}
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
return false, err
}
// retryLater handles an error after the claim. The claim is put back, guarded
// on the value just written so a concurrent edit to the policy is not undone,
// and the next tick retries the same occurrence. Decide's missed rule bounds
// the retries: once the occurrence is too late it is skipped as missed.
func retryLater(ctx context.Context, deps Deps, p models.PatchPolicy, cause error, due, next, now time.Time) {
if _, err := db.Col("patch_policies").UpdateOne(ctx,
bson.M{"policy_id": p.PolicyID, "next_run_at": next},
bson.M{"$set": bson.M{"next_run_at": due}}); err != nil {
log.Printf("patchsched: policy %s: put back claim: %v", p.PolicyID, err)
}
recordSkip(ctx, deps, p, "error: "+cause.Error(), due, now)
}
func recordSkip(ctx context.Context, deps Deps, p models.PatchPolicy, reason string, due, at time.Time) {
_, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID},
bson.M{"$set": bson.M{"last_skipped": models.Skip{Reason: reason, Due: due, At: at}}})
deps.LogEvent(p.InstanceID, "patch.skipped", "schedule", "", "",
"patch policy "+p.Name+" skipped "+due.Format(time.RFC3339)+": "+reason)
}
func disable(ctx context.Context, deps Deps, p models.PatchPolicy, reason string) {
_, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID}, bson.M{
"$set": bson.M{"enabled": false, "disabled_reason": reason},
"$unset": bson.M{"next_run_at": ""},
})
deps.LogEvent(p.InstanceID, "patch.policy_disabled", "schedule", "", "", "patch policy "+p.Name+" disabled: "+reason)
}
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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 != "" {
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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) {
+1 -1
View File
@@ -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 {

Some files were not shown because too many files have changed in this diff Show More