Compare commits
142
Commits
chart/v1.1.1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb6f47c8ed | ||
|
|
ffc6853142 | ||
|
|
42766968e3 | ||
|
|
cffa7e84b2 | ||
|
|
2701b32b5f | ||
|
|
753ba09e6e | ||
|
|
7c2c5d95e3 | ||
|
|
13e7160405 | ||
|
|
2afdc267f8 | ||
|
|
8908b435a0 | ||
|
|
4158696388 | ||
|
|
3cb8463dc7 | ||
|
|
2017c95a7a | ||
|
|
4068349afe | ||
|
|
f01375470e | ||
|
|
12136d1c17 | ||
|
|
1a1f6998a4 | ||
|
|
42358f57cb | ||
|
|
17ed0192c1 | ||
|
|
839d716a47 | ||
|
|
09888825a8 | ||
|
|
590c369d36 | ||
|
|
64dbad1d20 | ||
|
|
aaad7db09d | ||
|
|
fab9d11c7e | ||
|
|
73da464701 | ||
|
|
d59da68701 | ||
|
|
189bce55ba | ||
|
|
217e2dc5a9 | ||
|
|
934501f4ec | ||
|
|
b1193c59e3 | ||
|
|
069e7e7c61 | ||
|
|
19383abaf8 | ||
|
|
dcdfd3ce52 | ||
|
|
142b99e408 | ||
|
|
14e9db606a | ||
|
|
3e341b17ec | ||
|
|
32fd11cde7 | ||
|
|
26825841fa | ||
|
|
d8597ee3ae | ||
|
|
f87626cf17 | ||
|
|
bd0639acfa | ||
|
|
e2ff0dace9 | ||
|
|
d54d8971b2 | ||
|
|
14a1cdb2b0 | ||
|
|
1445af11ab | ||
|
|
3fa469c303 | ||
|
|
2d75832ceb | ||
|
|
dfcfd1d3e2 | ||
|
|
9c60adc836 | ||
|
|
330c326fb5 | ||
|
|
25541345a8 | ||
|
|
b78a9b3832 | ||
|
|
9aee0a61aa | ||
|
|
0aabb664e2 | ||
|
|
e0468aa7b9 | ||
|
|
fbcf436ef6 | ||
|
|
0e464c4bb8 | ||
|
|
fc10575b08 | ||
|
|
3f2d20868e | ||
|
|
3ecea7c39f | ||
|
|
68c613fd40 | ||
|
|
70c239021d | ||
|
|
3a7618f82f | ||
|
|
7809419202 | ||
|
|
59e7ef63fe | ||
|
|
b5bbf28c63 | ||
|
|
17c9f813fc | ||
|
|
139658864b | ||
|
|
1bb2ba7f2b | ||
|
|
fef886c93b | ||
|
|
3a1614066e | ||
|
|
c0e26d0493 | ||
|
|
b60daf0461 | ||
|
|
8f1ea6d5a0 | ||
|
|
e63e773cda | ||
|
|
48116bf737 | ||
|
|
2b4b630ae0 | ||
|
|
ad0f583d41 | ||
|
|
5ca705c88c | ||
|
|
2d96cb4224 | ||
|
|
d2672fc022 | ||
|
|
ec22696402 | ||
|
|
913d3da386 | ||
|
|
8bd0d790dd | ||
|
|
3e4db573bc | ||
|
|
5925717ca8 | ||
|
|
f3e7357735 | ||
|
|
6ee203f5e9 | ||
|
|
b36a696d0e | ||
|
|
1538ae9bd1 | ||
|
|
5377a1e585 | ||
|
|
2e3d2a33f9 | ||
|
|
52ac966cba | ||
|
|
bfe58c4cd2 | ||
|
|
9d7c4b72aa | ||
|
|
a2351c75dd | ||
|
|
3695bc9e1a | ||
|
|
9bcec168b9 | ||
|
|
d95f299562 | ||
|
|
ac8e957859 | ||
|
|
6a48dd5d73 | ||
|
|
5fcfb40084 | ||
|
|
705085d3c7 | ||
|
|
67520c677b | ||
|
|
bb698eba8a | ||
|
|
3bdbf33f90 | ||
|
|
e06f9d5670 | ||
|
|
dc6e1b3c29 | ||
|
|
cbf929fe2d | ||
|
|
b3651ab58c | ||
|
|
191a8e9074 | ||
|
|
ed79df4270 | ||
|
|
7ec97ae8c2 | ||
|
|
b96cd85e43 | ||
|
|
971bcece44 | ||
|
|
333d729026 | ||
|
|
8dd68e34c1 | ||
|
|
e5b9894384 | ||
|
|
14b947f791 | ||
|
|
98233b620c | ||
|
|
2d6b5bd8a3 | ||
|
|
ac9cc57e7e | ||
|
|
a0641e8ecb | ||
|
|
67ac029354 | ||
|
|
a0b5565a63 | ||
|
|
aedc388535 | ||
|
|
5e4c8afdd1 | ||
|
|
0166b17299 | ||
|
|
674236bb76 | ||
|
|
5943d98681 | ||
|
|
7ea8e2fff0 | ||
|
|
f87986b4f7 | ||
|
|
2481974b3a | ||
|
|
f9df426e6c | ||
|
|
e8e41f197a | ||
|
|
1f2b56ea29 | ||
|
|
8234bdf9f3 | ||
|
|
a2fe478c82 | ||
|
|
a2eee958f0 | ||
|
|
998e1c419d | ||
|
|
d92ca7591f |
@@ -6,7 +6,7 @@ on:
|
||||
# it must come from a tag someone chose, not from whatever landed on main.
|
||||
# No `paths` filter on push, deliberately. A paths filter applies to tag
|
||||
# pushes too, so tagging a commit that happened not to touch the chart
|
||||
# would skip the publish entirely — a release that silently does nothing.
|
||||
# would skip the publish entirely - a release that silently does nothing.
|
||||
# Validation is seconds of helm rendering; running it on every push to main
|
||||
# is cheaper than that failure mode.
|
||||
push:
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
--set server.env.grpcHost=agents.example.com:443 > /dev/null
|
||||
|
||||
# The shape the cloud deployment actually uses: a wildcard tenant
|
||||
# namespace, /api and /auth routed at the edge, and no apex — that
|
||||
# namespace, /api and /auth routed at the edge, and no apex - that
|
||||
# belongs to the marketing site, which this chart does not deploy.
|
||||
- name: Render a wildcard host with edge-routed API paths
|
||||
run: |
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
# Chart.yaml is the source of truth for the version; the tag only
|
||||
# says "publish this one". A mismatch is a mistake worth stopping
|
||||
# for — the alternative is stamping the tag over Chart.yaml, which
|
||||
# for - the alternative is stamping the tag over Chart.yaml, which
|
||||
# leaves the repository disagreeing with what was published.
|
||||
- name: Check the tag matches Chart.yaml
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
# Manual runs rebuild everything: there is no "before" commit to diff
|
||||
# against, which the change detection below treats as "build it all". That
|
||||
# is also the escape hatch for a repo VARIABLE change — editing HQ_URL
|
||||
# is also the escape hatch for a repo VARIABLE change - editing HQ_URL
|
||||
# pushes no commit, so nothing would rebuild on its own.
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
# file makes every filter below match, so there is no second
|
||||
# code path to keep correct.
|
||||
if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
||||
echo "No usable base commit — building every image."
|
||||
echo "No usable base commit - building every image."
|
||||
git ls-files > /tmp/changed.txt
|
||||
else
|
||||
git diff --name-only "$BEFORE" HEAD > /tmp/changed.txt
|
||||
|
||||
@@ -13,6 +13,10 @@ installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
web/test-results
|
||||
web/playwright-report
|
||||
web/blob-report
|
||||
web/playwright/.cache
|
||||
graphify-out
|
||||
docker-compose.live.yml
|
||||
.claude
|
||||
@@ -16,7 +16,7 @@ Services created:
|
||||
- {{ .Release.Name }}-web ({{ .Values.web.service.type }} {{ .Values.web.service.port }})
|
||||
|
||||
Scaling (server.replicaCount / web.replicaCount):
|
||||
- Both scale. Pin the image tags first — replicas on different builds serve
|
||||
- Both scale. Pin the image tags first - replicas on different builds serve
|
||||
mismatched web asset hashes, and mixed server versions share one bus.
|
||||
- server replicas route agent commands, step results and console relays to
|
||||
each other over Redis, so every replica must use the SAME Redis. Workflow
|
||||
@@ -71,7 +71,7 @@ Quick access via port-forward, e.g.:
|
||||
|
||||
No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and
|
||||
SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere
|
||||
but your own configuration — a database restored without it is permanently
|
||||
but your own configuration - a database restored without it is permanently
|
||||
unreadable.
|
||||
|
||||
Set backup.enabled, backup.image and backup.pvcName, and store
|
||||
|
||||
@@ -6,12 +6,12 @@ Browsers reach the web host, and the path decides what answers: /api, /auth,
|
||||
/public, /install* and /update* go to the server, everything else to `web`.
|
||||
|
||||
That split is not optional and ingress.api.enabled defaults to true. `web`
|
||||
proxies nothing — it holds no address for the server at all — so with these
|
||||
proxies nothing - it holds no address for the server at all - so with these
|
||||
paths absent the UI loads and every request it makes 404s against Next. The
|
||||
setting remains a value only so an installation terminating in front of this
|
||||
ingress can route the prefixes itself; it must be routed somewhere.
|
||||
|
||||
The web host is normally a wildcard — `*.vantage.example.com` — because that is
|
||||
The web host is normally a wildcard - `*.vantage.example.com` - because that is
|
||||
the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
|
||||
label. Kubernetes wildcard hosts match exactly one label, so this does not match
|
||||
the apex, and on the deployment this chart was written for it must not: the apex
|
||||
@@ -19,7 +19,7 @@ is the marketing site, a separate application in the vantage-site repository.
|
||||
extraHosts exists for a genuine second name, not for
|
||||
reclaiming the apex.
|
||||
|
||||
Agents reach the server's gRPC port, which is plain h2c — the server holds no
|
||||
Agents reach the server's gRPC port, which is plain h2c - the server holds no
|
||||
certificates of its own, TLS has always been terminated by whatever sits in
|
||||
front. Traefik will not speak h2c to a backend unless told to, and it is told
|
||||
per Service, which is why the gRPC route gets a Service of its own below rather
|
||||
@@ -103,7 +103,7 @@ spec:
|
||||
GRPC_HOST is what an agent is told to dial, and it is baked into every install
|
||||
one-liner. Left pointing at the in-cluster Service while agents are expected to
|
||||
arrive through the ingress, every install would succeed and every agent would
|
||||
fail to connect — with nothing in the control plane saying why.
|
||||
fail to connect - with nothing in the control plane saying why.
|
||||
*/}}
|
||||
{{- $grpcEnv := tpl .Values.server.env.grpcHost . }}
|
||||
{{- if contains (printf "%s-server" .Release.Name) $grpcEnv }}
|
||||
|
||||
@@ -9,7 +9,7 @@ corruption, not a retry.
|
||||
|
||||
A Helm hook Job runs it once, before any pod of the new version starts. The
|
||||
Deployment then sets VANTAGE_SKIP_MIGRATIONS, which is what makes the Job's
|
||||
existence load-bearing rather than decorative — if you disable the Job, the
|
||||
existence load-bearing rather than decorative - if you disable the Job, the
|
||||
pods go back to migrating themselves and you must go back to one replica.
|
||||
|
||||
hook-weight orders this after the dependency waits; before-hook-creation deletes
|
||||
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
# The server stops gRPC before draining HTTP, so that every CommandStream
|
||||
# handler returns and releases its agent's presence claim. A claim left
|
||||
# behind outlives the pod for its 30s TTL, and during that window other
|
||||
# replicas dispatch commands to a process that has exited — surfacing to
|
||||
# replicas dispatch commands to a process that has exited - surfacing to
|
||||
# the operator as "agent offline" on an agent that is perfectly healthy.
|
||||
# 10s for gRPC plus 10s for the HTTP drain, with headroom.
|
||||
terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }}
|
||||
@@ -62,7 +62,7 @@ spec:
|
||||
{{- if or .Values.redis.enabled .Values.mongo.enabled }}
|
||||
# Wait for the dependencies this chart deploys to be reachable,
|
||||
# approximating compose's `depends_on: condition: service_healthy`. An
|
||||
# external Redis or Mongo is assumed to be up already — waiting on one
|
||||
# external Redis or Mongo is assumed to be up already - waiting on one
|
||||
# would only turn someone else's outage into a stuck pod.
|
||||
initContainers:
|
||||
{{- if .Values.redis.enabled }}
|
||||
@@ -128,7 +128,7 @@ spec:
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
{{- if .Values.server.persistence.enabled }}
|
||||
# Nothing in the server writes here any more — workflow logs moved to
|
||||
# Nothing in the server writes here any more - workflow logs moved to
|
||||
# MongoDB so that every replica can read and write them. The mount
|
||||
# remains only so an operator upgrading from a file-log release can
|
||||
# still reach the old files before turning persistence off.
|
||||
|
||||
@@ -39,7 +39,7 @@ spec:
|
||||
ports:
|
||||
- containerPort: {{ .Values.web.service.port }}
|
||||
# /healthz is served by this Next process. /api never reaches this
|
||||
# pod at all — the ingress routes it to the server — so there is no
|
||||
# pod at all - the ingress routes it to the server - so there is no
|
||||
# backend address to configure and no probe here that could report
|
||||
# the backend's health by accident.
|
||||
startupProbe:
|
||||
|
||||
@@ -115,7 +115,7 @@ imagePullSecrets: []
|
||||
# Scheduled backups.
|
||||
#
|
||||
# Off by default, deliberately. A backup with nowhere durable to land is a
|
||||
# false sense of safety, and the chart cannot know where that is — pvcName
|
||||
# false sense of safety, and the chart cannot know where that is - pvcName
|
||||
# must name a volume you have decided will outlive the cluster.
|
||||
#
|
||||
# There is no restore manifest here on purpose: a restore is an operator
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Vantage self-hosted — copy to .env and fill in.
|
||||
# Vantage self-hosted - copy to .env and fill in.
|
||||
# Used by: docker compose up -d
|
||||
|
||||
# --- Required ---
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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;
|
||||
}
|
||||
|
||||
# Heartbeat ping URLs carry a credential. Log them with the token replaced;
|
||||
# the header form (X-Vantage-Token) is never logged by this format.
|
||||
map $request_uri $vantage_log_uri {
|
||||
"~^/public/hb/(?!start(?:[/?]|$)|fail(?:[/?]|$))[^/?]+(?<hb_rest>.*)$" "/public/hb/***$hb_rest";
|
||||
default $request_uri;
|
||||
}
|
||||
|
||||
log_format vantage '$remote_addr - $remote_user [$time_local] '
|
||||
'"$request_method $vantage_log_uri $server_protocol" '
|
||||
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
|
||||
|
||||
upstream vantage_server {
|
||||
server server:8080;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream vantage_web {
|
||||
server web:3000;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
access_log /var/log/nginx/access.log vantage;
|
||||
|
||||
# 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 1–5 are committed: the `mcp` scope resource exists, and `api_tokens.tag_selector` is modelled, accepted at creation (`POST /api/tokens`) and enforced at the server-resolution chokepoints. Tasks 6–15 are not started.
|
||||
|
||||
**That plan's Task 12 rewrites the same file this plan rewrites**, and its file list is stale - it names `web/app/(app)/settings/`, but the page moved to `web/app/(app)/tokens/` with its body in `web/components/apikeys/ApiKeysPanel.tsx`. Two plans editing one 500-line component from opposite ends is a guaranteed conflict.
|
||||
|
||||
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 1–5 depends on MCP.
|
||||
|
||||
> **Implemented 8 September 2026, all eight tasks, on `feat/mcp-server`.** MCP
|
||||
> Tasks 6–9 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 2–5 from MCP Task 12 and leave a pointer to this plan**, so the next worker through does not build the tag picker twice.
|
||||
|
||||
---
|
||||
|
||||
### 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;
|
||||
/** 0–100, 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 0–100, and guard the zero-length span (`created_at === expires_at`) so it cannot divide by zero.
|
||||
- A token with no `expires_at` is `eternal`, drawn full-width and grey. It is not `healthy` - "runs forever" is the state the posture strip counts as a risk.
|
||||
- `outsidePolicy` keeps the existing rule verbatim: with a cap set, a token that never expires, or that expires further out than the cap allows, is outside it. **The cap is not applied retroactively** - this is a prompt to rotate, never an error, and the copy must not imply the key has stopped working.
|
||||
- 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 3–5, which are already committed on `feat/mcp-server`.** Skip this task entirely on a branch that does not have them; `tag_selector` will be rejected by a server without them.
|
||||
|
||||
**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 6–11 (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 2–5 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
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 1–3 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.
|
||||
@@ -0,0 +1,342 @@
|
||||
# MFA for local sign-in (TOTP and passkeys) - design
|
||||
|
||||
Date: 2026-09-15
|
||||
Gap review entry: `#r-mfa` ("Build next", effort S - revised upward, see Scope)
|
||||
|
||||
## Problem
|
||||
|
||||
Local and HQ-projected members sign in with an email address and a password and
|
||||
nothing else. MFA exists only through SSO, which is a paid feature. One phished
|
||||
password on a Free instance gives an attacker root script execution across the
|
||||
fleet, root consoles and decrypted private keys. MFA is a hard requirement on
|
||||
most security questionnaires and must ship on every tier, with no licence gate.
|
||||
|
||||
## Scope
|
||||
|
||||
In:
|
||||
|
||||
- TOTP as a second factor, with 10 single-use recovery codes.
|
||||
- WebAuthn passkeys, both as a second factor after a password and as
|
||||
passwordless sign-in (discoverable credentials, user verification preferred).
|
||||
- An owner setting, `require_mfa`, that forces enrolment for password users.
|
||||
- Owner/admin reset of another member's MFA.
|
||||
- Step-up re-authentication before three sensitive actions: secret reveal,
|
||||
private key download, console connect.
|
||||
- A per-IP rate limit on every unauthenticated sign-in endpoint.
|
||||
|
||||
Out:
|
||||
|
||||
- MFA owned by Vantage HQ and projected to instances. HQ users enrol per
|
||||
instance, like local users.
|
||||
- Step-up for API tokens (see Known limitations).
|
||||
- Forcing OIDC users through their IdP again (`prompt=login`) for step-up.
|
||||
- A grace period for `require_mfa`.
|
||||
- SMS or email codes.
|
||||
|
||||
## Who MFA applies to
|
||||
|
||||
| `auth_source` | Can enrol | Covered by `require_mfa` | Step-up factor |
|
||||
| ------------- | --------- | ------------------------ | --------------------------- |
|
||||
| `local` | yes | yes | MFA if enrolled, else password |
|
||||
| `hq` | yes | yes | MFA if enrolled, else password |
|
||||
| `oidc` | no | no | none - passes through |
|
||||
|
||||
OIDC users are exempt because their IdP owns authentication; their session
|
||||
carries `amr: ["oidc"]`.
|
||||
|
||||
## Approach: pending-login ticket
|
||||
|
||||
A password that checks out no longer mints a session when the user has MFA or
|
||||
must enrol. It mints a **pending-login ticket** instead: a Redis key referenced
|
||||
by a separate short-lived cookie. Only the MFA endpoints accept the ticket, and
|
||||
only a completed second factor (or completed enrolment) exchanges it for a
|
||||
`km_session`.
|
||||
|
||||
The reason for this shape over "a session with an `mfa_pending` flag" is that a
|
||||
half-authenticated user never becomes a `*Session` at all, so no route mounted
|
||||
under `auth.Middleware` - today's or a future one - can serve them by
|
||||
forgetting a check. The flag design fails open; this one fails closed.
|
||||
|
||||
## Data model
|
||||
|
||||
### `user_mfa` (control plane, new)
|
||||
|
||||
One document per user who has started enrolment.
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ------------------- | ----------- | ----- |
|
||||
| `instance_id` | string | tenant scope |
|
||||
| `user_id` | string | unique index `{instance_id, user_id}` |
|
||||
| `webauthn_handle` | binary(64) | random, never the `user_id` (WebAuthn user handle) |
|
||||
| `totp_secret_enc` | string | AES-256-GCM via `services.encryptString`; empty when TOTP is off |
|
||||
| `totp_confirmed_at` | *time | nil means setup started but not confirmed - TOTP not active |
|
||||
| `recovery_codes` | []{hash, used_at} | 10 codes, SHA-256 of the normalised code |
|
||||
| `updated_at` | time | |
|
||||
|
||||
A user "has MFA" when `totp_confirmed_at` is set or they own at least one
|
||||
passkey. A pending unconfirmed TOTP secret is replaced on the next setup call.
|
||||
|
||||
### `webauthn_credentials` (control plane, new)
|
||||
|
||||
One document per passkey.
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --------------- | -------- | ----- |
|
||||
| `instance_id` | string | |
|
||||
| `user_id` | string | index `{instance_id, user_id}` |
|
||||
| `credential_id` | binary | unique index `{instance_id, credential_id}` |
|
||||
| `public_key` | binary | COSE key; not secret |
|
||||
| `sign_count` | uint32 | clone detection: a non-increasing non-zero count fails the assertion |
|
||||
| `aaguid` | binary | |
|
||||
| `transports` | []string | |
|
||||
| `name` | string | user-editable, default from AAGUID or "Passkey" |
|
||||
| `created_at`, `last_used_at` | time | |
|
||||
|
||||
Both collections are added to `ScopedCollections`, with a test asserting it,
|
||||
so an instance purge removes them. Their index builder is fatal on failure,
|
||||
like `EnsureAuthIndexes` - the unique indexes are a security property.
|
||||
|
||||
### `vantage-shared` changes (one release, then bump `server`'s pin)
|
||||
|
||||
- `models.Settings.RequireMFA bool` (`bson:"require_mfa"`). A plain bool is
|
||||
correct here: absent must mean off.
|
||||
- `backup.ciphertextFields["user_mfa"] = []string{"totp_secret_enc"}`. Without
|
||||
it `vantagectl verify` silently skips these secrets.
|
||||
|
||||
### Redis keys
|
||||
|
||||
| Key | TTL | Holds |
|
||||
| --------------------------- | ----- | ----- |
|
||||
| `km:mfa:<id>` | 5 min | pending ticket: `user_id`, `instance_id`, `methods`, `enrol_only`, `attempts` |
|
||||
| `km:wa:<id>` | 5 min | WebAuthn session data (challenge) for one ceremony |
|
||||
| `km:totp:<user_id>:<step>` | 90 s | `SET NX` replay guard: a TOTP code works once |
|
||||
| `km:rl:auth:<ip>` | 1 min | fixed-window counter for the sign-in rate limit |
|
||||
|
||||
The pending ticket cookie is `km_mfa_pending`: HttpOnly, SameSite=Lax, Secure
|
||||
under the same rule as `SetSessionCookie`, Path `/`, MaxAge 300. It is cleared
|
||||
when exchanged or when the ticket is deleted.
|
||||
|
||||
### `Session` additions
|
||||
|
||||
- `AMR []string` - how the session was authenticated: `pwd`, `otp`, `webauthn`,
|
||||
`recovery`, `oidc`. An HQ user's password sign-in is `pwd` like a local one.
|
||||
- `StepUpAt *time.Time` - last successful step-up. Sign-in itself counts as a
|
||||
step-up, so `StepUpAt` is set at session creation.
|
||||
|
||||
Updating `StepUpAt` rewrites the Redis value under the same session ID, keeping
|
||||
the remaining TTL (`KEEPTTL`).
|
||||
|
||||
## Sign-in flows
|
||||
|
||||
### Password, then second factor
|
||||
|
||||
1. `POST /auth/login` runs unchanged up to `VerifyPassword`.
|
||||
2. Then:
|
||||
- user is `oidc`: unreachable (no local password).
|
||||
- user has MFA: create a ticket with `methods` (`totp`, `webauthn`,
|
||||
`recovery` as applicable), set `km_mfa_pending`, answer
|
||||
`200 {mfa_required: true, methods: [...]}`.
|
||||
- user has no MFA and `require_mfa` is on: create a ticket with
|
||||
`enrol_only: true`, answer `200 {enrol_required: true}`.
|
||||
- otherwise: mint the session as today, `amr: ["pwd"]`.
|
||||
3. The client calls one of:
|
||||
- `POST /auth/mfa/totp {code}`
|
||||
- `POST /auth/mfa/recovery {code}`
|
||||
- `POST /auth/mfa/webauthn/begin` then `POST /auth/mfa/webauthn/finish`
|
||||
4. On success the ticket is deleted, the session is minted with
|
||||
`amr: ["pwd", <factor>]`, `TouchLastLogin` runs, and the response matches
|
||||
today's `{ok: true}`.
|
||||
|
||||
Each failure increments `attempts`; the fifth deletes the ticket.
|
||||
|
||||
### Forced enrolment
|
||||
|
||||
An `enrol_only` ticket is accepted only by:
|
||||
|
||||
- `POST /auth/mfa/enrol/totp/setup` → `{secret, otpauth_uri}`
|
||||
- `POST /auth/mfa/enrol/totp/confirm {code}` → `{recovery_codes}` and the session
|
||||
- `POST /auth/mfa/enrol/passkey/begin|finish` → `{recovery_codes}` and the session
|
||||
|
||||
It is refused by the plain `/auth/mfa/*` verify endpoints and vice versa, so a
|
||||
user cannot skip enrolment by presenting the ticket elsewhere.
|
||||
|
||||
Existing sessions are not revoked when `require_mfa` is switched on; they end
|
||||
at their 24h TTL and the next sign-in enforces enrolment.
|
||||
|
||||
### Passwordless passkey
|
||||
|
||||
1. `POST /auth/passkey/begin` - resolves the instance with
|
||||
`resolveLoginInstance`, applies the locked-instance and
|
||||
`LocalLoginPermitted` checks exactly as `/auth/login` does, and returns
|
||||
assertion options with an empty `allowCredentials` and
|
||||
`userVerification: "required"`.
|
||||
2. `POST /auth/passkey/finish` - looks the credential up by
|
||||
`{instance_id, credential_id}`, verifies the assertion including the UV flag,
|
||||
and mints the session with `amr: ["webauthn"]`.
|
||||
|
||||
User verification is preferred, not required. Requiring it made password
|
||||
managers such as NordPass prompt for their master password on every use, so it
|
||||
was relaxed on 2026-09-16 at the product owner's decision. A passkey used
|
||||
without verification is possession-only; passwordless sign-in and step-up then
|
||||
rest on the device or vault being unlocked, and still satisfy `require_mfa`.
|
||||
|
||||
The discoverable lookup is scoped by `instance_id` from the host, never by the
|
||||
credential alone - the same rule that makes `users` lookups instance-scoped.
|
||||
|
||||
### WebAuthn relying party
|
||||
|
||||
- RP ID: the request host with any port removed. On cloud that is the
|
||||
instance subdomain; self-hosted, the install's own host. The reverse proxy
|
||||
must preserve `Host`, which the instance host guard already requires.
|
||||
- Expected origin: `https://<host>` (or `http://` only when the request itself
|
||||
is plain HTTP on `localhost`, for development).
|
||||
- A passkey is bound to its host. Moving an instance to a new address
|
||||
invalidates its passkeys; TOTP and recovery codes still work. The docs say so.
|
||||
|
||||
## Step-up
|
||||
|
||||
`auth.RequireStepUp()` is new middleware, mounted after `auth.Middleware` on:
|
||||
|
||||
- `POST /api/secrets/:group/reveal`
|
||||
- `GET /api/keys/:id/private-key`
|
||||
- `POST /api/console/connect`
|
||||
- the MFA-management endpoints marked "step-up" below
|
||||
|
||||
It passes when any of these holds:
|
||||
|
||||
- the request authenticated with an API token (`TokenID != ""`);
|
||||
- `AMR` contains `oidc`;
|
||||
- `StepUpAt` is within the last 10 minutes.
|
||||
|
||||
Otherwise it answers `403 {error: "re-authentication required", code:
|
||||
"step_up_required", methods: [...]}`, where `methods` is the user's MFA factors,
|
||||
or `["password"]` when they have none.
|
||||
|
||||
`POST /api/me/step-up` accepts `{totp}`, `{recovery}` or `{password}` (password
|
||||
only for a user with no MFA), and `POST /api/me/step-up/webauthn/begin|finish`
|
||||
does the same with a passkey. Success sets `StepUpAt`. Failures are rate
|
||||
limited by the same per-IP limiter and audited.
|
||||
|
||||
## Account endpoints (session-authenticated, every role)
|
||||
|
||||
| Method and path | Step-up | Notes |
|
||||
| --------------------------------------- | ------- | ----- |
|
||||
| `GET /api/me/mfa` | no | TOTP on/off, passkeys, recovery codes remaining, `require_mfa` |
|
||||
| `POST /api/me/mfa/totp/setup` | yes | returns secret and `otpauth://` URI; replaces an unconfirmed one |
|
||||
| `POST /api/me/mfa/totp/confirm` | no | code must verify; returns recovery codes when none exist yet |
|
||||
| `DELETE /api/me/mfa/totp` | yes | refused if last factor and `require_mfa` |
|
||||
| `POST /api/me/mfa/recovery/regenerate` | yes | invalidates the old set, returns 10 new codes once |
|
||||
| `POST /api/me/passkeys/begin`, `/finish`| yes | registers a discoverable credential, UV required |
|
||||
| `PATCH /api/me/passkeys/:id` | no | rename |
|
||||
| `DELETE /api/me/passkeys/:id` | yes | refused if last factor and `require_mfa` |
|
||||
| `DELETE /api/org/users/:id/mfa` | yes | owner or admin; an admin cannot reset an owner; clears TOTP, passkeys, recovery codes |
|
||||
|
||||
The `/me/*` endpoints refuse OIDC users with 409 `mfa_not_applicable`.
|
||||
The first factor enrolled from `/account/security` issues recovery codes;
|
||||
later factors do not regenerate them.
|
||||
|
||||
`require_mfa` is set through the existing `PUT /api/settings`, owner only for
|
||||
that field (admins may save other settings).
|
||||
|
||||
Every handler carries swag annotations; `openapi.json` is regenerated.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
There is no rate limit on `/auth/login` today. A new `RateLimitAuth()` fixed
|
||||
window, on the `RateLimitTokens` pattern, applies to `/auth/login`,
|
||||
`/auth/mfa/*`, `/auth/passkey/*` and `/api/me/step-up*`: 20 requests per minute
|
||||
per `c.ClientIP()`, answering 429 with `Retry-After`. The ticket's 5-attempt
|
||||
cap still bounds guesses per ticket; the IP limit bounds tickets per attacker.
|
||||
|
||||
## Audit events
|
||||
|
||||
`mfa.enrolled` (factor), `mfa.removed` (factor), `mfa.reset` (target user),
|
||||
`mfa.recovery_used`, `mfa.recovery_regenerated`, `mfa.failed` (factor, at
|
||||
sign-in), `step_up.ok`, `step_up.failed`, `settings.require_mfa` (on/off).
|
||||
Details never contain codes, secrets or credential IDs.
|
||||
|
||||
## UI (`web/`)
|
||||
|
||||
- **`/login`**
|
||||
- "Sign in with passkey" button when `window.PublicKeyCredential` exists and
|
||||
local login is enabled.
|
||||
- After the password: a second-factor step with a 6-digit code input,
|
||||
"Use passkey", and "Use a recovery code".
|
||||
- `enrol_required`: an inline wizard - choose TOTP (QR code, secret as text,
|
||||
confirm code) or passkey, then recovery codes shown once with copy and an
|
||||
"I have saved these" checkbox before continuing.
|
||||
- `mfa_ticket_expired` returns the form to the password step with a message.
|
||||
- **`/account/security`** - new page, every role, linked from the sidebar user
|
||||
menu. TOTP status and setup/remove, passkey list with rename and remove,
|
||||
recovery codes remaining and regenerate. Hidden content with an explanation
|
||||
for OIDC users.
|
||||
- **`/settings`** - owner-only "Require MFA for password sign-in" toggle; the
|
||||
users table gains an MFA column and a "Reset MFA" action.
|
||||
- **`StepUpModal`** - `request()` in `web/lib/api.ts` catches 403 with
|
||||
`code: "step_up_required"`, opens the modal with the offered methods, and on
|
||||
success retries the original request once. Callers of the three sensitive
|
||||
actions need no changes.
|
||||
- QR codes are drawn client-side (the `qrcode` npm package); nothing is fetched
|
||||
from outside, so air-gapped installs work.
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Answer |
|
||||
| ---------------------------------- | ------ |
|
||||
| missing or expired ticket | 401 `mfa_ticket_expired` |
|
||||
| wrong code | 401 `invalid_code`, `attempts_left` |
|
||||
| fifth wrong code | ticket deleted, 401 `mfa_ticket_expired` |
|
||||
| replayed TOTP code | 401 `invalid_code` |
|
||||
| WebAuthn verification failure | 401 `invalid_assertion` |
|
||||
| origin or RP ID mismatch | 400 `origin_mismatch` |
|
||||
| removing last factor under policy | 409 `mfa_required_by_policy` |
|
||||
| rate limited | 429 with `Retry-After` |
|
||||
| Redis unavailable | 503; sign-in already depends on Redis |
|
||||
|
||||
TOTP: SHA-1, 6 digits, 30 s, ±1 step, issuer = instance name, account = email.
|
||||
|
||||
## Libraries
|
||||
|
||||
- `github.com/pquerna/otp` - TOTP generation and validation.
|
||||
- `github.com/go-webauthn/webauthn` - WebAuthn ceremonies.
|
||||
- `qrcode` (npm) - QR rendering in the browser.
|
||||
|
||||
## Testing
|
||||
|
||||
Go unit tests:
|
||||
|
||||
- TOTP verify, skew window, replay guard.
|
||||
- Recovery codes: normalisation, single use, regenerate invalidates the old set.
|
||||
- Ticket state machine: TTL, attempt cap, `enrol_only` scope in both directions.
|
||||
- Login branching: no MFA, MFA, `require_mfa` without MFA, OIDC-exempt.
|
||||
- `RequireStepUp`: inside and outside the window, token exemption, OIDC exemption.
|
||||
- Last-factor refusal under `require_mfa`; admin cannot reset an owner.
|
||||
- Passwordless passkey honours locked instances and `LocalLoginPermitted`.
|
||||
- `user_mfa` and `webauthn_credentials` present in `ScopedCollections`.
|
||||
- WebAuthn ceremonies against a software authenticator fixture.
|
||||
|
||||
Playwright:
|
||||
|
||||
- TOTP enrol and sign-in, with codes generated in the test.
|
||||
- Passkey enrol, second-factor sign-in, passwordless sign-in and step-up using
|
||||
Chrome's virtual authenticator (`WebAuthn.addVirtualAuthenticator` over CDP).
|
||||
- Forced enrolment when `require_mfa` is on.
|
||||
- Step-up modal on secret reveal, then no prompt within 10 minutes.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **API tokens bypass step-up.** A token with `secrets:read` or `keys:read` can
|
||||
reveal secrets and download private keys without re-authentication. Tokens
|
||||
have no human present to prompt. Scoped, short-lived tokens are the mitigation
|
||||
and are tracked separately in the gap review.
|
||||
- OIDC users are never prompted for step-up; their IdP's session policy governs.
|
||||
- HQ users on several instances enrol once per instance.
|
||||
- Passkeys stop working if an instance changes host.
|
||||
|
||||
## Documentation
|
||||
|
||||
- `CLAUDE.md`: a "Multi-factor authentication" subsystem section covering the
|
||||
ticket design, step-up, the shared-module changes and `ciphertextFields`.
|
||||
- `vantage-docs`: enrolling, recovery codes, owner reset, `require_mfa`,
|
||||
passkeys bound to the host.
|
||||
- Gap review: mark `#r-mfa` shipped, and the MFA row in the comparison table.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Metric alerts and heartbeat monitors
|
||||
|
||||
Date: 2026-09-17
|
||||
Source: competitive gap review, "Alerts on agent metrics" (effort M) and "Heartbeat (push) monitors" (effort S).
|
||||
|
||||
## Goal
|
||||
|
||||
1. Alert on data agents already report (disk, memory, load, failed units, unhealthy containers, reboot pending, agent offline), targeted by tag so one rule covers the fleet.
|
||||
2. Alert when a push-based job (backup, cron) stops pinging, reports failure, or starts and never finishes.
|
||||
|
||||
Both reuse the existing monitor model, states, incidents, notification channels, groups and status pages.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Scheduled workflows pinging a heartbeat automatically.
|
||||
- Metric history graphs (separate gap review item).
|
||||
- Agent-side evaluation. No agent release is required.
|
||||
|
||||
## Data model
|
||||
|
||||
New constants in `models/monitor.go`: `MonitorMetric = "metric"`, `MonitorHeartbeat = "heartbeat"`.
|
||||
|
||||
`MonitorTarget` gains:
|
||||
|
||||
| Field | Type | Used by | Meaning |
|
||||
|---|---|---|---|
|
||||
| `Selector` | `map[string]string` | metric | Server tag selector. Empty means the whole org fleet. |
|
||||
| `Metric` | `string` | metric | One of the metric kinds below. |
|
||||
| `Threshold` | `float64` | metric | Breach threshold, unit depends on kind. |
|
||||
| `Mount` | `string` | metric (disk kinds) | Mountpoint. Empty means any mount breaches. |
|
||||
| `PeriodSec` | `int` | heartbeat | Expected time between pings. |
|
||||
| `GraceSec` | `int` | heartbeat | Extra time allowed before overdue, and the max run time after `/start`. |
|
||||
|
||||
`Monitor` gains `ForSec int` (metric): the condition must hold continuously this long before a server goes down. 0 means on first evaluation.
|
||||
|
||||
`Monitor` gains `HeartbeatTokenHash string` (`json:"-"`), SHA-256 of the ping token. The plaintext token is returned only on create and on rotate.
|
||||
|
||||
`MonitorState` gains, for heartbeats: `LastPingAt *time.Time`, `StartedAt *time.Time`.
|
||||
|
||||
`Incident` gains `ServerID string` (`omitempty`). Empty for every non-metric monitor.
|
||||
|
||||
`Inventory` gains `RebootRequiredSince *time.Time`, set by `StoreInventory` when `reboot_required` turns true and unset when it turns false.
|
||||
|
||||
New collection `monitor_server_states`, one document per (metric monitor, matching server):
|
||||
|
||||
```go
|
||||
type MonitorServerState struct {
|
||||
InstanceID string // org
|
||||
MonitorID string
|
||||
ServerID string
|
||||
Status string // up | down | pending
|
||||
BreachSince *time.Time // first evaluation where the condition held; nil when clear
|
||||
Value float64 // last evaluated value, for display
|
||||
Message string // e.g. "/var 94.2% used"
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Unique index on `(monitor_id, server_id)`.
|
||||
|
||||
### Metric kinds
|
||||
|
||||
| Kind | Source | Breach when | Threshold unit |
|
||||
|---|---|---|---|
|
||||
| `disk_pct` | `inventory.partitions` | used/total*100 >= threshold on `Mount` (or any mount) | percent |
|
||||
| `disk_free_gb` | `inventory.partitions` | (total-used)/1e9 <= threshold | GB |
|
||||
| `mem_pct` | `inventory.memory` | used/total*100 >= threshold | percent |
|
||||
| `load_per_core` | `inventory.cpu.load1 / cores` | >= threshold | ratio |
|
||||
| `unit_failed` | workloads, kind `unit` | any unit state `failed` (threshold unused) | - |
|
||||
| `container_unhealthy` | workloads, kind `container` | any health `unhealthy` (threshold unused) | - |
|
||||
| `reboot_pending_days` | `inventory.reboot_required_since` | now - since >= threshold days | days |
|
||||
| `agent_offline_min` | `server.last_seen` | now - last_seen >= threshold minutes | minutes |
|
||||
|
||||
Validation on create/update: `Metric` is a known kind; `Threshold` > 0 for kinds that use it; `disk_pct` and `mem_pct` threshold <= 100; `Mount`, if set, is an absolute path; `Selector` keys and values follow existing tag rules. `IntervalSec`, `Runner` and `Retries` are ignored for metric and heartbeat monitors and stored as `runner = "server"`, so `ListServerScheduledMonitors` must exclude these two types from the pull scheduler.
|
||||
|
||||
Heartbeat validation: `PeriodSec` >= 60, `GraceSec` >= 0 (default 300 when unset).
|
||||
|
||||
## Shared transition logic
|
||||
|
||||
Extract from `ingestResult` in `services/monitors.go`:
|
||||
|
||||
```go
|
||||
func applyTransition(ctx context.Context, m *models.Monitor, serverID, prev, next, message string, now time.Time)
|
||||
```
|
||||
|
||||
It opens an incident (with `ServerID`) on a change to down, resolves the matching open incident (same `monitor_id` and `server_id`) on down to up, and calls `notifyTransition`. `ingestResult` calls it with `serverID = ""`. Behaviour of existing monitor types does not change.
|
||||
|
||||
`notify.Event` gains `ServerName string`. Channel formatters include it when set ("disk on web-01: /var 94.2% used").
|
||||
|
||||
## Metric evaluation
|
||||
|
||||
New package `server/internal/metricsched`, started from `main.go` beside `monitorsched`. Like monitors, it runs regardless of licence state.
|
||||
|
||||
Every 30 seconds, for each enabled metric monitor:
|
||||
|
||||
1. Resolve servers with `ListServersFiltered(instanceID, selector)`.
|
||||
2. Load existing `monitor_server_states` for the monitor.
|
||||
3. For each server:
|
||||
- For kinds other than `agent_offline_min`: skip (keep previous state) when `inventory.metrics_at` is older than 5 minutes or missing. A dead agent must not flap other alerts; `agent_offline_min` covers it.
|
||||
- Evaluate the kind to get `(breach bool, value float64, message string)`.
|
||||
- Breach: set `BreachSince` if nil. If `now - BreachSince >= ForSec`, next = down, otherwise next = pending when previously up or new.
|
||||
- No breach: `BreachSince = nil`, next = up.
|
||||
- Upsert the state doc. On a status change call `applyTransition` with the server ID. A new server's first state is never treated as a transition from down.
|
||||
4. State docs for servers no longer matched (tag removed, server deleted): resolve any open incident without notifying, and delete the doc.
|
||||
5. Roll up the parent `Monitor.State`: status = down if any child down, else pending if any pending, else up (no matching servers means up). Message is "N of M servers breaching". `last_check_at = now`. Write one `MonitorSample` (up = no child down, latency 0) and increment the hourly rollup, so uptime graphs and status pages work unchanged.
|
||||
|
||||
Deleting a metric monitor deletes its `monitor_server_states`.
|
||||
|
||||
Evaluators are pure functions `func(kind string, t models.MonitorTarget, srv models.Server, wls []models.Workload, now time.Time) (bool, float64, string)` so they can be table-tested without Mongo. Workloads are loaded only for monitors whose kind needs them.
|
||||
|
||||
## Heartbeats
|
||||
|
||||
### Public endpoints
|
||||
|
||||
Registered outside `/api`, unauthenticated, no scope declarations needed:
|
||||
|
||||
- `GET|POST /public/hb/:token`: success ping
|
||||
- `GET|POST /public/hb/:token/start`: run started
|
||||
- `GET|POST /public/hb/:token/fail`: run failed
|
||||
|
||||
Mounted under /public because every deployment already routes that prefix to the server. The token may instead be sent in the `X-Vantage-Token` header to `/public/hb`, `/public/hb/start` or `/public/hb/fail`; a URL token wins when both are present. The server's request log and the bundled nginx access log mask the URL token.
|
||||
|
||||
Lookup is by SHA-256 of the token. Unknown token or disabled monitor gives 404. Rate limit is one accepted request per second per token (in-process, Redis-backed if a limiter helper already exists); excess gives 429. The response body is `OK`. Request bodies over 1 KB are truncated; only `/fail` uses the body.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **ping**: `LastPingAt = now`. If `StartedAt` is set, duration = now - StartedAt, write a sample with `LatencyMs = duration`, then clear `StartedAt`. Otherwise write a sample with latency 0. Next status is up; call `applyTransition` if changed.
|
||||
- **start**: `StartedAt = now`. No status change.
|
||||
- **fail**: clear `StartedAt`, write a down sample, next status is down immediately with message `reported failure: <body>` (body defaults to empty). The failure is also recorded as `LastPingAt` so the overdue timer restarts from it.
|
||||
|
||||
All state writes go through one conditional update per request, so concurrent pings don't lose updates.
|
||||
|
||||
### Overdue sweep
|
||||
|
||||
The same 30-second `metricsched` loop checks enabled heartbeat monitors:
|
||||
|
||||
- New monitor with no ping yet: pending, never down. The overdue clock starts at the first ping.
|
||||
- `now > LastPingAt + PeriodSec + GraceSec`: down, message `no ping since <time>`.
|
||||
- `StartedAt != nil && now > StartedAt + GraceSec`: down, message `started <time>, never finished`.
|
||||
|
||||
Recovery happens only through a ping.
|
||||
|
||||
### Token management
|
||||
|
||||
- Create returns `heartbeat_token` and `heartbeat_url` once.
|
||||
- `POST /api/monitors/:id/rotate-token` issues a new token and invalidates the old one. It needs `routeScopes` (`monitors:write`) and `serverScopedRoutes` entries.
|
||||
- The UI shows the URL only right after create or rotate, with a copy button and curl examples for ping, start and fail.
|
||||
|
||||
## API
|
||||
|
||||
- Existing create/update monitor endpoints accept the new types and fields. OpenAPI annotations are updated.
|
||||
- `GET /api/monitors/:id/servers` returns `monitor_server_states` for a metric monitor, with hostnames. It needs `routeScopes` (`monitors:read`) and `serverScopedRoutes` entries, and filters rows to servers visible to the acting token.
|
||||
- A tag-scoped token may create or update a metric monitor only when its selector includes every key/value of the token's scope. Otherwise the response is 403.
|
||||
- The MCP `create_monitor` tool accepts the new types.
|
||||
|
||||
## Web UI
|
||||
|
||||
- Monitor form: a type picker adds Metric and Heartbeat. Metric shows a selector editor (reuse the tag selector component), a kind dropdown, a threshold with a unit label, mount (disk kinds only) and "for N minutes". Heartbeat shows period and grace.
|
||||
- Monitors list: metric rows show "N of M servers breaching". Heartbeat rows show last ping as relative time.
|
||||
- Metric detail page: a per-server table with server, status, value, since and message, linking to the server page. The incident list shows the server name.
|
||||
- Heartbeat detail page: URL panel (after create or rotate), rotate button with confirm, last ping, and duration in the latency chart.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Evaluator panics or bad inventory data: recover per monitor, log, and leave states unchanged.
|
||||
- Mongo errors in the sweep: log and continue to the next monitor. The next tick retries.
|
||||
- Notification failures are logged only, as today.
|
||||
|
||||
## Testing
|
||||
|
||||
- Table tests for every evaluator: breach, clear, mount filter, missing inventory, zero totals.
|
||||
- Sweep tests (Mongo test helper): the for-duration gate, pending to down, stale inventory skipped, a server leaving the selector resolves its incident silently, parent rollup counts.
|
||||
- Heartbeat handler tests: ping/start/fail transitions, duration sample, unknown token 404, rate limit 429, fail body truncation.
|
||||
- Overdue tests: never pinged stays pending, overdue goes down, start without finish goes down, ping recovers.
|
||||
- `applyTransition` regression: existing http monitor incident open/resolve unchanged.
|
||||
- `go test ./internal/api/` including `TestRegisteredRoutesPassBootAssertions`.
|
||||
- Playwright: create a heartbeat, curl a ping, see up.
|
||||
|
||||
## Phases
|
||||
|
||||
1. Shared `applyTransition` refactor, heartbeat type, public endpoints, overdue sweep, rotate route, UI. Ships alone.
|
||||
2. Metric type, `RebootRequiredSince`, `monitor_server_states`, evaluators, sweep, servers route, UI.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update the Monitors section of `CLAUDE.md` and add a user guide page in vantage-docs for both types.
|
||||
+1
-1
@@ -25,7 +25,7 @@ RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
|
||||
# itself — scratch has no shell — and os.MkdirTemp fails outright without it.
|
||||
# itself - scratch has no shell - and os.MkdirTemp fails outright without it.
|
||||
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
|
||||
|
||||
# Runtime stage
|
||||
|
||||
+82
-7
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -22,7 +23,11 @@ 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/metricsched"
|
||||
"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 +43,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 +67,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.
|
||||
@@ -135,6 +140,10 @@ func runSchemaSetup() {
|
||||
log.Fatalf("failed to ensure auth indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureMFAIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure mfa indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAPITokenIndexes(); err != nil {
|
||||
log.Fatalf("api token indexes: %v", err)
|
||||
}
|
||||
@@ -166,6 +175,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureMonitorServerStateIndexes(); err != nil {
|
||||
log.Printf("warning: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureVulnIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure vuln indexes: %v", err)
|
||||
}
|
||||
@@ -178,6 +191,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 +213,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")
|
||||
@@ -204,6 +231,7 @@ func serve() {
|
||||
log.Fatalf("failed to connect to Redis: %v", err)
|
||||
}
|
||||
log.Println("connected to Redis")
|
||||
services.RedisClient = auth.Redis()
|
||||
|
||||
// The bus carries agent commands and step results between replicas. It is
|
||||
// not optional even on a single-replica deployment: dispatch takes the same
|
||||
@@ -214,8 +242,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,18 +258,30 @@ 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)
|
||||
services.StartAuditSweeper(jobCtx)
|
||||
services.StartReaper(jobCtx)
|
||||
monitorsched.Start(jobCtx)
|
||||
metricsched.Start(jobCtx)
|
||||
workflowsched.Start(jobCtx, workflowsched.Deps{
|
||||
TriggerWorkflow: services.TriggerWorkflow,
|
||||
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,12 +307,21 @@ 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)
|
||||
}
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{
|
||||
SkipPaths: []string{"/api/console/tunnel"},
|
||||
// Heartbeat URLs carry a credential; the request log must not.
|
||||
Formatter: func(p gin.LogFormatterParams) string {
|
||||
return fmt.Sprintf("[GIN] %v | %3d | %13v | %15s | %-7s %#v\n%s",
|
||||
p.TimeStamp.Format("2006/01/02 - 15:04:05"),
|
||||
p.StatusCode, p.Latency, p.ClientIP, p.Method,
|
||||
api.MaskLogPath(p.Path), p.ErrorMessage)
|
||||
},
|
||||
}))
|
||||
r.Use(corsMiddleware())
|
||||
services.SetStatusRedis(auth.Redis())
|
||||
|
||||
@@ -282,6 +331,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 +396,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
|
||||
}
|
||||
|
||||
+65
-47
@@ -1,79 +1,97 @@
|
||||
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/go-webauthn/webauthn v0.18.1
|
||||
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/pquerna/otp v1.5.0
|
||||
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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.3.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // 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/philhofer/fwd v1.2.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/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // 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.7.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
|
||||
)
|
||||
|
||||
+155
-103
@@ -1,206 +1,258 @@
|
||||
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.7.0 h1:wwXvHfDKZB44EEj6BXl9O68hLC3kfPz3eak9wvupIRA=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
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/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
|
||||
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0=
|
||||
github.com/go-webauthn/webauthn v0.18.1/go.mod h1:s4rZTQnKHWxIh6G3yEGqlxvtiLceA1jigll8FpqSgQ8=
|
||||
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
|
||||
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
|
||||
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/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
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/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The two boot assertions only run against a real engine in main.go, so a
|
||||
// route added without its declarations compiled, passed every unit test and
|
||||
// then refused to start in production. This registers the real routes and
|
||||
// runs both assertions, so that failure lands in CI instead.
|
||||
func TestRegisteredRoutesPassBootAssertions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
RegisterRoutes(r)
|
||||
|
||||
if err := AssertScopeMapComplete(r); err != nil {
|
||||
t.Fatalf("scope map: %v", err)
|
||||
}
|
||||
|
||||
var routes []string
|
||||
for _, route := range r.Routes() {
|
||||
if strings.HasPrefix(route.Path, "/api/") {
|
||||
routes = append(routes, route.Method+" "+route.Path)
|
||||
}
|
||||
}
|
||||
if err := AssertServerScopeMapComplete(routes); err != nil {
|
||||
t.Fatalf("server scope map: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A route cannot be both session-only and token-reachable: an entry in both
|
||||
// maps would silently hand the MFA routes to any token holding that scope.
|
||||
func TestSessionOnlyRoutesHaveNoTokenScope(t *testing.T) {
|
||||
for route := range sessionOnlyRoutes {
|
||||
if scope, ok := routeScopes[route]; ok {
|
||||
t.Errorf("%s is session-only but routeScopes grants it to tokens with %q", route, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
+150
-33
@@ -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"
|
||||
)
|
||||
|
||||
@@ -40,7 +42,25 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
|
||||
r.POST("/auth/bootstrap", auth.HandleBootstrap)
|
||||
r.POST("/auth/login", auth.HandleLocalLogin)
|
||||
|
||||
// Every unauthenticated sign-in and enrolment step lives behind
|
||||
// RateLimitAuth: without it, the five-attempt cap on a single ticket is
|
||||
// trivially sidestepped by starting a fresh sign-in each time.
|
||||
authGroup := r.Group("", RateLimitAuth())
|
||||
{
|
||||
authGroup.POST("/auth/login", auth.HandleLocalLogin)
|
||||
authGroup.POST("/auth/mfa/totp", auth.HandleMFATOTP)
|
||||
authGroup.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
|
||||
authGroup.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin)
|
||||
authGroup.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish)
|
||||
authGroup.POST("/auth/passkey/begin", auth.HandlePasskeyLoginBegin)
|
||||
authGroup.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish)
|
||||
authGroup.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
|
||||
authGroup.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
|
||||
authGroup.POST("/auth/mfa/enrol/passkey/begin", auth.HandleEnrolPasskeyBegin)
|
||||
authGroup.POST("/auth/mfa/enrol/passkey/finish", auth.HandleEnrolPasskeyFinish)
|
||||
}
|
||||
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/me", auth.HandleMe)
|
||||
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
|
||||
@@ -51,6 +71,14 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
// rather than under /api precisely so that none of those apply.
|
||||
r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage)
|
||||
|
||||
// Ping endpoints for heartbeat monitors. The token is in the URL or the
|
||||
// X-Vantage-Token header; see resolveHeartbeat for the shapes. Rate
|
||||
// limiting is per token inside the handler.
|
||||
for _, p := range []string{"/public/hb", "/public/hb/:a", "/public/hb/:a/:b"} {
|
||||
r.GET(p, handleHeartbeat)
|
||||
r.POST(p, handleHeartbeat)
|
||||
}
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
// Scope enforcement sits between authentication and the licence gate, and
|
||||
@@ -68,7 +96,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)
|
||||
@@ -89,6 +117,20 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/tokens", createToken)
|
||||
apiGroup.DELETE("/tokens/:id", revokeToken)
|
||||
|
||||
apiGroup.GET("/me/mfa", getMyMFA)
|
||||
apiGroup.POST("/me/mfa/totp/setup", auth.RequireStepUp(), setupTOTP)
|
||||
apiGroup.POST("/me/mfa/totp/confirm", confirmTOTP)
|
||||
apiGroup.DELETE("/me/mfa/totp", auth.RequireStepUp(), removeTOTP)
|
||||
apiGroup.POST("/me/mfa/recovery/regenerate", auth.RequireStepUp(), regenerateRecoveryCodes)
|
||||
apiGroup.POST("/me/passkeys/begin", auth.RequireStepUp(), auth.HandleRegisterPasskeyBegin)
|
||||
apiGroup.POST("/me/passkeys/finish", auth.RequireStepUp(), auth.HandleRegisterPasskeyFinish)
|
||||
apiGroup.PATCH("/me/passkeys/:id", renamePasskey)
|
||||
apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey)
|
||||
apiGroup.POST("/me/step-up", RateLimitAuth(), stepUp)
|
||||
apiGroup.POST("/me/step-up/webauthn/begin", RateLimitAuth(), auth.HandleStepUpWebAuthnBegin)
|
||||
apiGroup.POST("/me/step-up/webauthn/finish", RateLimitAuth(), auth.HandleStepUpWebAuthnFinish)
|
||||
apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA)
|
||||
|
||||
apiGroup.GET("/openapi.json", getOpenAPI)
|
||||
apiGroup.GET("/docs", getAPIDocs)
|
||||
apiGroup.GET("/docs/scalar.js", getScalarJS)
|
||||
@@ -105,21 +147,38 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/secrets", createSecretGroup)
|
||||
apiGroup.GET("/secrets/:group", getSecretGroup)
|
||||
apiGroup.PUT("/secrets/:group", putSecretGroup)
|
||||
apiGroup.POST("/secrets/:group/reveal", revealSecret)
|
||||
apiGroup.POST("/secrets/:group/reveal", auth.RequireStepUp(), revealSecret)
|
||||
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
|
||||
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
|
||||
|
||||
apiGroup.GET("/keys", listKeys)
|
||||
apiGroup.POST("/keys", createKey)
|
||||
apiGroup.GET("/keys/:id", getKey)
|
||||
apiGroup.GET("/keys/:id/private-key", getPrivateKey)
|
||||
apiGroup.GET("/keys/:id/private-key", auth.RequireStepUp(), getPrivateKey)
|
||||
apiGroup.DELETE("/keys/:id", deleteKey)
|
||||
apiGroup.POST("/keys/:id/assign", assignKey)
|
||||
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
||||
|
||||
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
|
||||
apiGroup.POST("/console/connect", auth.RequireStepUp(), 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 +227,7 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
|
||||
|
||||
registerStatusPageRoutes(apiGroup)
|
||||
registerPatchingRoutes(apiGroup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,11 +245,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 +313,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 +419,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 +446,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 +493,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 +530,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 +614,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 +679,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 +688,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 +718,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 +768,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 +789,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) {
|
||||
@@ -857,6 +966,7 @@ func saveSettings(c *gin.Context) {
|
||||
Alerts models.AlertSettings `json:"alerts"`
|
||||
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
|
||||
LocalLoginEnabled *bool `json:"local_login_enabled"`
|
||||
RequireMFA *bool `json:"require_mfa"`
|
||||
APITokenMaxDays *int `json:"api_token_max_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -867,7 +977,13 @@ func saveSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil {
|
||||
// The MFA requirement gates every future sign-in, so only an owner may
|
||||
// change it; an admin can still save the rest of this endpoint's settings.
|
||||
if body.RequireMFA != nil && auth.Role(c) != models.RoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change the MFA requirement"})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.RequireMFA, body.APITokenMaxDays); err != nil {
|
||||
if errors.Is(err, services.ErrLockout) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
|
||||
return
|
||||
@@ -880,6 +996,10 @@ func saveSettings(c *gin.Context) {
|
||||
services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays))
|
||||
}
|
||||
if body.RequireMFA != nil {
|
||||
services.LogEvent(auth.InstanceID(c), "settings.require_mfa", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("enabled=%v", *body.RequireMFA))
|
||||
}
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
@@ -893,10 +1013,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HeartbeatTokenHeader carries the ping token for callers who want it out of
|
||||
// URLs, and so out of their own proxies' access logs.
|
||||
const HeartbeatTokenHeader = "X-Vantage-Token"
|
||||
|
||||
const heartbeatPathPrefix = "/public/hb/"
|
||||
|
||||
func heartbeatKind(seg string) string {
|
||||
switch seg {
|
||||
case "":
|
||||
return services.HeartbeatPing
|
||||
case "start":
|
||||
return services.HeartbeatStart
|
||||
case "fail":
|
||||
return services.HeartbeatFail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveHeartbeat maps the three route shapes onto a token and a kind. a and b
|
||||
// are the first and second path segments after /public/hb. A token in the URL
|
||||
// wins over the header, so a URL copied from the UI behaves the same no matter
|
||||
// what headers a client adds.
|
||||
func resolveHeartbeat(header, a, b string) (string, string, bool) {
|
||||
var token, kindSeg string
|
||||
switch {
|
||||
// a is a URL token unless a header is present, no second segment follows
|
||||
// and a is itself a kind word (the header routes /public/hb/start|fail).
|
||||
case a != "" && (header == "" || b != "" || heartbeatKind(a) == ""):
|
||||
token, kindSeg = a, b
|
||||
case header != "":
|
||||
token, kindSeg = header, a
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
kind := heartbeatKind(kindSeg)
|
||||
if token == "" || kind == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return token, kind, true
|
||||
}
|
||||
|
||||
// MaskLogPath hides a heartbeat token in a request path before it is logged.
|
||||
// "start" and "fail" directly under the prefix are the header-token routes and
|
||||
// carry no secret, so they are left readable.
|
||||
func MaskLogPath(path string) string {
|
||||
rest, found := strings.CutPrefix(path, heartbeatPathPrefix)
|
||||
if !found {
|
||||
return path
|
||||
}
|
||||
end := strings.IndexAny(rest, "/?")
|
||||
if end < 0 {
|
||||
end = len(rest)
|
||||
}
|
||||
seg := rest[:end]
|
||||
if seg == "" || seg == "start" || seg == "fail" {
|
||||
return path
|
||||
}
|
||||
return heartbeatPathPrefix + "***" + rest[end:]
|
||||
}
|
||||
|
||||
// heartbeatLimitKey buckets requests by token, kind and wall-clock second. The
|
||||
// kind is part of the key so a fast job's success ping is not rejected for
|
||||
// landing in the same second as its /start, which would leave the run marked
|
||||
// started and page as never finished. The token is hashed so Redis never holds
|
||||
// the secret.
|
||||
func heartbeatLimitKey(token, kind string, now time.Time) string {
|
||||
return "vantage:hbrl:" + services.HashHeartbeatToken(token) + ":" + kind + ":" + strconv.FormatInt(now.Unix(), 10)
|
||||
}
|
||||
|
||||
// readHeartbeatBody keeps up to MaxHeartbeatBody bytes of a /fail body as the
|
||||
// incident message. Other kinds carry no message, so their bodies are ignored.
|
||||
func readHeartbeatBody(kind string, r io.Reader) string {
|
||||
if kind != services.HeartbeatFail || r == nil {
|
||||
return ""
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(r, services.MaxHeartbeatBody))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func writeHeartbeatLimited(c *gin.Context) {
|
||||
c.Header("Retry-After", "1")
|
||||
c.String(http.StatusTooManyRequests, "too many requests")
|
||||
}
|
||||
|
||||
// heartbeatAllowed admits one request per token and kind per second. A cron job pinging
|
||||
// in a loop should not become a write per request, and a leaked URL should not
|
||||
// be a way to hammer Mongo. Like the status page limiter it allows when Redis
|
||||
// is down: a missed ping pages someone. It runs inside the handler rather than
|
||||
// as middleware because the token may come from a header.
|
||||
func heartbeatAllowed(c *gin.Context, token, kind string) bool {
|
||||
rdb := auth.Redis()
|
||||
if rdb == nil {
|
||||
return true
|
||||
}
|
||||
key := heartbeatLimitKey(token, kind, time.Now())
|
||||
count, err := rdb.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if count == 1 {
|
||||
rdb.Expire(c.Request.Context(), key, 2*time.Second)
|
||||
}
|
||||
return count <= 1
|
||||
}
|
||||
|
||||
// handleHeartbeat records a push from a job. It is mounted on the gin root
|
||||
// under /public for the same reasons as the status page (see
|
||||
// getPublicStatusPage): no session, no token, no licence gate, and /public is
|
||||
// already routed to this server by every deployment.
|
||||
//
|
||||
// Unknown token, disabled monitor and an unresolvable path all answer the same
|
||||
// 404.
|
||||
func handleHeartbeat(c *gin.Context) {
|
||||
token, kind, ok := resolveHeartbeat(c.GetHeader(HeartbeatTokenHeader), c.Param("a"), c.Param("b"))
|
||||
if !ok {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if !heartbeatAllowed(c, token, kind) {
|
||||
writeHeartbeatLimited(c)
|
||||
return
|
||||
}
|
||||
err := services.RecordHeartbeat(token, kind, readHeartbeatBody(kind, c.Request.Body), time.Now())
|
||||
if errors.Is(err, services.ErrHeartbeatNotFound) {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("heartbeat: %v", err)
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, "OK")
|
||||
}
|
||||
|
||||
// rotateHeartbeatToken godoc
|
||||
//
|
||||
// @Summary Rotate a heartbeat monitor's ping token
|
||||
// @Description Issues a new token and invalidates the old ping URL immediately. The token is returned only in this response.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {object} object{heartbeat_token=string}
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/rotate-token [post]
|
||||
func rotateHeartbeatToken(c *gin.Context) {
|
||||
tok, err := services.RotateHeartbeatToken(auth.InstanceID(c), c.Param("id"))
|
||||
if errors.Is(err, services.ErrHeartbeatNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "heartbeat monitor not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"heartbeat_token": tok})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResolveHeartbeat(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, header, a, b string
|
||||
token, kind string
|
||||
ok bool
|
||||
}{
|
||||
{"url ping", "", "tok", "", "tok", "ping", true},
|
||||
{"url start", "", "tok", "start", "tok", "start", true},
|
||||
{"url fail", "", "tok", "fail", "tok", "fail", true},
|
||||
{"url bad kind", "", "tok", "explode", "", "", false},
|
||||
{"header ping", "htok", "", "", "htok", "ping", true},
|
||||
{"header start", "htok", "start", "", "htok", "start", true},
|
||||
{"header fail", "htok", "fail", "", "htok", "fail", true},
|
||||
{"url token wins over header", "htok", "tok", "", "tok", "ping", true},
|
||||
{"url token and kind win over header", "htok", "tok", "fail", "tok", "fail", true},
|
||||
{"nothing", "", "", "", "", "", false},
|
||||
// Without a header, /public/hb/start is a token called "start": it
|
||||
// resolves, and the lookup answers 404 like any unknown token.
|
||||
{"bare start without header", "", "start", "", "start", "ping", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
tok, kind, ok := resolveHeartbeat(c.header, c.a, c.b)
|
||||
if tok != c.token || kind != c.kind || ok != c.ok {
|
||||
t.Fatalf("got (%q,%q,%v), want (%q,%q,%v)", tok, kind, ok, c.token, c.kind, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A ping URL is a credential. Anything that logs request paths must see a
|
||||
// masked one.
|
||||
func TestMaskLogPath(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/public/hb/abc123": "/public/hb/***",
|
||||
"/public/hb/abc123/fail": "/public/hb/***/fail",
|
||||
"/public/hb/abc123?x=1": "/public/hb/***?x=1",
|
||||
"/public/hb/start": "/public/hb/start",
|
||||
"/public/hb/fail": "/public/hb/fail",
|
||||
"/public/hb": "/public/hb",
|
||||
"/public/status/page": "/public/status/page",
|
||||
"/api/monitors/abc/uptime": "/api/monitors/abc/uptime",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := MaskLogPath(in); got != want {
|
||||
t.Errorf("MaskLogPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatUnresolvableIs404(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/public/hb/:a/:b", handleHeartbeat)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/public/hb/abc/explode", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatLimitKey(t *testing.T) {
|
||||
now := time.Unix(1700000000, 0)
|
||||
start := heartbeatLimitKey("tok", services.HeartbeatStart, now)
|
||||
ping := heartbeatLimitKey("tok", services.HeartbeatPing, now)
|
||||
if start == ping {
|
||||
t.Fatalf("start and ping share a limiter key: %s", start)
|
||||
}
|
||||
if ping != heartbeatLimitKey("tok", services.HeartbeatPing, now.Add(500*time.Millisecond)) {
|
||||
t.Fatal("same kind in the same second should share a key")
|
||||
}
|
||||
if strings.Contains(ping, "tok") {
|
||||
t.Fatalf("key leaks the plaintext token: %s", ping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeartbeatBody(t *testing.T) {
|
||||
long := strings.Repeat("x", services.MaxHeartbeatBody+500)
|
||||
if got := readHeartbeatBody(services.HeartbeatFail, strings.NewReader(long)); len(got) != services.MaxHeartbeatBody {
|
||||
t.Fatalf("fail body not capped: %d", len(got))
|
||||
}
|
||||
if got := readHeartbeatBody(services.HeartbeatPing, strings.NewReader("hi")); got != "" {
|
||||
t.Fatalf("ping body should be ignored, got %q", got)
|
||||
}
|
||||
if got := readHeartbeatBody(services.HeartbeatFail, nil); got != "" {
|
||||
t.Fatalf("nil body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatRateLimitedWritesRetryAfter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
writeHeartbeatLimited(c)
|
||||
if w.Code != http.StatusTooManyRequests || w.Header().Get("Retry-After") != "1" {
|
||||
t.Fatalf("got %d retry-after %q", w.Code, w.Header().Get("Retry-After"))
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,32 @@ import (
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [get]
|
||||
// instanceUserResponse wraps a member with whether they hold an MFA factor,
|
||||
// for the settings page's member column and reset action. A wrapper rather
|
||||
// than a field on models.User because User is shared with Vantage HQ.
|
||||
type instanceUserResponse struct {
|
||||
models.User `bson:",inline"`
|
||||
MFAEnabled bool `json:"mfa_enabled"`
|
||||
}
|
||||
|
||||
func listInstanceUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
// One aggregate over two small collections beats N round trips for a
|
||||
// member list that renders on every settings page load.
|
||||
enabled, err := services.UsersWithMFA(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]instanceUserResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, instanceUserResponse{User: u, MFAEnabled: enabled[u.UserID]})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func actorMayGrantOwner(c *gin.Context) bool {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// oidcUser refuses MFA management for a user whose IdP owns authentication.
|
||||
func oidcUser(c *gin.Context) bool {
|
||||
u, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
|
||||
return err == nil && u.AuthSource == models.AuthOIDC
|
||||
}
|
||||
|
||||
// getMyMFA reports this user's factors.
|
||||
//
|
||||
// @Summary Get my MFA status
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{totp_enabled=bool,passkeys=[]models.WebAuthnCredential,recovery_remaining=int,require_mfa=bool,applicable=bool}
|
||||
// @Router /me/mfa [get]
|
||||
func getMyMFA(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
m, err := services.GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
passkeys, err := services.ListPasskeys(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"totp_enabled": m != nil && m.TOTPConfirmedAt != nil,
|
||||
"passkeys": passkeys,
|
||||
"recovery_remaining": services.RecoveryCodesRemaining(m),
|
||||
"require_mfa": services.RequireMFAForInstance(instanceID),
|
||||
"applicable": !oidcUser(c),
|
||||
})
|
||||
}
|
||||
|
||||
// setupTOTP issues a new unconfirmed secret.
|
||||
//
|
||||
// @Summary Start TOTP setup
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{secret=string,otpauth_uri=string}
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp/setup [post]
|
||||
func setupTOTP(c *gin.Context) {
|
||||
if oidcUser(c) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "your identity provider manages sign-in", "code": "mfa_not_applicable"})
|
||||
return
|
||||
}
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
issuer := "Vantage"
|
||||
if inst, err := services.GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
|
||||
issuer = inst.Name
|
||||
}
|
||||
secret, uri, err := services.StartTOTPSetup(instanceID, userID, issuer, auth.GetSessionFromContext(c).Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
|
||||
}
|
||||
|
||||
// confirmTOTP activates the pending secret and issues recovery codes if this
|
||||
// is the user's first factor.
|
||||
//
|
||||
// @Summary Confirm TOTP setup
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Six-digit code"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp/confirm [post]
|
||||
func confirmTOTP(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
|
||||
return
|
||||
}
|
||||
if err := services.ConfirmTOTP(instanceID, userID, body.Code); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that code is not valid", "code": "invalid_code"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.enrolled", actorFromCtx(c), "", "", "factor=totp")
|
||||
|
||||
m, _ := services.GetUserMFA(instanceID, userID)
|
||||
if services.RecoveryCodesRemaining(m) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
codes, err := services.IssueRecoveryCodes(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
}
|
||||
|
||||
// removeTOTP drops the TOTP factor. Step-up guarded at the route.
|
||||
//
|
||||
// @Summary Remove TOTP
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 204
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp [delete]
|
||||
func removeTOTP(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorTOTP); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
|
||||
return
|
||||
}
|
||||
if err := services.RemoveTOTP(instanceID, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=totp")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// regenerateRecoveryCodes invalidates the old set. Step-up guarded.
|
||||
//
|
||||
// @Summary Regenerate recovery codes
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{recovery_codes=[]string}
|
||||
// @Router /me/mfa/recovery/regenerate [post]
|
||||
func regenerateRecoveryCodes(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
codes, err := services.IssueRecoveryCodes(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.recovery_regenerated", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
||||
}
|
||||
|
||||
// renamePasskey and deletePasskey work on the hex credential ID.
|
||||
//
|
||||
// @Summary Rename a passkey
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Credential ID"
|
||||
// @Param body body object{name=string} true "New name"
|
||||
// @Success 204
|
||||
// @Router /me/passkeys/{id} [patch]
|
||||
func renamePasskey(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||||
return
|
||||
}
|
||||
err := services.RenamePasskey(auth.InstanceID(c), auth.UserID(c), c.Param("id"), body.Name)
|
||||
if errors.Is(err, services.ErrNoPasskey) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Delete a passkey
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Param id path string true "Credential ID"
|
||||
// @Success 204
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/passkeys/{id} [delete]
|
||||
func deletePasskey(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorWebAuthn); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
|
||||
return
|
||||
}
|
||||
err := services.DeletePasskey(instanceID, userID, c.Param("id"))
|
||||
if errors.Is(err, services.ErrNoPasskey) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=webauthn")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// resetUserMFA lets an owner or admin clear somebody else's factors.
|
||||
//
|
||||
// @Summary Reset another member's MFA
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204
|
||||
// @Failure 403 {object} object{error=string}
|
||||
// @Router /org/users/{id}/mfa [delete]
|
||||
func resetUserMFA(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
target, err := services.GetUserInInstance(instanceID, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such member"})
|
||||
return
|
||||
}
|
||||
// An admin resetting an owner's MFA would be a promotion path: clear the
|
||||
// factor, phish the password, hold the instance.
|
||||
if auth.Role(c) != models.RoleOwner && target.Role == models.RoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can reset an owner's MFA"})
|
||||
return
|
||||
}
|
||||
if err := services.ClearMFA(instanceID, target.UserID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.reset", actorFromCtx(c), "", "", "target="+target.Email)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// stepUp re-authenticates the current session.
|
||||
//
|
||||
// @Summary Re-authenticate before a sensitive action
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{totp=string,recovery=string,password=string} true "One factor"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/step-up [post]
|
||||
func stepUp(c *gin.Context) {
|
||||
sess := auth.GetSessionFromContext(c)
|
||||
var body struct {
|
||||
TOTP string `json:"totp"`
|
||||
Recovery string `json:"recovery"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case body.TOTP != "":
|
||||
err = services.VerifyTOTPCode(sess.InstanceID, sess.UserID, body.TOTP)
|
||||
case body.Recovery != "":
|
||||
err = services.UseRecoveryCode(sess.InstanceID, sess.UserID, body.Recovery)
|
||||
case body.Password != "":
|
||||
// Password is offered only to a user with no MFA at all; accepting it
|
||||
// from an enrolled user would demote step-up to what they already did.
|
||||
has, herr := services.HasMFA(sess.InstanceID, sess.UserID)
|
||||
if herr != nil || has {
|
||||
err = services.ErrBadCode
|
||||
} else {
|
||||
u, uerr := services.GetUserInInstance(sess.InstanceID, sess.UserID)
|
||||
if uerr != nil || !services.VerifyPassword(u, body.Password) {
|
||||
err = services.ErrBadCode
|
||||
}
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
services.LogEvent(sess.InstanceID, "step_up.failed", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that did not verify", "code": "invalid_code"})
|
||||
return
|
||||
}
|
||||
if err := auth.TouchStepUpFromRequest(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "step_up.ok", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -21,6 +22,8 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/monitors/:id/incidents", getMonitorIncidents)
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
g.GET("/monitors/:id/samples", getMonitorSamples)
|
||||
g.GET("/monitors/:id/servers", getMonitorServers)
|
||||
g.POST("/monitors/:id/rotate-token", rotateHeartbeatToken)
|
||||
}
|
||||
|
||||
// listMonitors godoc
|
||||
@@ -39,6 +42,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,8 +86,16 @@ 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 {
|
||||
if errors.Is(err, services.ErrInvalidMonitor) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrMonitorOutOfScope) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -103,7 +124,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
|
||||
@@ -113,9 +142,11 @@ func getMonitor(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string,for_sec=int} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
@@ -131,6 +162,7 @@ func updateMonitor(c *gin.Context) {
|
||||
Retries *int `json:"retries"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ChannelIDs *[]string `json:"channel_ids"`
|
||||
ForSec *int `json:"for_sec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -164,11 +196,26 @@ func updateMonitor(c *gin.Context) {
|
||||
if body.ChannelIDs != nil {
|
||||
upd["channel_ids"] = *body.ChannelIDs
|
||||
}
|
||||
if body.ForSec != nil {
|
||||
upd["for_sec"] = *body.ForSec
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
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 {
|
||||
if errors.Is(err, services.ErrInvalidMonitor) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrMonitorOutOfScope) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrMonitorNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -181,12 +228,17 @@ func updateMonitor(c *gin.Context) {
|
||||
// @Tags monitors
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 204
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [delete]
|
||||
func deleteMonitor(c *gin.Context) {
|
||||
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id"), auth.ServerScope(c)); err != nil {
|
||||
if errors.Is(err, services.ErrMonitorOutOfScope) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -220,9 +272,63 @@ func getMonitorIncidents(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
|
||||
}
|
||||
incidents = services.FilterByVisibleServer(incidents, func(i models.Incident) string { return i.ServerID }, visible, restricted)
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
// getMonitorServers godoc
|
||||
//
|
||||
// @Summary List a metric monitor's per-server states
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {array} models.MonitorServerState
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/servers [get]
|
||||
func getMonitorServers(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
m, err := services.GetMonitor(instanceID, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil || m.Type != models.MonitorMetric {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "metric monitor not found"})
|
||||
return
|
||||
}
|
||||
states, err := services.ListMonitorServerStates(instanceID, m.MonitorID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
visible, restricted, err := services.VisibleServerIDs(instanceID, auth.ServerScope(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
states = services.FilterByVisibleServer(states, func(s models.MonitorServerState) string { return s.ServerID }, visible, restricted)
|
||||
|
||||
servers, err := services.ListServersFiltered(instanceID, m.Target.Selector)
|
||||
if err == nil {
|
||||
names := map[string]string{}
|
||||
for _, s := range servers {
|
||||
names[s.ServerID] = s.Hostname
|
||||
}
|
||||
for i := range states {
|
||||
states[i].Hostname = names[states[i].ServerID]
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, states)
|
||||
}
|
||||
|
||||
// getMonitorSamples godoc
|
||||
//
|
||||
// @Summary Get a monitor's individual check results
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// authRateLimit is per client address per minute. It bounds how many tickets an
|
||||
// attacker can start; the ticket's own five-attempt cap bounds guesses inside
|
||||
// one. Neither alone is enough.
|
||||
const authRateLimit = 20
|
||||
|
||||
// RateLimitAuth guards every unauthenticated sign-in endpoint. Without it, the
|
||||
// per-ticket cap is trivially sidestepped by starting a new sign-in each time.
|
||||
func RateLimitAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rdb := services.RedisClient
|
||||
if rdb == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
window := time.Now().Unix() / 60
|
||||
key := fmt.Sprintf("km:rl:auth:%s:%d", c.ClientIP(), window)
|
||||
ctx := c.Request.Context()
|
||||
n, err := rdb.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
// A limiter that cannot reach Redis must not lock out sign-in: fail
|
||||
// open rather than turn a Redis blip into a second outage.
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if n == 1 {
|
||||
rdb.Expire(ctx, key, time.Minute)
|
||||
}
|
||||
if n > authRateLimit {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "too many sign-in attempts; try again in a minute",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -93,14 +99,16 @@ var routeScopes = map[string]string{
|
||||
"GET /api/runs/:runId/servers/:serverId/logs/stream": "workflows:read",
|
||||
|
||||
// Monitor and incident routes, registered by registerMonitorRoutes.
|
||||
"GET /api/monitors": "monitors:read",
|
||||
"POST /api/monitors": "monitors:write",
|
||||
"GET /api/monitors/:id": "monitors:read",
|
||||
"PUT /api/monitors/:id": "monitors:write",
|
||||
"DELETE /api/monitors/:id": "monitors:write",
|
||||
"GET /api/monitors/:id/incidents": "monitors:read",
|
||||
"GET /api/monitors/:id/uptime": "monitors:read",
|
||||
"GET /api/monitors/:id/samples": "monitors:read",
|
||||
"GET /api/monitors": "monitors:read",
|
||||
"POST /api/monitors": "monitors:write",
|
||||
"GET /api/monitors/:id": "monitors:read",
|
||||
"PUT /api/monitors/:id": "monitors:write",
|
||||
"DELETE /api/monitors/:id": "monitors:write",
|
||||
"GET /api/monitors/:id/incidents": "monitors:read",
|
||||
"GET /api/monitors/:id/uptime": "monitors:read",
|
||||
"GET /api/monitors/:id/samples": "monitors:read",
|
||||
"GET /api/monitors/:id/servers": "monitors:read",
|
||||
"POST /api/monitors/:id/rotate-token": "monitors:write",
|
||||
|
||||
// Channel routes, registered by registerChannelRoutes. Channels exist to
|
||||
// serve alerts, so they share the monitors scope rather than getting their
|
||||
@@ -169,6 +177,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 +229,41 @@ 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,
|
||||
}
|
||||
|
||||
// sessionOnlyRoutes are /api routes that an API token must never reach, so they
|
||||
// deliberately have no entry in routeScopes. RequireScopes already refuses any
|
||||
// unmapped route to a token; this set is what tells AssertScopeMapComplete the
|
||||
// absence is a decision rather than an omission.
|
||||
//
|
||||
// Every entry manages the caller's own second factors or proves the caller is
|
||||
// present. A token that could enrol a passkey, regenerate recovery codes or
|
||||
// satisfy step-up would turn one leaked token into a way around MFA itself.
|
||||
var sessionOnlyRoutes = map[string]bool{
|
||||
"GET /api/me/mfa": true,
|
||||
"POST /api/me/mfa/totp/setup": true,
|
||||
"POST /api/me/mfa/totp/confirm": true,
|
||||
"DELETE /api/me/mfa/totp": true,
|
||||
"POST /api/me/mfa/recovery/regenerate": true,
|
||||
"POST /api/me/passkeys/begin": true,
|
||||
"POST /api/me/passkeys/finish": true,
|
||||
"PATCH /api/me/passkeys/:id": true,
|
||||
"DELETE /api/me/passkeys/:id": true,
|
||||
"POST /api/me/step-up": true,
|
||||
"POST /api/me/step-up/webauthn/begin": true,
|
||||
"POST /api/me/step-up/webauthn/finish": true,
|
||||
"DELETE /api/org/users/:id/mfa": 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 +274,13 @@ 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
|
||||
}
|
||||
// Session-only routes are refused to every token by having no scope.
|
||||
if sessionOnlyRoutes[route.Method+" "+route.Path] {
|
||||
continue
|
||||
}
|
||||
if _, ok := routeScopes[route.Method+" "+route.Path]; !ok {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
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,
|
||||
|
||||
// Rotating a ping token touches no server and returns only the token.
|
||||
"POST /api/monitors/:id/rotate-token": fleetWide,
|
||||
|
||||
// Uptime rollups and recent samples are 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/uptime": exempt,
|
||||
"GET /api/monitors/:id/samples": exempt,
|
||||
|
||||
// A metric monitor's incidents carry server_id, so a restricted token
|
||||
// must not see an incident for a server outside its scope. Handled by
|
||||
// services.FilterByVisibleServer rather than by the route classification
|
||||
// itself.
|
||||
"GET /api/monitors/:id/incidents": scoped,
|
||||
|
||||
// Per-server metric state names servers, so it is filtered to the ones the
|
||||
// credential can see.
|
||||
"GET /api/monitors/:id/servers": scoped,
|
||||
|
||||
// ---- 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,
|
||||
|
||||
// Multi-factor authentication. These act on the caller's own factors (or,
|
||||
// for the reset, on another member's), never on a server, and no API token
|
||||
// can reach them at all - see sessionOnlyRoutes.
|
||||
"GET /api/me/mfa": exempt,
|
||||
"POST /api/me/mfa/totp/setup": exempt,
|
||||
"POST /api/me/mfa/totp/confirm": exempt,
|
||||
"DELETE /api/me/mfa/totp": exempt,
|
||||
"POST /api/me/mfa/recovery/regenerate": exempt,
|
||||
"POST /api/me/passkeys/begin": exempt,
|
||||
"POST /api/me/passkeys/finish": exempt,
|
||||
"PATCH /api/me/passkeys/:id": exempt,
|
||||
"DELETE /api/me/passkeys/:id": exempt,
|
||||
"POST /api/me/step-up": exempt,
|
||||
"POST /api/me/step-up/webauthn/begin": exempt,
|
||||
"POST /api/me/step-up/webauthn/finish": exempt,
|
||||
"DELETE /api/org/users/:id/mfa": exempt,
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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] || sessionOnlyRoutes[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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,7 @@ func githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oaut
|
||||
//
|
||||
// Verified alone is not enough: a non-primary address is one the person happens
|
||||
// to have proved, not the one they present as themselves. Primary alone is far
|
||||
// worse — an unverified address is not proof of control at all, and accepting
|
||||
// worse - an unverified address is not proof of control at all, and accepting
|
||||
// one would let anyone with a GitHub account claim any address in the instance.
|
||||
func selectGitHubEmail(emails []githubEmail) (string, error) {
|
||||
for _, e := range emails {
|
||||
|
||||
@@ -61,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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -76,20 +102,52 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
sessionID, err := SaveSession(c.Request.Context(), &Session{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
})
|
||||
requireMFA := services.RequireMFAForInstance(instanceID)
|
||||
hasMFA, err := services.HasMFA(u.InstanceID, u.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
|
||||
return
|
||||
}
|
||||
_ = services.TouchLastLogin(u.UserID)
|
||||
SetSessionCookie(c, sessionID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
|
||||
switch loginDecision(hasMFA, requireMFA) {
|
||||
case "session":
|
||||
if err := mintSession(c, u, []string{"pwd"}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
|
||||
case "verify":
|
||||
methods, err := services.MFAMethods(u.InstanceID, u.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
|
||||
return
|
||||
}
|
||||
id, err := CreateTicket(c.Request.Context(), &Ticket{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, Methods: methods,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
SetPendingCookie(c, id)
|
||||
c.JSON(http.StatusOK, gin.H{"mfa_required": true, "methods": methods})
|
||||
|
||||
case "enrol":
|
||||
id, err := CreateTicket(c.Request.Context(), &Ticket{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, EnrolOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
SetPendingCookie(c, id)
|
||||
c.JSON(http.StatusOK, gin.H{"enrol_required": true})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +159,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 +189,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
|
||||
@@ -191,14 +259,10 @@ func HandleBootstrap(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sessionID, err := SaveSession(c.Request.Context(), &Session{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
})
|
||||
if err != nil {
|
||||
if err := mintSession(c, u, []string{"pwd"}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
SetSessionCookie(c, sessionID)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"instance": inst,
|
||||
"slug": inst.Slug,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// enrolTicketStillValid refuses an enrol-only ticket once the user has any
|
||||
// factor. Otherwise someone holding only the password who started sign-in
|
||||
// before the real user enrolled could, within the ticket's lifetime, add
|
||||
// their own passkey and replace the user's recovery codes. Answered as an
|
||||
// expired ticket, the single indistinguishable error, and the ticket is
|
||||
// destroyed. A lookup failure is treated the same way: fail closed.
|
||||
//
|
||||
// It runs before the factor is written, not in finishEnrolment: by then the
|
||||
// factor being enrolled already counts, so HasMFA would always be true.
|
||||
func enrolTicketStillValid(c *gin.Context, t *Ticket, ticketID string) bool {
|
||||
has, err := services.HasMFA(t.InstanceID, t.UserID)
|
||||
if err != nil || has {
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
abortTicketExpired(c)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
|
||||
// from, before they hold a session. Only an enrol-only ticket reaches it.
|
||||
//
|
||||
// @Summary Start forced TOTP enrolment during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{secret=string,otpauth_uri=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/totp/setup [post]
|
||||
func HandleEnrolTOTPSetup(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
inst, err := services.GetInstance(t.InstanceID)
|
||||
issuer := "Vantage"
|
||||
if err == nil && inst != nil && inst.Name != "" {
|
||||
issuer = inst.Name
|
||||
}
|
||||
secret, uri, err := services.StartTOTPSetup(t.InstanceID, t.UserID, issuer, t.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
|
||||
}
|
||||
|
||||
// HandleEnrolTOTPConfirm finishes forced enrolment and signs the user in.
|
||||
//
|
||||
// @Summary Confirm forced TOTP enrolment and sign in
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Six-digit code"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/totp/confirm [post]
|
||||
func HandleEnrolTOTPConfirm(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
|
||||
return
|
||||
}
|
||||
if err := services.ConfirmTOTP(t.InstanceID, t.UserID, body.Code); err != nil {
|
||||
left, ferr := FailTicket(c.Request.Context(), ticketID)
|
||||
if ferr != nil || left == 0 {
|
||||
abortTicketExpired(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
|
||||
})
|
||||
return
|
||||
}
|
||||
finishEnrolment(c, t, ticketID, services.FactorTOTP)
|
||||
}
|
||||
|
||||
// finishEnrolment issues recovery codes, mints the session and audits, so the
|
||||
// TOTP and passkey enrolment paths cannot drift apart.
|
||||
func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
|
||||
codes, err := services.IssueRecoveryCodes(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue recovery codes"})
|
||||
return
|
||||
}
|
||||
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyBegin starts forced passkey enrolment during sign-in.
|
||||
// Identical to HandleRegisterPasskeyBegin except the user comes from the
|
||||
// enrol-only ticket rather than a session, since none exists yet.
|
||||
//
|
||||
// @Summary Begin forced passkey enrolment during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/begin [post]
|
||||
func HandleEnrolPasskeyBegin(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: t.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyFinish stores the new credential and finishes forced
|
||||
// enrolment, minting the session that HandleEnrolTOTPConfirm also produces.
|
||||
//
|
||||
// @Summary Complete forced passkey enrolment and sign in
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/finish [post]
|
||||
func HandleEnrolPasskeyFinish(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that enrolment expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "enrolment registration", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, tr := range parsed.Response.Transports {
|
||||
transports = append(transports, string(tr))
|
||||
}
|
||||
if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
finishEnrolment(c, t, ticketID, services.FactorWebAuthn)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// loginDecision is the branch a verified password takes.
|
||||
func loginDecision(hasMFA, requireMFA bool) string {
|
||||
switch {
|
||||
case hasMFA:
|
||||
return "verify"
|
||||
case requireMFA:
|
||||
return "enrol"
|
||||
default:
|
||||
return "session"
|
||||
}
|
||||
}
|
||||
|
||||
// newSession builds the session a sign-in produces. Sign-in counts as a
|
||||
// step-up, so StepUpAt is always the moment of sign-in.
|
||||
func newSession(u *models.User, amr []string, now time.Time) *Session {
|
||||
return &Session{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
AMR: amr, StepUpAt: &now,
|
||||
}
|
||||
}
|
||||
|
||||
// oidcSession is the session an SSO callback mints. AMR "oidc" is what exempts
|
||||
// it from step-up: the IdP owns authentication policy, and these users have
|
||||
// no password or local factor to step up with.
|
||||
func oidcSession(u *models.User, name string, now time.Time) *Session {
|
||||
s := newSession(u, []string{"oidc"}, now)
|
||||
s.Name = name
|
||||
return s
|
||||
}
|
||||
|
||||
// mintSession is the single place a session is created from a user, so every
|
||||
// path records amr and step-up freshness the same way.
|
||||
func mintSession(c *gin.Context, u *models.User, amr []string) error {
|
||||
return saveSignIn(c, newSession(u, amr, time.Now()))
|
||||
}
|
||||
|
||||
// saveSignIn persists a sign-in session and sets its cookie. The pending-MFA
|
||||
// cookie is cleared because a completed sign-in supersedes any ticket.
|
||||
func saveSignIn(c *gin.Context, sess *Session) error {
|
||||
sessionID, err := SaveSession(c.Request.Context(), sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = services.TouchLastLogin(sess.UserID)
|
||||
ClearPendingCookie(c)
|
||||
SetSessionCookie(c, sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleMFATOTP completes a sign-in with a TOTP code.
|
||||
//
|
||||
// @Summary Complete sign-in with a TOTP code
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Six-digit code"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/totp [post]
|
||||
func HandleMFATOTP(c *gin.Context) {
|
||||
handleMFAVerify(c, services.FactorTOTP)
|
||||
}
|
||||
|
||||
// HandleMFARecovery completes a sign-in with a recovery code.
|
||||
//
|
||||
// @Summary Complete sign-in with a recovery code
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Recovery code"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/recovery [post]
|
||||
func HandleMFARecovery(c *gin.Context) {
|
||||
handleMFAVerify(c, services.FactorRecovery)
|
||||
}
|
||||
|
||||
func handleMFAVerify(c *gin.Context, factor string) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch factor {
|
||||
case services.FactorTOTP:
|
||||
err = services.VerifyTOTPCode(t.InstanceID, t.UserID, body.Code)
|
||||
case services.FactorRecovery:
|
||||
err = services.UseRecoveryCode(t.InstanceID, t.UserID, body.Code)
|
||||
}
|
||||
if err != nil {
|
||||
left, ferr := FailTicket(c.Request.Context(), ticketID)
|
||||
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor="+factor)
|
||||
if ferr != nil || left == 0 {
|
||||
abortTicketExpired(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
if factor == services.FactorRecovery {
|
||||
services.LogEvent(t.InstanceID, "mfa.recovery_used", u.Email, "", "", "")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
// The whole point of the feature is in this table: a user with MFA is never
|
||||
// handed a session by a password alone, and require_mfa turns "no factor" into
|
||||
// forced enrolment rather than a free pass.
|
||||
func TestLoginDecision(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hasMFA bool
|
||||
requireMFA bool
|
||||
want string
|
||||
}{
|
||||
{"no mfa, not required", false, false, "session"},
|
||||
{"no mfa, required", false, true, "enrol"},
|
||||
{"has mfa, not required", true, false, "verify"},
|
||||
{"has mfa, required", true, true, "verify"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := loginDecision(tc.hasMFA, tc.requireMFA); got != tc.want {
|
||||
t.Fatalf("loginDecision(%v,%v) = %q, want %q", tc.hasMFA, tc.requireMFA, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
ticketTTL = 5 * time.Minute
|
||||
maxTicketAttempts = 5
|
||||
ticketPrefix = "km:mfa:"
|
||||
pendingCookieName = "km_mfa_pending"
|
||||
)
|
||||
|
||||
// ErrTicketExpired covers every unusable ticket - missing, timed out, or
|
||||
// destroyed by too many wrong codes. They are one message on purpose: which of
|
||||
// the three it was tells an attacker whether the password was right.
|
||||
var ErrTicketExpired = errors.New("this sign-in attempt has expired; start again")
|
||||
|
||||
// Ticket is a password that verified but has not yet become a session. It is
|
||||
// deliberately NOT a Session: nothing half-authenticated may reach a route
|
||||
// under Middleware, and the way to guarantee that is for it never to be the
|
||||
// type those routes read.
|
||||
type Ticket struct {
|
||||
UserID string `json:"user_id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Email string `json:"email"`
|
||||
Methods []string `json:"methods"`
|
||||
EnrolOnly bool `json:"enrol_only"`
|
||||
}
|
||||
|
||||
type ticketScope int
|
||||
|
||||
const (
|
||||
scopeVerify ticketScope = iota
|
||||
scopeEnrol
|
||||
)
|
||||
|
||||
func (t *Ticket) allows(s ticketScope) bool {
|
||||
if t.EnrolOnly {
|
||||
return s == scopeEnrol
|
||||
}
|
||||
return s == scopeVerify
|
||||
}
|
||||
|
||||
func attemptsLeft(attempts int) int {
|
||||
if attempts >= maxTicketAttempts {
|
||||
return 0
|
||||
}
|
||||
return maxTicketAttempts - attempts
|
||||
}
|
||||
|
||||
// attemptsKey is the counter backing a ticket's brute-force cap. It is a
|
||||
// separate key rather than a field on the ticket JSON so INCR can make the
|
||||
// count atomic: two requests racing on the same ticket must each cost one
|
||||
// attempt, not both read the same count and both write count+1.
|
||||
func attemptsKey(id string) string {
|
||||
return ticketPrefix + id + ":attempts"
|
||||
}
|
||||
|
||||
func CreateTicket(ctx context.Context, t *Ticket) (string, error) {
|
||||
id, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, ticketPrefix+id, data, ticketTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func LoadTicket(ctx context.Context, id string) (*Ticket, error) {
|
||||
data, err := rdb.Get(ctx, ticketPrefix+id).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t Ticket
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// FailTicket records a wrong code and returns how many attempts remain. The
|
||||
// count is kept in its own INCR-backed key rather than the ticket JSON: a
|
||||
// read-modify-write on the JSON lets requests racing on the same ticket all
|
||||
// read the same count and all write count+1, which bypasses the cap instead
|
||||
// of costing one attempt each. At zero the ticket is destroyed rather than
|
||||
// left to time out.
|
||||
func FailTicket(ctx context.Context, id string) (int, error) {
|
||||
// Confirms the ticket exists first, so a missing/expired/destroyed ticket
|
||||
// still answers with the one indistinguishable ErrTicketExpired rather
|
||||
// than incrementing a counter for an id nobody holds.
|
||||
if _, err := LoadTicket(ctx, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
key := attemptsKey(id)
|
||||
n, err := rdb.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n == 1 {
|
||||
// Only the creator of the counter sets its expiry, so a later
|
||||
// increment never extends it past the ticket's own window.
|
||||
if err := rdb.Expire(ctx, key, ticketTTL).Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
left := attemptsLeft(int(n))
|
||||
if left == 0 {
|
||||
_ = DeleteTicket(ctx, id)
|
||||
return 0, nil
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func DeleteTicket(ctx context.Context, id string) error {
|
||||
return rdb.Del(ctx, ticketPrefix+id, attemptsKey(id)).Err()
|
||||
}
|
||||
|
||||
func SetPendingCookie(c *gin.Context, id string) {
|
||||
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: pendingCookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(ticketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearPendingCookie(c *gin.Context) {
|
||||
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: pendingCookieName, Value: "", Path: "/",
|
||||
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// ticketFromRequest resolves the pending ticket and enforces its scope. It
|
||||
// writes the response and returns false when the ticket is unusable.
|
||||
func ticketFromRequest(c *gin.Context, scope ticketScope) (*Ticket, string, bool) {
|
||||
cookie, err := c.Request.Cookie(pendingCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
abortTicketExpired(c)
|
||||
return nil, "", false
|
||||
}
|
||||
t, err := LoadTicket(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
abortTicketExpired(c)
|
||||
return nil, "", false
|
||||
}
|
||||
if !t.allows(scope) {
|
||||
abortTicketExpired(c)
|
||||
return nil, "", false
|
||||
}
|
||||
return t, cookie.Value, true
|
||||
}
|
||||
|
||||
func abortTicketExpired(c *gin.Context) {
|
||||
ClearPendingCookie(c)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": ErrTicketExpired.Error(), "code": "mfa_ticket_expired",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
// The ticket's attempt cap is the only per-ticket brute-force guard: five
|
||||
// wrong codes must destroy it rather than let an attacker keep guessing
|
||||
// against a single stolen password.
|
||||
func TestAttemptsLeftCountsDownAndHitsZero(t *testing.T) {
|
||||
cases := []struct {
|
||||
attempts int
|
||||
want int
|
||||
}{
|
||||
{0, 5}, {1, 4}, {4, 1}, {5, 0}, {9, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := attemptsLeft(tc.attempts); got != tc.want {
|
||||
t.Errorf("attemptsLeft(%d) = %d, want %d", tc.attempts, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attemptsKey must derive deterministically from the ticket id and stay
|
||||
// distinct from the ticket's own key, since FailTicket relies on INCR against
|
||||
// it being the sole writer of the attempt count.
|
||||
func TestAttemptsKeyIsDerivedFromTicketID(t *testing.T) {
|
||||
got := attemptsKey("abc123")
|
||||
want := "km:mfa:abc123:attempts"
|
||||
if got != want {
|
||||
t.Errorf("attemptsKey(%q) = %q, want %q", "abc123", got, want)
|
||||
}
|
||||
if attemptsKey("abc123") == ticketPrefix+"abc123" {
|
||||
t.Error("attempts key must not collide with the ticket's own key")
|
||||
}
|
||||
}
|
||||
|
||||
// An enrol-only ticket exists because the instance requires MFA the user does
|
||||
// not have. It must not satisfy a verification endpoint, and a verification
|
||||
// ticket must not reach the enrolment endpoints - each would skip the other's
|
||||
// purpose.
|
||||
func TestTicketScopeIsEnforcedInBothDirections(t *testing.T) {
|
||||
verify := &Ticket{Methods: []string{"totp"}}
|
||||
enrol := &Ticket{EnrolOnly: true}
|
||||
|
||||
if !verify.allows(scopeVerify) || verify.allows(scopeEnrol) {
|
||||
t.Error("a verification ticket must allow only verification")
|
||||
}
|
||||
if !enrol.allows(scopeEnrol) || enrol.allows(scopeVerify) {
|
||||
t.Error("an enrolment ticket must allow only enrolment")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
@@ -88,6 +89,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
|
||||
}
|
||||
@@ -221,15 +226,10 @@ func completeSSOLogin(c *gin.Context, instanceID, email, name string) {
|
||||
}
|
||||
}
|
||||
|
||||
sessionID, err := SaveSession(c.Request.Context(), &Session{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
if err := saveSignIn(c, oidcSession(u, name, time.Now())); err != nil {
|
||||
c.Redirect(http.StatusFound, "/login?error=session_failed")
|
||||
return
|
||||
}
|
||||
_ = services.TouchLastLogin(u.UserID)
|
||||
SetSessionCookie(c, sessionID)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// HandlePasskeyLoginBegin starts a passwordless sign-in.
|
||||
//
|
||||
// It repeats every gate /auth/login applies - instance resolution, the locked
|
||||
// instance refusal, and the local-login setting - because this is a second
|
||||
// front door, and a front door that skips the locks is not a shortcut.
|
||||
//
|
||||
// @Summary Begin passwordless passkey sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 403 {object} object{error=string}
|
||||
// @Router /auth/passkey/begin [post]
|
||||
func HandlePasskeyLoginBegin(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
if !services.LocalLoginPermitted(instanceID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
|
||||
return
|
||||
}
|
||||
// Discoverable login: no allowCredentials, so the authenticator offers
|
||||
// whichever resident credential it holds for this RP ID.
|
||||
options, sessionData, err := w.BeginDiscoverableLogin(
|
||||
webauthn.WithUserVerification(protocol.VerificationPreferred))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandlePasskeyLoginFinish verifies a discoverable assertion and mints a
|
||||
// session. A user-verified passkey is possession plus a PIN or biometric, so it
|
||||
// satisfies require_mfa on its own.
|
||||
//
|
||||
// @Summary Complete passwordless passkey sign-in
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/passkey/finish [post]
|
||||
func HandlePasskeyLoginFinish(c *gin.Context) {
|
||||
instanceID, err := resolveLoginInstance(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "sign-in is not available here"})
|
||||
return
|
||||
}
|
||||
if !services.LocalLoginPermitted(instanceID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
|
||||
return
|
||||
}
|
||||
// finishAssertion is given no userID or email, so it resolves the owning
|
||||
// user from the credential ID scoped to this instance and validates with
|
||||
// the library's discoverable path, refusing a user handle that is not
|
||||
// that owner's.
|
||||
cred, err := finishAssertion(c, instanceID, "", "")
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "passwordless assertion", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that passkey could not be verified", "code": "invalid_assertion",
|
||||
})
|
||||
return
|
||||
}
|
||||
stored, err := services.GetPasskeyByCredentialID(instanceID, cred.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
u, err := services.GetUserInInstance(instanceID, stored.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{services.FactorWebAuthn}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = services.TouchPasskey(instanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -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"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,13 +23,22 @@ 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:"-"`
|
||||
|
||||
// AMR records how this session authenticated: pwd, otp, webauthn,
|
||||
// recovery, oidc. Step-up reads it to exempt OIDC sessions, whose IdP owns
|
||||
// authentication policy.
|
||||
AMR []string `json:"amr,omitempty"`
|
||||
|
||||
// StepUpAt is the last successful re-authentication. Sign-in counts as one.
|
||||
StepUpAt *time.Time `json:"step_up_at,omitempty"`
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
@@ -37,7 +46,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{
|
||||
@@ -102,3 +111,15 @@ func GetSession(ctx context.Context, id string) (*Session, error) {
|
||||
func DeleteSession(ctx context.Context, id string) error {
|
||||
return rdb.Del(ctx, sessionPrefix+id).Err()
|
||||
}
|
||||
|
||||
// TouchStepUp records a fresh re-authentication without disturbing the
|
||||
// session's remaining lifetime.
|
||||
func TouchStepUp(ctx context.Context, id string, sess *Session) error {
|
||||
now := time.Now()
|
||||
sess.StepUpAt = &now
|
||||
data, err := json.Marshal(sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rdb.Set(ctx, sessionPrefix+id, data, redis.KeepTTL).Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// StepUpWindow is how long one re-authentication covers. Ten minutes is long
|
||||
// enough to open several consoles in a row and short enough that a walked-away
|
||||
// laptop is not a fleet-wide credential.
|
||||
const StepUpWindow = 10 * time.Minute
|
||||
|
||||
func stepUpFresh(sess *Session, now time.Time) bool {
|
||||
if sess == nil {
|
||||
return false
|
||||
}
|
||||
// An API token authenticates per request and has no human to prompt.
|
||||
if sess.TokenID != "" {
|
||||
return true
|
||||
}
|
||||
for _, a := range sess.AMR {
|
||||
if a == "oidc" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return sess.StepUpAt != nil && now.Sub(*sess.StepUpAt) < StepUpWindow
|
||||
}
|
||||
|
||||
// RequireStepUp guards the actions that hand out credentials rather than
|
||||
// describe them: secret reveal, private key download, console connect.
|
||||
//
|
||||
// It answers a machine-readable code rather than a bare 403 so web/ can open
|
||||
// the re-authentication modal and retry the original request.
|
||||
func RequireStepUp() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
if stepUpFresh(sess, time.Now()) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if sess == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
methods, err := services.MFAMethods(sess.InstanceID, sess.UserID)
|
||||
if err != nil || len(methods) == 0 {
|
||||
methods = []string{services.FactorPassword}
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "re-authentication required",
|
||||
"code": "step_up_required",
|
||||
"methods": methods,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TouchStepUpFromRequest records a fresh step-up against the session the
|
||||
// request arrived on. It lives in auth because the cookie name and the Redis
|
||||
// key are this package's business.
|
||||
func TouchStepUpFromRequest(c *gin.Context) error {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := GetSessionFromContext(c)
|
||||
return TouchStepUp(c.Request.Context(), cookie.Value, sess)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestStepUpFresh(t *testing.T) {
|
||||
now := time.Now()
|
||||
ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sess *Session
|
||||
want bool
|
||||
}{
|
||||
{"just signed in", &Session{StepUpAt: ago(time.Minute)}, true},
|
||||
{"nine minutes ago", &Session{StepUpAt: ago(9 * time.Minute)}, true},
|
||||
{"eleven minutes ago", &Session{StepUpAt: ago(11 * time.Minute)}, false},
|
||||
{"never", &Session{}, false},
|
||||
// An API token has no human to prompt; the spec exempts it and records
|
||||
// the bypass as a known limitation.
|
||||
{"api token", &Session{TokenID: "tok_1"}, true},
|
||||
// An OIDC session's IdP owns authentication policy.
|
||||
{"oidc session", &Session{AMR: []string{"oidc"}, StepUpAt: ago(time.Hour)}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := stepUpFresh(tc.sess, now); got != tc.want {
|
||||
t.Fatalf("stepUpFresh = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A missing session must refuse rather than panic dereferencing sess.
|
||||
// auth.Middleware guarantees a non-nil session on every route today, but
|
||||
// RequireStepUp must not rely on that holding forever.
|
||||
func TestRequireStepUpNilSessionRefusesWithoutPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/console/connect", nil)
|
||||
|
||||
RequireStepUp()(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionIsExemptFromStepUp(t *testing.T) {
|
||||
now := time.Now()
|
||||
u := &models.User{UserID: "u1", InstanceID: "i1", Role: "member", Email: "a@example.com"}
|
||||
s := oidcSession(u, "Ann", now.Add(-24*time.Hour))
|
||||
if !stepUpFresh(s, now) {
|
||||
t.Fatal("an OIDC session must be exempt from step-up however old its sign-in")
|
||||
}
|
||||
if s.StepUpAt == nil || s.Name != "Ann" || s.UserID != "u1" || s.InstanceID != "i1" {
|
||||
t.Fatalf("oidc session missing fields: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInSessionIsFreshAtSignIn(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := newSession(&models.User{UserID: "u1"}, []string{"pwd"}, now)
|
||||
if !stepUpFresh(s, now) {
|
||||
t.Fatal("a session is fresh at the moment of sign-in")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
ceremonyPrefix = "km:wa:"
|
||||
ceremonyTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// rpConfig derives the relying party from the request. The RP ID is the host
|
||||
// without its port - WebAuthn forbids a port there - while the origin keeps it.
|
||||
//
|
||||
// This is why the reverse proxy must preserve Host: a proxy rewriting it makes
|
||||
// every passkey on the instance fail to verify, with no error that says so.
|
||||
func rpConfig(c *gin.Context) (string, string) {
|
||||
host := c.Request.Host
|
||||
rpID := host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
rpID = h
|
||||
}
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
// Only development is ever plain HTTP; WebAuthn permits it on localhost.
|
||||
scheme = "http"
|
||||
}
|
||||
return rpID, scheme + "://" + host
|
||||
}
|
||||
|
||||
// logWebAuthnFailure records why a ceremony was refused. The response stays
|
||||
// deliberately vague, so without this the only evidence of a misconfigured
|
||||
// relying party - most often a proxy that terminates TLS without passing
|
||||
// X-Forwarded-Proto: https - is a user reporting that their passkey "could not
|
||||
// be verified". Nothing logged here is secret: RP ID, origins, and the
|
||||
// library's error, whose DevInfo names expected and received values.
|
||||
func logWebAuthnFailure(c *gin.Context, stage string, err error) {
|
||||
rpID, origin := rpConfig(c)
|
||||
detail := err.Error()
|
||||
var perr *protocol.Error
|
||||
if errors.As(err, &perr) && perr.DevInfo != "" {
|
||||
detail += " (" + perr.DevInfo + ")"
|
||||
}
|
||||
log.Printf("webauthn: %s refused: rp_id=%q expected_origin=%q request_origin=%q x_forwarded_proto=%q: %s",
|
||||
stage, rpID, origin, c.GetHeader("Origin"), c.GetHeader("X-Forwarded-Proto"), detail)
|
||||
}
|
||||
|
||||
func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
|
||||
rpID, origin := rpConfig(c)
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: "Vantage",
|
||||
RPID: rpID,
|
||||
RPOrigins: []string{origin},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
// Preferred, not required, and not checked on the result. Requiring
|
||||
// it made password managers such as NordPass ask for their master
|
||||
// password on every use, which other sites avoid by preferring it.
|
||||
// The cost is deliberate: an authenticator that skips verification
|
||||
// makes a passkey possession-only, so passwordless sign-in and
|
||||
// step-up then rest on the device or vault being unlocked.
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// waUser adapts our records to the library's interface. The handle is random
|
||||
// and per-user: a resident credential hands its user handle to any origin that
|
||||
// asks, so the user ID must not be it.
|
||||
type waUser struct {
|
||||
handle []byte
|
||||
name string
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u waUser) WebAuthnID() []byte { return u.handle }
|
||||
func (u waUser) WebAuthnName() string { return u.name }
|
||||
func (u waUser) WebAuthnDisplayName() string { return u.name }
|
||||
func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
|
||||
func toLibCredential(c models.WebAuthnCredential) webauthn.Credential {
|
||||
return webauthn.Credential{
|
||||
ID: c.CredentialID,
|
||||
PublicKey: c.PublicKey,
|
||||
AttestationType: "none",
|
||||
Flags: webauthn.CredentialFlags{
|
||||
BackupEligible: c.BackupEligible != nil && *c.BackupEligible,
|
||||
BackupState: c.BackupState,
|
||||
},
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: c.AAGUID,
|
||||
SignCount: c.SignCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// backupEligibleFor is the eligibility to validate an assertion against.
|
||||
//
|
||||
// A recorded value always stands, so the library still refuses a credential
|
||||
// whose eligibility changed. A row registered before the flag was stored has no
|
||||
// baseline at all, and comparing against a zero value refused every synced
|
||||
// passkey; for those the flag in the signed authenticator data is adopted, and
|
||||
// TouchPasskey records it after the assertion verifies.
|
||||
func backupEligibleFor(stored *bool, observed bool) bool {
|
||||
if stored != nil {
|
||||
return *stored
|
||||
}
|
||||
return observed
|
||||
}
|
||||
|
||||
func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) {
|
||||
id, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blob, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so
|
||||
// it is deleted as it is read.
|
||||
func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) {
|
||||
blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data webauthn.SessionData
|
||||
if err := json.Unmarshal(blob, &data); err != nil {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in.
|
||||
//
|
||||
// @Summary Begin passkey verification during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/webauthn/begin [post]
|
||||
func HandleMFAWebAuthnBegin(c *gin.Context) {
|
||||
t, _, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
creds, err := services.ListPasskeys(t.InstanceID, t.UserID)
|
||||
if err != nil || len(creds) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(creds))
|
||||
for _, cr := range creds {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib},
|
||||
webauthn.WithUserVerification(protocol.VerificationPreferred))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleMFAWebAuthnFinish verifies the assertion and signs the user in.
|
||||
//
|
||||
// @Summary Complete sign-in with a passkey
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/webauthn/finish [post]
|
||||
func HandleMFAWebAuthnFinish(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "second-factor assertion", err)
|
||||
left, ferr := FailTicket(c.Request.Context(), ticketID)
|
||||
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn")
|
||||
if ferr != nil || left == 0 {
|
||||
abortTicketExpired(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
|
||||
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyBegin starts registration for the signed-in user.
|
||||
//
|
||||
// @Summary Begin passkey registration
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Router /me/passkeys/begin [post]
|
||||
func HandleRegisterPasskeyBegin(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: sess.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyFinish stores the new credential.
|
||||
//
|
||||
// @Summary Complete passkey registration
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Router /me/passkeys/finish [post]
|
||||
func HandleRegisterPasskeyFinish(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "registration", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, t := range parsed.Response.Transports {
|
||||
transports = append(transports, string(t))
|
||||
}
|
||||
if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn")
|
||||
|
||||
// A first factor earns recovery codes; later ones do not reissue them.
|
||||
m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID)
|
||||
if services.RecoveryCodesRemaining(m) == 0 {
|
||||
codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleStepUpWebAuthnBegin offers an assertion challenge for step-up
|
||||
// re-authentication: the signed-in user proving it is still them before a
|
||||
// guarded action, rather than a pending ticket proving it during sign-in.
|
||||
//
|
||||
// @Summary Begin passkey step-up
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 400 {object} object{error=string}
|
||||
// @Router /me/step-up/webauthn/begin [post]
|
||||
func HandleStepUpWebAuthnBegin(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
creds, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
|
||||
if err != nil || len(creds) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(creds))
|
||||
for _, cr := range creds {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib},
|
||||
webauthn.WithUserVerification(protocol.VerificationPreferred))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleStepUpWebAuthnFinish verifies the assertion and records a fresh
|
||||
// step-up for the current session. Unlike HandleMFAWebAuthnFinish this does
|
||||
// not mint a session: the caller is already signed in, this only proves they
|
||||
// still hold the passkey. finishAssertion is called with the session's own
|
||||
// user ID, so a credential belonging to somebody else is refused rather than
|
||||
// stepping up this session on their behalf.
|
||||
//
|
||||
// @Summary Complete passkey step-up
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/step-up/webauthn/finish [post]
|
||||
func HandleStepUpWebAuthnFinish(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
cred, err := finishAssertion(c, sess.InstanceID, sess.UserID, sess.Email)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "step-up assertion", err)
|
||||
services.LogEvent(sess.InstanceID, "step_up.failed", sess.Email, "", "", "factor=webauthn")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"})
|
||||
return
|
||||
}
|
||||
_ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
|
||||
if err := TouchStepUpFromRequest(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "step_up.ok", sess.Email, "", "", "factor=webauthn")
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
|
||||
// step-up, so the verification rules (backup eligibility, clone detection,
|
||||
// instance scope) exist once.
|
||||
func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) {
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
return nil, errors.New("assertion required")
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID != "" && stored.UserID != userID {
|
||||
return nil, errors.New("credential belongs to another user")
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(instanceID, stored.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libCred := toLibCredential(*stored)
|
||||
libCred.Flags.BackupEligible = backupEligibleFor(stored.BackupEligible,
|
||||
parsed.Response.AuthenticatorData.Flags.HasBackupEligible())
|
||||
user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{libCred}}
|
||||
var cred *webauthn.Credential
|
||||
if userID == "" {
|
||||
// Discoverable (passwordless) ceremony: the session carries no user,
|
||||
// so the library asks us to resolve the authenticator's user handle.
|
||||
// The only acceptable answer is the credential's own stored owner in
|
||||
// this instance; any other handle is refused.
|
||||
cred, err = w.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
|
||||
if !discoverableHandleMatches(handle, userHandle) {
|
||||
return nil, errors.New("user handle does not match the credential owner")
|
||||
}
|
||||
return user, nil
|
||||
}, *sessionData, parsed)
|
||||
} else {
|
||||
cred, err = w.ValidateLogin(user, *sessionData, parsed)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A counter that fails to advance is the library's clone signal. Zero on
|
||||
// both sides means the authenticator does not keep one, which is normal.
|
||||
if cred.Authenticator.CloneWarning {
|
||||
return nil, errors.New("authenticator may be cloned")
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// discoverableHandleMatches reports whether the user handle an authenticator
|
||||
// returned belongs to the credential's stored owner. Empty never matches.
|
||||
func discoverableHandleMatches(ownerHandle, userHandle []byte) bool {
|
||||
return len(ownerHandle) > 0 && subtle.ConstantTimeCompare(ownerHandle, userHandle) == 1
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it
|
||||
// silently makes every existing passkey unusable - so the port-stripping and
|
||||
// scheme rules are pinned here.
|
||||
func TestRPConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, host, proto string
|
||||
wantID, wantOrig string
|
||||
}{
|
||||
{"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"},
|
||||
{"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"},
|
||||
{"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil)
|
||||
c.Request.Host = tc.host
|
||||
if tc.proto != "" {
|
||||
c.Request.Header.Set("X-Forwarded-Proto", tc.proto)
|
||||
}
|
||||
id, origin := rpConfig(c)
|
||||
if id != tc.wantID || origin != tc.wantOrig {
|
||||
t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverableHandleMatches(t *testing.T) {
|
||||
owner := bytes.Repeat([]byte{7}, 64)
|
||||
if !discoverableHandleMatches(owner, bytes.Repeat([]byte{7}, 64)) {
|
||||
t.Fatal("the owner's own handle must match")
|
||||
}
|
||||
other := bytes.Repeat([]byte{7}, 64)
|
||||
other[63] = 8
|
||||
if discoverableHandleMatches(owner, other) {
|
||||
t.Fatal("another user's handle must not match")
|
||||
}
|
||||
if discoverableHandleMatches(nil, nil) || discoverableHandleMatches(owner, nil) {
|
||||
t.Fatal("an empty handle must never match")
|
||||
}
|
||||
}
|
||||
|
||||
// Synced passkeys (iCloud Keychain, Google Password Manager, 1Password) report
|
||||
// backup eligibility, and go-webauthn refuses any assertion whose flag differs
|
||||
// from the stored credential's. Not storing it made every synced passkey fail
|
||||
// with "Backup Eligible flag inconsistency".
|
||||
func TestLibCredentialCarriesStoredBackupFlags(t *testing.T) {
|
||||
yes := true
|
||||
lc := toLibCredential(models.WebAuthnCredential{BackupEligible: &yes, BackupState: true})
|
||||
if !lc.Flags.BackupEligible || !lc.Flags.BackupState {
|
||||
t.Fatalf("flags not carried: %+v", lc.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupEligibleFor(t *testing.T) {
|
||||
yes, no := true, false
|
||||
cases := []struct {
|
||||
name string
|
||||
stored *bool
|
||||
observed bool
|
||||
want bool
|
||||
}{
|
||||
// Registered before the flag was recorded: no baseline, adopt it.
|
||||
{"legacy row, synced passkey", nil, true, true},
|
||||
{"legacy row, hardware key", nil, false, false},
|
||||
// Recorded: the stored value stands, so the library still refuses a
|
||||
// credential whose eligibility genuinely changed.
|
||||
{"recorded eligible", &yes, true, true},
|
||||
{"recorded not eligible, now claims eligible", &no, true, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := backupEligibleFor(tc.stored, tc.observed); got != tc.want {
|
||||
t.Fatalf("backupEligibleFor = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
//
|
||||
// Everything here is deliberately best-effort delivery with an explicit ack
|
||||
// rather than a queue. A command whose owner pod died between the presence
|
||||
// check and the publish must fail loudly and immediately — the caller answers
|
||||
// 503 and the operator retries — not sit in a queue waiting for a stream that
|
||||
// check and the publish must fail loudly and immediately - the caller answers
|
||||
// 503 and the operator retries - not sit in a queue waiting for a stream that
|
||||
// no longer exists.
|
||||
package bus
|
||||
|
||||
@@ -97,7 +97,7 @@ const (
|
||||
//
|
||||
// The listener cannot be bound in advance on any particular pod. An agent's
|
||||
// ProxyStream is a separate HTTP/2 request from its CommandStream, and an
|
||||
// L7 proxy (Traefik) balances requests, not connections — so it may land on
|
||||
// L7 proxy (Traefik) balances requests, not connections - so it may land on
|
||||
// any replica, not the one holding the command stream. The pod it does land
|
||||
// on binds the listener and announces it here.
|
||||
ProxyAddrChannel = prefix + "proxyaddr:"
|
||||
@@ -295,7 +295,7 @@ func SetPendingProxy(ctx context.Context, proxyID, instanceID, serverID string,
|
||||
// round trips: single use is the whole security property, and two agents
|
||||
// racing the same proxy_id must not both be served.
|
||||
//
|
||||
// A missing record is reported as "", "" rather than an error — an unknown
|
||||
// A missing record is reported as "", "" rather than an error - an unknown
|
||||
// proxy_id, an expired one and a second claim are all the same refusal.
|
||||
func ClaimPendingProxy(ctx context.Context, proxyID string) (instanceID, serverID string) {
|
||||
v, err := claimPending.Run(ctx, rdb, []string{ProxyPendingKey + proxyID}).Text()
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
// a race anyone wins.
|
||||
//
|
||||
// Redis rather than a Kubernetes Lease so that Docker Compose, which has no
|
||||
// API server, takes the identical code path — one implementation to reason
|
||||
// API server, takes the identical code path - one implementation to reason
|
||||
// about, not two.
|
||||
//
|
||||
// job is given a context cancelled the moment leadership is lost, and must
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error {
|
||||
// testable without a real stream.
|
||||
//
|
||||
// The listener is bound here, on whichever replica the stream reached, rather
|
||||
// than in advance on the pod holding the agent's command stream — those are not
|
||||
// than in advance on the pod holding the agent's command stream - those are not
|
||||
// the same pod, because an L7 proxy balances HTTP/2 requests independently.
|
||||
func serveProxy(open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error {
|
||||
sess, err := services.ClaimProxyStream(instanceID, open.ServerId, open.ProxyId)
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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
|
||||
}
|
||||
target.Metric = stringArg(rawTarget, "metric")
|
||||
target.Mount = stringArg(rawTarget, "mount")
|
||||
if n, ok := rawTarget["threshold"].(float64); ok {
|
||||
target.Threshold = n
|
||||
}
|
||||
if n, ok := rawTarget["period_sec"].(float64); ok {
|
||||
target.PeriodSec = int(n)
|
||||
}
|
||||
if n, ok := rawTarget["grace_sec"].(float64); ok {
|
||||
target.GraceSec = int(n)
|
||||
}
|
||||
if sel, ok := rawTarget["selector"].(map[string]any); ok {
|
||||
target.Selector = map[string]string{}
|
||||
for k, v := range sel {
|
||||
if s, ok := v.(string); ok {
|
||||
target.Selector[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
case models.MonitorHeartbeat, models.MonitorMetric:
|
||||
// Full validation (validateHeartbeat / validateMetric) runs inside
|
||||
// services.CreateMonitor; nothing further is required here.
|
||||
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)
|
||||
}
|
||||
forSec := 0
|
||||
if n, ok := args["for_sec"].(float64); ok && int(n) > 0 {
|
||||
forSec = int(n)
|
||||
}
|
||||
|
||||
return models.Monitor{
|
||||
Name: name,
|
||||
Group: stringArg(args, "group"),
|
||||
Type: monitorType,
|
||||
Target: target,
|
||||
IntervalSec: interval,
|
||||
ForSec: forSec,
|
||||
// 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, tls, heartbeat or metric.", 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. heartbeat takes period_sec and optional grace_sec; metric takes metric (disk_pct, disk_free_gb, mem_pct, load_per_core, unit_failed, container_unhealthy, reboot_pending_days, agent_offline_min), threshold, optional mount and selector (tag map).", 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."},
|
||||
{Name: "for_sec", Type: ArgInteger, Description: "For a metric monitor, how long the condition must hold before a server counts as down."},
|
||||
},
|
||||
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)
|
||||
out := map[string]any{
|
||||
"monitor_id": created.MonitorID,
|
||||
"name": created.Name,
|
||||
"enabled": false,
|
||||
"note": "Created disabled. Enable it in Vantage to start checking.",
|
||||
}
|
||||
if created.HeartbeatToken != "" {
|
||||
out["heartbeat_token"] = created.HeartbeatToken
|
||||
out["heartbeat_token_note"] = "Shown once."
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Metric incidents name a server, so a server-scoped token sees only
|
||||
// the ones on servers it can see, matching the REST endpoint.
|
||||
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve visible servers: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
incidents = services.FilterByVisibleServer(incidents, func(i models.Incident) string { return i.ServerID }, visible, restricted)
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user