Compare commits

..
84 Commits
Author SHA1 Message Date
mrhid6 7a0a1953f6 fix: Fixed api url on web
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 7m37s
2026-08-25 13:53:59 +00:00
mrhid6 3e4ccc9720 feat: Added more debug logging
Chart Release / chart (push) Successful in 16s
Server Deploy / deploy (push) Successful in 7m1s
2026-08-25 13:26:23 +00:00
mrhid6 e5947489e4 fix: Fixed status page published switch 2026-08-25 13:26:10 +00:00
mrhid6 0a7a10aeed feat: Added status pages to license page 2026-08-25 13:10:28 +00:00
mrhid6 28b813ba64 feat: sell status pages as a per-instance licence feature
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 9m30s
2026-08-25 09:25:05 +00:00
mrhid6 68160dc681 docs: correct the status page URL for self-hosted, trim to what ships
- The status page URL was given only as `<instance>.vantage.<tld>`, which a
  self-hosted install does not serve. Both deployments are now described.
- The banner is documented as one notice: the editor exposes no level picker
  and the view renders every level identically.
- `pending` added to the component states, which a monitor with no result yet
  renders.
- Delete page documented alongside un-publish.
- `TRUSTED_PROXIES` names the LAN case: with the RFC1918 default, a client on a
  private range reaching the server directly is itself trusted and can spoof
  `X-Forwarded-For` — and now `X-Forwarded-Host`. Narrow it to the proxy.
- CLAUDE.md: scopes are nine resources, not eight; `status-pages` added to the
  REST route table; the host-resolution rules recorded under Status pages.
2026-08-25 09:05:06 +00:00
mrhid6 32e7420d89 fix: wire status page and incident delete, stop promising a name we do not publish
- The display-name placeholder showed the monitor's own name, reading as "leave
  this blank and we will use it". The server deliberately does the opposite: a
  blank `display_name` publishes the raw monitor id, because publishing an
  internal name has to be a decision. The placeholder now says "Public name
  (required)" and Save is refused until every component has one, so nobody adds
  five monitors and discovers five UUIDs on their public page. The server
  fallback is unchanged.
- `deleteStatusPage` and `deleteStatusIncident` existed in the api client and
  were wired to nothing, and the page address is immutable — delete was the
  only correction for a typo and there was no way to reach it. The editor
  header gains a typed-confirmation Delete page, and each incident row a
  confirmed delete, both on the existing ConfirmDialog.
- The create modal's address hint had lost its em dash and read as a broken
  sentence.
2026-08-25 09:05:06 +00:00
mrhid6 7e1d67dba4 fix: file live outages as active, refuse "operational" over zero components
- A derived monitor outage with no `resolved_at` went to `History`, so an
  ongoing disruption was listed under "Past incidents" while the component pill
  beside it read Down. Unresolved now goes to `ActiveIncidents`.
- `overallState` returned `up` when nothing was counted: "all systems
  operational" claimed from no evidence at all. A page with no components now
  reports `no_data`, which the view already renders as "Status unknown".
- `EnsureStatusPageIndexes` returned on the first failure, so a transient
  failure on the `status_pages` index left `status_incidents` with no unique
  `(instance_id, incident_id)` index — a correctness property, not a scan
  optimisation. All three are attempted and the failures joined.
2026-08-25 09:04:54 +00:00
mrhid6 da1dc90ac5 fix: resolve the public status page's tenant from a trusted X-Forwarded-Host
The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.

- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
  address on `X-Forwarded-For` — without the latter gin sees a request from the
  Next pod with no XFF and every visitor of every page shares one 120/min
  bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
  in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
  be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
  is reconstructed from the very headers being judged. `TrustedProxies()` moves
  from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
  the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
  has no slug and could never serve a status page; more than one instance is a
  404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
  of the slug rules, and now caches negative lookups: an unknown host cost a
  Mongo query per anonymous request, which is also a timing oracle separating
  "no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
  server of `/api`, so it published `/api/public/status/{pageId}` — a path that
  does not exist. The real address is described in prose instead.
2026-08-25 09:04:47 +00:00
mrhid6 72c9492223 docs: status pages 2026-08-25 08:44:38 +00:00
mrhid6 fa67d839cd fix: status page editor error handling, maintenance validation and UTC display 2026-08-25 08:40:05 +00:00
mrhid6 1452928b75 feat: status page authoring UI 2026-08-25 08:32:43 +00:00
mrhid6 bf10023f35 fix: empty slices rather than null on the unavailable status snapshot 2026-08-24 19:42:16 +00:00
mrhid6 f4f41e400b feat: public status page 2026-08-24 14:53:42 +00:00
mrhid6 3abbdc41d6 feat: status page authoring API
Adds owner|admin routes under /api/status-pages for authoring status pages
and their incidents/maintenance windows, gated by the status_pages licence
feature. Adds the "status" token scope resource and the ten route-scope
entries, and regenerates the committed OpenAPI document.

Also types ErrPageInvalid as a sentinel for status page/incident validation
failures (previously bare errors), so statusPageError maps them to 400
instead of 500, and createStatusIncident/updateStatusIncident route through
the shared error mapper rather than hand-rolling a 400 for any service error.
2026-08-24 14:43:17 +00:00
mrhid6 6ba54f690c fix: default TRUSTED_PROXIES in shipped deployments and route /public/ through ingress 2026-08-24 14:35:29 +00:00
mrhid6 a3c6b2a305 feat: public status page endpoint with per-address rate limit 2026-08-24 14:29:24 +00:00
mrhid6 21a2d077d8 feat: cached public status snapshot with licence gate 2026-08-24 14:24:07 +00:00
mrhid6 6263c7e16f fix: clear resolved_at when reopening a status incident via appended update 2026-08-24 14:21:26 +00:00
mrhid6 161835802d feat: authored status incidents and maintenance windows 2026-08-24 14:18:06 +00:00
mrhid6 6f998ff506 feat: status page CRUD and cache invalidation 2026-08-24 14:15:25 +00:00
mrhid6 d192589790 fix: maintenance repaint no longer zeroes no_data uptime; strengthen redaction test 2026-08-24 14:12:38 +00:00
mrhid6 21c2bb2646 feat: public status snapshot assembly and redaction boundary 2026-08-24 14:07:10 +00:00
mrhid6 9c0bbd13dd feat: status page id validation and cache key 2026-08-24 14:02:46 +00:00
mrhid6 1c15961309 feat: status page schema, licence feature and indexes 2026-08-24 14:00:00 +00:00
mrhid6 383b763a66 docs: attach approved status page mockup to the implementation plan 2026-08-24 13:52:07 +00:00
mrhid6 3e99a9df33 docs: public status pages implementation plan 2026-08-24 13:42:44 +00:00
mrhid6 f1b6f90345 docs: public status pages design spec 2026-08-24 13:32:24 +00:00
mrhid6 2a660697c5 docs: Updated troubleshotting doc
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m4s
2026-08-24 12:50:44 +00:00
mrhid6 0c08dda635 feat: Useragent
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 6m5s
2026-08-24 12:17:46 +00:00
mrhid6 22b99ff895 feat: Monitor grath zoom
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 6m31s
2026-08-24 10:58:50 +00:00
mrhid6 2fab784ba7 feat: Monitor groups and chart information
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 6m35s
2026-08-24 10:30:23 +00:00
mrhid6 83cdf92575 feat: Updated edit monitor page
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 43s
2026-08-24 09:25:43 +00:00
mrhid6 aa1c8e4aa1 feat: Hide secrets on api and channels
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 8m5s
2026-08-14 12:23:36 +00:00
mrhid6 ac61015cc0 fix: Fixed incorrect openapi doc
Chart Release / chart (push) Canceled after 0s
Server Deploy / deploy (push) Successful in 9m51s
2026-08-13 13:04:44 +00:00
mrhid6 a0fbf5b9ba fix: Word and colour Windows workloads correctly across the UI
Server Deploy / deploy (push) Canceled after 0s
Chart Release / chart (push) Canceled after 0s
Agent Release / build (push) Successful in 14m1s
Agent Release / msi (push) Successful in 1m22s
2026-08-13 12:57:37 +00:00
mrhid6 ddf0814803 docs: Fix Windows package-inventory and poll-loop claims in CLAUDE.md 2026-08-13 12:28:51 +00:00
mrhid6 2bc15648b7 docs: Describe the Windows agent's update and workload support
Chart Release / chart (push) Successful in 34s
Server Deploy / deploy (push) Failing after 5m34s
Agent Release / build (push) Successful in 12m50s
Agent Release / msi (push) Successful in 4m22s
2026-08-13 12:23:34 +00:00
mrhid6 7f348b7b2b fix: Follow Windows Th/Td labels through the updates table 2026-08-13 12:19:47 +00:00
mrhid6 a71fd9a9f4 fix: Fixed docs entitlement 2026-08-13 12:18:21 +00:00
mrhid6 36848a519a feat: Word the workload and update panels for Windows servers 2026-08-13 12:16:10 +00:00
mrhid6 ea6d0b969c fix: Trim Windows event log after SCM filtering, not before 2026-08-13 12:12:11 +00:00
mrhid6 4ba9983cf8 feat: Control Windows services and read their event log as workloads 2026-08-13 12:04:49 +00:00
mrhid6 8caaa4540f fix: Respect .exe word boundary and unterminated quotes in servicePath 2026-08-13 12:00:59 +00:00
mrhid6 42f0cb67cb feat: Collect Windows services as workloads 2026-08-13 11:52:08 +00:00
mrhid6 1dfb3cc28c refactor: Split the agent workloads package by build tag 2026-08-13 10:49:24 +00:00
mrhid6 b51e87477e feat: Report whether a managed host is waiting on a reboot 2026-08-13 10:39:27 +00:00
mrhid6 05c0ad43d2 feat: Check and apply Windows updates through the Windows Update COM API 2026-08-13 10:36:22 +00:00
mrhid6 4a07af049c refactor: Split the agent updates package by build tag 2026-08-13 10:33:35 +00:00
mrhid6 057c193e26 feat: Add winexec helper for running PowerShell from the agent 2026-08-13 10:26:31 +00:00
mrhid6 38a731f269 docs: Add implementation plan for Windows agent parity 2026-08-13 10:21:04 +00:00
mrhid6 06d69590fc docs: Design for Windows agent parity on updates and workloads 2026-08-13 10:08:25 +00:00
mrhid6 6adee810dc feat: Cleanup old docs 2026-08-13 09:41:32 +00:00
mrhid6 00d4307346 fix: Fixed chart for ingress
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m40s
2026-08-13 09:29:01 +00:00
mrhid6 7e767ecb4f fix: Move the API keys page off the /api prefix
/api-keys shares a raw string prefix with /api, and the proxies in front
of this app do not all match by path segment. Nginx Proxy Manager routes
/api straight to the Go server with a prefix location, so /api-keys never
reached Next at all — it reached a control plane with no such route and
came back as a JSON 404. Traefik's PathPrefix has the same shape of
matcher, which puts the Helm ingress at risk whenever ingress.api.enabled
is on.

The page is /tokens now, which cannot collide with anything, and which
matches the /api/tokens the REST API already publishes. The sidebar still
says API Keys — the label is for the reader, the path is for the router.

A permanent redirect covers anyone who bookmarked the old path today.
Fixing the proxy config instead would have left the trap set for the next
deployment, and for whatever sits in front of it.
2026-08-13 09:26:34 +00:00
mrhid6 18495dba68 feat: Restyle the API keys page onto the shared list patterns
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 7m54s
The page was hand-rolling its loading spinner, error line and empty
paragraph while the rest of the console routes these through
AsyncBoundary with a TableSkeleton and an EmptyState — the same drift
Async.tsx was written to end. It also passed className="p-0" where Card
takes padding={false}.

The admin-only scope switch becomes the vulnerabilities page's pill
filter rather than a loose checkbox: seeing everyone's keys is a filter
over the list, not a preference, and someone who has learned one control
has now learned both.

Scopes collapse to one chip per resource with an r/rw qualifier. Sixteen
badges made the row taller than everything around it and still had to be
read one at a time.

In the create dialog Copy is now the primary action and Done the quiet
one, because the value is unrecoverable once the dialog closes, and the
result panel repeats the role, scopes and expiry that were just granted.
2026-08-13 08:59:06 +00:00
mrhid6 689d0e1d5b feat: Give API keys their own page and group the sidebar
The token management card sat on /settings, which is owner|admin
throughout, so it hid a capability every member already had: the API has
never required a role to mint or revoke your own key. It is now the
/api-keys page, reachable at every role, with the instance-wide lifetime
cap left behind on /settings because that is policy rather than one
person's credentials — and that split is what lets the page be ungated.

The sidebar gains groups: Fleet, Access, Automation, Instance, each with a
small-caps heading and a rule above it. Grouping is by what the operator
is doing rather than by which service answers, so SSH keys, secrets and
API keys sit together as credentials. A group whose every item is
admin-only disappears whole for a member; a labelled section with nothing
under it reads as a failure rather than a restriction.

The UI says keys while the collection, prefix and routes still say tokens.
Renaming a published endpoint to match a nav label would break every
script already written against it.
2026-08-13 08:54:42 +00:00
mrhid6 95527b3956 fix: Exclude /install and /update scripts from the OpenAPI document
handleInstallScript and handleUpdateScript are registered on the bare
gin engine at /install and /update, outside the /api group the
generated document's BasePath assumes. Their @Router annotations
therefore published /api/install and /api/update, paths that 404 —
the reference page told a reader to curl a URL that does not exist.

Removed the swag annotations from both handlers (replaced with a plain
comment explaining why) rather than adding a corrected @Router, since
swag has no per-route BasePath override and there is nothing lost by
leaving two shell-script endpoints out of a JSON API reference — their
.ps1 counterparts were already undocumented for the same reason.
Regenerated internal/api/docs/openapi.json accordingly.
2026-08-13 08:31:20 +00:00
mrhid6 225b53bfa7 fix: Throttle audit logging for expired API token use
Every request presenting an expired token wrote a token.expired_use
audit row, and RateLimitTokens only applies once a session exists, so a
rejected token was never rate-limited. A looping job with one expired
token could write an unbounded number of audit rows, drowning the real
audit trail.

services.ShouldLogExpiredTokenUse now dedupes to at most one
token.expired_use record per token per minute, mirroring the throttle
TouchAPIToken already uses for last-used. It lives in services rather
than auth because the storage concern belongs beside the token's other
storage-backed state. The first use per window is still recorded, which
is what makes a forgotten job visible.
2026-08-13 08:31:12 +00:00
mrhid6 965419b2b8 fix: Confine created API token scopes to the calling token's own
CreateAPIToken capped a new token's role at the creator's role but never
capped its scopes against the calling credential's scopes, and POST
/api/tokens required only settings:write. A token holding settings:write
alone could therefore mint a token holding keys:write or secrets:write,
reaching every SSH private key and vault secret in the instance.

createToken now refuses (403 scope_confinement) when the calling
credential is itself a token and any requested scope is not satisfied by
that token's own scopes, via services.ScopeSatisfied so servers:write
still permits granting servers:read. Cookie sessions are unaffected,
since their authority is the user's role. Also correct the createToken
doc comment, which claimed the scope cap already existed.

Also document why Hint stores 5 hex characters of the token secret.
2026-08-13 08:31:07 +00:00
mrhid6 f6988b0f1e docs: Correct API token access-control claim in CLAUDE.md 2026-08-13 08:12:48 +00:00
mrhid6 0784ef3719 docs: Document API tokens and the OpenAPI reference 2026-08-13 08:04:09 +00:00
mrhid6 9df4a29210 fix: Separate stacked securityDefinitions into distinct comment groups
swag v2.0.0-rc5's parseSecAttributesV3 resolves a security scheme's map key
via getSecurityDefinitionKey(lines), which scans from the start of whatever
comment-line slice it was handed and returns the first @securitydefinitions
match — ignoring the current parse position entirely. Three
@securityDefinitions.apikey blocks stacked in one Go comment group (the
three were separated only by bare '//' lines, which do not split an
ast.CommentGroup) therefore all resolved to the first block's name
(cookieAuth), with the last block's in/name/description winning: the
generated document had exactly one securityScheme, keyed cookieAuth, body
esoAuth.

Separating the three blocks with real blank source lines splits them into
three distinct ast.CommentGroups, so swag's file-level comment scan (which
requires no other tokens between them, same rule Go uses for doc comments)
hands each block its own line slice and each resolves its own key.
Regenerated openapi.json now carries all three schemes with correct
bodies, referenced with no dangling security requirements.
2026-08-13 07:54:56 +00:00
mrhid6 4c88d6e768 feat: Publish an OpenAPI 3.1 document and a Scalar reference
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 10m17s
Generated from swaggo v2 annotations, committed rather than built into the
image: the runtime stage is scratch and adding codegen puts the toolchain
in the build. CI regenerates and diffs, so an annotation edited without
regenerating fails the build — without that the annotations would drift
while still looking authoritative.

Scalar is vendored rather than loaded from a CDN, because air-gapped
self-hosted installs are supported and a reference page that fails closed
offline is a support ticket.
2026-08-12 15:23:02 +00:00
mrhid6 a85a354e57 feat: Annotate workflow, step, run and workload routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. This is the last of the handler files under
server/internal/api/.
2026-08-12 15:22:54 +00:00
mrhid6 9b18d09d9b feat: Annotate monitor, secrets and vulnerability routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. VulnSummaryResponse uses pointer fields so the
db-freshness block stays entirely absent when no vulndb_meta document
exists yet, matching the handler's original conditional gin.H exactly.
2026-08-12 15:22:51 +00:00
mrhid6 a398da0eac feat: Annotate SSO, channel, console, instance and licence routes
Same treatment as the previous commit: named types replace gin.H literals,
and every handler gets a swaggo doc block.
2026-08-12 15:22:48 +00:00
mrhid6 edb8406e05 feat: Annotate server, key and token routes for OpenAPI
Converts their gin.H responses to the named types added in the previous
commit and adds swaggo doc blocks for every handler in handlers.go and
tokens.go.
2026-08-12 15:22:45 +00:00
mrhid6 bfd185adbb feat: Add OpenAPI response types and top-level swag annotations
Named response types for handlers that were returning anonymous gin.H
literals, so a generated annotation and what the handler actually returns
cannot disagree. main.go carries the top-level swaggo info block (title,
description, security schemes for cookie, bearer token and ESO auth).
2026-08-12 15:22:42 +00:00
mrhid6 182752d9ab feat: Manage API tokens from settings
A card in the Access group beside Members and single sign-on rather than a
new nav entry — /settings/instance was folded back in for exactly this
reason. The plaintext is shown once in a well block and never again.

Tokens outside a newly tightened lifetime policy are flagged rather than
broken, because the policy governs issuance, not existing credentials.
2026-08-12 14:58:57 +00:00
mrhid6 3b4c87a292 feat: Rate limit API token requests
600 per minute per token, in the Redis that sessions already require.
Cookie sessions are untouched. A Redis failure falls through rather than
refusing traffic — it is already a larger problem and should not become a
second outage.
2026-08-12 14:51:22 +00:00
mrhid6 2685e9ad06 fix: Distinguish caller mistakes from backend failures in CreateAPIToken
createToken's catch-all mapped every unmatched error to 400, so a
database outage reported itself as a malformed client request. Wrap the
genuine validation failures with ErrTokenInvalid and let the handler
answer 500 with a fixed message for everything else.
2026-08-12 14:48:17 +00:00
mrhid6 4de67e4bea docs: Separate caller mistakes from backend failures in the plan
createToken's catch-all answered 400 for every unmatched error, so a
database failure reported itself as the caller's malformed request. Found
in review of Task 7.
2026-08-12 14:47:36 +00:00
mrhid6 524ccc6412 feat: Add the API token endpoints
Create, list and revoke, with no update: editing what a credential already
deployed in CI can do, with no record of what it could do before, is worse
than requiring a rotation. Revoking a token that is not yours answers
not-found, since a 403 confirms it exists.

The audit actor stays the human and names the credential alongside, so a
person clicking and their CI job are told apart.
2026-08-12 14:44:50 +00:00
mrhid6 be4f488db3 feat: Enforce API token scopes from the route map
Keyed on the registered gin route pattern rather than a per-route
decorator, because a route registered without a decorator would be
unguarded. An unmapped route reached by a token is a 403, and a boot-time
check refuses to start when any /api route is missing, so the failure
lands at deploy rather than as a customer's surprise 403.
2026-08-12 14:40:00 +00:00
mrhid6 2f60b81962 fix: Stop the session middleware writing two responses on an expired cookie
sessionFromCookie already writes "session expired" when a cookie was
presented and rejected with no bearer to fall through to. Middleware
called sessionFromToken anyway, which wrote a second "not authenticated"
body onto the same response for every ordinary browser-session timeout -
gin logged "superfluous response.WriteHeader call" on ordinary use, not a
rare edge case.

Guard on c.IsAborted() after sessionFromCookie: true only in that one
rejected-cookie-no-bearer branch, so it short-circuits there while the
other three credential paths (no credential, bearer only, stale cookie
plus valid bearer) are unaffected.
2026-08-12 14:35:20 +00:00
mrhid6 92692de94d docs: Correct the middleware fallback in the plan
The plan's Middleware called sessionFromToken even when sessionFromCookie
had already answered a rejected cookie, putting two JSON bodies on the
wire for the ordinary expired-session case. Found in review of Task 5.
2026-08-12 14:34:54 +00:00
mrhid6 a5f9fca59e feat: Authenticate the API with a bearer token as well as a cookie
One middleware, two ways to arrive at the same *Session, so every handler,
role guard, licence gate and audit call is untouched. The host guard
applies to both: a token carries an instance, and the tenant boundary must
not have a token-shaped hole in it.

The effective role is min(user, token) recomputed per request, so demoting
somebody demotes their tokens with them. A stale cookie beside a valid
bearer falls through rather than refusing a credential that would work.
2026-08-12 14:32:40 +00:00
mrhid6 72e5228351 feat: Add the API token service
Mint, resolve, list and revoke, with the effective role capped at the
owner's and recomputed per request rather than frozen at creation.

Deleting a user deletes their tokens in the same call, so offboarding is
one action. Revoking somebody else's token answers not-found rather than
forbidden, since a 403 confirms the credential exists.

Also re-exports shared.APITokenMaxDays into server/internal/models,
following the existing ValidRole wrapper pattern, since the token
service needs it and it was never re-exported.
2026-08-12 14:28:10 +00:00
mrhid6 33b5ec0788 feat: Add a per-instance API token lifetime cap
A pointer with absent meaning no cap, so an upgrade allows never-expire
tokens exactly as before and an instance opts into the policy. It governs
issuance only: changing it never invalidates a token that already exists.
2026-08-12 14:22:12 +00:00
mrhid6 1b718e7c59 feat: Define the API token scope vocabulary
Eight resources with read and write, write implying read. Coarse on
purpose: a scope per endpoint is a table nobody maintains, and a route
added without an entry either breaks or is unguarded.
2026-08-12 14:19:47 +00:00
mrhid6 6ad65a1242 feat: Add the api_tokens collection and its indexes
The unique index on token_hash is what makes authentication an indexed
lookup rather than a scan, so this builder is fatal on failure like
EnsureAuthIndexes rather than warning like the secrets one.

Registered in ScopedCollections so instance purge reaches it.
2026-08-12 14:16:12 +00:00
mrhid6 a41f2b26cc docs: Plan the API token and OpenAPI implementation
Twelve tasks from model through middleware, scope enforcement, endpoints,
web UI, generated OpenAPI and documentation. Verification is build plus
curl and UI checks rather than test cycles, matching a repository with no
Go test harness beyond shared/mail.
2026-08-12 14:09:03 +00:00
mrhid6 71f9a9dca5 docs: Specify scoped API tokens and an OpenAPI reference
Adds the approved design for personal access tokens on the control plane
REST API, and for the generated OpenAPI 3.1 document served as a Scalar
reference page.

Tokens fall back into the existing session middleware rather than getting
their own route group, so every handler, role guard and audit call works
unchanged. Scope enforcement derives from the registered route pattern and
fails closed, with a boot-time check for unmapped routes.

A Terraform provider is deliberately left to a follow-on spec.
2026-08-12 13:59:03 +00:00
145 changed files with 28666 additions and 13072 deletions
+5
View File
@@ -140,6 +140,11 @@ jobs:
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set server.env.grpcHost=agents.example.com:443
refuses "an ingress that leaves /api unrouted" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.enabled=false \
--set ingress.api.enabled=false
refuses "gRPC ingress while grpcHost is still in-cluster" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
+18
View File
@@ -95,6 +95,24 @@ jobs:
docker login ${{ vars.DOCKER_HOST }} \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Set up Go
if: steps.changed.outputs.server == 'true'
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: server/go.sum
- name: Verify the OpenAPI document is current
if: steps.changed.outputs.server == 'true'
run: |
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
cd server
swag init --generalInfo cmd/main.go --dir ./,../shared \
--output internal/api/docs --outputTypes json --v3.1
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
git diff --exit-code internal/api/docs/openapi.json
- name: Build and push server image
if: steps.changed.outputs.server == 'true'
run: |
+210 -11
View File
@@ -315,6 +315,17 @@ a Service cannot address the one pod holding a console listener.
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
Windows update checking and applying go through the Windows Update COM API
(`Microsoft.Update.Session`) rather than the PSWindowsUpdate module, which would
need a PowerShell Gallery install on every host and fails on an air-gapped
fleet. `CurrentVersion` is empty on Windows and `NewVersion` carries the KB
article ID: a Windows update is not a version bump of a named package.
**The agent never reboots a host.** `ApplyUpdatesCmd` installs and stops there;
`inventory.reboot_required` reports that one is owed, set on the static snapshot
every 15 minutes. Linux fills it too, from `/var/run/reboot-required` or
`dnf needs-restarting -r`.
### Package inventory and CVE findings
Agents report their installed packages hourly; the control plane matches them
@@ -364,10 +375,24 @@ scheduler off entirely.
A **workload** is one Docker container or one systemd unit — one word for the
page, the collection and the commands, rather than saying "container or
service" in every identifier. Linux only, and **not gated by licence**: this
reads as core fleet management, so v1 ships everywhere with no `HasFeature`
check. If that changes the check belongs at `ReportWorkloads`, gating collection
rather than display, exactly as sub-project A does.
service" in every identifier.
On Windows a workload is a Docker container or a Windows **service**, reported
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields —
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`. Every
script that reports data emits JSON that a build-tag-free parser reads, so
those parsers are tested on Linux — the agent module has no Windows CI. The
control verbs and `serviceDisplayName` emit no JSON and have no parser; they
are exercised only by running the agent on Windows.
**Not gated by licence**: this reads as core fleet management, so v1 ships
everywhere with no `HasFeature` check. If that changes the check belongs at
`ReportWorkloads`, gating collection rather than display, exactly as sub-project
A does.
Agents collect on a 60-second ticker and report through `ReportWorkloads` with
the **offer-then-send** handshake the package report already uses. The offer is
@@ -391,8 +416,9 @@ reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
no-op, so this costs nothing and avoids a second result path.
**The protected set is computed agent-side and enforced agent-side.**
`vantage-agent.service`, plus the container ID read from `/proc/self/cgroup`
should the agent ever run in a container. As with the console relay hardcoding
`vantage-agent.service` on Linux, `VantageAgent` on Windows, plus the container
ID read from `/proc/self/cgroup` should the agent ever run in a container. As
with the console relay hardcoding
`127.0.0.1`, the control plane may name a target but the agent decides what it
will do to itself; a server-side denylist alone would be bypassed by the next
dispatch path someone adds, and the failure is unrecoverable from the UI. The
@@ -430,10 +456,153 @@ there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
A message added to one must be added to the other and to the `.proto`, in the
same commit.
### Status pages
Two collections: `status_pages` is the page itself — title, banner, published
flag, and an ordered list of sections each holding entries that pair a
`monitor_id` with a per-page display name. `status_incidents` holds both
operator-authored incidents and maintenance windows, sharing one document
shape because they share a timeline, an impact and a set of affected
components; each carries an explicit `page_ids` rather than deriving it from
`affected_monitors`, because adding a monitor to a page later must not
retroactively republish that monitor's old incidents to a new audience.
**`services.assembleSnapshot` is the redaction boundary, and it is the only
one.** It takes a `snapshotInput` built from already-fetched
`models.Monitor`/`models.Rollup`/`models.Incident` documents and returns a
`StatusSnapshot` built entirely from a parallel, deliberately smaller
vocabulary (`PublicComponent`, `PublicIncident`, …) that has no field for a
target URL, host, port, expected status, keyword, failure message,
certificate expiry, latency, runner or notification channel — `models.Monitor`
itself never reaches an anonymous caller, only the handful of fields
`assembleSnapshot` chooses to copy out of it. Being a pure function of already-
fetched data (no DB calls inside it) is what makes the boundary testable
without a database, which is the only thing standing between an editor adding
a field to `PublicComponent` and that field being a hostname.
Monitor-detected outages are **derived at read time, never copied**: each
snapshot assembly reads recent `incidents` for the page's monitors and folds
them into the timeline alongside the authored ones. There is no second
incidents table for automatic ones and no reconciliation between two records
of the same outage. A maintenance window in progress **repaints how a day is
drawn, never the uptime number** — `buildDays` computes each day's up/down
state and the 90-day percentage from rollups first, and
`applyMaintenanceRepaint` only overwrites today's display state afterward, so
a component that stayed up throughout a maintenance window still shows as up
in its history.
The public route, `GET /public/status/:pageId`, is mounted on the gin **root**,
outside `/api`, on purpose: `/api` carries `auth.Middleware`, `RequireScopes`,
`RateLimitTokens` and `RequireActiveLicense` by virtue of where it is mounted,
and a public route living there would need four exemptions — each one a hole a
later change to any of those four could widen back open. A missing page, an
unpublished page, and a page on the wrong host all answer the same 404;
inventing a distinct code for "exists but unpublished" would itself leak that
the page exists. A lapsed licence or a tier lacking `status_pages` answers 200
with `available:false` and a `reason`, never a 403 or a blank page — the
reader is a member of the public who can do nothing about either condition and
deserves an explanation, not a browser error.
**The instance is resolved from `X-Forwarded-Host`, not `Host`.** The public
page is server-rendered by `web/`, and the SSR fetch cannot set `Host` at all:
it is a forbidden header name and undici drops it silently, so the Go server
saw `server:8080` and every status page 404'd on every deployment. `web/`
forwards the visitor's host in `X-Forwarded-Host` (and their address in
`X-Forwarded-For`, or the whole deployment shares one rate-limit bucket), and
`publicStatusInstance` honours that header **only when `c.RemoteIP()` is in
`TRUSTED_PROXIES`** — it selects a tenant, so an untrusted peer must not be
able to name one. It uses `RemoteIP()` and not `ClientIP()` deliberately: the
latter is reconstructed from the very headers being judged.
**A host naming no slug falls back to the sole instance on a non-cloud
deployment.** `hostSlug` requires `<slug>.vantage.<tld>`; a self-hosted install
at `vantage.acme.com` or an IP has no slug and would otherwise 404 forever. It
has exactly one instance, resolved with the same count-then-read bootstrap
uses, cached alongside the slug lookups. More than one instance is a 404, not a
guess. A host that *does* name a slug which does not exist stays a 404 —
falling back there would serve one tenant's page on another's address.
Assembled snapshots are cached in Redis for **30 seconds**, keyed per
instance and page, and every authoring write (`UpdateStatusPage`,
`DeleteStatusPage`, and every incident mutation) invalidates its page's entry
immediately rather than waiting out the TTL — an operator posting an update
mid-incident should not wonder for half a minute whether it saved. A cache
miss, on Redis being down or on any read error, degrades to reassembly rather
than an error: the status page has to survive the outage it exists to report.
The public endpoint itself is rate limited to **120 requests per minute per
client address**, answering 429 with `Retry-After`, on the same fixed-window
pattern as `RateLimitTokens`.
**`TRUSTED_PROXIES` is load-bearing for that limiter, not cosmetic.** `main.go`
always calls `gin.SetTrustedProxies` with it; left unset, gin trusts no proxy
and `c.ClientIP()` falls back to the direct peer address — which, sat behind a
real reverse proxy, is the proxy's own address for every visitor. The rate
limiter then keys on one address for the whole fleet of readers, and the first
burst of legitimate traffic during an incident is what trips it. Set it to the
proxy's real address or CIDR, not merely a private range guess; the shipped
compose file and Helm chart default it to the RFC1918 ranges, which is right
for their own bundled reverse proxy but wrong the moment another one is
inserted in front. The same setting also decides the address recorded in
`audit_logs` and `console_sessions`.
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
### API tokens and OpenAPI
A token is `vt_` plus 32 random bytes hex, shown once at creation and stored
only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
token, and for the same reason: nothing downstream ever needs the plaintext
back. It belongs to the user who created it, and its role can never exceed
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
true across a demotion rather than only at issuance. Scopes are nine
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
`workloads`, `status`, `settings` — each split into `:read` and `:write`, with `:write`
satisfying a `:read` requirement on the same resource so a caller does not have
to hold both. Any signed-in member may mint and revoke their **own** tokens —
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
`roleRank` already bounds what a token can do to no more than its creator's
own role, so a member cannot use a token to reach past themselves. Owner and
admin additionally see and revoke every token in the instance — `all=true` on
`GET /tokens` is gated by `elevated()` in `api/tokens.go`, and
`RevokeAPIToken` in `services/tokens.go` checks the same owner/admin condition
before letting a revoke target somebody else's token — neither is a
`RequireRole` middleware.
The `settings:read`/`settings:write` entries in `routeScopes` govern a
**token-authenticated** caller reaching the token endpoints — `RequireScopes`
no-ops entirely for a cookie session — so they say nothing about which human
role may call these routes with a session; that is `roleRank` and `elevated()`,
not the scope map. Expiry is optional per token; `settings.api_token_max_days` caps how
far out a new one may be set, and when that cap is set a token requested with
no expiry is refused rather than silently capped — the policy governs
issuance only and never reaches back to invalidate a token already issued.
`RateLimitTokens` holds every token to 600 requests/minute in a Redis fixed
window, answering 429 with `Retry-After`; cookie sessions are untouched; it
exists so a runaway script cannot take an instance down, not as the general
API rate-limiting project some future ticket might build.
**The UI calls them API keys and lives at `/tokens`, not on `/settings`.**
The page is reachable at **every** role, which is the whole reason it is a page:
`/settings` is owner|admin throughout, so a card there hid a capability every
member has. `settings.api_token_max_days` stays on `/settings` because it is
instance policy rather than one person's credentials, and that split is exactly
what lets the page be ungated. The label differs from the identifiers on
purpose — the collection is `api_tokens`, the prefix is `vt_`, the routes are
`/api/tokens`, and renaming a published endpoint to match a nav label would
break every script already written against it.
`server/internal/api/docs/openapi.json` is a **generated, committed** OpenAPI
3.1 document — `swag v2` reading `@…` annotations off the handlers — served at
`GET /api/openapi.json` and rendered as a reference page by a vendored Scalar
bundle at `GET /api/docs`. `server-deploy.yml` regenerates it on every server
build and runs `git diff --exit-code` against the committed copy: a handler
whose annotation drifted from its code fails CI rather than shipping a
reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
`GET /api/docs/scalar.js`) rather than pulled from a CDN, because the
reference page has to work on an air-gapped install with no outbound access at
all — the same requirement licence verification already meets.
### Marketing site and sitesvc
`site/` is a separate Next.js app built exactly like `web/``output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
@@ -668,6 +837,10 @@ workloads GET /workloads · GET /servers/:id/workloads
POST /servers/:id/workloads/refresh
POST /servers/:id/workloads/:wid/action (owner|admin)
GET /servers/:id/workloads/:wid/logs (owner|admin)
status-pages GET,POST /status-pages · GET,PUT,DELETE /status-pages/:pageId (owner|admin)
GET,POST /status-pages/:pageId/incidents
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
POST /status-pages/:pageId/incidents/:incidentId/updates
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
@@ -675,6 +848,8 @@ licence GET /license · POST /license (POST: self-hosted onl
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin)
tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id
GET /openapi.json · GET /docs
```
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
@@ -749,7 +924,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
@@ -768,11 +943,14 @@ Notes that are not obvious from the structs:
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable".
- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`.
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more `KindFeature` row per paid plan in `SeedCatalogue` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on `(kind, deployment, tier, feature_key)`, so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path.
### Migrations
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
@@ -814,7 +992,7 @@ tls: true
```
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
2. Non-Linux hosts stop here — the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
```
@@ -857,6 +1035,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `TRUSTED_PROXIES` | no | comma-separated CIDRs or addresses gin trusts for `X-Forwarded-For`. Empty means trust none: `c.ClientIP()` falls back to the direct peer address, which behind a real reverse proxy is that proxy's own address for every visitor — the public status page's per-address rate limit then keys on one address for the whole fleet of readers. Also the address recorded in `audit_logs` and `console_sessions`. Compose and the Helm chart default it to the RFC1918 ranges, right for their own bundled proxy and wrong the moment another one is inserted in front |
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
@@ -888,7 +1067,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.api.enabled` routes `/api` and `/auth` straight to the server.** Both arrangements work — without it `web` proxies those prefixes onward itself (`web/next.config.ts`) — but edge routing is one hop shorter and matches what the Nginx Proxy Manager in front of the Docker deployment already does, so leaving it off makes the request path a different shape on Kubernetes than in production. It stays **off by default** because it only helps where the server is reachable on the same host and certificate as `web`; turning it on blindly moves the whole API onto a route that may not be provisioned. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
@@ -898,6 +1077,8 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
---
**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all.
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk.
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
@@ -965,9 +1146,20 @@ Customer nav is three destinations — Overview, People, Billing. Settings is in
| `/steps` | Reusable step library |
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
| `/secrets`, `/secrets/[group]` | Vault |
| `/tokens` | Personal API keys — reachable at **every** role, unlike `/settings` |
| `/audit` | Audit log |
| `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence |
**The sidebar is grouped, and the groups are the nav's structure rather than
decoration.** `web/components/Sidebar.tsx` holds `navGroups` — Fleet, Access,
Automation, Instance — each rendered with a mono small-caps heading and a
hairline rule above it, the first group excepted. Grouping is by what the
operator is doing, not by which service answers: SSH keys, vault secrets and
API keys sit together under Access because all three are credentials. A group
whose every item is `adminOnly` disappears **whole**, heading and rule
included, for a member — a labelled section with nothing under it reads as
something that failed to load rather than something withheld.
**`/settings` is one page, not a section.** Members and single sign-on used to
live at `/settings/instance` with their own sidebar entry; they are now the
**Access** group at the top of `/settings`, above **Monitoring** and
@@ -1045,7 +1237,7 @@ git push origin main # server + web deploy
| `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected |
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
@@ -1076,7 +1268,14 @@ git push origin main # server + web deploy
- **guacd for console** — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
- **Windows agents cover the fleet-management path** — register, heartbeat, run
steps, report inventory, OS updates through the Windows Update COM API, and
workloads (services plus containers, with control and logs). They still do no
`authorized_keys` management, and no package inventory or CVE matching: a
Windows agent never calls `ReportPackages`, so no `server_packages` document
exists for it and it reports no package inventory at all — a different,
earlier state than the `unsupported` a Linux distribution reaches when its
family has no security feed.
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
+5 -2
View File
@@ -67,8 +67,10 @@ func (r CatalogueRow) Priced(env string) bool {
return false
}
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
// server limit, and one row per feature key.
// SeedCatalogue inserts the twenty-four rows the four PAID plans need: a base, a
// server limit, and one row per feature key. The count is deliberate — it moves
// whenever shared/license gains a feature, and this comment is how the next
// person knows the number was chosen rather than drifted.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
@@ -86,6 +88,7 @@ func SeedCatalogue(ctx context.Context) error {
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureStatusPages},
}
for _, r := range rows {
filter := bson.M{
+2
View File
@@ -10,12 +10,14 @@ export const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
vuln_scanning: "Vulnerability scanning",
status_pages: "Status pages",
};
export const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
vuln_scanning: "Package inventory matched against distribution security advisories",
status_pages: "Public status pages for your customers, built from your monitors",
};
export function featureLabel(key: string): string {
+5
View File
@@ -18,6 +18,10 @@ const (
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
// out. Match on a prefix, not equality: the version moves.
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
)
@@ -84,6 +88,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{Message: err.Error()}
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
+17 -31
View File
@@ -1,5 +1,3 @@
package pb
import (
@@ -83,8 +81,6 @@ type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
@@ -99,8 +95,6 @@ type ReportUpdatesRequest struct {
type ReportUpdatesResponse struct{}
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
@@ -119,20 +113,19 @@ type PartitionReport struct {
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
RebootRequired bool `json:"reboot_required,omitempty"`
}
type InventoryReportResponse struct{}
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
@@ -217,8 +210,6 @@ type ServerCommand struct {
// keepalive is not sufficient on its own.
type PingCmd struct{}
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
@@ -241,12 +232,12 @@ type GenerateKeyCmd struct {
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
}
@@ -264,8 +255,7 @@ type RunStepCmd struct {
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
WorkspaceId string `json:"workspace_id,omitempty"`
}
@@ -284,8 +274,6 @@ type StepOutputChunk struct {
Eof bool `json:"eof,omitempty"`
}
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
@@ -308,8 +296,6 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
return m, nil
}
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
+10
View File
@@ -450,6 +450,16 @@ func runInventory(ctx context.Context, cfg *config.Config) {
r := inventory.Collect(static)
r.ServerId = cfg.ServerID
r.AgentToken = cfg.AgentToken
// 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.
//
// Computed here rather than inside inventory.Collect so the inventory
// package gains no dependency on updates.
if static {
r.RebootRequired = updates.RebootRequired()
}
if err := client.ReportInventory(r); err != nil {
log.Printf("report inventory: %v", err)
}
-9
View File
@@ -3,7 +3,6 @@ package agentsync
import (
"context"
"log"
"runtime"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
@@ -18,10 +17,6 @@ const workloadInterval = 60 * time.Second
// runWorkloads reports what this host runs, on its own ticker.
func runWorkloads(ctx context.Context, cfg *config.Config) {
if runtime.GOOS != "linux" {
return
}
reportWorkloads(cfg)
ticker := time.NewTicker(workloadInterval)
@@ -42,10 +37,6 @@ func runWorkloads(ctx context.Context, cfg *config.Config) {
// This is the ONLY writer of the server_workloads collection. RefreshWorkloadsCmd
// calls straight into here rather than answering with data of its own.
func reportWorkloads(cfg *config.Config) {
if runtime.GOOS != "linux" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
+14 -228
View File
@@ -1,238 +1,24 @@
package updates
import (
"bufio"
"bytes"
"context"
"os/exec"
"strings"
"time"
)
// PackageUpdate is one pending update. On Linux it is a package with a version
// on each side. On Windows CurrentVersion is empty and NewVersion carries the
// KB article ID: a Windows update is not a version bump of a named package,
// and inventing a current version would put a wrong string in front of an
// operator.
type PackageUpdate struct {
Name string
CurrentVersion string
NewVersion string
}
func detectPM() string {
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
if _, err := exec.LookPath(pm); err == nil {
if pm == "apt-get" {
return "apt"
}
return pm
}
}
return ""
}
// CheckAvailable lists pending OS updates.
func CheckAvailable() ([]PackageUpdate, error) { return checkAvailable() }
// ApplyAll installs every pending update. It never reboots: a control plane
// silently restarting a production server is unrecoverable from the UI, so the
// reboot stays a decision a person or a workflow makes. RebootRequired reports
// when one is owed.
func ApplyAll() error { return applyAll() }
func CheckAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
return checkApt()
case "dnf":
return checkDnfYum("dnf")
case "yum":
return checkDnfYum("yum")
case "pacman":
return checkPacman()
case "zypper":
return checkZypper()
case "apk":
return checkApk()
default:
return nil, nil
}
}
func ApplyAll() error {
switch detectPM() {
case "apt":
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
return err
}
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
case "dnf":
return exec.Command("dnf", "upgrade", "-y").Run()
case "yum":
return exec.Command("yum", "upgrade", "-y").Run()
case "pacman":
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
case "zypper":
return exec.Command("zypper", "update", "-y").Run()
case "apk":
return exec.Command("apk", "upgrade").Run()
default:
return nil
}
}
func checkApt() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
out, err := exec.Command("apt", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable from:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], "/", 2)[0]
newVer := parts[1]
oldVer := ""
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
rest := line[idx+len("upgradable from: "):]
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func checkDnfYum(pm string) ([]PackageUpdate, error) {
cmd := exec.Command(pm, "check-update")
out, err := cmd.Output()
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
err = nil
}
if err != nil {
return nil, err
}
var updates []PackageUpdate
pastHeader := false
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !pastHeader {
if strings.TrimSpace(line) == "" {
pastHeader = true
}
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], ".", 2)[0]
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
}
return updates, nil
}
func checkPacman() ([]PackageUpdate, error) {
out, _ := exec.Command("pacman", "-Qu").Output()
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) < 4 {
continue
}
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
}
return updates, nil
}
func checkZypper() ([]PackageUpdate, error) {
out, err := exec.Command("zypper", "list-updates").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 5 {
continue
}
updates = append(updates, PackageUpdate{
Name: strings.TrimSpace(parts[2]),
CurrentVersion: strings.TrimSpace(parts[3]),
NewVersion: strings.TrimSpace(parts[4]),
})
}
return updates, nil
}
func checkApk() ([]PackageUpdate, error) {
out, err := exec.Command("apk", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable") {
continue
}
parts := strings.Fields(line)
if len(parts) < 1 {
continue
}
pkgVer := parts[0]
name := apkName(pkgVer)
newVer := apkVersion(pkgVer)
oldVer := ""
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
rest = strings.TrimSuffix(rest, "]")
oldVer = apkVersion(strings.TrimSpace(rest))
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func apkName(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var name []string
for _, p := range parts {
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
break
}
name = append(name, p)
}
return strings.Join(name, "-")
}
func apkVersion(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var ver []string
inVer := false
for _, p := range parts {
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
inVer = true
}
if inVer {
ver = append(ver, p)
}
}
return strings.Join(ver, "-")
}
// RebootRequired reports whether this host is waiting on a restart.
func RebootRequired() bool { return rebootRequired() }
+252
View File
@@ -0,0 +1,252 @@
package updates
import (
"bufio"
"bytes"
"context"
"os"
"os/exec"
"strings"
"time"
)
func detectPM() string {
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
if _, err := exec.LookPath(pm); err == nil {
if pm == "apt-get" {
return "apt"
}
return pm
}
}
return ""
}
func checkAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
return checkApt()
case "dnf":
return checkDnfYum("dnf")
case "yum":
return checkDnfYum("yum")
case "pacman":
return checkPacman()
case "zypper":
return checkZypper()
case "apk":
return checkApk()
default:
return nil, nil
}
}
func applyAll() error {
switch detectPM() {
case "apt":
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
return err
}
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
case "dnf":
return exec.Command("dnf", "upgrade", "-y").Run()
case "yum":
return exec.Command("yum", "upgrade", "-y").Run()
case "pacman":
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
case "zypper":
return exec.Command("zypper", "update", "-y").Run()
case "apk":
return exec.Command("apk", "upgrade").Run()
default:
return nil
}
}
// rebootRequired reads what the distributions themselves record. Debian and
// Ubuntu drop a file; the RPM family answers through needs-restarting, whose
// exit code is 1 when a reboot is owed and 0 when it is not.
func rebootRequired() bool {
if _, err := os.Stat("/var/run/reboot-required"); err == nil {
return true
}
if _, err := exec.LookPath("dnf"); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "dnf", "needs-restarting", "-r").Run(); err != nil {
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
return true
}
}
}
return false
}
func checkApt() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
out, err := exec.Command("apt", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable from:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], "/", 2)[0]
newVer := parts[1]
oldVer := ""
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
rest := line[idx+len("upgradable from: "):]
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func checkDnfYum(pm string) ([]PackageUpdate, error) {
cmd := exec.Command(pm, "check-update")
out, err := cmd.Output()
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
err = nil
}
if err != nil {
return nil, err
}
var updates []PackageUpdate
pastHeader := false
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !pastHeader {
if strings.TrimSpace(line) == "" {
pastHeader = true
}
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], ".", 2)[0]
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
}
return updates, nil
}
func checkPacman() ([]PackageUpdate, error) {
out, _ := exec.Command("pacman", "-Qu").Output()
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) < 4 {
continue
}
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
}
return updates, nil
}
func checkZypper() ([]PackageUpdate, error) {
out, err := exec.Command("zypper", "list-updates").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 5 {
continue
}
updates = append(updates, PackageUpdate{
Name: strings.TrimSpace(parts[2]),
CurrentVersion: strings.TrimSpace(parts[3]),
NewVersion: strings.TrimSpace(parts[4]),
})
}
return updates, nil
}
func checkApk() ([]PackageUpdate, error) {
out, err := exec.Command("apk", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable") {
continue
}
parts := strings.Fields(line)
if len(parts) < 1 {
continue
}
pkgVer := parts[0]
name := apkName(pkgVer)
newVer := apkVersion(pkgVer)
oldVer := ""
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
rest = strings.TrimSuffix(rest, "]")
oldVer = apkVersion(strings.TrimSpace(rest))
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func apkName(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var name []string
for _, p := range parts {
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
break
}
name = append(name, p)
}
return strings.Join(name, "-")
}
func apkVersion(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var ver []string
inVer := false
for _, p := range parts {
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
inVer = true
}
if inVer {
ver = append(ver, p)
}
}
return strings.Join(ver, "-")
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !windows
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
// without it this file compiles on Linux too and collides with updates_linux.go.
package updates
func checkAvailable() ([]PackageUpdate, error) { return nil, nil }
func applyAll() error { return nil }
func rebootRequired() bool { return false }
+117
View File
@@ -0,0 +1,117 @@
package updates
import (
"context"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
const (
// The first search after a boot contacts Microsoft Update (or WSUS) and is
// routinely slow. Ten minutes is not generous, it is realistic.
searchTimeout = 10 * time.Minute
// A patch-Tuesday cumulative genuinely takes this long to download and
// install on a modest server.
applyTimeout = 60 * time.Minute
rebootTimeout = 2 * time.Minute
)
// The Windows Update COM API is used rather than the PSWindowsUpdate module: it
// is present on every supported Windows, needs no PowerShell Gallery install,
// and works unchanged against a WSUS server on an air-gapped fleet. The agent
// runs as LocalSystem, which holds the rights it requires.
const searchScript = `
$ErrorActionPreference = 'Stop'
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
$rows = @()
foreach ($u in $result.Updates) {
$ids = @($u.KBArticleIDs)
$kb = ''
if ($ids.Count -gt 0) { $kb = [string]$ids[0] }
$rows += [pscustomobject]@{ title = [string]$u.Title; kb = $kb }
}
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
`
const applyScript = `
$ErrorActionPreference = 'Stop'
$session = New-Object -ComObject Microsoft.Update.Session
$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0")
$batch = New-Object -ComObject Microsoft.Update.UpdateColl
foreach ($u in $result.Updates) {
if ($u.InstallationBehavior.CanRequestUserInput) { continue }
if (-not $u.EulaAccepted) {
try { $u.AcceptEula() } catch { continue }
}
$null = $batch.Add($u)
}
if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 }
$downloader = $session.CreateUpdateDownloader()
$downloader.Updates = $batch
$null = $downloader.Download()
$installer = $session.CreateUpdateInstaller()
$installer.Updates = $batch
$r = $installer.Install()
Write-Output ('resultcode=' + $r.ResultCode)
# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this
# process must exit non-zero so the agent logs a failure rather than an ack.
if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 }
exit 0
`
const rebootScript = `
$ErrorActionPreference = 'SilentlyContinue'
$si = New-Object -ComObject Microsoft.Update.SystemInfo
if ($si.RebootRequired) { Write-Output 'true'; exit 0 }
$keys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
)
foreach ($k in $keys) { if (Test-Path $k) { Write-Output 'true'; exit 0 } }
$sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations
if ($sm -and $sm.PendingFileRenameOperations) { Write-Output 'true'; exit 0 }
Write-Output 'false'
`
func checkAvailable() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), searchTimeout)
defer cancel()
out, err := winexec.Run(ctx, searchScript)
if err != nil {
return nil, fmt.Errorf("windows update search: %w", err)
}
return parseUpdateSearch(out)
}
func applyAll() error {
ctx, cancel := context.WithTimeout(context.Background(), applyTimeout)
defer cancel()
if _, err := winexec.Run(ctx, applyScript); err != nil {
return fmt.Errorf("windows update install: %w", err)
}
return nil
}
func rebootRequired() bool {
ctx, cancel := context.WithTimeout(context.Background(), rebootTimeout)
defer cancel()
out, err := winexec.Run(ctx, rebootScript)
if err != nil {
return false
}
return strings.TrimSpace(out) == "true"
}
+48
View File
@@ -0,0 +1,48 @@
package updates
import (
"encoding/json"
"strings"
)
// winUpdate is one row of the Windows Update searcher's output, in the shape
// searchScript emits it.
type winUpdate struct {
Title string `json:"title"`
KB string `json:"kb"`
}
// parseUpdateSearch reads the searcher's JSON.
//
// It carries no build tag on purpose: this is the half of the Windows update
// path that can be tested on a development machine, and the agent module has no
// Windows CI.
func parseUpdateSearch(jsonText string) ([]PackageUpdate, error) {
s := strings.TrimSpace(jsonText)
if s == "" || s == "null" {
return nil, nil
}
var rows []winUpdate
if err := json.Unmarshal([]byte(s), &rows); err != nil {
// ConvertTo-Json renders a one-element array as a bare object.
var one winUpdate
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
return nil, err
}
rows = []winUpdate{one}
}
out := make([]PackageUpdate, 0, len(rows))
for _, r := range rows {
u := PackageUpdate{Name: r.Title}
if kb := strings.TrimSpace(r.KB); kb != "" {
if !strings.HasPrefix(strings.ToUpper(kb), "KB") {
kb = "KB" + kb
}
u.NewVersion = kb
}
out = append(out, u)
}
return out, nil
}
+69
View File
@@ -0,0 +1,69 @@
package updates
import "testing"
func TestParseUpdateSearchArray(t *testing.T) {
in := `[{"title":"2026-08 Cumulative Update for Windows Server 2022","kb":"5034123"},
{"title":"Windows Malicious Software Removal Tool","kb":"890830"}]`
got, err := parseUpdateSearch(in)
if err != nil {
t.Fatalf("parseUpdateSearch: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d updates, want 2", len(got))
}
if got[0].Name != "2026-08 Cumulative Update for Windows Server 2022" {
t.Errorf("Name = %q", got[0].Name)
}
if got[0].NewVersion != "KB5034123" {
t.Errorf("NewVersion = %q, want KB5034123", got[0].NewVersion)
}
if got[0].CurrentVersion != "" {
t.Errorf("CurrentVersion = %q, want empty", got[0].CurrentVersion)
}
}
// PowerShell 5.1's ConvertTo-Json collapses a one-element array into a bare
// object. A host with exactly one pending update is common, and a parser that
// only accepts arrays reports it as zero.
func TestParseUpdateSearchSingleObject(t *testing.T) {
got, err := parseUpdateSearch(`{"title":"Security Intelligence Update","kb":"2267602"}`)
if err != nil {
t.Fatalf("parseUpdateSearch: %v", err)
}
if len(got) != 1 || got[0].NewVersion != "KB2267602" {
t.Fatalf("got %+v", got)
}
}
func TestParseUpdateSearchNoKB(t *testing.T) {
got, err := parseUpdateSearch(`[{"title":"Driver update for Contoso NIC","kb":""}]`)
if err != nil {
t.Fatalf("parseUpdateSearch: %v", err)
}
if len(got) != 1 || got[0].NewVersion != "" {
t.Fatalf("got %+v, want one update with an empty NewVersion", got)
}
}
// An empty result set is "nothing pending", not a parse failure.
func TestParseUpdateSearchEmpty(t *testing.T) {
for _, in := range []string{"", " \r\n", "[]", "null"} {
got, err := parseUpdateSearch(in)
if err != nil {
t.Fatalf("parseUpdateSearch(%q): %v", in, err)
}
if len(got) != 0 {
t.Fatalf("parseUpdateSearch(%q) = %+v, want none", in, got)
}
}
}
// A KB already carrying its prefix must not become KBKB5034123.
func TestParseUpdateSearchPrefixedKB(t *testing.T) {
got, _ := parseUpdateSearch(`[{"title":"x","kb":"KB5034123"}]`)
if got[0].NewVersion != "KB5034123" {
t.Fatalf("NewVersion = %q", got[0].NewVersion)
}
}
+24
View File
@@ -0,0 +1,24 @@
// 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
// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own
// parser is a problem worth solving once.
package winexec
import (
"encoding/base64"
"unicode/utf16"
)
// EncodeCommand renders a script for powershell.exe -EncodedCommand: UTF-16LE,
// no byte-order mark, base64. This is deliberately free of build tags so it is
// tested on a Linux development machine like every other pure function here.
func EncodeCommand(script string) string {
units := utf16.Encode([]rune(script))
b := make([]byte, 0, len(units)*2)
for _, u := range units {
b = append(b, byte(u), byte(u>>8))
}
return base64.StdEncoding.EncodeToString(b)
}
+20
View File
@@ -0,0 +1,20 @@
package winexec
import "testing"
func TestEncodeCommand(t *testing.T) {
// "hi" as UTF-16LE is 68 00 69 00, which base64-encodes to aABpAA==.
if got := EncodeCommand("hi"); got != "aABpAA==" {
t.Fatalf("EncodeCommand(hi) = %q, want aABpAA==", got)
}
}
func TestEncodeCommandMultiline(t *testing.T) {
// Only that it round-trips through the same encoding PowerShell expects:
// every ASCII byte followed by a zero byte, no BOM.
got := EncodeCommand("a\nb")
want := "YQAKAGIA"
if got != want {
t.Fatalf("EncodeCommand = %q, want %q", got, want)
}
}
+35
View File
@@ -0,0 +1,35 @@
package winexec
import (
"context"
"fmt"
"os/exec"
"strings"
)
// Run executes a PowerShell script and returns its stdout.
//
// powershell.exe rather than pwsh: everything this agent runs through here
// touches Windows Update COM or CIM, both of which are most reliable under
// Windows PowerShell 5.1, and 5.1 is present on every supported Windows while
// pwsh is an optional install.
func Run(ctx context.Context, script string) (string, error) {
cmd := exec.CommandContext(ctx, "powershell.exe",
"-NoProfile", "-NonInteractive", "-EncodedCommand", EncodeCommand(script))
out, err := cmd.Output()
if err != nil {
// Checked before the ExitError/stderr branch: CommandContext kills the
// process on timeout, and that kill can itself produce an ExitError
// carrying stderr text, so a genuine timeout would otherwise surface
// as that stderr instead of the "timed out" message callers match on.
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("powershell: timed out")
}
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
}
return "", fmt.Errorf("powershell: %w", err)
}
return string(out), nil
}
+4 -45
View File
@@ -4,9 +4,6 @@ import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
@@ -14,34 +11,12 @@ import (
// ErrProtected is returned for a workload the agent will not act on.
var ErrProtected = errors.New("workload is protected")
// AgentUnit is the systemd unit this agent runs as.
const AgentUnit = "vantage-agent.service"
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit
// with a long TimeoutStopSec blocks for exactly as long as that says. A
// waits on a container that may ignore SIGTERM, and both systemctl and
// Stop-Service block for as long as the unit's own stop timeout says. A
// timeout must return a real error rather than an ack implying success.
const controlTimeout = 90 * time.Second
// ownContainerID is read once: the container this agent runs in, if any.
var ownContainerID = detectOwnContainer()
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
// detectOwnContainer returns this process's container ID, or "" on a host
// install. The agent is normally a systemd service, so "" is the common case;
// this exists so containerising it later cannot silently remove the guard.
func detectOwnContainer() string {
b, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return ""
}
if m := cgroupContainerRe.FindString(string(b)); m != "" {
return m
}
return ""
}
// isProtected reports whether the agent refuses to act on this workload.
//
// The refusal lives here, in the agent, and not in the control plane. As with
@@ -50,7 +25,7 @@ func detectOwnContainer() string {
// denylist alone would be bypassed by the next dispatch path someone adds.
func isProtected(kind, id, name string) bool {
if kind == "unit" {
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
return isProtectedUnit(id, name)
}
if ownContainerID == "" {
return false
@@ -85,21 +60,5 @@ func Control(ctx context.Context, kind, id, action string) error {
ctx, cancel := context.WithTimeout(ctx, controlTimeout)
defer cancel()
var cmd *exec.Cmd
switch kind {
case "container":
cmd = exec.CommandContext(ctx, "docker", action, id)
case "unit":
cmd = exec.CommandContext(ctx, "systemctl", action, id)
default:
return fmt.Errorf("unknown workload kind %q", kind)
}
if out, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
}
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
}
return nil
return controlPlatform(ctx, kind, id, action)
}
+56
View File
@@ -0,0 +1,56 @@
package workloads
import (
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
)
// AgentUnit is the systemd unit this agent runs as.
const AgentUnit = "vantage-agent.service"
// ownContainerID is read once: the container this agent runs in, if any.
var ownContainerID = detectOwnContainer()
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
// detectOwnContainer returns this process's container ID, or "" on a host
// install. The agent is normally a systemd service, so "" is the common case;
// this exists so containerising it later cannot silently remove the guard.
func detectOwnContainer() string {
b, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return ""
}
if m := cgroupContainerRe.FindString(string(b)); m != "" {
return m
}
return ""
}
func isProtectedUnit(id, name string) bool {
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
}
func controlPlatform(ctx context.Context, kind, id, action string) error {
var cmd *exec.Cmd
switch kind {
case "container":
cmd = exec.CommandContext(ctx, "docker", action, id)
case "unit":
cmd = exec.CommandContext(ctx, "systemctl", action, id)
default:
return fmt.Errorf("unknown workload kind %q", kind)
}
if out, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
}
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
}
return nil
}
@@ -0,0 +1,74 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
// 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"
// A Windows agent is never itself in a container; the Linux build reads
// /proc/self/cgroup, and there is no equivalent question to ask here.
var ownContainerID = ""
// Windows service names are case-insensitive, so the comparison must be too.
func isProtectedUnit(id, name string) bool {
return strings.EqualFold(id, AgentUnit) || strings.EqualFold(name, AgentUnit)
}
func controlPlatform(ctx context.Context, kind, id, action string) error {
switch kind {
case "container":
// Docker behaves identically on Windows, so this path is shared in
// spirit with the Linux one rather than routed through PowerShell.
cmd := exec.CommandContext(ctx, "docker", action, id)
if out, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
}
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
}
return nil
case "unit":
// -Force is required: Stop-Service without it refuses outright when
// another service depends on the target, and that refusal reads to an
// operator as a silent no-op.
//
// sc.exe is avoided because it returns before the operation completes,
// which turns a timeout into a false success.
var verb string
switch action {
case "start":
verb = "Start-Service"
case "stop":
verb = "Stop-Service"
case "restart":
verb = "Restart-Service"
default:
return fmt.Errorf("unknown action %q", action)
}
script := "$ErrorActionPreference='Stop'\n" + verb + " -Name " + psQuote(id)
if action != "start" {
script += " -Force"
}
if _, err := winexec.Run(ctx, script); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
}
return fmt.Errorf("%s %s: %w", action, id, err)
}
return nil
default:
return fmt.Errorf("unknown workload kind %q", kind)
}
}
+4 -21
View File
@@ -2,9 +2,6 @@ package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
@@ -35,26 +32,12 @@ func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error)
ctx, cancel := context.WithTimeout(ctx, logTimeout)
defer cancel()
var cmd *exec.Cmd
switch kind {
case "container":
cmd = exec.CommandContext(ctx, "docker", "logs",
"--tail", strconv.Itoa(tail), "--timestamps", id)
case "unit":
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
default:
return "", false, fmt.Errorf("unknown workload kind %q", kind)
out, err := logsPlatform(ctx, kind, id, tail)
if err != nil {
return "", false, err
}
// docker logs writes container stderr to our stderr, so both streams must
// be captured or half the output silently disappears.
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
return "", false, fmt.Errorf("read logs for %s: %s", id, errText(err))
}
text, truncated := capLog(string(out))
text, truncated := capLog(out)
return text, truncated, nil
}
+30
View File
@@ -0,0 +1,30 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
)
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
var cmd *exec.Cmd
switch kind {
case "container":
cmd = exec.CommandContext(ctx, "docker", "logs",
"--tail", strconv.Itoa(tail), "--timestamps", id)
case "unit":
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
default:
return "", fmt.Errorf("unknown workload kind %q", kind)
}
// docker logs writes container stderr to our stderr, so both streams must
// be captured or half the output silently disappears.
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
}
return string(out), nil
}
+89
View File
@@ -0,0 +1,89 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
switch kind {
case "container":
cmd := exec.CommandContext(ctx, "docker", "logs",
"--tail", strconv.Itoa(tail), "--timestamps", id)
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
}
return string(out), nil
case "unit":
display := serviceDisplayName(ctx, id)
// Timestamps are formatted PowerShell-side rather than left to
// ConvertTo-Json, whose DateTime rendering differs between PowerShell
// versions — one of them emits /Date(1699...)/.
//
// $ErrorActionPreference = 'SilentlyContinue' because Get-WinEvent
// treats "no events matched" as a terminating error, and a quiet
// service is normal.
names := psQuote(id)
if display != "" && display != id {
names += "," + psQuote(display)
}
names += "," + psQuote(scmProvider)
// ProviderName includes the host-wide Service Control Manager, so a
// -MaxEvents cap of exactly tail would apply to the combined stream
// before parseEvents narrows SCM rows down to this service — on a
// host with busy service churn the target's own events could be
// squeezed out of the window entirely. Over-fetch instead, hard-capped
// so a pathological host cannot pull an unbounded batch across the
// wire, and let parseEvents trim to the last tail lines after
// filtering.
fetch := tail * 5
if fetch > 2500 {
fetch = 2500
}
script := `
$ErrorActionPreference = 'SilentlyContinue'
$rows = Get-WinEvent -FilterHashtable @{LogName='System','Application'; ProviderName=@(` + names + `)} ` +
`-MaxEvents ` + strconv.Itoa(fetch) + ` |
ForEach-Object {
[pscustomobject]@{
t = $_.TimeCreated.ToUniversalTime().ToString('o')
l = [string]$_.LevelDisplayName
p = [string]$_.ProviderName
m = [string]$_.Message
}
}
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
`
out, err := winexec.Run(ctx, script)
if err != nil {
return "", fmt.Errorf("read events for %s: %w", id, err)
}
return parseEvents(out, id, display, tail)
default:
return "", fmt.Errorf("unknown workload kind %q", kind)
}
}
// 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
// matches on the service name alone.
func serviceDisplayName(ctx context.Context, id string) string {
out, err := winexec.Run(ctx,
"$ErrorActionPreference='SilentlyContinue'\n"+
"(Get-Service -Name "+psQuote(id)+").DisplayName")
if err != nil {
return ""
}
return trimLine(out)
}
@@ -0,0 +1,50 @@
package workloads
import (
"context"
"os"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
const servicesTimeout = 60 * time.Second
const servicesScript = `
$ErrorActionPreference = 'Stop'
$svcs = Get-CimInstance Win32_Service | ForEach-Object {
[pscustomobject]@{
Name = $_.Name
DisplayName = $_.DisplayName
State = $_.State
StartMode = $_.StartMode
PathName = $_.PathName
ExitCode = $_.ExitCode
}
}
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
`
// collectUnits enumerates Windows services. The bool and string it returns are
// the same SystemdOK / SystemdError pair the Linux collector fills: the wire
// shape is shared, and the UI words it per platform.
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
ctx, cancel := context.WithTimeout(ctx, servicesTimeout)
defer cancel()
out, err := winexec.Run(ctx, servicesScript)
if err != nil {
return nil, false, "Win32_Service query failed: " + err.Error()
}
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = `C:\Windows`
}
wls, err := parseServices(out, systemRoot)
if err != nil {
return nil, false, "Win32_Service output could not be read: " + err.Error()
}
return wls, true, ""
}
@@ -14,10 +14,10 @@ const systemdTimeout = 30 * time.Second
// ten anyone cares about.
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}
// collectSystemd enumerates services in two passes, because "running or
// collectUnits enumerates services in two passes, because "running or
// failed" and "enabled but stopped" are different questions — and an enabled
// unit that is not running is exactly the one worth seeing.
func collectSystemd(ctx context.Context) ([]Workload, bool, string) {
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
if _, err := exec.LookPath("systemctl"); err != nil {
return nil, false, ""
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !linux && !windows
// The build constraint is load-bearing — see updates_other.go.
package workloads
import (
"context"
"fmt"
)
var ownContainerID = ""
func collectUnits(context.Context) ([]Workload, bool, string) { return nil, false, "" }
func isProtectedUnit(string, string) bool { return false }
func controlPlatform(context.Context, string, string, string) error {
return fmt.Errorf("workload control is not supported on this platform")
}
func logsPlatform(context.Context, string, string, int) (string, error) {
return "", fmt.Errorf("workload logs are not supported on this platform")
}
+224
View File
@@ -0,0 +1,224 @@
package workloads
import (
"encoding/json"
"strings"
)
// winService is one row of Get-CimInstance Win32_Service.
//
// Win32_Service rather than Get-Service: Get-Service exposes neither PathName
// nor StartMode, and the filter below needs both.
type winService struct {
Name string `json:"Name"`
DisplayName string `json:"DisplayName"`
State string `json:"State"`
StartMode string `json:"StartMode"`
PathName string `json:"PathName"`
ExitCode int `json:"ExitCode"`
}
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
// carrying it has not failed — it has not run since boot — and painting that
// red would cry wolf on every host.
const exitCodeNeverStarted = 1077
// servicePath extracts the executable from a Win32_Service PathName.
//
// A naive split on whitespace misfiles a substantial share of a real fleet:
// `"C:\Program Files\X\x.exe" -service` is one path and one argument.
func servicePath(pathName string) string {
s := strings.TrimSpace(pathName)
if s == "" {
return ""
}
if s[0] == '"' {
if end := strings.IndexByte(s[1:], '"'); end >= 0 {
return s[1 : 1+end]
}
// No closing quote: a malformed or truncated PathName. Fall back to
// the unquoted handling below on the text after the opening quote,
// so this yields a bare path rather than a path plus trailing
// argument text.
s = s[1:]
}
if i := exeBoundaryIndex(s); i >= 0 {
return s[:i+len(".exe")]
}
if i := strings.IndexAny(s, " \t"); i >= 0 {
return s[:i]
}
return s
}
// exeBoundaryIndex finds the first ".exe" (case-insensitive) in s that
// actually ends the executable name — followed by end-of-string, whitespace,
// or a double quote — rather than continuing into a longer segment such as
// ".exec". It returns -1 when no such occurrence exists, so a path like
// `C:\Program Files\Ad.exec\tool.com -flag` is not misparsed by matching the
// ".exe" inside "Ad.exec" and silently dropping the real filename.
func exeBoundaryIndex(s string) int {
lower := strings.ToLower(s)
from := 0
for {
rel := strings.Index(lower[from:], ".exe")
if rel < 0 {
return -1
}
idx := from + rel
end := idx + len(".exe")
if end == len(s) || s[end] == ' ' || s[end] == '\t' || s[end] == '"' {
return idx
}
from = idx + 1
}
}
// parseServices turns the collector's JSON into workloads.
//
// systemRoot is a parameter rather than an environment read so this is testable
// off Windows. The caller passes %SystemRoot%.
//
// The filter mirrors the systemd collector's intent: show what an operator
// installed, and show what is meant to be up but is not. Services under
// %SystemRoot%\System32 are the platform's own, and a typical host has well
// over a hundred of them.
func parseServices(jsonText, systemRoot string) ([]Workload, error) {
s := strings.TrimSpace(jsonText)
if s == "" || s == "null" {
return nil, nil
}
var rows []winService
if err := json.Unmarshal([]byte(s), &rows); err != nil {
var one winService
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
return nil, err
}
rows = []winService{one}
}
sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`)
var wls []Workload
for _, r := range rows {
if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) {
continue
}
running := strings.EqualFold(r.State, "Running")
failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted
auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto")
if !running && !failed && !auto {
continue
}
// The wire shape is shared with the systemd collector — both report
// under kind "unit" — so the state word has to be too, or the UI
// (which colours and filters on it, and does so before it knows
// which platform sent the row) needs two vocabularies for one kind.
// running/stopped/failed become active/inactive/failed to match.
state := "inactive"
switch {
case running:
state = "active"
case failed:
state = "failed"
}
name := r.DisplayName
if name == "" {
name = r.Name
}
wls = append(wls, Workload{
Kind: "unit",
ID: r.Name,
Name: name,
State: state,
})
}
return wls, nil
}
// psQuote renders a Go string as a PowerShell single-quoted literal. Single
// quotes suppress every form of expansion, so the only character needing an
// escape is the quote itself, which is doubled.
func psQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// scmProvider is the provider every service's start and stop is logged under,
// host-wide.
const scmProvider = "Service Control Manager"
type winEvent struct {
T string `json:"t"`
L string `json:"l"`
P string `json:"p"`
M string `json:"m"`
}
// parseEvents renders Get-WinEvent output as text in the shape journalctl
// --output=short-iso produces, so the log dialog needs no per-platform
// rendering: "<timestamp> <level> <message>", oldest first.
//
// The caller over-fetches from Get-WinEvent because the ProviderName filter
// includes the host-wide Service Control Manager, and a -MaxEvents cap
// applied before SCM rows are narrowed down to this service would squeeze the
// target's own events out of the window on a host with busy service churn.
// tail is therefore applied here, AFTER filtering and AFTER the oldest-first
// reversal, keeping the last tail lines — the most recent lines are the ones
// worth keeping, matching capLog's front-trim reasoning in the shared
// logs.go.
func parseEvents(jsonText, serviceName, displayName string, tail int) (string, error) {
s := strings.TrimSpace(jsonText)
if s == "" || s == "null" {
return "", nil
}
var rows []winEvent
if err := json.Unmarshal([]byte(s), &rows); err != nil {
var one winEvent
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
return "", err
}
rows = []winEvent{one}
}
var lines []string
for _, e := range rows {
if strings.EqualFold(e.P, scmProvider) {
if !strings.Contains(e.M, serviceName) &&
(displayName == "" || !strings.Contains(e.M, displayName)) {
continue
}
}
// Collapse every newline form, not just "\r\n": a message containing a
// bare "\n" would otherwise still break the one-line-per-event shape
// this renders for the log dialog, and undercount the tail trim above.
msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M))
lines = append(lines, e.T+" "+e.L+" "+msg)
}
// Get-WinEvent is newest-first. Reverse it.
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
lines[i], lines[j] = lines[j], lines[i]
}
if tail > 0 && len(lines) > tail {
lines = lines[len(lines)-tail:]
}
return strings.Join(lines, "\n"), nil
}
// trimLine reduces single-value PowerShell output to its first non-empty line.
func trimLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if t := strings.TrimSpace(l); t != "" {
return t
}
}
return ""
}
+207
View File
@@ -0,0 +1,207 @@
package workloads
import (
"strings"
"testing"
)
func TestServicePath(t *testing.T) {
cases := []struct{ in, want string }{
{`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`},
{`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`},
{`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`},
{`"C:\no\args.exe"`, `C:\no\args.exe`},
{``, ``},
// ".exe" appearing inside an earlier segment ("Ad.exec") must not be
// treated as the end of the executable — that would drop the real
// filename and arguments.
{`C:\Program Files\Ad.exec\tool.com -flag`, `C:\Program`},
// An unterminated quote falls back to the unquoted handling on the
// text after the opening quote, yielding a bare path rather than a
// path plus trailing argument text.
{`"C:\Program Files\Contoso\svc.exe -service`, `C:\Program Files\Contoso\svc.exe`},
}
for _, c := range cases {
if got := servicePath(c.in); got != c.want {
t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseServicesFilters(t *testing.T) {
in := `[
{"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0},
{"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0},
{"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0},
{"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0},
{"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067}
]`
got, err := parseServices(in, `C:\WINDOWS`)
if err != nil {
t.Fatalf("parseServices: %v", err)
}
byID := map[string]Workload{}
for _, w := range got {
byID[w.ID] = w
}
// The OS's own svchost service is dropped; a manual, stopped, never-failed
// service is nobody's business either.
if _, ok := byID["Themes"]; ok {
t.Error("Themes (under %SystemRoot%) should be filtered out")
}
if _, ok := byID["Northwind"]; ok {
t.Error("stopped Manual service should be filtered out")
}
if len(got) != 3 {
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
}
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" {
t.Errorf("Contoso = %+v", w)
}
// Enabled but not running is exactly the row worth seeing.
if byID["Fabrikam"].State != "inactive" {
t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State)
}
// A non-zero exit code on a stopped service is a crash, not a clean stop.
if byID["Crashed"].State != "failed" {
t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State)
}
}
// 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}]`
got, err := parseServices(in, `C:\WINDOWS`)
if err != nil {
t.Fatalf("parseServices: %v", err)
}
if len(got) != 1 || got[0].State != "inactive" {
t.Fatalf("got %+v, want one inactive workload", got)
}
}
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
got, err := parseServices(one, `C:\WINDOWS`)
if err != nil || len(got) != 1 || got[0].State != "active" {
t.Fatalf("single object: got %+v, err %v", got, err)
}
for _, in := range []string{"", "[]", "null"} {
got, err := parseServices(in, `C:\WINDOWS`)
if err != nil || len(got) != 0 {
t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err)
}
}
}
func TestPSQuote(t *testing.T) {
if got := psQuote(`it's`); got != `'it''s'` {
t.Fatalf("psQuote = %s", got)
}
if got := psQuote(`plain`); got != `'plain'` {
t.Fatalf("psQuote = %s", got)
}
}
func TestParseEventsFormatsAndOrders(t *testing.T) {
// Get-WinEvent returns newest first; journalctl --output=short-iso returns
// oldest first, and the log dialog and capLog's front-trim both assume the
// most recent line is at the bottom.
in := `[
{"t":"2026-08-13T10:22:31.0000000Z","l":"Error","p":"Contoso","m":"broker died"},
{"t":"2026-08-13T10:22:03.0000000Z","l":"Information","p":"Contoso","m":"broker starting"}
]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
want := "2026-08-13T10:22:03.0000000Z Information broker starting\n" +
"2026-08-13T10:22:31.0000000Z Error broker died"
if got != want {
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
}
}
// A message containing a bare "\n" (no carriage return) must still collapse to
// one line, or it silently multiplies into several output lines and throws
// off the tail trim's count.
func TestParseEventsCollapsesBareLF(t *testing.T) {
in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
if strings.Count(got, "\n") != 0 {
t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got)
}
want := "2026-08-13T10:00:00Z Error broker died stack trace here"
if got != want {
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
}
}
// Service Control Manager logs every service on the host under one provider, so
// its rows must be filtered down to the target or the log is somebody else's.
func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {
in := `[
{"t":"2026-08-13T10:00:00Z","l":"Information","p":"Service Control Manager","m":"The Print Spooler service entered the running state."},
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Service Control Manager","m":"The Contoso Broker service entered the running state."}
]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
if strings.Contains(got, "Print Spooler") {
t.Errorf("another service's SCM event leaked in:\n%s", got)
}
if !strings.Contains(got, "Contoso Broker") {
t.Errorf("the target's SCM event was dropped:\n%s", got)
}
}
// A service that has logged nothing is normal. An error there would read as a
// broken feature.
func TestParseEventsEmpty(t *testing.T) {
for _, in := range []string{"", "[]", "null"} {
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil || got != "" {
t.Fatalf("parseEvents(%q) = %q, err %v", in, got, err)
}
}
}
// The over-fetch in logs_windows.go can return more events than the caller
// asked for once SCM rows are filtered down to the target; parseEvents must
// keep the most RECENT tail lines, not the oldest, matching capLog's
// front-trim reasoning in the shared logs.go.
func TestParseEventsTrimsToTailKeepingMostRecent(t *testing.T) {
in := `[
{"t":"2026-08-13T10:00:06Z","l":"Information","p":"Contoso","m":"event 6"},
{"t":"2026-08-13T10:00:05Z","l":"Information","p":"Contoso","m":"event 5"},
{"t":"2026-08-13T10:00:04Z","l":"Information","p":"Contoso","m":"event 4"},
{"t":"2026-08-13T10:00:03Z","l":"Information","p":"Contoso","m":"event 3"},
{"t":"2026-08-13T10:00:02Z","l":"Information","p":"Contoso","m":"event 2"},
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Contoso","m":"event 1"}
]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 2)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
want := "2026-08-13T10:00:05Z Information event 5\n" +
"2026-08-13T10:00:06Z Information event 6"
if got != want {
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
}
}
+3 -7
View File
@@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"runtime"
"sort"
"strconv"
"strings"
@@ -19,15 +18,12 @@ type Result struct {
SystemdError string
}
// Collect enumerates every workload on this host. Linux only.
// Collect enumerates every workload on this host: containers from Docker, and
// units from systemd on Linux or the service control manager on Windows.
func Collect(ctx context.Context) Result {
if runtime.GOOS != "linux" {
return Result{}
}
var r Result
containers, dockerOK, dockerErr := collectDocker(ctx)
units, systemdOK, systemdErr := collectSystemd(ctx)
units, systemdOK, systemdErr := collectUnits(ctx)
r.DockerOK, r.DockerError = dockerOK, dockerErr
r.SystemdOK, r.SystemdError = systemdOK, systemdErr
-30
View File
@@ -1,30 +0,0 @@
# Current cloud instance process
The current processs for creating cloud instances is incorrect.
At the moment the cloud instance process is the following:
- Customer goes to `https://vantage.hostxtra.co.uk/start` then fills in the form.
- Customer is then sent and email to verify
- Customer clicks the link and the instance is created in the DB.
- Customer can then access the instance.
As the `/start` process is auto creating a new instance this should default to the free tier instance.
The issue is that this doesn't create an `account` and `admin_instance` on the admin side.
The Cloud instance creation / account creation needs to be restructured.
for context when I say `hq` I mean `admin`
- customer goes to `https://vantage.hostxtra.co.uk/start` and fills in the form.
- This is where the HQ account is created.
- The customer is then sent and email to verify their email address.
- The customer can then access the HQ customer portal.
- The customer can then create a free new instance in the HQ portal.
- The cloud instance is created in the DB.
- The HQ `account` and `admin_instance` is created and populated in the DB.
- A `Free` License is created and attached to the instance.
- The customer is then sent an email letting them know the instance has been created and when the license expires.
- The customer will need to renew the license after expiry, if they are on a Free license.
- This is so that unused instances can be cleaned up if no renew after a length of time has passed.
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: vantage
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
type: application
version: 1.0.7
appVersion: "1.0.7"
version: 1.1.0
appVersion: "1.0.8"
+3 -6
View File
@@ -41,12 +41,9 @@ Ingress (Traefik):
{{- range .Values.ingress.web.extraHosts }}
https://{{ . }}
{{- end }}
{{- if .Values.ingress.api.enabled }}
{{ join ", " .Values.ingress.api.paths }} go straight to the server; everything else to web.
{{- else }}
Everything goes to web, which proxies /api and /auth onward. Set
ingress.api.enabled=true to route them at the edge instead.
{{- end }}
{{ join ", " .Values.ingress.api.paths }} go to the server; everything else to web.
web proxies nothing, so those paths must be routed here or by a terminator
in front of this ingress.
{{- if .Values.ingress.grpc.enabled }}
- Agents: {{ .Values.ingress.grpc.host }} (gRPC, h2c behind TLS)
Agents dial server.env.grpcHost, currently {{ tpl .Values.server.env.grpcHost . }}.
@@ -72,6 +72,8 @@ both read it.
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
- name: PROXY_LISTEN_HOST
value: {{ .Values.server.env.proxyListenHost | quote }}
- name: TRUSTED_PROXIES
value: {{ .Values.server.env.trustedProxies | quote }}
{{- if eq .Values.server.env.deploymentType "cloud" }}
- name: VANTAGE_DEPLOYMENT
value: "cloud"
+10 -9
View File
@@ -2,16 +2,14 @@
{{/*
Two hostnames, because the two audiences arrive over different protocols.
Browsers reach the web host. What answers there depends on the path: with
ingress.api.enabled, /api and /auth go straight to the server and everything
else goes to `web`. Without it, everything goes to `web`, which proxies those
prefixes onward itself (web/next.config.ts).
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`.
Both work. Routing at the edge is one hop shorter and is what the Nginx Proxy
Manager deployment in front of the Docker install already does, so leaving it
off changes the shape of the request path between the two deployments. It is
still off by default, because turning it on where `web` is the only thing with
a public certificate would strand /api behind a route nobody can reach.
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
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 per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
@@ -35,6 +33,9 @@ its HTTP port too.
{{- if and .Values.ingress.api.enabled (not $apiPaths) }}
{{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }}
{{- end }}
{{- if not .Values.ingress.api.enabled }}
{{- fail "ingress.api.enabled=false leaves /api, /auth and /public unrouted: web proxies nothing. Route those prefixes to the server at your own terminator, or leave this enabled." }}
{{- end }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
+4 -6
View File
@@ -38,12 +38,10 @@ spec:
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
ports:
- containerPort: {{ .Values.web.service.port }}
env:
- name: API_URL
value: {{ tpl .Values.web.env.apiUrl . | quote }}
# /healthz is served by this Next process; /api is rewritten to the
# server, so a probe there would report the backend's health and keep
# passing while this pod was wedged.
# /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
# backend address to configure and no probe here that could report
# the backend's health by accident.
startupProbe:
httpGet:
path: /healthz
+7 -5
View File
@@ -63,6 +63,7 @@ server:
appRootLabel: vantage
proxyAdvertiseHost: "{{ .Release.Name }}-server"
proxyListenHost: "0.0.0.0"
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
persistence:
enabled: false
size: 1Gi
@@ -78,8 +79,6 @@ web:
service:
type: ClusterIP
port: 3000
env:
apiUrl: "http://{{ .Release.Name }}-server:8080"
ingress:
enabled: false
@@ -89,11 +88,14 @@ ingress:
web:
host: ""
extraHosts: []
# Not optional: web proxies nothing, so these prefixes reach the server
# only through this ingress. Turning it off serves the UI with a dead API.
api:
enabled: false
enabled: true
paths:
- /api
- /auth
- /api/
- /auth/
- /public/
- /update
- /install
- /update.ps1
+5 -2
View File
@@ -47,6 +47,7 @@ services:
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
GUACD_ADDR: guacd:4822
PROXY_ADVERTISE_HOST: server
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
depends_on:
redis:
condition: service_healthy
@@ -59,8 +60,10 @@ services:
restart: unless-stopped
ports:
- 3000:3000
environment:
API_URL: ${API_URL:-http://server:8080}
# 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.
depends_on:
- server
volumes:
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
File diff suppressed because it is too large Load Diff
@@ -1,938 +0,0 @@
# Instance Rename in Vantage HQ — 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:** Let an HQ customer (owner or admin) rename a cloud instance, which re-derives its slug and moves it to a new DNS host, with staff able to do the same without the cooldown.
**Architecture:** Slug derivation stays in `shared/provision`, beside the create path that already owns it. Admin reaches the control plane only through `cloudprov`, writing `instances` — a collection it already writes. Admin's own row (`admin_instances`) is updated second and carries the 24h cooldown timestamp, because the cooldown is admin's policy and the control plane has no opinion about it. The portal shows the new host and asks the customer to click through; it does not redirect.
**Note:** This repo has no automated test suite and the user has ruled out adding test files. Every task verifies by build, vet and (Task 8) manual exercise.
**Tech Stack:** Go 1.x (gin, mongo-driver v2), Next.js 16 App Router + TanStack Query + Tailwind 3 (`adminsite`).
**Spec:** `docs/superpowers/specs/2026-08-12-instance-rename-design.md`
## Global Constraints
- A licence binds an instance **UUID**, not a slug. A rename must not issue a licence, call Paddle, or touch `licenses`, `subscriptions` or `entitlements`.
- Admin's control-plane write boundary is unchanged: `cloudprov` writes `instances` and `users` only. Do not add a write to any other control-plane collection.
- Customer rename is **cloud only**. Self-hosted is refused with the existing `selfHostedRefusal` constant and HTTP **400**, matching `members.go`.
- Cooldown for customers is **24 hours**, tracked by `admin_instances.renamed_at`. Staff bypass it and must **not** write `renamed_at`.
- No `-2` suffix loop on rename. A taken slug is a refusal (`ErrSlugTaken` → HTTP 409).
- No component in `adminsite` may carry a hex colour; use the existing token classes (`text-ink-2`, `text-ink-3`, `border-rule`, `text-accent`, `text-expired`, `bg-panel-2`).
- The host domain used for display is `vantage.hostxtra.co.uk`, already hardcoded in `adminsite/components/InstanceRecord.tsx` and the customer instance page.
- Commit messages follow the repo's existing style: `feat: Sentence case summary` / `fix: …` / `docs: …`.
---
### Task 1: Slug derivation and the control-plane rename
**Files:**
- Modify: `shared/provision/instance.go`
**Interfaces:**
- Consumes: `BaseSlug(name string) (string, error)`, `ErrNameRejected` — both already in `shared/provision`.
- Produces:
- `provision.ErrSlugTaken` (`error`)
- `provision.RenameSlug(name, currentSlug string) (string, error)`
- `provision.RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)`
- `provision.RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error`
Behaviour `RenameSlug` must have, verified by reading rather than by test (this
repo has no Go test suite and the user has ruled out adding one):
| Input name | Current slug | Result |
|---|---|---|
| `Acme Ltd` | `acme` | `acme-ltd` |
| `ACME!` | `acme` | `acme` — still derives to the current slug, so not a move |
| `Acme` | `acme-2` | `acme` — a creation-time collision suffix derives from no name, so moving off it is a real move |
| `ab` | any | `ErrNameRejected` |
| `Admin` | any | `ErrNameRejected` (reserved) |
| `!!!` | any | `ErrNameRejected` |
| 50 `a`s | any | truncated to `MaxSlugLength`, exactly as `BaseSlug` truncates on create |
- [ ] **Step 1: Write the implementation**
Append to `shared/provision/instance.go`:
```go
// ErrSlugTaken means the slug a new name derives to already belongs to another
// instance.
//
// Rename refuses rather than appending a counter the way creation does. Creation
// appends because the customer is waiting on an instance and any free slug will
// do; a rename is a request for one specific host, and silently landing them on
// "acme-2" answers a question they did not ask.
var ErrSlugTaken = errors.New("slug taken")
// RenameSlug derives the slug a rename to name would move an instance to, given
// the slug it holds now.
//
// It returns the current slug unchanged when the name still derives to it, so a
// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move
// and cannot collide with the instance's own slug.
func RenameSlug(name, currentSlug string) (string, error) {
base, err := BaseSlug(name)
if err != nil {
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
}
if base == currentSlug {
return currentSlug, nil
}
return base, nil
}
// RenameInstance changes an instance's name and re-derives its slug from it.
//
// The count-then-update is racy on its own, and is safe for the same reason
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
// to retry with — the caller asked for one specific name — so it becomes
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
// index.
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) {
var inst models.Instance
if err := db.Collection("instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
return nil, err
}
slug, err := RenameSlug(name, inst.Slug)
if err != nil {
return nil, err
}
if slug != inst.Slug {
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{
"slug": slug,
"instance_id": bson.M{"$ne": instanceID},
})
if err != nil {
return nil, err
}
if n > 0 {
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
}
}
if _, err := db.Collection("instances").UpdateOne(ctx,
bson.M{"instance_id": instanceID},
bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
}
return nil, err
}
inst.Name = name
inst.Slug = slug
return &inst, nil
}
// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename
// whose caller-side bookkeeping then failed.
//
// It derives nothing. The values being restored may include a creation-time
// collision suffix that no name derives to, so re-running RenameInstance with the
// old name would not reproduce them.
func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error {
_, err := db.Collection("instances").UpdateOne(ctx,
bson.M{"instance_id": instanceID},
bson.M{"$set": bson.M{"name": name, "slug": slug}})
return err
}
```
- [ ] **Step 2: Build and vet**
Run: `cd /go-projects/vantage && go build ./shared/... && go vet ./shared/provision/`
Expected: clean.
- [ ] **Step 3: Commit**
```bash
git add shared/provision/instance.go
git commit -m "feat: Add instance rename to shared provisioning"
```
---
### Task 2: Admin's row and the cloudprov wrappers
**Files:**
- Modify: `admin/internal/models/models.go` (the `Instance` struct, ~line 129; constants block near `RenewWindow`, ~line 105)
- Modify: `admin/internal/cloudprov/cloudprov.go`
**Interfaces:**
- Consumes: `provision.RenameInstance`, `provision.RestoreInstanceIdentity` (Task 1).
- Produces:
- `models.RenameCooldown` (`time.Duration`)
- `models.Instance.RenamedAt *time.Time` (bson `renamed_at`, json `renamed_at`)
- `cloudprov.RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)`
- `cloudprov.RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error`
- [ ] **Step 1: Add the cooldown constant**
In `admin/internal/models/models.go`, directly beneath the `RenewWindow` block:
```go
// RenameCooldown is how long a customer must wait between renames of one
// instance.
//
// A rename moves the instance's DNS host and invalidates every saved link to it,
// so this exists to make that a considered act rather than a slider. Staff are
// not subject to it: a support conversation about a name is already a human
// deciding.
const RenameCooldown = 24 * time.Hour
```
- [ ] **Step 2: Add the field to `Instance`**
In the same file, inside the `Instance` struct, after `RelinkCount`:
```go
// RenamedAt is when this instance last changed name, and backs the customer
// rename cooldown. It is a pointer because absent means "never renamed"; a
// zero time.Time would read as year 1 — an inert cooldown, but only by
// accident. Staff renames deliberately leave it alone.
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
```
- [ ] **Step 3: Add the cloudprov wrappers**
Append to `admin/internal/cloudprov/cloudprov.go`:
```go
// RenameInstance changes a cloud instance's name and moves it to the slug that
// name derives to.
//
// It writes `instances` and nothing else, so admin's control-plane write
// boundary is unchanged. It issues no licence: a licence binds the instance
// UUID, which a rename never touches.
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error) {
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
}
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
// caller unwinding a rename whose admin-side write failed. Leaving the two
// databases disagreeing would have HQ print a host that is not the host.
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
}
```
- [ ] **Step 4: Build**
Run: `cd /go-projects/vantage && go build ./admin/... ./shared/...`
Expected: clean build, no output.
- [ ] **Step 5: Commit**
```bash
git add admin/internal/models/models.go admin/internal/cloudprov/cloudprov.go
git commit -m "feat: Add rename cooldown field and cloudprov rename"
```
---
### Task 3: Customer rename endpoint
**Files:**
- Modify: `admin/internal/api/customer.go` (add handler; `loginURLFor` at ~line 442 is already in this file)
- Modify: `admin/internal/api/routes.go` (~line 77, beside the other `/instances/:id/*` customer routes)
**Interfaces:**
- Consumes: `ownedInstance(c, id) (*models.Instance, bool)`, `selfHostedRefusal` (`members.go`), `loginURLFor(slug) string`, `cloudprov.RenameInstance`, `cloudprov.RestoreInstanceIdentity`, `models.RenameCooldown`, `provision.ErrSlugTaken`, `provision.ErrNameRejected`.
- Produces: `PUT /api/instances/:id/name` returning `{instance_id, name, slug, login_url}`.
- [ ] **Step 1: Write the handler**
Append to `admin/internal/api/customer.go`:
```go
// renameInstance changes a cloud instance's name and moves it to the slug that
// name derives to.
//
// The control plane is written FIRST, because instances.slug carries the unique
// index and that index is what actually settles a race between two accounts
// reaching for the same name. Admin's own row follows; if that write fails the
// control plane is put back, because HQ printing a host that is not the host is
// worse than a failed rename.
//
// No licence is issued and Paddle is not called: a licence binds the instance
// UUID, and a rename does not change it.
func renameInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
if inst.Placeholder {
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
return
}
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if inst.RenamedAt != nil {
if until := inst.RenamedAt.Add(models.RenameCooldown); time.Now().UTC().Before(until) {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
"retry_after": until,
})
return
}
}
ctx := c.Request.Context()
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
return
case errors.Is(err, provision.ErrNameRejected):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$set": bson.M{
"name": renamed.Name,
"slug": renamed.Slug,
"renamed_at": time.Now().UTC(),
}}); err != nil {
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: inst.Slug + " -> " + renamed.Slug, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{
"instance_id": inst.InstanceID,
"name": renamed.Name,
"slug": renamed.Slug,
// The same builder the licence emails use, rather than a second opinion
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
"login_url": loginURLFor(renamed.Slug),
})
}
```
- [ ] **Step 2: Check the imports**
`customer.go` must import `errors`, `fmt`, `log`, `net/http`, `strings`, `time`, `audit`, `auth`, `cloudprov`, `db`, `models`, `license`, `provision`, `gin`, `bson`. Most are already there — add only what the compiler asks for. `provision` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision`; `license` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`.
- [ ] **Step 3: Mount the route**
In `admin/internal/api/routes.go`, in the `cust` group beside the other instance routes (after `cust.POST("/instances/:id/claim-free", …)`):
```go
// Renaming moves the instance's DNS host, so it is owner-or-admin like
// every other instance mutation. Cloud only; the handler refuses the rest.
cust.PUT("/instances/:id/name",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
renameInstance)
```
- [ ] **Step 4: Build**
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
Expected: clean.
- [ ] **Step 5: Commit**
```bash
git add admin/internal/api/customer.go admin/internal/api/routes.go
git commit -m "feat: Add customer instance rename endpoint"
```
---
### Task 4: Staff rename endpoint
**Files:**
- Modify: `admin/internal/api/staff.go`
- Modify: `admin/internal/api/routes.go` (the `staff` group, beside `staff.POST("/instances/:id/relink", …)`)
**Interfaces:**
- Consumes: everything Task 3 consumes, plus `db.Admin`.
- Produces: `PUT /api/staff/instances/:id/name` returning `{instance_id, name, slug}`.
- [ ] **Step 1: Write the handler**
Append to `admin/internal/api/staff.go`:
```go
// staffRenameInstance renames any instance, with no cooldown.
//
// It does NOT write renamed_at: a staff rename must not start the customer's
// 24h clock, or fixing a name for someone locks them out of fixing it further.
//
// On self-hosted it changes admin's label only. There is no control-plane row to
// write — the install is the customer's — and no slug, because self-hosted has
// no tenant subdomain.
func staffRenameInstance(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
ctx := c.Request.Context()
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
set := bson.M{"name": name}
slug := inst.Slug
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
return
case errors.Is(err, provision.ErrNameRejected):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slug = renamed.Slug
set["slug"] = renamed.Slug
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
Target: inst.InstanceID, Detail: inst.Slug + " -> " + slug, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
}
```
`staff.go` will need `errors`, `log`, `cloudprov` and `provision` added to its imports; `fmt`, `net/http`, `strings`, `time`, `audit`, `auth`, `db`, `models`, `license`, `bson` are already there.
- [ ] **Step 2: Mount the route**
In `routes.go`, in the `staff` group after `staff.POST("/instances/:id/relink", staffRelink)`:
```go
staff.PUT("/instances/:id/name", staffRenameInstance)
```
- [ ] **Step 3: Build**
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add admin/internal/api/staff.go admin/internal/api/routes.go
git commit -m "feat: Add staff instance rename endpoint"
```
---
### Task 5: `adminsite` API client and slug preview
**Files:**
- Create: `adminsite/lib/slug.ts`
- Modify: `adminsite/lib/api.ts` (the `Instance` interface ~line 123; the `api` object's instance calls ~line 305; `api.staff` ~line 360)
**Interfaces:**
- Consumes: `PUT /api/instances/:id/name`, `PUT /api/staff/instances/:id/name` (Tasks 3 and 4).
- Produces:
- `INSTANCE_DOMAIN`, `slugify(name: string): string`, `slugError(name: string): string | undefined` from `@/lib/slug`
- `RenameResult` interface, `api.renameInstance(id, name): Promise<RenameResult>`, `api.staff.renameInstance(id, name): Promise<RenameResult>`
- `Instance.renamed_at?: string`
- [ ] **Step 1: Create the slug mirror**
Create `adminsite/lib/slug.ts`:
```ts
/*
* A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the
* host a rename would move an instance to while the customer types.
*
* It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it
* must change in the same commit as the Go one — the same hazard as
* web/lib/targets.ts. The preview is a courtesy; the server's 409 is the
* boundary, and the two are allowed to disagree without anything breaking.
*/
/** Mirrors provision.MinSlugLength / MaxSlugLength. */
export const MIN_SLUG_LENGTH = 3;
export const MAX_SLUG_LENGTH = 40;
/** Mirrors provision.ReservedSlugs. */
const RESERVED = new Set([
"www", "api", "app", "admin", "auth",
"install", "static", "_next", "default",
]);
/*
* The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the
* customer instance page; those predate this file and are left alone rather than
* refactored under a rename change.
*/
export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk";
/** Mirrors provision.Slugify. */
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** Mirrors provision.BaseSlug's truncation. */
export function baseSlug(name: string): string {
return slugify(name).slice(0, MAX_SLUG_LENGTH);
}
/** The reason a name cannot become a slug, or undefined when it can. */
export function slugError(name: string): string | undefined {
const base = slugify(name);
if (base.length < MIN_SLUG_LENGTH) {
return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`;
}
if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) {
return "That name is reserved.";
}
return undefined;
}
/** The host an instance on this slug is reached at. */
export function hostFor(slug: string): string {
return `${slug}.${INSTANCE_DOMAIN}`;
}
```
- [ ] **Step 2: Extend the API client**
In `adminsite/lib/api.ts`, add `renamed_at` to `Instance` (after `relink_count`):
```ts
renamed_at?: string;
```
Add the response type beside the other interfaces:
```ts
export interface RenameResult {
instance_id: string;
name: string;
slug: string;
/** Empty when APP_LOGIN_URL is unset on the server. */
login_url?: string;
}
```
Add the call to the `api` object, after `renewInstance`:
```ts
renameInstance: (id: string, name: string) =>
put<RenameResult>(`/api/instances/${id}/name`, { name }),
```
And to `api.staff`, after `relink`:
```ts
renameInstance: (id: string, name: string) =>
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
```
- [ ] **Step 3: Type-check**
Run: `cd /go-projects/vantage/adminsite && npx tsc --noEmit`
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add adminsite/lib/slug.ts adminsite/lib/api.ts
git commit -m "feat: Add rename calls and slug preview to the HQ client"
```
---
### Task 6: The rename panel and the customer instance page
**Files:**
- Create: `adminsite/components/RenamePanel.tsx`
- Modify: `adminsite/app/(customer)/instances/[id]/page.tsx`
**Interfaces:**
- Consumes: `api.renameInstance` / `api.staff.renameInstance`, `RenameResult` (Task 5); `slugError`, `baseSlug`, `hostFor` (Task 5); `Panel`, `Note` (`@/components/Panel`), `Button` (`@/components/Button`), `Field` (`@/components/Field`), `ApiError` (`@/lib/api`).
- Produces: `RenamePanel({ currentName, currentSlug, onRename })` — a default-collapsed control; `onRename` is `(name: string) => Promise<RenameResult>`.
- [ ] **Step 1: Create the component**
Create `adminsite/components/RenamePanel.tsx`:
```tsx
"use client";
import { useState } from "react";
import { Button } from "./Button";
import { Field } from "./Field";
import { Note } from "./Panel";
import { ApiError, type RenameResult } from "@/lib/api";
import { baseSlug, hostFor, slugError } from "@/lib/slug";
/*
* The rename control, and only the control — the same shape as RelinkPanel: an
* input that expands in place rather than a modal, because this app has no modal
* and one action with one field does not need one.
*
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
* disagree with the server; the 409 that comes back is the answer that counts.
*/
export function RenamePanel({
currentName,
currentSlug,
onRename,
}: {
currentName: string;
currentSlug: string;
onRename: (name: string) => Promise<RenameResult>;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(currentName);
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const [done, setDone] = useState<RenameResult | undefined>();
const name = value.trim();
const derived = baseSlug(name);
const invalid = slugError(name);
// A cosmetic edit that lands on the same slug is still a rename worth doing —
// the name is what the customer reads. Only an empty or unchanged name is
// nothing to submit.
const unchanged = name === currentName.trim();
async function submit() {
setError(undefined);
setBusy(true);
try {
const res = await onRename(name);
setDone(res);
setOpen(false);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
} finally {
setBusy(false);
}
}
if (done) {
const host = done.login_url || `https://${hostFor(done.slug)}`;
return (
<Note tone="warn">
<span className="grid gap-2">
<span>
This instance is now <strong>{done.name}</strong>, at{" "}
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and your
sign-in does not follow it — you will need to sign in again there.
</span>
<a href={host} className="justify-self-start font-mono text-[0.78rem] text-accent underline">
Open {hostFor(done.slug)} &rarr;
</a>
</span>
</Note>
);
}
return (
<div className="grid gap-3">
{open && (
<Field
label="Instance name"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error ?? (name ? invalid : undefined)}
hint={
name && !invalid ? (
<>
Moves to <span className="font-mono">{hostFor(derived)}</span>
{derived === currentSlug && " — the address does not change"}
</>
) : (
"Letters and digits; everything else becomes a hyphen."
)
}
/>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
onClick={() => (open ? submit() : setOpen(true))}
>
{busy ? "Renaming…" : "Rename instance"}
</Button>
{open && (
<span className="text-[0.82rem] text-ink-3">
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
</span>
)}
</div>
</div>
);
}
```
`Note` is `({ tone = "accent" | "warn" | "expired", children })` and renders a `<p>`, which is why the success state wraps its two lines in a `<span className="grid gap-2">` rather than block elements.
- [ ] **Step 2: Mount it on the customer instance page**
In `adminsite/app/(customer)/instances/[id]/page.tsx`:
Add the imports:
```tsx
import { RenamePanel } from "@/components/RenamePanel";
```
and
```tsx
import { useSession } from "@/lib/session";
```
Inside `InstancePage`, with the other hooks (hooks must precede the early returns already in this component):
```tsx
// useSession is the app's one way to ask who the caller is — it shares the
// ["me"] query, so this adds no request.
const { session } = useSession();
```
and after the `cloud` const:
```tsx
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
```
Then add the panel to `PageFrame`'s children, directly after the `MembersPanel` line:
```tsx
{/*
* Address rather than "Rename": the panel is about where this
* instance lives, and the rename is how you change it. Cloud
* only — a self-hosted install has no tenant subdomain for us to
* move.
*/}
{cloud && mayRename && (
<Panel title="Address" meta={host ?? undefined}>
<p className="text-[0.86rem] text-ink-2">
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
one, so saved links and bookmarks to it stop working.
</p>
<RenamePanel
currentName={instance.name}
currentSlug={instance.slug ?? ""}
onRename={async (name) => {
const res = await api.renameInstance(instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["account"] });
return res;
}}
/>
</Panel>
)}
```
- [ ] **Step 3: Build**
Run: `cd /go-projects/vantage/adminsite && npm run build`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add adminsite/components/RenamePanel.tsx "adminsite/app/(customer)/instances/[id]/page.tsx"
git commit -m "feat: Let a customer rename a cloud instance from HQ"
```
---
### Task 7: Staff instance page rename
**Files:**
- Modify: `adminsite/app/(staff)/staff/instances/[id]/page.tsx`
**Interfaces:**
- Consumes: `RenamePanel` (Task 6), `api.staff.renameInstance` (Task 5).
- Produces: nothing later tasks depend on.
- [ ] **Step 1: Add the panel**
In `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, add the imports:
```tsx
import { RenamePanel } from "@/components/RenamePanel";
```
and, inside `StaffInstancePage`, add `const qc = useQueryClient();` at the top of the component if it is not already there (`useQueryClient` is already imported for `EntitlementSection`).
Add this panel after the "Licence history" panel:
```tsx
{/*
* Staff rename has no cooldown and does not start the customer's:
* fixing a name on someone's behalf must not spend their next 24
* hours.
*/}
<Panel title="Name" meta={data.instance.deployment === "cloud" ? "Moves the address" : "Label only"}>
<RenamePanel
currentName={data.instance.name}
currentSlug={data.instance.slug ?? ""}
onRename={async (name) => {
const res = await api.staff.renameInstance(data.instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
return res;
}}
/>
</Panel>
```
- [ ] **Step 2: Build**
Run: `cd /go-projects/vantage/adminsite && npm run build`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add "adminsite/app/(staff)/staff/instances/[id]/page.tsx"
git commit -m "feat: Let staff rename an instance"
```
---
### Task 8: Documentation and end-to-end verification
**Files:**
- Modify: `CLAUDE.md` (the Admin REST API route list, and the `admin_instances` note under MongoDB Collections)
**Interfaces:**
- Consumes: everything above.
- Produces: nothing.
- [ ] **Step 1: Update the Admin REST API route list**
In `CLAUDE.md`, in the customer-session block, after the `POST /instances/:id/claim-free` line:
```
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
```
and in the staff-session block, after `POST /instances/:id/issue · /instances/:id/relink`:
```
PUT /instances/:id/name # rename any instance, no cooldown
```
- [ ] **Step 2: Add the design note**
In `CLAUDE.md`, under "Grants project, they do not federate" (admin's control-plane write boundary is described nearby), add a short paragraph:
```markdown
**A rename moves the host, and the licence does not care.** `PUT
/api/instances/:id/name` re-derives the slug from the new name through
`provision.RenameSlug` — the same rules that named the instance at creation —
and writes the control plane first, because `instances.slug`'s unique index is
what settles a race between two accounts reaching for one name. A taken slug is
a refusal, not an `acme-2`: creation appends a counter because any free slug
will do, and a rename is a request for one specific host. A licence binds the
instance UUID, so nothing is reissued and Paddle is not called. The old host
keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot
reach into), and `km_session` is host-only, so the customer signs in again on
the new address — the portal says so rather than redirecting them into a login
screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at`
because it is admin's policy; staff bypass it and must not write the field.
```
- [ ] **Step 3: Full build**
Run:
```bash
cd /go-projects/vantage && go build ./... && go vet ./admin/... ./shared/... && (cd adminsite && npm run build)
```
Expected: all clean.
- [ ] **Step 4: Manual verification against a running stack**
Work through each and record the result:
1. Rename a cloud instance from `/instances/<id>` as an owner. Panel reports the new host.
2. In Mongo: `db.instances.findOne({instance_id})` and `db.admin_instances.findOne({instance_id})` agree on `name` and `slug`; `admin_instances.renamed_at` is set.
3. The new host serves a login page. The old host stops resolving to the instance within ~60 seconds.
4. A second rename inside 24 hours answers `429` with the unlock time.
5. Renaming onto a slug another instance holds answers `409` and changes neither database.
6. `PUT /api/instances/:id/name` on a self-hosted instance answers `400` with the `selfHostedRefusal` message.
7. `GET /api/staff/audit` shows `instance.renamed` with `old-slug -> new-slug`.
8. Staff rename of the same instance succeeds immediately and leaves `renamed_at` unchanged.
- [ ] **Step 5: Commit**
```bash
git add CLAUDE.md
git commit -m "docs: Document instance rename in HQ"
```
- [ ] **Step 6: Refresh the knowledge graph**
```bash
graphify update .
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
<title>Vantage Status Pages</title>
<style>
:root{
color-scheme: dark;
/* Vantage web/ dark tokens, copied verbatim from web/app/globals.css.
This mockup commits to one theme because web/ does. */
--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; --logo:#7fb2f0;
--shadow:0 1px 0 rgba(0,0,0,.35), 0 20px 44px -26px rgba(0,0,0,.85);
--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;line-height:1.5}
a{color:inherit}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.page{max-width:1180px;margin:0 auto;padding:48px 24px 96px;display:flex;flex-direction:column;gap:56px}
.lede h1{font-size:1.6rem;font-weight:800;letter-spacing:-.035em;text-wrap:balance}
.lede p{color:var(--ink-2);font-size:.9rem;max-width:65ch;margin-top:8px}
.cap{font-family:var(--mono);font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
.board{display:flex;flex-direction:column;gap:10px}
.board__head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
.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 */
.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}
.addr__url{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);overflow-x:auto;white-space:nowrap}
.addr__url b{color:var(--ink);font-weight:600}
.addr__tag{margin-left:auto;font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);border:1px solid var(--rule);border-radius:999px;padding:2px 8px;white-space:nowrap}
/* ---------- public status page ---------- */
.pub{padding:40px 28px 32px}
.pub__inner{max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:28px}
.pub__head{display:flex;align-items:center;gap:14px}
.mark{width:38px;height:38px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--rule);display:grid;place-items:center;color:var(--logo);font-family:var(--mono);font-weight:700;font-size:.85rem;flex-shrink:0}
.pub__head h2{font-size:1.35rem;font-weight:800;letter-spacing:-.03em}
.pub__head p{color:var(--ink-2);font-size:.85rem;margin-top:2px}
.overall{display:flex;align-items:center;gap:11px;border:1px solid;border-radius:var(--r);padding:14px 16px;font-weight:600;font-size:.95rem}
.overall--down{background:rgba(226,112,90,.10);border-color:rgba(226,112,90,.30);color:var(--down)}
.glyph{width:16px;height:16px;flex-shrink:0}
.banner{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r);padding:12px 14px;font-size:.85rem;color:var(--ink-2);display:flex;gap:10px}
.banner b{color:var(--ink);font-weight:600}
.group{display:flex;flex-direction:column;gap:10px}
.group > .cap{padding-left:2px}
.card{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
.rows > * + *{border-top:1px solid var(--rule-soft)}
.comp{padding:16px}
.comp__top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:10px}
.comp__name{font-weight:600;font-size:.92rem}
.state{display:inline-flex;align-items:center;gap:7px;font-size:.78rem;color:var(--ink-2);white-space:nowrap}
.dot{width:7px;height:7px;border-radius:999px;display:block;flex-shrink:0}
.dot--up{background:var(--up)} .dot--down{background:var(--down)}
.dot--maint{background:var(--accent)} .dot--pend{background:var(--pend)}
.dot--none{background:var(--rule)}
.bar{display:flex;gap:2px;overflow-x:auto;padding-bottom:2px}
.bar span{height:26px;width:3px;border-radius:999px;flex:0 0 auto;background:var(--rule)}
.bar .up{background:var(--up)} .bar .down{background:var(--down)}
.bar .maint{background:var(--accent)} .bar .none{background:var(--rule)}
.scale{display:flex;justify-content:space-between;margin-top:7px;font-size:.7rem;color:var(--ink-3)}
.scale b{color:var(--ink-2);font-weight:600;font-variant-numeric:tabular-nums}
.inc{padding:14px 16px}
.inc__top{display:flex;align-items:baseline;justify-content:space-between;gap:14px}
.inc__title{font-weight:600;font-size:.92rem}
.inc__meta{font-size:.75rem;color:var(--ink-3);margin-top:3px}
.inc__affects{font-size:.75rem;color:var(--ink-2);margin-top:5px}
.pill{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;border-radius:999px;padding:3px 9px;border:1px solid;white-space:nowrap}
.pill--inv{color:var(--down);border-color:rgba(226,112,90,.35);background:rgba(226,112,90,.10)}
.pill--mon{color:var(--pend);border-color:rgba(214,166,63,.35);background:rgba(214,166,63,.10)}
.pill--res{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
.pill--sch{color:var(--accent);border-color:rgba(91,155,232,.35);background:rgba(91,155,232,.10)}
.pill--draft{color:var(--ink-2);border-color:var(--rule);background:var(--panel-2)}
.pill--live{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
.timeline{margin-top:12px;border-left:1px solid var(--rule);padding-left:14px;display:flex;flex-direction:column;gap:12px}
.tl__head{display:flex;align-items:baseline;gap:9px}
.tl__st{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
.tl__at{font-size:.7rem;color:var(--ink-3);font-variant-numeric:tabular-nums}
.tl__body{font-size:.85rem;margin-top:3px;color:var(--ink)}
.pub__foot{text-align:center;font-size:.72rem;color:var(--ink-3);padding-top:6px}
/* ---------- editor ---------- */
.app{display:grid;grid-template-columns:236px 1fr;min-height:660px}
.side{background:var(--panel);border-right:1px solid var(--rule);display:flex;flex-direction:column}
.side__brand{height:64px;display:flex;align-items:center;gap:12px;padding:0 20px;border-bottom:1px solid var(--rule);flex-shrink:0}
.side__brand .mark{width:32px;height:32px;font-size:.78rem}
.side__brand b{font-size:1rem;font-weight:800;letter-spacing:-.035em;display:block;line-height:1.2}
.side__nav{padding:16px 12px;display:flex;flex-direction:column;gap:16px}
.navgrp + .navgrp{border-top:1px solid var(--rule);padding-top:16px}
.navgrp > .cap{padding:0 12px 6px}
.navgrp ul{list-style:none;display:flex;flex-direction:column;gap:4px}
.navgrp a{position:relative;display:flex;align-items:center;gap:12px;border-radius:var(--r);padding:9px 12px;font-size:.85rem;font-weight:500;color:var(--ink-2);text-decoration:none}
.navgrp a:hover{background:var(--panel-2);color:var(--ink)}
.navgrp a.on{background:var(--panel-2);color:var(--ink);font-weight:600}
.navgrp a.on::before{content:"";position:absolute;left:0;top:4px;bottom:4px;width:2px;border-radius:999px;background:var(--accent)}
.navgrp svg{width:16px;height:16px;flex-shrink:0;opacity:.9}
.main{padding:26px 28px 36px;display:flex;flex-direction:column;gap:22px;min-width:0}
.back{font-size:.78rem;color:var(--ink-2);text-decoration:none;display:inline-flex;gap:6px;align-items:center}
.back:hover{color:var(--ink)}
.phead{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap}
.phead h2{font-size:1.3rem;font-weight:800;letter-spacing:-.03em}
.record{display:flex;align-items:center;gap:8px;margin-top:6px}
.record code{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:3px 8px}
.copy{background:none;border:0;color:var(--ink-3);cursor:pointer;font-size:.72rem;font-family:var(--mono)}
.copy:hover{color:var(--accent)}
.acts{display:flex;gap:9px;flex-wrap:wrap}
.btn{font-size:.82rem;font-weight:600;border-radius:var(--r);padding:8px 14px;border:1px solid var(--rule);background:var(--panel);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:7px}
.btn:hover{background:var(--panel-2)}
.btn--p{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
.btn--p:hover{background:var(--accent-hover)}
.panel{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
.panel__head{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 16px;border-bottom:1px solid var(--rule)}
.panel__head h3{font-size:.95rem;font-weight:700}
.panel__head p{font-size:.76rem;color:var(--ink-3);margin-top:2px}
.panel__body{padding:16px;display:flex;flex-direction:column;gap:16px}
.fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
.field{display:flex;flex-direction:column;gap:6px;min-width:0}
.field > label{font-size:.76rem;font-weight:600;color:var(--ink-2)}
.field .hint{font-size:.72rem;color:var(--ink-3)}
.in{background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:8px 11px;font:inherit;font-size:.85rem;color:var(--ink);width:100%}
.in::placeholder{color:var(--ink-3)}
.in:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
.in--mono{font-family:var(--mono);font-size:.8rem}
.toggle{display:flex;align-items:center;justify-content:space-between;gap:16px;background:var(--panel-2);border:1px solid var(--rule);border-radius:var(--r);padding:12px 14px}
.toggle p{font-size:.76rem;color:var(--ink-3);margin-top:3px;max-width:52ch}
.toggle b{font-size:.85rem}
.sw{width:38px;height:21px;border-radius:999px;background:var(--up);border:0;position:relative;cursor:pointer;flex-shrink:0}
.sw::after{content:"";position:absolute;top:2px;left:19px;width:17px;height:17px;border-radius:999px;background:var(--accent-ink)}
.sw[aria-checked="false"]{background:var(--rule)}
.sw[aria-checked="false"]::after{left:2px;background:var(--ink-3)}
.sect{border:1px solid var(--rule);border-radius:var(--r);background:var(--panel-2)}
.sect__head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--rule)}
.sect__head .in{max-width:220px}
.sect__head .rm{margin-left:auto}
.rm{background:none;border:0;color:var(--ink-3);font-size:.75rem;cursor:pointer;font-family:var(--mono)}
.rm:hover{color:var(--down)}
.entry{display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:center;padding:11px 12px}
.entry + .entry{border-top:1px solid var(--rule-soft)}
.entry__mon{display:flex;flex-direction:column;gap:2px;min-width:0}
.entry__mon b{font-size:.84rem;font-weight:600}
.entry__mon span{font-family:var(--mono);font-size:.68rem;color:var(--ink-3)}
.adds{display:flex;gap:9px;flex-wrap:wrap;padding:0 12px 12px}
.inc-row{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:13px 14px}
.inc-row + .inc-row{border-top:1px solid var(--rule-soft)}
.inc-row__l{min-width:0}
.inc-row__l b{font-size:.88rem;font-weight:600;display:block}
.inc-row__l span{font-size:.74rem;color:var(--ink-3)}
.inc-row__r{display:flex;align-items:center;gap:9px;flex-shrink:0}
.notes{border-top:1px solid var(--rule);padding-top:14px;display:flex;flex-direction:column;gap:7px}
.notes li{font-size:.82rem;color:var(--ink-2);display:flex;gap:10px;list-style:none}
.notes li b{color:var(--ink);font-weight:600}
.notes .k{font-family:var(--mono);font-size:.66rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);flex:0 0 76px;padding-top:2px}
@media (max-width:820px){
.app{grid-template-columns:1fr}
.side{display:none}
.entry{grid-template-columns:1fr}
.page{padding:32px 16px 64px}
}
</style>
<div class="page">
<header class="lede">
<p class="cap" style="margin-bottom:10px">Vantage · status pages · mockup for approval</p>
<h1>Two screens: what the public sees, and what the operator edits</h1>
<p>Drawn with the real <code style="font-family:var(--mono);font-size:.85em">web/</code> dark tokens and the existing sidebar idioms, so what gets approved here is what gets built. The public page is shown mid-incident rather than all-green, because that is the state it exists for.</p>
</header>
<!-- ================= PUBLIC ================= -->
<section class="board">
<div class="board__head">
<p class="cap">1 · Public status page</p>
<p class="board__route">web/app/status/[pageId]/page.tsx · no auth, no sidebar</p>
</div>
<div class="frame">
<div class="addr">
<span class="addr__dots"><i></i><i></i><i></i></span>
<span class="addr__url">https://acme.vantage.example.com<b>/status/api</b></span>
<span class="addr__tag">signed out</span>
</div>
<div class="pub">
<div class="pub__inner">
<div class="pub__head">
<div class="mark">AC</div>
<div>
<h2>Acme Platform Status</h2>
<p>Live availability for the Acme API and dashboard.</p>
</div>
</div>
<div class="overall overall--down">
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="8" cy="8" r="6.4"/><path d="M8 4.8v3.6M8 11.1h.01" stroke-linecap="round"/>
</svg>
Service disruption
</div>
<div class="banner">
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.6" aria-hidden="true" style="margin-top:2px">
<circle cx="8" cy="8" r="6.4"/><path d="M8 7.4v3.8M8 5.1h.01" stroke-linecap="round"/>
</svg>
<span><b>Europe region only.</b> US and APAC are unaffected. Follow this page for updates.</span>
</div>
<div class="group">
<p class="cap">Active</p>
<div class="card">
<article class="inc">
<div class="inc__top">
<div>
<p class="inc__title">Elevated error rates on database writes</p>
<p class="inc__meta">Started 24 Aug 2026, 09:12 UTC</p>
</div>
<span class="pill pill--mon">monitoring</span>
</div>
<p class="inc__affects">Affects Primary database, Public API</p>
<div class="timeline">
<div>
<div class="tl__head"><span class="tl__st">monitoring</span><span class="tl__at">11:40 UTC</span></div>
<p class="tl__body">Failover completed. Write latency is back to normal and we are watching for recurrence before calling this resolved.</p>
</div>
<div>
<div class="tl__head"><span class="tl__st">identified</span><span class="tl__at">09:48 UTC</span></div>
<p class="tl__body">A failing disk on the primary database node is causing write timeouts. Failover to the standby node is in progress.</p>
</div>
<div>
<div class="tl__head"><span class="tl__st">investigating</span><span class="tl__at">09:15 UTC</span></div>
<p class="tl__body">We are investigating a rise in write errors affecting the API.</p>
</div>
</div>
</article>
</div>
</div>
<div class="group">
<p class="cap">Scheduled maintenance</p>
<div class="card">
<article class="inc">
<div class="inc__top">
<div>
<p class="inc__title">Object storage capacity upgrade</p>
<p class="inc__meta">31 Aug 2026, 02:00 04:00 UTC</p>
</div>
<span class="pill pill--sch">scheduled</span>
</div>
<p class="inc__affects">Affects Object storage</p>
</article>
</div>
</div>
<div class="group">
<p class="cap">API</p>
<div class="card rows">
<div class="comp" data-bar="api" data-state="down">
<div class="comp__top">
<span class="comp__name">Public API</span>
<span class="state"><i class="dot dot--down"></i>Down</span>
</div>
<div class="bar"></div>
<div class="scale"><span>90 days ago</span><span><b>99.81%</b> uptime</span><span>Today</span></div>
</div>
<div class="comp" data-bar="hooks" data-state="up">
<div class="comp__top">
<span class="comp__name">Webhook delivery</span>
<span class="state"><i class="dot dot--up"></i>Operational</span>
</div>
<div class="bar"></div>
<div class="scale"><span>90 days ago</span><span><b>99.99%</b> uptime</span><span>Today</span></div>
</div>
</div>
</div>
<div class="group">
<p class="cap">Web</p>
<div class="card rows">
<div class="comp" data-bar="dash" data-state="up">
<div class="comp__top">
<span class="comp__name">Dashboard</span>
<span class="state"><i class="dot dot--up"></i>Operational</span>
</div>
<div class="bar"></div>
<div class="scale"><span>90 days ago</span><span><b>99.97%</b> uptime</span><span>Today</span></div>
</div>
</div>
</div>
<div class="group">
<p class="cap">Data</p>
<div class="card rows">
<div class="comp" data-bar="db" data-state="down">
<div class="comp__top">
<span class="comp__name">Primary database</span>
<span class="state"><i class="dot dot--down"></i>Down</span>
</div>
<div class="bar"></div>
<div class="scale"><span>90 days ago</span><span><b>99.62%</b> uptime</span><span>Today</span></div>
</div>
<div class="comp" data-bar="obj" data-state="maint">
<div class="comp__top">
<span class="comp__name">Object storage</span>
<span class="state"><i class="dot dot--maint"></i>Maintenance</span>
</div>
<div class="bar"></div>
<div class="scale"><span>90 days ago</span><span><b>99.94%</b> uptime</span><span>Today</span></div>
</div>
<div class="comp" data-bar="new" data-state="up">
<div class="comp__top">
<span class="comp__name">Search index</span>
<span class="state"><i class="dot dot--up"></i>Operational</span>
</div>
<div class="bar"></div>
<div class="scale"><span>90 days ago</span><span><b>100.00%</b> uptime</span><span>Today</span></div>
</div>
</div>
</div>
<div class="group">
<p class="cap">Past incidents</p>
<div class="card rows">
<article class="inc">
<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>
</div>
<span class="pill pill--res">resolved</span>
</div>
<p class="inc__affects">Affects Public API</p>
</article>
<article class="inc">
<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>
</div>
<span class="pill pill--res">resolved</span>
</div>
<p class="inc__affects">Affects Dashboard</p>
</article>
</div>
</div>
<p class="pub__foot">Updated 24 Aug 2026, 11:58 UTC · refreshes every 60 seconds</p>
</div>
</div>
</div>
<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">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>
<!-- ================= EDITOR ================= -->
<section class="board">
<div class="board__head">
<p class="cap">2 · Status page editor</p>
<p class="board__route">web/app/(app)/status-pages/[pageId]/page.tsx · owner or admin</p>
</div>
<div class="frame">
<div class="app">
<aside class="side">
<div class="side__brand">
<div class="mark">V</div>
<div>
<b>Vantage</b>
<span class="cap">Acme Ltd</span>
</div>
</div>
<nav class="side__nav">
<div class="navgrp">
<p class="cap">Fleet</p>
<ul>
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="4" rx="1"/><rect x="2" y="9" width="12" height="4" rx="1"/></svg>Servers</a></li>
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5"/><path d="M6 6h4v4H6z"/></svg>Workloads</a></li>
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1.5 8.5h3l2-4 3 7 2-3h3"/></svg>Monitors</a></li>
</ul>
</div>
<div class="navgrp">
<p class="cap">Access</p>
<ul>
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5.5" cy="8" r="3"/><path d="M8.5 8h6M12 8v2.5"/></svg>SSH Keys</a></li>
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="7" width="10" height="6.5" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 015 0v2"/></svg>Secrets</a></li>
</ul>
</div>
<div class="navgrp">
<p class="cap">Instance</p>
<ul>
<li><a href="#" class="on"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M4.5 10.5v-2M8 10.5v-4M11.5 10.5v-3" stroke-linecap="round"/></svg>Status Pages</a></li>
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10v10H3z"/><path d="M5.5 6.5h5M5.5 9.5h3"/></svg>Audit Log</a></li>
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.8v1.6M8 12.6v1.6M14.2 8h-1.6M3.4 8H1.8"/></svg>Settings</a></li>
</ul>
</div>
</nav>
</aside>
<div class="main">
<a class="back" href="#">
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M9.5 3.5L5 8l4.5 4.5"/></svg>
All status pages
</a>
<div class="phead">
<div>
<h2>Acme Platform Status</h2>
<div class="record">
<code>acme.vantage.example.com/status/api</code>
<button class="copy" type="button">copy</button>
</div>
</div>
<div class="acts">
<a class="btn" href="#">
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M6.5 3.5h6v6M12.5 3.5L7 9"/><path d="M11 10.5v2h-8v-8h2"/></svg>
View page
</a>
<button class="btn btn--p" type="button">Save changes</button>
</div>
</div>
<div class="panel">
<div class="panel__head">
<div>
<h3>Details</h3>
<p>What visitors see at the top of the page.</p>
</div>
<span class="pill pill--live">published</span>
</div>
<div class="panel__body">
<div class="toggle">
<div>
<b>Published</b>
<p>Anyone with the link can read this page. Unpublished pages return not found, so you can compose before announcing.</p>
</div>
<button class="sw" type="button" role="switch" aria-checked="true" aria-label="Published"></button>
</div>
<div class="fields">
<div class="field">
<label for="f-title">Title</label>
<input class="in" id="f-title" value="Acme Platform Status">
</div>
<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>
</div>
<div class="field">
<label for="f-desc">Description</label>
<input class="in" id="f-desc" value="Live availability for the Acme API and dashboard.">
</div>
<div class="field">
<label for="f-logo">Logo URL</label>
<input class="in in--mono" id="f-logo" placeholder="https://acme.example.com/logo.svg">
</div>
</div>
<div class="field">
<label for="f-ban">Notice</label>
<input class="in" id="f-ban" value="Europe region only. US and APAC are unaffected. Follow this page for updates.">
<span class="hint">Shown above everything else. Clear it to remove the notice.</span>
</div>
</div>
</div>
<div class="panel">
<div class="panel__head">
<div>
<h3>Components</h3>
<p>Monitors grouped for the public page. Grouping here is separate from the groups on Monitors.</p>
</div>
<button class="btn" type="button">Add section</button>
</div>
<div class="panel__body">
<div class="sect">
<div class="sect__head">
<input class="in" value="API" aria-label="Section name">
<button class="rm" type="button">remove section</button>
</div>
<div class="entry">
<div class="entry__mon">
<b>prod-api-eu-health</b>
<span>http · every 30s</span>
</div>
<input class="in" value="Public API" aria-label="Public name for prod-api-eu-health">
<button class="rm" type="button">remove</button>
</div>
<div class="entry">
<div class="entry__mon">
<b>hooks-dispatch-probe</b>
<span>http · every 60s</span>
</div>
<input class="in" value="Webhook delivery" aria-label="Public name for hooks-dispatch-probe">
<button class="rm" type="button">remove</button>
</div>
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
</div>
<div class="sect">
<div class="sect__head">
<input class="in" value="Data" aria-label="Section name">
<button class="rm" type="button">remove section</button>
</div>
<div class="entry">
<div class="entry__mon">
<b>pg-primary-10-0-0-5</b>
<span>tcp · every 30s</span>
</div>
<input class="in" value="Primary database" aria-label="Public name for pg-primary-10-0-0-5">
<button class="rm" type="button">remove</button>
</div>
<div class="entry">
<div class="entry__mon">
<b>minio-gw</b>
<span>http · every 60s</span>
</div>
<input class="in" placeholder="minio-gw" aria-label="Public name for minio-gw">
<button class="rm" type="button">remove</button>
</div>
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
</div>
</div>
</div>
<div class="panel">
<div class="panel__head">
<div>
<h3>Incidents</h3>
<p>Written by you. Outages Vantage detects appear on the page automatically.</p>
</div>
<div class="acts">
<button class="btn" type="button">Schedule maintenance</button>
<button class="btn btn--p" type="button">Open incident</button>
</div>
</div>
<div>
<div class="inc-row">
<div class="inc-row__l">
<b>Elevated error rates on database writes</b>
<span>Opened 09:12 UTC · 3 updates · affects Primary database, Public API</span>
</div>
<div class="inc-row__r">
<span class="pill pill--mon">monitoring</span>
<button class="btn" type="button">Post update</button>
</div>
</div>
<div class="inc-row">
<div class="inc-row__l">
<b>Object storage capacity upgrade</b>
<span>31 Aug, 02:0004:00 UTC · affects Object storage</span>
</div>
<div class="inc-row__r">
<span class="pill pill--sch">scheduled</span>
<button class="btn" type="button">Edit</button>
</div>
</div>
<div class="inc-row">
<div class="inc-row__l">
<b>Slow dashboard loads in Europe</b>
<span>17 Jul · resolved after 1h 35m · affects Dashboard</span>
</div>
<div class="inc-row__r">
<span class="pill pill--res">resolved</span>
<button class="btn" type="button">Edit</button>
</div>
</div>
</div>
</div>
</div>
</div>
</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">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>
</ul>
</section>
</div>
<script>
// 90 daily cells per component. Seeded rather than random so the mockup is
// stable between reloads and reviewers are looking at the same picture.
const PATTERNS = {
api: { downs: [2, 22], maint: [], noData: 0 },
hooks: { downs: [], maint: [], noData: 0 },
dash: { downs: [38], maint: [], noData: 0 },
db: { downs: [0, 1, 12, 13, 47], maint: [], noData: 0 },
obj: { downs: [61], maint: [0], noData: 0 },
new: { downs: [], maint: [], noData: 61 }
};
document.querySelectorAll(".comp").forEach((comp) => {
const p = PATTERNS[comp.dataset.bar];
const bar = comp.querySelector(".bar");
const frag = document.createDocumentFragment();
for (let i = 89; i >= 0; i--) {
const cell = document.createElement("span");
let cls = "up";
if (i >= 90 - p.noData) cls = "none";
else if (p.maint.includes(i)) cls = "maint";
else if (p.downs.includes(i)) cls = "down";
cell.className = cls;
cell.title = cls === "none" ? "no data" : cls === "maint" ? "maintenance" : cls === "down" ? "outage" : "operational";
frag.appendChild(cell);
}
bar.appendChild(frag);
});
</script>
File diff suppressed because it is too large Load Diff
@@ -1,307 +0,0 @@
# Multiple auth providers
Date: 2026-08-03
## Problem
An instance can configure exactly one OIDC provider. `instance_oidc` holds one
document per instance, `/auth/oidc/start` takes no argument, and `/login`
renders an unconditional "Sign in with your instance's SSO" button whether or
not anything is configured behind it. Customers who federate with more than one
identity source cannot, and customers who federate with none are shown a button
that leads to an error.
## Goals
- N auth providers per instance, each independently enabled and named.
- Login page renders one button per enabled provider, and none when there are
none.
- Local email/password login can be turned off per instance.
- Presets for the common identity providers, so a customer supplies a tenant ID
rather than an issuer URL.
- Existing configured SSO keeps working across the upgrade with no customer
action.
## Non-goals
- SAML. Different protocol, metadata parsing and certificate handling; not in
this work.
- Per-provider role or group mapping. Provisioned users remain `member`, as
today.
- Provider-specific account linking. An email address is an email address; the
existing instance-scoped lookup stands.
## Data model
New collection `auth_providers`, one document per provider:
```go
type AuthProvider struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ProviderID string `bson:"provider_id" json:"provider_id"`
Name string `bson:"name" json:"name"`
Kind string `bson:"kind" json:"kind"` // "oidc" | "oauth2"
Preset string `bson:"preset" json:"preset"` // "" for custom
Issuer string `bson:"issuer" json:"issuer"`
ClientID string `bson:"client_id" json:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
Scopes []string `bson:"scopes" json:"scopes"`
Enabled bool `bson:"enabled" json:"enabled"`
CallbackNotice bool `bson:"callback_notice" json:"callback_notice"`
Order int `bson:"order" json:"order"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
```
`ProviderID` is a short random identifier, not the Mongo `_id`: it appears in
the callback URL a customer pastes into their IdP, and an `_id` there would
publish a database key.
Unique index on `(instance_id, provider_id)`. Index build is fatal on failure,
matching `EnsureAuthIndexes` — a duplicate `provider_id` within an instance
would make the callback ambiguous.
`ClientSecretEnc` is AES-256-GCM under `KEY_ENCRYPTION_KEY`, as
`instance_oidc.client_secret_enc` is today, and is never serialised.
### Presets
A Go table in `server/internal/auth/presets.go`, not database rows — adding one
is a commit, not a migration.
| Preset | Kind | Issuer | Input asked of the customer | Default scopes |
| ------- | -------- | ------------------------------------------------- | --------------------------- | ----------------------------- |
| `entra` | `oidc` | `https://login.microsoftonline.com/{tenant}/v2.0` | Directory (tenant) ID | `openid profile email` |
| `google`| `oidc` | `https://accounts.google.com` | none | `openid profile email` |
| `okta` | `oidc` | `https://{domain}/oauth2/default` | Okta org domain | `openid profile email` |
| `github`| `oauth2` | n/a | none | `read:user user:email` |
| `` (custom) | `oidc` | supplied verbatim | Issuer URL | `openid profile email` |
The issuer template is expanded server-side on save; the stored `Issuer` is
always the resolved URL, so nothing downstream has to know a preset existed.
### Settings
`settings.local_login_enabled bool`, defaulting true. Absent on existing
documents, and Go's zero value for `bool` is false, so the field is read through
a `*bool` and a nil pointer means enabled. A plain `bool` would silently
disable password login on every instance in the fleet at upgrade.
## Migration
`0005_auth_providers` — the next free number; `0004_instance_rename` is the
highest recorded today. For each document in `instance_oidc`, insert one
`auth_providers` document:
- `Name: "Single sign-on"`
- `Preset: ""`, `Kind: "oidc"`
- `Issuer`, `ClientID`, `Enabled` copied
- `ClientSecretEnc` copied **verbatim**, not decrypted and re-encrypted — a
migration that needs `KEY_ENCRYPTION_KEY` fails on an instance that has none
and strands the SSO configuration.
- `Scopes: ["openid", "profile", "email"]`, matching what `oidc.go` hardcodes
today.
- `ProviderID` freshly generated.
- `CallbackNotice: true` — this provider's redirect URI has changed and an
administrator has not yet acknowledged it. Set only by the migration; cleared
by the settings UI. New providers are created `false`.
`instance_oidc` is left in place and no longer read. Idempotent by skipping any
instance that already has an `auth_providers` document, so a re-run after a
partial failure completes rather than duplicating.
## Auth flow
Routes:
```
GET /auth/oidc/:providerId/start
GET /auth/oidc/:providerId/callback
```
The old unparameterised `/auth/oidc/start` and `/auth/oidc/callback` are
**removed**, not retained. See Upgrade impact below — this breaks configured SSO
until the customer updates their IdP, and that is accepted deliberately rather
than carried as a compatibility path.
The state token in Redis stores `{instance_id, provider_id}` rather than the
bare instance ID. The callback resolves its provider from the consumed state
and cross-checks it against `:providerId` in the path, refusing a mismatch —
the path alone is attacker-controlled, and the state is the half that was
issued by the start handler.
`providerForInstance` becomes `providerFor(ctx, c, instanceID, providerID)`.
The `go-oidc` provider cache keys on `provider_id`, not instance. Saving,
disabling or deleting a provider evicts that key.
`redirectURL(c, providerID)` returns the one per-provider shape, and returns the
same URL in the start and callback halves of a flow — an IdP rejects the token
exchange if they differ.
### OIDC providers
Unchanged from the current implementation: `AuthCodeURL` with the stored
scopes, exchange, `id_token` verified against the provider's key set with
`ClientID` as audience, `email` and `name` claims extracted.
### GitHub (`kind: "oauth2"`)
GitHub is OAuth2 and issues no `id_token`, so it takes a separate branch:
exchange the code, then `GET https://api.github.com/user/emails` with the access
token and take the address that is both `primary` and `verified`. An
unverified-only response is refused — an unverified address is not proof of
control, and accepting one would let anyone holding a GitHub account claim any
address in the instance. `name` comes from `GET https://api.github.com/user`.
Both branches converge on one function:
```go
func completeSSOLogin(c *gin.Context, instanceID, email, name string) error
```
which holds today's lookup-or-provision, session creation, `TouchLastLogin` and
cookie set, verbatim. Email is lower-cased before lookup, and the lookup stays
`GetUserInInstanceByEmail` — instance-scoped, as it is now.
### Licence gate
`services.GetLicenseState(instanceID).Feature("oidc")` continues to gate both
the start and the callback, for every provider kind, and is checked on the
callback against the instance named by the consumed state rather than the host.
Unchanged behaviour, applied to more providers.
## REST API
Unauthenticated:
```
GET /auth/providers
-> {"local_enabled": true,
"providers": [{"id": "...", "name": "...", "preset": "entra"}]}
```
Instance is resolved from the host, as `/auth/bootstrap-status` already does.
The response carries **no issuer, no client ID and no secret** — it is served to
anyone who can reach the login page.
Session-authed, `owner|admin`, under `/api`:
```
GET,POST /auth/providers
PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/test
```
`test` fetches the provider's discovery document (or, for GitHub, calls the API
with the stored credentials) and reports reachability. It does not sign anyone
in.
`GET,PUT /api/org/oidc` is removed along with the old auth routes. Its only
caller is `OIDCCard.tsx`, which this work replaces, and a compatibility shim
over a one-of-many model would have to invent which provider it means.
Every mutation writes an audit event, as every mutating path does.
### Lockout guards
Both refused with 409 and a distinct error code:
- `local_login_required` — disabling local login while zero providers are
enabled.
- `last_provider` — disabling or deleting the last enabled provider while local
login is off.
These are enforced in the service layer, not the handler, so the two endpoints
that can reach the condition cannot disagree.
## Frontend
### Settings
`web/components/settings/OIDCCard.tsx` becomes `AuthProvidersCard`, in the
Access group of `/settings` where the OIDC card already lives. It renders the
provider list with per-row enable toggle, edit, delete and drag ordering, an
Add flow that asks for the preset first and then only the fields that preset
needs, and the local-login toggle beneath the list. A guard violation surfaces
the 409's message rather than a generic failure.
Every provider row shows its **callback URL** with click-to-copy — that is the
value the customer pastes into their IdP, it now differs per provider, and after
the upgrade every migrated provider needs it re-pasted. A migrated provider
additionally carries a warning until an administrator dismisses it, naming the
change and the URL. Dismissal is per provider, stored on the document.
### Login page
`web/app/login/page.tsx` calls `/auth/providers` on mount alongside the existing
`bootstrapStatus` call, and renders on the result:
| `local_enabled` | providers | Rendered |
| --------------- | --------- | --------------------------------------------------- |
| true | none | Password form only. No divider, no buttons. |
| true | some | Password form, divider, one button per provider. |
| false | some | Buttons only. No form, no divider. |
| false | none | Password form (see below). |
The last row cannot be reached through the API — the guards above prevent it —
but a hand-edited database could produce it, and a login page that renders
nothing at all is unrecoverable without database access. It therefore falls back
to the password form.
The current unconditional SSO button and its "SSO must be enabled for this
instance by an administrator" note are both removed; the button now only exists
when it works.
Buttons are labelled with the provider's `Name` and carry the preset's icon
where there is one, a neutral key glyph otherwise. Presets never override the
name — a customer who calls their Entra provider "Staff" gets "Staff".
Errors keep the existing `/login?error=<code>` redirect convention.
## Testing
- Migration: an `instance_oidc` document produces one enabled provider with the
ciphertext byte-identical; a re-run inserts nothing further.
- `local_login_enabled` absent decodes as enabled.
- Guards: both 409 paths, and the enable/disable sequences that approach them
without crossing.
- Per-provider callback: two providers in one instance, each resolving to its
own configuration; a `provider_id` from another instance answers 404.
- A callback whose `:providerId` disagrees with the consumed state is refused,
and the state is consumed rather than left replayable.
- The removed routes (`/auth/oidc/start`, `/auth/oidc/callback`,
`/api/org/oidc`) answer 404.
- GitHub: primary+verified selected; verified-only-absent refused.
- `/auth/providers` response contains no issuer, client ID or secret.
## Upgrade impact
**This release breaks configured SSO until each customer updates their identity
provider.** The old `/auth/oidc/callback` is gone, migrated providers are
reachable only at `/auth/oidc/<providerId>/callback`, and an IdP still pointing
at the old URL fails the flow.
It is a deliberate trade: one callback shape rather than two, no
`legacy_callback` branch through `redirectURL`, and no permanently retained
route whose only purpose is a single past upgrade.
Mitigations, in order of who sees them first:
- The settings card shows the new callback URL per provider with click-to-copy,
and a migrated provider carries a dismissable warning naming the change.
- The failure is visible rather than silent: an IdP rejects the redirect URI
before Vantage is reached, so the customer sees their own provider's error.
- Local password login is unaffected, so no instance is locked out — an
administrator can always sign in to fix the URL. This is why
`local_login_enabled` defaults to true and why nothing in this migration
turns it off.
- Release notes and `docsite/docs/vantage/settings.md` state the required
action.
## Deployment notes
No new environment variables. No agent change. `KEY_ENCRYPTION_KEY` is already
required wherever OIDC was configured, and the migration does not add a
dependency on it.
@@ -1,235 +0,0 @@
# Server tags and scheduled workflows
Date: 2026-08-04
Two features, designed together because the second is worth much less without
the first. Tags make a target set describable; schedules make it recur. A
nightly job that patches "everything tagged `env:staging`" needs both halves,
and neither half is large on its own.
---
## Part A — Server tags
### Model
`models.Server` gains one field:
```go
Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
```
Keys and values are lowercase `[a-z0-9_-]`. Keys are capped at 32 characters,
values at 64, and a server holds at most 20 tags. Validation lives in the
service layer rather than the handler, so the tag endpoint, the server-create
path and anything added later cannot disagree about what a valid tag is.
There is **no `tags` collection.** A tag is a property of a server, not an
entity with a lifecycle: a registry would need reference counting to know when
a tag stopped existing, and garbage collection to act on it, which is work
bought for nothing. The list of known keys and values that the UI offers for
autocomplete is a distinct aggregation over `servers`, cached for 60 seconds —
the same treatment org lookups already get.
No reserved keys ship in this change. If inventory-derived tags (`os`, `arch`)
are added later they take a `sys:` key prefix, so a user tag written today can
never collide with a system tag invented tomorrow.
Index: `{instance_id: 1, "tags.$**": 1}` — a wildcard index over the tag
subdocument, because the queried key is chosen by the user at request time and
cannot be named in advance.
### API
```
PUT /api/servers/:id/tags # replace the whole map
GET /api/servers/tags # known keys and values, for pickers
GET /api/servers?tag=env:prod # repeatable; AND across keys
```
`PUT` replaces the entire map rather than patching one tag. A tag set is small
enough that sending all of it is free, and last-write-wins over a whole map is
easier to reason about than merge semantics between two people editing the same
server. The audit event records the map before and after.
`?tag=` is repeatable and ANDs: `?tag=env:prod&tag=role:web` matches servers
carrying both. A malformed value (no colon, unknown characters) is a 400 rather
than a silent empty result — a filter that matches nothing and a filter that is
nonsense look identical in a list, and only one of them is the user's fault.
### Targeting
`models.Workflow` gains `TargetTags map[string]string` beside the existing
`TargetServerIDs`. One function in `services` resolves them:
```go
ResolveTargets(ctx, instanceID string, ids []string, tags map[string]string) ([]Server, error)
```
- Result is the **distinct union** of the explicit IDs and the tag matches.
- Tag matching ANDs across keys.
- Offline servers are included. The dispatcher already answers 503 per server,
and a patch run that silently omits an unreachable machine is worse than one
that visibly fails on it.
- Empty IDs **and** empty tags returns `ErrNoTargets` (400). A workflow that
matches nothing must say so rather than report success over zero servers.
The resolved set is snapshotted into `WorkflowRun.ServerRuns` exactly as today.
History records what actually ran, not what the selector would match when the
run is later read back — the same reason `steps_snapshot` exists.
### Frontend
- **Server detail**: tag chips in the header with an inline editor. Keys
autocomplete from `GET /api/servers/tags`, values autocomplete per key.
- **`/servers`**: a filter bar that reads and writes the same `?tag=` query
params the API takes, so a filtered fleet view is a URL someone can send.
- **Workflow designer**: a target section holding both inputs, with a live
"runs on 14 servers" readout that lists them on hover. The union model costs
us the at-a-glance answer to "what will this touch"; this readout buys it
back, and it is the reason the union is acceptable.
---
## Part B — Scheduled workflows
### Model
```go
type Schedule struct {
Enabled bool `bson:"enabled" json:"enabled"`
Cron string `bson:"cron" json:"cron"` // 5-field
TZ string `bson:"tz" json:"tz"` // IANA name
}
type Skip struct {
Reason string `bson:"reason" json:"reason"` // "missed" | "already_running"
Due time.Time `bson:"due" json:"due"`
At time.Time `bson:"at" json:"at"`
}
```
On `Workflow`:
```go
Schedule *Schedule `bson:"schedule,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty"` // UTC, indexed
LastRunAt *time.Time `bson:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty"`
```
`next_run_at` is **persisted, not held in memory.** A leader handover between
computing the next occurrence and firing it would otherwise either lose the
occurrence or fire it twice. Coordination state has to live where every replica
can see it — the same argument that put `workflow_log_seq` in MongoDB.
Cron parsing uses `robfig/cron/v3`'s **parser only**`Parse` and
`Next(time)`. Its scheduler and goroutines are not used; the loop below is ours
and has to be, because it runs under the leader lock.
**Alpine ships no tzdata.** `server/Dockerfile` builds a slim image, so
`time.LoadLocation("Europe/London")` returns an error and every schedule
falls back to UTC — an hour wrong for half the year, in the direction nobody
notices until a maintenance window lands in business hours. `main` therefore
imports `_ "time/tzdata"`, embedding the database in the binary. Zone names are
also validated at save time, so an unknown zone is a 400 rather than a surprise
at 2am.
### Scheduler
A new `server/internal/workflowsched` package, started inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched`, `StartReaper`
and the sweepers. One role, one lock. It takes the same cancellable context and
returns the instant leadership is lost.
The loop ticks every 30 seconds:
1. `find({schedule.enabled: true, next_run_at: {$lte: now}})`.
2. **Claim atomically.** `findOneAndUpdate` matching the document *and* its
current `next_run_at`, setting the recomputed next occurrence. A process
that reaches the same document after another has claimed it matches nothing
and does nothing. The claim is what makes this correct; the leader lock only
makes it cheap.
3. **Grace check.** If `now - due > 1h`, record
`last_skipped{reason: "missed"}`, write an audit event, and do not run. A
job missed by ten minutes during a deploy should still run; one missed by
two days should not fire at lunchtime.
4. **Overlap check.** If a run for this workflow is still active, record
`last_skipped{reason: "already_running"}`, audit, and do not run. A patch
workflow must never run twice at once, and a silent skip is how a week goes
by before anyone notices nothing ran.
5. Otherwise start the run through the **same** `RunWorkflow` path a person
uses, with `TriggeredBy: "schedule"`.
Step 5 is the design. A scheduled run is an ordinary run with a different
trigger: no second dispatch path, no second snapshot format, and the run detail
page needs no changes to display one.
### API
```
PUT /api/workflows/:id/schedule # {enabled, cron, tz}
GET /api/workflows/:id/schedule/preview?cron=…&tz=… # next 3 occurrences
```
`PUT` validates the expression and the zone, then computes and stores
`next_run_at`. The preview endpoint exists so the browser and the scheduler
agree on what a cron string means — a client-side cron parser that disagrees
with the server by one field is a bug found in production, at night.
### Frontend
- **Workflow page**: a schedule card with preset buttons (hourly, nightly at
HH:MM, weekly on DAY at HH:MM) that write cron underneath, a raw cron field
for anything else, a timezone select, and the next three occurrences rendered
from the preview endpoint in mono.
- **Workflows list**: a schedule chip and the next run as relative time.
- **Skips are surfaced**, not just stored: a warning line reading
"Skipped Sun 02:00 — previous run still active". Recording a reason nobody
reads is the same as not recording one.
---
## Out of scope
**Notification on scheduled-run failure.** It needs the monitor channel
machinery pointed at workflow outcomes and its own answer to what counts as
failure — a non-zero exit on a step with `on_failure: continue` is not
obviously an alert. Visibility in this change is the run list and the recorded
skip reason. Excluded deliberately, not overlooked.
**Tag-scoped permissions.** Roles stay instance-wide. Tags describe servers;
they do not yet gate who may act on them.
**Inventory-derived tags.** Reserved via the `sys:` prefix, not implemented.
---
## Migration and compatibility
No migration is required. `Tags`, `TargetTags` and `Schedule` are all
`omitempty` and absent means what it meant before: no tags, no selector, no
schedule. Existing workflows keep their explicit server lists and behave
identically.
The wildcard tag index and the `next_run_at` index are declared by a new
`EnsureServerIndexes`, following the convention `EnsureSecretIndexes` and
`EnsureWorkflowIndexes` already set: it warns rather than aborting boot,
because a missing index degrades
tag filtering to a collection scan on a small collection rather than breaking
the fleet list.
## Testing
- `ResolveTargets`: union deduplicates; AND across tag keys; empty/empty
returns `ErrNoTargets`; offline servers are included.
- Tag validation: charset, length caps, tag count cap, malformed `?tag=` is a
400.
- Schedule validation: bad cron and unknown zone both 400; `next_run_at` is
computed in the stored zone, verified across a DST boundary.
- Scheduler claim: two concurrent claims of the same due workflow start exactly
one run.
- Grace window: due 10 minutes ago runs; due 2 hours ago records `missed`.
- Overlap: an active run yields `already_running` and no second run.
- Preview endpoint and the scheduler agree on the next occurrence for a table
of expressions, including a DST-crossing one.
@@ -1,538 +0,0 @@
# Package inventory and CVE findings
Date: 2026-08-06
Agents report the packages installed on each server. The control plane matches
them against distro security feeds and raises findings that link straight to
the patching path that already exists. A finding nobody can fix today can be
accepted with a reason and an expiry date rather than sitting red forever.
This is one of four sub-projects sketched together and deliberately separated:
| # | Sub-project | Depends on |
| - | ----------- | ---------- |
| A | **Package inventory + CVE findings** — this spec | nothing |
| B | Container/service registry | nothing |
| C | Container image scanning | A and B |
| D | Compliance profiles (baseline assertions) | shares A's findings UI only |
A and B are independent of one another. C is the joiner and must not be
designed before both exist. D shares a page with A and nothing else — a
different collector, a different evaluation model and a different remediation
story — so folding it in here would double the size for no shared machinery.
Scope of this spec is **A, Linux only.** Windows needs a separate source
(MSRC CVRF), a separate collector (`Get-HotFix` plus registry) and a KB
supersedence matcher that shares no code with the Linux path. That matches the
existing position that Windows agents are second-class by design, and the six
package managers `updates.go` already detects cover the whole Linux surface.
---
## The trap this design is built around
Distributions **backport** security fixes without changing the upstream
version. Ubuntu ships `openssl 3.0.2-0ubuntu1.15` patched against
CVE-2023-0286; NVD says version 3.0.2 is vulnerable. Matching installed
versions against NVD or CPE ranges therefore reports a fleet full of criticals
that are all already fixed.
That is not merely noisy. It is fatal to the feature: once the first report is
mostly wrong, nobody reads the second one, and a genuine finding is lost in the
noise it created. Everything below follows from refusing to make that mistake.
The correct source is the **distribution's own security feed**, keyed on the
distribution's own version string — Debian and Ubuntu OVAL/USN, Red Hat OVAL
v2, Alpine secdb. `trivy-db` is those feeds pre-merged into one BoltDB
artifact, rebuilt every six hours and published as an OCI artifact.
---
## Where the vulnerability data comes from
`trivy-db`, pulled server-side from `ghcr.io/aquasecurity/trivy-db:2`.
The alternative considered was querying OSV.dev per scan, which needs no
storage and no puller. It was rejected on two counts: it requires outbound
internet on every scan, which breaks air-gapped installs; and it sends the
package list of a customer's entire fleet to a third party. The audience most
likely to buy vulnerability scanning is the audience least willing to do that.
The blob is roughly 50MB, read-only, reproducible, and identified by a version
number. **It is not stored in Mongo and not written to `/data`**
`server.persistence` defaults to off and nothing writes to `/data` any more.
It does not need durable storage: whichever pod needs it pulls it to its own
ephemeral temp directory. Nothing shared, nothing to back up, nothing to
migrate.
`VANTAGE_TRIVY_DB_REF` overrides the default reference so a customer can mirror
the artifact into their own registry. It also covers the anonymous ghcr rate
limit, which the six-hourly pull cadence already makes unlikely to bite.
---
## Only the leader matches
This is the crux, and it falls out of the replica model already in the
codebase.
Two things trigger matching, and they happen on different pods:
1. a fleet-wide rescan when `trivy-db` updates — naturally the leader's job
2. a server's package list changing — handled by whichever pod holds *that
agent's* command stream
If (2) matched inline, **every replica would need the 50MB database resident**,
and a database refresh would have N pods racing to rescan the same fleet and N
digests reaching the customer. That is the exact failure `RunAsLeader` exists
to prevent, and it is the same argument that put `monitorsched` behind the
lock.
So `ReportPackages` does not match. It upserts the package list and sets
`scan_pending: true`. That is all it does.
`server/internal/vulnsched` then runs inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched`,
`workflowsched` and the sweepers — one role, one lock. Every 60 seconds it:
1. pulls `trivy-db` if the local copy is older than six hours
2. if the pulled version differs from `vulndb_meta.db_version`, marks **every**
server `scan_pending`
3. matches all `scan_pending` servers, clears the flag, diffs against existing
findings
4. emits **one** digest per tick covering everything newly opened
Step 4 is why batching is structural rather than bolted on. A `trivy-db`
refresh can open several hundred findings across a fleet at once; one message
per finding would rate-limit the webhook or get the channel muted, and either
way the customer stops receiving the alerts they are paying for. The tick is
already the natural batch boundary, so **the failure cannot occur by
construction** rather than by a debounce someone has to maintain.
`scan_pending` lives on the document rather than in memory, for the same reason
`next_run_at` and `workflow_log_seq` do: a leader handover between marking and
scanning would otherwise lose it. A handover costs the new leader one re-pull
of the database.
The cost of this indirection is up to 60 seconds between an agent reporting a
changed package set and its findings updating. For vulnerability data that is
nothing, and it buys a single matching path instead of two.
---
## Components
```
agent/internal/packages/ collect installed packages + /etc/os-release
proto/ ReportPackages RPC
server/internal/vulndb/ puller, BoltDB access, matcher
server/internal/vulnsched/ leader-owned tick: pull, scan, digest
server/internal/services/ findings, acceptance, alert rules
web/app/(app)/vulnerabilities/ fleet board; plus two server-detail tabs
```
`vulnsched` takes the dependencies it needs — `LogEvent` and the notification
dispatch — as a `vulnsched.Deps` injected from `main.go`, following
`workflowsched`. The manual rescan endpoint does not call into `vulnsched` at
all: it sets `scan_pending` on every server and lets the next tick find them,
so there is no path by which `services` imports the scheduler and no cycle to
avoid later.
---
## The wire path
A new `ReportPackages` RPC on the agent's existing hourly loop — the same
`runUpdateCheck` cadence, reusing `updates.go`'s `detectPM()`.
```protobuf
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
message ReportPackagesRequest {
string server_id = 1;
string agent_token = 2;
string hash = 3; // sha256 of the sorted list
OSRelease os = 4;
repeated InstalledPackage packages = 5; // omitted when only offering a hash
}
message ReportPackagesResponse {
bool need_full = 1; // hash differs; resend with packages populated
}
```
The agent calls once with `packages` empty. `need_full` true means the hash
differs from what the server holds, and the agent immediately calls again with
the list populated.
The agent sends a SHA-256 of its sorted package list first. If it matches what
the server already holds, the server answers `unchanged` and the ~150KB body is
never sent. A machine's package set changes rarely, so almost every hour costs
one small message, and the rare changed hour costs one extra round trip.
Folding the list into the existing 15-minute `InventoryReport` static snapshot
was rejected: it would re-send ~150KB per server every 15 minutes regardless of
change, roughly 40MB/hour of gRPC traffic on a 100-server fleet to transmit
data that is almost always identical.
---
## Data model
Four new collections. Every one carries `instance_id` except `vulndb_meta`,
which is explained below.
### `server_packages` — one document per server, not per package
```go
type ServerPackages struct {
ID primitive.ObjectID `bson:"_id"`
InstanceID primitive.ObjectID `bson:"instance_id"`
ServerID string `bson:"server_id"`
OS OSRelease `bson:"os"` // family, version_id, arch
Hash string `bson:"hash"` // sha256 of the sorted list
Packages []InstalledPackage `bson:"packages"`
CollectedAt time.Time `bson:"collected_at"`
ScanPending bool `bson:"scan_pending"`
ScannedAt time.Time `bson:"scanned_at"`
Status string `bson:"status"` // ok | unsupported
DBVersion int `bson:"db_version"` // last matched against
}
type InstalledPackage struct {
Name string `bson:"name"`
Version string `bson:"version"` // distro version string, verbatim
Epoch int `bson:"epoch,omitempty"`
Arch string `bson:"arch"`
SourceName string `bson:"source_name,omitempty"`
}
```
One document rather than two thousand is what makes a report a **single atomic
upsert with no delta logic** — the hash already established that something
changed, so there is nothing to reconcile field by field. A typical Linux host
lands near 150KB, comfortably inside the 16MB document limit.
Indexes: `{instance_id, server_id}` unique, and a multikey
`{instance_id, "packages.name"}` for fleet-wide package search.
`SourceName` is not decoration. **Debian and Ubuntu advisories are keyed on the
source package**: a CVE against `openssl` covers the binaries `libssl3`,
`openssl` and `libssl-dev`, so matching on binary name alone misses two of the
three.
`OS.VersionID` selects the feed. Ubuntu 22.04 and 24.04 publish different fixed
versions for the same CVE, so a scan without it is guesswork.
### `vuln_findings` — one document per (server, CVE, package)
```go
type VulnFinding struct {
ID primitive.ObjectID `bson:"_id"`
InstanceID primitive.ObjectID `bson:"instance_id"`
ServerID string `bson:"server_id"`
CVEID string `bson:"cve_id"`
PackageName string `bson:"package_name"`
Installed string `bson:"installed_version"`
FixedIn string `bson:"fixed_in,omitempty"`
Severity string `bson:"severity"`
CVSSScore float64 `bson:"cvss_score,omitempty"`
Title string `bson:"title,omitempty"`
References []string `bson:"references,omitempty"`
State string `bson:"state"` // open | fixed | accepted
FirstSeen time.Time `bson:"first_seen"`
LastSeen time.Time `bson:"last_seen"`
FixedAt *time.Time `bson:"fixed_at,omitempty"`
Accepted *Acceptance `bson:"accepted,omitempty"`
}
type Acceptance struct {
By primitive.ObjectID `bson:"by"`
Reason string `bson:"reason"`
Until time.Time `bson:"until"`
At time.Time `bson:"at"`
}
```
Unique on `{instance_id, server_id, cve_id, package_name}`. That key is what
makes a rescan an idempotent upsert rather than a duplicate factory, and it is
what lets `first_seen` survive across scans. Query index
`{instance_id, state, severity}`.
**An empty `FixedIn` is a real and common state** and must never be conflated
with "not vulnerable". A CVE with no vendor fix published yet is exactly the
finding people most need to see, and also the one that most needs acceptance,
because there is nothing to patch.
Findings are **not deleted when a package is patched**. State moves to `fixed`
with `fixed_at` set, so "what did we remediate last quarter" remains
answerable — which is the question an auditor asks.
### `vulndb_meta` — singleton, deliberately unscoped
`db_version`, `pulled_at`, `last_full_scan_at`, `last_error`. It carries no
`instance_id` because the vulnerability database is a property of the
deployment, not of a tenant. Same reasoning as `migrations`.
### `vuln_alert_rules`
`instance_id`, `name`, `enabled`, `min_severity`, `tags map[string]string`,
`channel_ids []`, timestamps.
The tag filter resolves through **`services.ResolveTargets`**, not a second
matcher. That function is already the single answer to which servers a
selector touches, and an alert rule that disagreed with a workflow about what
`env:prod` means would be worse than having no filter at all.
---
## The matching engine
```
server/internal/vulndb/
pull.go OCI fetch → temp dir, version compare against vulndb_meta
db.go BoltDB open, advisory lookup by (ecosystem, source, version)
match.go per-family matching, severity resolution
version.go dispatch to deb/rpm/apk comparator by OS family
```
Dependencies: `github.com/aquasecurity/trivy-db` for the BoltDB schema, plus
`go-deb-version`, `go-rpm-version` and `go-apk-version` — each a small
standalone module doing one job. The roughly 200 lines of per-distro advisory
lookup are ours.
Importing `trivy` itself was rejected: it would pull a very large transitive
dependency tree into the server binary for one feature, and its Go API carries
no stability guarantee across minor versions. Shelling out to the `trivy`
binary against a generated SBOM was rejected for shipping a second binary in
the image and turning a library call into subprocess lifecycle, timeouts and
output-format drift.
### Why the comparators are bought rather than written
Version ordering is where this feature lives or dies, and its failure mode is
silent. `dpkg` ordering has epochs, and `~` sorts *before* the empty string, so
`3.0.2-0ubuntu1.15~rc1` precedes `3.0.2-0ubuntu1.15`. `rpmvercmp` has its own
segment rules and treats `~` and `^` differently again. A `strings.Compare` or
a semver parse orders `1.9` above `1.10` and reports a vulnerable fleet as
clean — a false negative, which nobody notices until it matters.
### Scanning one server
1. Load `server_packages`; resolve OS family and version to a `trivy-db`
ecosystem.
2. **Unsupported ecosystem → record `status: unsupported`, clear the flag,
write no findings.**
3. For each package: resolve source name, look up advisories, compare versions.
4. Upsert vulnerable results as `open`, preserving `first_seen`.
5. Any currently-`open` finding absent from this result set → `fixed`, stamp
`fixed_at`.
6. Any `accepted` finding past its `until` → back to `open`.
7. Clear `scan_pending`, stamp `scanned_at` and `db_version`.
Steps 5 and 6 must run in that order, so a finding that is both absent and
expired settles as `fixed` rather than reopening on a package that no longer
carries it.
Step 2 matters as much as any of the matching. Arch has no feed in `trivy-db`,
so an Arch host must report **unsupported**, never "0 findings". Reporting
clean when the truth is unknown is the same class of lie as a silently stale
database, and it is the reason `vulndb_meta.pulled_at` appears on screen rather
than only in a log.
### Severity
Resolved **vendor → NVD → unknown**, in that order, never invented.
This will surface as "why is this critical CVE marked low", and the answer is
that Debian and Red Hat routinely downgrade an NVD score because the vulnerable
code path is not reachable in their build. Their rating is the accurate one for
that package, and showing NVD's above it would manufacture work that does not
need doing.
---
## Findings lifecycle
`open | fixed | accepted`.
An accepted finding is suppressed from counts and alerts until its `until`
date, then reopens automatically. A reason is required.
Acceptance with a mandatory expiry, rather than permanent dismissal, is what
keeps the feature usable in both directions. Without any acceptance mechanism,
a kernel CVE awaiting a reboot window sits red indefinitely and trains people
to ignore the page. With permanent dismissal, accepted findings accumulate
silently and nobody revisits them — the dismissal list becomes where risk goes
to be forgotten, which is precisely what an auditor asks to see.
Retention: `settings.vuln_finding_retention_days`, a `*int` on the same pattern
as `workflow_log_retention_days` — nil means 90 days, 0 means forever. Only
`fixed` findings are swept, by a `StartVulnSweeper` inside the same
`RunAsLeader("housekeeping", …)` as the existing sweepers. `open` and
`accepted` findings are never swept at any setting.
---
## Alerting
Per-org rules over the existing `notification_channels`: severity threshold,
optional tag filter, target channels.
A rescan emits one message summarising what newly opened — "12 new critical
across 4 servers" — never one message per finding. See the leader section for
why the tick boundary makes this structural.
Modelling findings as a monitor type was rejected. It would reuse monitors'
state machine and channel wiring for free, but monitors are up/down for one
endpoint with retries and hourly rollups, none of which means anything for a
CVE; most fields would be disabled in the UI and the uptime graphs would be
polluted with a signal that is not uptime.
This adds one `notify` payload type and a `vuln_digest.html.tmpl` /
`vuln_digest.txt.tmpl` pair in `shared/mail`. Note that `shared/mail` templates
are parsed in `init()`, so a mistyped field is a boot-time panic — CLAUDE.md
describes a `render_test.go` guarding against exactly this, but **that file does
not exist**; the repository has no Go tests at all, and by instruction this
feature adds none. The template pair must therefore be verified by starting the
binary and sending one digest through a real channel.
---
## Entitlement
The feature name is `vuln_scanning`, and it crosses the two services the way
every other feature does:
- **admin** carries it as a per-instance entitlement toggle, so it can later be
priced as a catalogue `feature` component without a second migration;
- **the licence** snapshots it into `License.Features []string` at issue time;
- **the server** asks `lic.HasFeature("vuln_scanning")` and never switches on
tier, so changing what a tier includes needs no server release.
Off on Free.
**The gate is checked at `ReportPackages`, not at display.** Gating only the UI
would still pay every write cost, and storage is the expensive half.
The agent learns of it through the existing 30-second `SyncKeys` poll:
`SyncResponse` gains a `collect_packages` bool, and the hourly loop skips
collection entirely when it is false. So an ungated instance produces no
collection, no gRPC body, no document and no storage. `ReportPackages` still
re-checks the entitlement server-side and refuses — the agent flag is an
optimisation, the server check is the boundary.
Turning the feature off does not delete existing findings; they stop being
served and stop updating. Deletion is the instance-deletion path's job.
---
## REST API
```
GET /api/vulnerabilities # filter: severity, state, server, tags
GET /api/vulnerabilities/summary # severity counts + database freshness
POST /api/vulnerabilities/rescan # marks all scan_pending (owner|admin)
POST /api/vulnerabilities/:id/accept # reason + until (owner|admin)
DELETE /api/vulnerabilities/:id/accept # (owner|admin)
GET /api/servers/:id/vulnerabilities
GET /api/servers/:id/packages
GET /api/packages/search?name= # fleet-wide
GET,POST /api/vuln-rules · PUT,DELETE /api/vuln-rules/:id
```
Every mutating path writes an audit event, as all of them do. Acceptance is the
one decision people will be asked to justify, so `by`, `reason`, `until` and
`at` land in the audit record and not only on the document.
---
## UI
`/vulnerabilities` is a fleet board **grouped by CVE** — one row per CVE with
an affected-server count, expandable to the individual servers. The same CVE
across 40 servers is one decision, and a flat list of findings makes it look
like forty.
Server detail gains **Vulnerabilities** and **Packages** tabs. Alert rules go
on `/settings/notifications`, beside the channels they consume.
Remediation introduces no new mechanism: a finding carrying `fixed_in` renders
an **Apply updates** action calling the existing
`POST /api/servers/:id/apply-updates`, which is already `ApplyUpdatesCmd`. See
it, patch it, one place — and no second patching path to keep consistent with
the first.
Database freshness is shown wherever findings are, not tucked into settings. A
fleet scanning against a three-week-old database must say so rather than
quietly report all-clear.
---
## Verification
**No automated tests.** The repository has none today, and by explicit
instruction this feature adds none — no `*_test.go`, no frontend test files.
That is a deliberate decision by the repository owner, recorded here so the
absence reads as a choice rather than an omission.
It does change the risk profile, and the places it changes it are worth naming,
because each fails by producing a **wrong answer rather than a crash**:
- **Version comparison.** The backport case — installed `1:3.0.2-0ubuntu1.15`
against advisory fixed-in `1:3.0.2-0ubuntu1.15` resolving to *not
vulnerable* — plus tilde ordering (`1.0~rc1` < `1.0`), epoch dominance
(`1:1.0` > `2.0`) and `1.9` < `1.10`. Wrong here means a vulnerable fleet
reported clean.
- **Source-package fan-out.** One advisory against `openssl` must flag
`libssl3`, `openssl` and `libssl-dev`. Matching on binary name alone silently
finds one of three.
- **`first_seen` preservation.** An upsert that overwrites it makes every
finding look discovered today, and nothing surfaces that until someone reads
a report.
- **Fixed-before-reopen ordering.** A finding both absent from a scan and past
its acceptance expiry must settle `fixed`, not reopen.
The implementation plan carries a manual verification table for each, to be
walked before the relevant task is committed. They are the substitute for the
tests, not a formality.
---
## Failure modes
| Failure | Behaviour |
| ------- | --------- |
| Database pull fails | Keep the last good copy and serve stale. Record `last_error`, surface `pulled_at` age. **Never clear findings** — a network blip must not read as "all fixed" |
| Unsupported distribution | `status: unsupported`, not zero findings |
| Agent stops reporting | Findings persist and `collected_at` age is shown. No auto-expiry: a silent agent is not a patched server |
| `trivy-db` schema version bumps | The puller refuses an unknown schema rather than mis-parsing it |
| ghcr anonymous rate limit | Backoff; `VANTAGE_TRIVY_DB_REF` mirrors to a private registry |
| Leadership lost mid-scan | The context is cancelled and the scan returns; `scan_pending` is still set, so the next leader picks it up |
| Instance deleted | **`server_packages` and `vuln_findings` must be added to the control plane's instance-deletion collection list.** Easy to miss, and missing it orphans a tenant's package data indefinitely |
---
## Environment variables
| Name | Required | Notes |
| ---- | -------- | ----- |
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for air-gapped installs or to avoid the anonymous ghcr rate limit |
| `VANTAGE_VULNDB_DISABLED` | no | disables the puller and the scheduler entirely. Findings already written are still served and still marked stale |
---
## Deliberately out of scope
- **Windows.** Separate source, collector and matcher; its own spec.
- **Container image scanning.** Sub-project C; needs the container registry.
- **Compliance baseline assertions.** Sub-project D; shares this findings UI
and nothing else.
- **Language-level dependency scanning** (npm, pip, Go modules). `trivy-db`
covers these ecosystems, but finding the manifests on a host is a different
collection problem from asking the package manager what is installed.
- **Automatic patching on a finding.** Remediation is one click, not zero. An
unattended upgrade triggered by a CVE feed is a fleet-wide change driven by a
third party's data, which is not a decision to take away from an operator.
@@ -1,441 +0,0 @@
# Workload registry
Date: 2026-08-06
Agents enumerate what each server actually runs — Docker containers, the
compose stacks grouping them, and systemd services — and report it to the
control plane. Containers and units can be started, stopped and restarted from
the UI, and a bounded snapshot of their logs can be read without opening a
console.
This is **sub-project B** of the four sketched in
`2026-08-06-package-inventory-and-cve-findings-design.md`:
| # | Sub-project | Depends on |
| - | ----------- | ---------- |
| A | Package inventory + CVE findings — its own spec | nothing |
| B | **Workload registry** — this spec | nothing |
| C | Container image scanning | A and B |
| D | Compliance profiles | shares A's findings UI only |
A and B are independent. C is the joiner and must not be designed before both
exist: it needs B's image list and A's findings model.
**Workload** is the domain word throughout: one container or one systemd unit.
It gives the collection, the commands and the page a single honest name rather
than saying "container or service" in every identifier.
Scope is **Linux only**, matching sub-project A and the existing position that
Windows agents are second-class by design. Docker runs on Windows; systemd does
not, and half a feature per platform is worse than a clear line.
---
## What this is for
The control plane can manage a fleet's keys, run workflows across it and watch
its endpoints, but it has no idea what any of those servers actually *runs*.
"Restart nginx on that box" means opening a console. "Which of these 80 servers
is still on the old image" is unanswerable.
---
## Reporting and refresh are one path
The agent reports on its own 60-second ticker through a `ReportWorkloads` RPC,
using the same hash short-circuit as the package report: it offers a SHA-256 of
the sorted workload list, and sends the body only when the server does not
already hold that hash. An unchanged list costs one small message, which on a
60-second cadence is the common case by a wide margin.
The on-demand refresh **does not return data**. `RefreshWorkloadsCmd` carries
no payload back; it makes the agent report immediately through the normal RPC,
and the UI refetches the stored document.
That is deliberate. A refresh that returned workloads inline would be a second
writer for the same collection, arriving by a different route, with its own
serialisation and its own opportunity to disagree with the periodic one. One
writer, one shape; the refresh is a nudge, not a channel.
Opening a server's Workloads tab dispatches a refresh, so what is on screen is
live rather than up to a minute stale. That matters because the page has a
Restart button on it: a stale list is not merely a wrong impression, it is a
wrong action aimed at a container that already died.
## What does answer back
Two operations genuinely return something:
| Command | Answers with |
| ------- | ------------ |
| `ControlWorkloadCmd{kind, id, action}` | the existing `CommandResult` — ok or error |
| `WorkloadLogsCmd{kind, id, tail}` | a new `WorkloadLogsResult{command_id, text, truncated}` |
Both ride the proven path: `commandDispatcher.send()` for request and ack, and
a `WorkloadResults` registry mirroring `StepResults.Await`/`Deliver` over the
bus. **`Await` must subscribe before the command is dispatched** — the pod
driving the request is usually not the pod holding the agent's stream, and a
fast agent otherwise answers into a channel nobody has joined. This is not a
new hazard; it is the one `stepresults.go` already documents.
```protobuf
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
message ReportWorkloadsRequest {
string server_id = 1;
string agent_token = 2;
string hash = 3;
bool docker_ok = 4;
string docker_error = 5;
bool systemd_ok = 6;
string systemd_error = 7;
repeated Workload workloads = 8; // empty on the offer call
}
message ReportWorkloadsResponse {
bool need_full = 1;
}
// ServerCommand gains three variants.
message RefreshWorkloadsCmd {}
message ControlWorkloadCmd {
string kind = 1; // "container" | "unit"
string id = 2;
string action = 3; // "start" | "stop" | "restart"
}
message WorkloadLogsCmd {
string kind = 1;
string id = 2;
int32 tail = 3;
}
// AgentMessage gains one variant.
message WorkloadLogsResult {
string command_id = 1;
string text = 2;
bool truncated = 3;
string error = 4;
}
```
The offer-then-send handshake is the package report's, unchanged: the agent
calls once with `workloads` empty, and resends with the body only if the
response sets `need_full`.
An agent whose stream no pod holds gets a 503 from the dispatcher, as
everything else does. Commands are not queued: a command whose owner died must
fail loudly rather than be delivered to nobody while the operator is told it
worked.
---
## Not gated by licence
Unlike CVE scanning, this reads as core fleet management rather than a premium
add-on, so v1 ships to every instance with no entitlement check.
If that changes it is a one-line `HasFeature` check at `ReportWorkloads`,
gating collection rather than display — the same placement and the same
reasoning as sub-project A, where gating the UI alone would still pay every
write cost.
---
## Data model
One new collection, `server_workloads`, one document per server, mirroring
`server_packages`.
```go
type ServerWorkloads struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
ServerID string `bson:"server_id" json:"server_id"`
Hash string `bson:"hash" json:"hash"`
Workloads []Workload `bson:"workloads" json:"workloads"`
CollectedAt time.Time `bson:"collected_at" json:"collected_at"`
DockerOK bool `bson:"docker_ok" json:"docker_ok"`
DockerError string `bson:"docker_error,omitempty" json:"docker_error,omitempty"`
SystemdOK bool `bson:"systemd_ok" json:"systemd_ok"`
SystemdError string `bson:"systemd_error,omitempty" json:"systemd_error,omitempty"`
}
type Workload struct {
Kind string `bson:"kind" json:"kind"` // "container" | "unit"
ID string `bson:"id" json:"id"` // container id, or unit name
Name string `bson:"name" json:"name"`
State string `bson:"state" json:"state"`
Health string `bson:"health,omitempty" json:"health,omitempty"`
Image string `bson:"image,omitempty" json:"image,omitempty"`
Stack string `bson:"stack,omitempty" json:"stack,omitempty"`
Ports []string `bson:"ports,omitempty" json:"ports,omitempty"`
Restarts int `bson:"restarts,omitempty" json:"restarts,omitempty"`
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
Protected bool `bson:"protected" json:"protected"`
}
```
`State` is normalised across the two kinds: containers report `running`,
`exited`, `paused`, `restarting`, `created`; units report `active`, `inactive`,
`failed`, `activating`. They are deliberately **not** collapsed into a shared
vocabulary — a failed unit and an exited container mean different things, and
flattening them would lose the distinction the operator needs.
Indexes: `{instance_id, server_id}` unique, plus multikey
`{instance_id, "workloads.image"}` for the fleet-wide "which servers run image
X" query.
### Why the OK/Error pairs exist
A host with no Docker installed and a host where Docker is installed and
running nothing both produce an empty list. One should read "not in use here",
the other "nothing running", and only the second deserves any alarm.
The error strings separate a third case the booleans alone cannot: Docker
installed with the daemon down. "Not installed" and "installed but not
responding" are different problems with different fixes, and collapsing them
into one false boolean throws away the only thing that tells them apart.
### Why `Protected` is reported rather than derived
The agent already knows which unit and container it is. Sending that up lets
the UI render the action disabled with a reason instead of offering a button
whose refusal is already known.
The field is the courtesy; the agent's own check is the boundary. See the
control section.
### No history
A workload list is state, not a record. Nobody asks what containers ran last
Tuesday, and keeping it would grow a collection per server per minute in
exchange for a question nobody has.
---
## Collectors
### Docker: two commands, no English parsing
```
docker ps -aq
docker inspect --format '{{json .}}' <ids…>
```
Not `docker ps --format '{{json .}}'` alone. That reports health and uptime
inside a human `Status` string — `"Up 2 hours (healthy)"` — and anything built
on it is parsing English that is localised, reworded between releases, and
silently different for a paused or restarting container. `inspect` returns
`State.Health.Status`, `State.StartedAt` and `RestartCount` as typed fields.
Two execs instead of one, and no parser to be wrong.
`RestartCount` justifies the second call by itself: a container cycling is the
single thing this page most needs to show, and it is invisible in a list that
only ever says "Up".
Compose stacks come from the `com.docker.compose.project` label. **No YAML is
read from disk** — the label is what Docker itself treats as authoritative, and
a compose file on disk may not be what is actually running.
Docker absent, or a socket that cannot be reached, sets `DockerOK: false`. It
is not an error and produces no log line: most servers in a fleet built around
SSH key management will not have Docker, and treating the normal case as a
fault makes the feature look broken on the majority of the estate.
### systemd: filtered on purpose
```
systemctl list-units --type=service --state=running,failed --no-legend --plain --no-pager
systemctl list-unit-files --type=service --state=enabled --no-legend --plain --no-pager
```
Two calls because "running or failed" and "enabled but stopped" are different
questions, and an enabled unit that is not running is exactly the one worth
seeing.
Excluded by prefix: `systemd-`, `user@`, `session-`, `init.scope`. A typical
host carries 300+ units, the platform's own accounting for most of them.
Listing all of them buries the ten anyone cares about — the same failure mode
as an unfiltered vulnerability report, and the same fix.
Column output rather than `--output=json`: the JSON flag requires systemd 246+,
and this fleet includes older stable distributions. The column format has been
stable considerably longer than the JSON one has existed.
---
## Control actions
```
container: docker {start|stop|restart} <id>
unit: systemctl {start|stop|restart} <unit>
```
Owner or admin only. Every action writes an audit event naming the actor, the
server and the target.
### The protected set
Computed agent-side: `vantage-agent.service`, plus the container ID read from
`/proc/self/cgroup` should the agent ever be run inside a container.
The agent refuses those before doing anything. As with the console relay
hardcoding `127.0.0.1` agent-side, **the control plane may name a target, but
the agent decides what it will do to itself**. A server-side denylist alone
would be bypassed by the next dispatch path someone adds, and the failure is
unrecoverable from the UI: a server that stops its own agent goes offline, and
the way back is SSH or physical access — precisely what this feature exists to
avoid needing.
### Timeouts
`docker stop` waits on a container that may ignore SIGTERM. `systemctl stop`
on a unit with a long `TimeoutStopSec` blocks for exactly as long as that says.
Both run under a 90-second context, and a timeout returns a real error rather
than an ack implying success.
---
## Logs
```
container: docker logs --tail 500 --timestamps <id>
unit: journalctl -u <unit> -n 500 --no-pager --output=short-iso
```
Capped at **500 lines and 256KB, whichever binds first**, with `truncated` set
so the UI can say so. Two caps because 500 lines of a container emitting 4KB
JSON blobs is 2MB, and a line count alone does not stop it — the same reasoning
that gave workflow logs both a per-line and a per-run cap.
Live following is deliberately absent. The browser console already offers a
real terminal on the same server, where `docker logs -f` works properly with
its own scrollback and cancellation. Building a second streaming path — a
relay listener, proxy bus keys, a WebSocket upgrade and a cancellation story
for a follow nobody closed — to duplicate that would be a large amount of
machinery aimed at a capability already shipped. A bounded snapshot answers
"why did this restart", which is the question that sends people to the console
in the first place.
### Log reads are owner or admin only, and audited
Unlike workflow logs, these cannot be masked. A workflow's logs can be masked
because the run injected the secrets and therefore knows their values. A
container's stdout is arbitrary and may contain credentials nobody declared —
a connection string in a startup banner, a token in a stack trace.
So log reads sit behind the same role check as control actions and are audited.
A member who can see the fleet cannot read its logs. This is a deliberate
access decision, not an oversight, and it is why log reading is not simply
folded in with the read-only snapshot endpoints.
---
## REST API
```
GET /api/servers/:id/workloads # stored snapshot
POST /api/servers/:id/workloads/refresh # dispatch, then refetch
POST /api/servers/:id/workloads/:wid/action # {"action":"start|stop|restart"} (owner|admin)
GET /api/servers/:id/workloads/:wid/logs?tail= # (owner|admin)
GET /api/workloads?image=&stack=&state= # fleet-wide
```
`:wid` is a container ID or a unit name, URL-encoded. Unit names carry dots and
`@`, which are legal in a path segment but not worth relying on unencoded.
`tail` is clamped to the 500-line cap server-side; a client asking for more
gets 500, not an error.
---
## UI
Server detail gains a **Workloads** tab, ordered compose stacks first — grouped
under the stack name — then loose containers, then units.
That ordering is not cosmetic. A stack is one thing to an operator even when it
is six containers, and a flat list turns one decision into six rows. It is the
same argument that groups the vulnerabilities board by CVE rather than by
finding.
A `/workloads` fleet view answers "which servers run image X", which is the
reason the snapshot is stored at all rather than fetched on demand and
discarded.
Three rules that follow directly from the model:
- **Protected rows render their actions disabled, with the reason**, rather
than offering a button whose refusal is already known.
- **`DockerOK: false` reads "Docker not in use on this server"**, never an
empty list, and `DockerError` when present is shown as a distinct problem.
- State never reads by colour alone: every pill carries a distinct shape and a
text label, matching the existing monitor and severity pills.
---
## Verification
**No automated tests.** The repository has none today, and by explicit
instruction this feature adds none — no `*_test.go`, no frontend test files.
A deliberate decision by the repository owner, recorded so the absence reads as
a choice rather than an omission.
The behaviours that would otherwise have been tested are the ones that fail
quietly, and the implementation plan carries a manual check for each:
- **Parser output against real command output.** `docker inspect` must yield
`RestartCount`, health and the compose label as `Stack`; the `systemctl`
exclusion filter must drop `systemd-*` and `user@*` while keeping
`nginx.service`. Both are verified against a live host rather than a fixture.
- **Protected-set computation.** `vantage-agent.service` marked,
`nginx.service` not. Getting this wrong in the permissive direction lets a
server stop its own agent, which is unrecoverable from the UI.
- **Hash order-independence.** An ordering-sensitive hash resends the full list
every 60 seconds, which is invisible except as traffic.
- **Log capping in both directions.** 600 lines in → 500 out with `truncated`;
a 300KB blob of fewer than 500 lines → capped, `truncated`. The second is the
case a line-count-only implementation silently fails, and it fails by sending
megabytes rather than by erroring.
---
## Failure modes
| Failure | Behaviour |
| ------- | --------- |
| Docker not installed | `DockerOK: false`, no error, UI reads "not in use" |
| Docker installed, daemon down | `DockerOK: false` **plus** `DockerError` — different message, different fix |
| Agent offline | 503 from the existing dispatcher. No queueing: a command whose owner died must fail loudly |
| Action on a protected workload | Agent refuses; API answers 409 naming the reason |
| `stop` exceeds its timeout | Real error surfaced, never a hopeful ack. Snapshot refreshed afterwards |
| Container removed between snapshot and action | Docker's "No such container" surfaced and a refresh dispatched — this is what on-demand refresh is for |
| Log exceeds either cap | Truncated, flagged, and stated in the UI |
| Instance deleted | **`server_workloads` must be added to the control plane's instance-deletion collection list**, alongside sub-project A's two collections |
---
## Deliberately out of scope
- **Live log following.** The console already does it. See the logs section.
- **Creating, deleting or updating containers and units.** This is a control
and visibility surface, not a deployment tool — workflows already exist for
changing what a server runs, with snapshots, audit and rollback.
- **`docker exec` into a container.** The console reaches the host; exec from
the control plane is a second remote-execution path with its own audit and
authorisation story, and it belongs in its own spec if anywhere.
- **Kubernetes and containerd.** The Docker collector shells to the `docker`
CLI, so a node whose runtime is containerd or CRI-O reports nothing from it —
`DockerOK: false`, correctly, since Docker genuinely is not in use. Covering
those runtimes means a `crictl`/`nerdctl` collector, and talking to a
Kubernetes API server is a different subsystem again. Neither is v1.
- **Podman as a supported runtime.** Its `docker`-compatible CLI means an
aliased install will largely work, and that is a happy accident rather than a
claim: nothing here is tested against Podman and its `RestartCount` and
compose-label behaviour are not verified.
- **Windows.** No systemd, and a different container story.
- **Image vulnerability scanning.** Sub-project C, which needs this spec's
image list and sub-project A's findings model.
@@ -1,236 +0,0 @@
# Instance rename in Vantage HQ
**Date:** 2026-08-12
**Status:** approved, not yet implemented
## Problem
A cloud instance is named once, at creation, and never again. The name is
chosen in the first thirty seconds of a customer's relationship with the
product — before they have decided whether this is "Acme" or "Acme
Production" — and it is the name that becomes their DNS host, appears in every
sign-in link and heads every page of their control plane. Today the only way to
change it is to create a second instance and move, or to open a support ticket
that has no tooling behind it.
## What a rename is
One customer-initiated action on a **cloud** instance: a new name, from which a
new slug is derived, which moves the instance to a new DNS host.
Name and slug move together. The slug is re-derived through
`provision.BaseSlug`, so the rules that named the instance at creation are the
rules that rename it — the same reserved-label list, the same 340 character
bound, the same `Slugify` collapse of non-alphanumeric runs. There is no
separate slug field for the customer to edit, because two fields invite the
state where the name says one thing and the host says another, and that
divergence is exactly what a rename exists to fix.
A licence binds an instance **UUID**, not a slug. A rename therefore issues no
licence, calls Paddle not at all, and consumes no relink. This is the property
that makes the whole feature cheap, and it should be stated in any future change
that tempts someone to touch the licence from this path.
### What breaks, deliberately
- **The old host stops working.** The old slug is released the moment the rename
commits; another account may take it. Bookmarks, saved sign-in links and any
agent install one-liner that named the web host are stale. Agents themselves
are unaffected — they dial `GRPC_HOST`, which is not per-tenant.
- **The old host keeps working for up to 60 seconds.** `server/internal/auth/instancehost.go`
caches slug-to-instance lookups for 60s, and admin has no path to invalidate
another process's memory. The released slug can be claimed by another account
inside that window, so for up to a minute a replica still maps that host to the
previous tenant. No data is exposed — the host/session guard rejects a session
belonging to a different instance — but the new owner's users can briefly reach
the old tenant's instance on their own host, and see its login page rather than
theirs. Adding a cross-service invalidation channel for a 60-second window is
not worth the coupling.
- **The customer must sign in again.** `km_session` is set with no `Domain`
attribute, so it is host-only and does not follow the instance to its new
subdomain. The UI says so rather than letting the customer discover it.
## Scope
| | Customer (owner or admin) | Staff |
|---|---|---|
| Cloud instance | rename, 24h cooldown | rename, no cooldown |
| Self-hosted instance | refused, 400 | name only; there is no slug |
| Cloud placeholder | refused, 409 | refused, 409 |
Self-hosted is refused on the customer side for the same reason the member
endpoints refuse it: there is no control-plane row to write. The install is the
customer's, on their own database, and admin cannot reach it. Staff may still
correct the label on admin's own row, because that label is what staff search
by.
## Data flow
Two writes, in this order:
1. **Control plane `instances`**`{name, slug}`.
2. **Admin `admin_instances`**`{name, slug, renamed_at}`.
The control plane goes first because `instances.slug` carries the unique index,
and that index is what actually decides a race between two accounts reaching for
the same name. Deciding it anywhere else would be guessing.
If the second write fails, the first is rolled back best-effort — restoring the
previous name and slug — and the request answers 500. Leaving them divergent
would have HQ print a host that is not the host, which is worse than a failed
rename.
## Backend
### `shared/provision/instance.go`
```go
// ErrSlugTaken means the derived slug belongs to another instance.
var ErrSlugTaken = errors.New("slug taken")
// RenameInstance changes an instance's name and re-derives its slug.
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)
```
It lives beside `CreateInstanceWithID` so slug derivation keeps one home, and it
behaves as that function's rules imply:
- `BaseSlug(name)` failures wrap `ErrNameRejected` — too short, too long,
reserved.
- The derived slug is compared against the instance's current one. If they are
equal, only the name is written; a cosmetic capitalisation change is not a
move, and must not fail on its own slug.
- **No `-2` suffix loop.** Creation appends a counter because the customer is
waiting on an instance and any free slug will do. A rename is a request for a
specific host, and silently landing the customer on `acme-2` is a worse answer
than refusing.
- A duplicate-key error on the update surfaces as `ErrSlugTaken`, exactly as the
create path treats it as "that slug is taken". The pre-check is a courtesy;
the index is the boundary.
### `admin/internal/cloudprov`
```go
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)
```
A thin wrapper over `provision.RenameInstance` on `db.ControlDB()`. It writes
`instances` and nothing else, so admin's documented control-plane write boundary
`instances` and `users`, from `cloudprov` and `inject` only — is unchanged.
### `admin/internal/models`
`Instance` gains:
```go
// RenamedAt is when this instance last changed name, and backs the 24h
// customer cooldown. The cooldown is admin's policy, so it lives on admin's
// row rather than in the control plane, which has no opinion about how often
// a customer may move.
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
```
A pointer because absent means "never renamed", and a zero `time.Time` would
read as 1 January year 1 — far enough in the past that the cooldown is inert,
but only by accident.
### `PUT /api/instances/:id/name` (customer)
Mounted in the `cust` group behind `auth.RequireAccountRole(owner, admin)`, and
resolving the instance through `ownedInstance` like every other instance route,
so another account's instance answers 404 rather than 403.
Body: `{"name": "..."}`, trimmed before use.
Refusals, in the order checked:
| Condition | Status | Body |
|---|---|---|
| `deployment != cloud` | 400 | `selfHostedRefusal`, the same constant and status the member endpoints already answer with |
| `placeholder` | 409 | instance is not provisioned yet |
| within 24h of `renamed_at` | 429 | includes the UTC time it unlocks |
| `provision.ErrNameRejected` | 422 | the wrapped reason, verbatim |
| `provision.ErrSlugTaken` | 409 | that name is already in use |
Success returns `{"instance_id", "name", "slug", "login_url"}` and writes an
audit entry `instance.renamed` with detail `<old-slug> -> <new-slug>`, so the
history of a host is answerable from the audit log alone.
`login_url` comes from the existing `loginURLFor(slug)`, which fills `{slug}`
into `APP_LOGIN_URL` — the same builder the licence emails already use, rather
than a second opinion about how a tenant host is spelled. It is empty when
`APP_LOGIN_URL` is unset, and the portal then falls back to the host string it
already composes from the slug in `InstanceRecord` and the instance page.
### `PUT /api/staff/instances/:id/name`
The same core, without the cooldown, actor recorded as the staff user. On a
self-hosted instance it updates `admin_instances.name` only and does not call
`cloudprov`.
## Frontend (`adminsite`)
### `lib/slug.ts`
A TypeScript mirror of `provision.Slugify` and the length/reserved checks, used
only to preview the resulting host while the customer types. It carries the same
warning as `web/lib/targets.ts`: it is a second implementation and must change in
the same commit as the Go one. The preview can disagree with the server — the
409 is the answer that counts.
### `components/RenamePanel.tsx`
An inline panel, not a modal — `adminsite` has no modal component, and the
codebase's idiom for a destructive-ish action with one input is `RelinkPanel`:
a control that expands in place inside a `Panel`.
Prefilled with the current name. Below the input, a live line reading
`acme-ltd.vantage.hostxtra.co.uk` as the customer types, and a note that they
will need to sign in again on the new host. Submit is disabled while the derived
slug is unchanged or invalid.
It lives in an "Address" panel on `app/(customer)/instances/[id]/page.tsx`,
rendered only when the instance is cloud and `account_role` is `owner` or
`admin`. The staff instance page mounts the same component against the staff
route.
`InstanceRecord` on the Overview page is not touched: it stays a summary, and
the rename is a decision that deserves the detail page.
### After a successful rename
Invalidate `["account"]`, collapse the panel, and let the page redraw with the new
name and host. The Console rail card shows the new host, with a note:
> This instance now lives at `acme-ltd.vantage.hostxtra.co.uk`. You will need to
> sign in again there.
**No automatic redirect.** Sending the browser to the new host lands the customer
on a login screen with no explanation, having just lost the HQ page they were
standing on. The link is right there; they click it when they are ready.
## Verification
The repository has no Go test suite, so verification is build plus manual
exercise, matching existing practice:
- `go build ./...` in `shared` and `admin`; `npm run build` in `adminsite`.
- Rename a cloud instance; confirm `instances` and `admin_instances` agree on
name and slug.
- The new host serves a login page; the old host stops resolving to the instance
within ~60 seconds.
- A second rename within 24 hours answers 429.
- A rename onto an occupied slug answers 409 and changes nothing.
- A rename attempt on a self-hosted instance from the customer portal answers
400, the same status and constant the member endpoints already answer with.
- The audit log carries `instance.renamed` with both slugs.
## Out of scope
- Slug aliases or redirects from the old host. The control plane resolves one
slug per instance, and an alias table is a second identity to keep correct for
the sake of stale bookmarks.
- Renaming from inside the control plane's own `/settings`. HQ owns instance
identity, the same way it owns licences and `hq`-sourced users; a second
writer would need the same collision handling and the same cooldown.
- Any change to the licence, subscription or Paddle line items.
@@ -0,0 +1,297 @@
# Windows agent parity: OS updates and workloads
Date: 2026-08-13
## Goal
Bring the Windows agent up to the Linux agent on two subsystems: OS update
check/apply, and the workload registry (collection, control, logs). Everything
else about the Windows agent stays as it is.
Out of scope, deliberately:
- **Package inventory and CVE findings.** `trivy-db` carries no Windows feed, so
a Windows finding needs a different source, a different matcher and a
different version comparison. That is its own project, and until it exists a
Windows host correctly reports `status: unsupported` rather than "0 findings".
- **SSH key management on Windows.** `administrators_authorized_keys` is a real
possibility but a separate decision.
- **winget.** Third-party app upgrades are a different question from OS
patching, and winget is absent on Server Core and older builds.
## Current state
The Windows agent registers, heartbeats, reports inventory, runs workflow steps
through PowerShell, relays console connections and self-updates via MSI. Four
gates stop it doing more:
| Gate | Location |
| --- | --- |
| `runtime.GOOS != "linux"` early return | `agent/internal/workloads/workloads.go`, `agent/internal/sync/workloads.go` (twice) |
| package-manager detection finds nothing | `agent/internal/updates/updates.go` (`detectPM`) |
| hard error | `agent/internal/packages/packages.go` (out of scope here) |
| `authorized_keys` write skipped | `agent/internal/sync/sync.go` (out of scope here) |
## Approach
The platform split moves into the agent, expressed as build tags following the
existing `inventory/collect_linux.go` / `collect_windows.go` /
`collect_other.go` precedent. The control plane stays OS-blind: `ReportWorkloads`,
`ControlWorkload`, `WorkloadLogs` and `ApplyUpdates` need no changes at all,
because a Windows service is reported as the same `unit` kind a systemd service
is.
Build tags rather than `runtime.GOOS` switches so PowerShell command strings do
not ship in the Linux binary, and so a platform left unimplemented is a compile
error rather than a silent no-op at runtime.
## Updates
### Layout
```
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` carries the build constraint for the same reason
`inventory/collect_other.go` does: `_other` is not a GOOS suffix, so without the
constraint the file compiles everywhere and collides.
### Checking
One PowerShell invocation, `-NoProfile -NonInteractive`, emitting JSON:
```powershell
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
```
The Windows Update COM API is used rather than the `PSWindowsUpdate` module
because it is present on every supported Windows, needs no PowerShell Gallery
install, and works unchanged against a WSUS server on an air-gapped fleet. The
agent runs as `LocalSystem`, which has the rights the API requires.
Each result maps to a `PackageUpdate`:
| Field | Value |
| --- | --- |
| `Name` | the update Title |
| `CurrentVersion` | empty |
| `NewVersion` | the KB article ID, e.g. `KB5034123` |
`CurrentVersion` is empty because a Windows update is not a version bump of a
named package, and inventing a current version would put a wrong string in front
of an operator. The KB ID goes in `NewVersion` because it is the identifier
people actually search for.
Timeout: 10 minutes. The first search after a boot contacts Microsoft Update and
is routinely slow.
### Applying
The same COM session: `CreateUpdateDownloader` then `CreateUpdateInstaller`,
over the updates returned by the search above, skipping any that require user
input. EULAs are accepted programmatically; an update whose EULA cannot be
accepted is skipped rather than failing the batch.
The operation fails when the installer's `ResultCode` is not 2 (succeeded) or 3
(succeeded with errors).
Timeout: 60 minutes. A patch-Tuesday cumulative genuinely takes that long, and
the Linux path's existing 5-minute cap is already tight.
**The agent never reboots the host.** A control plane silently restarting a
production server is unrecoverable from the UI, and the reboot is a decision a
person or a workflow makes. Instead the need for one is reported.
### Reboot required
A new field `reboot_required` on `InventoryReport`, added to
`proto/vantage/v1/vantage.proto` and to both hand-written `pb` copies
(`agent/internal/grpc/pb`, `server/internal/grpc/pb`) in the same commit.
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
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.
It is set in `agentsync.runInventory`, not inside the `inventory` package, so
`inventory` gains no dependency on `updates`.
Both platforms set it, since parity is free here:
- Linux: `/var/run/reboot-required` exists, or `dnf needs-restarting -r` exits
non-zero.
- Windows: the `Microsoft.Update.SystemInfo` COM object's `RebootRequired`
property, falling back to the pending-reboot registry keys
(`Component Based Servicing\RebootPending`,
`WindowsUpdate\Auto Update\RebootRequired`,
`Session Manager\PendingFileRenameOperations`).
`services.ReportInventory` stores it on `servers.inventory`.
## Workloads
### Layout
```
agent/internal/workloads/
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
control.go # shared validation; platform halves split out
control_linux.go # docker/systemctl, /proc/self/cgroup own-container check
control_windows.go # Start/Stop/Restart-Service, VantageAgent protection
logs.go # shared: capLog, MaxLogLines, MaxLogBytes
logs_linux.go # docker logs / journalctl
logs_windows.go # docker logs / Get-WinEvent
```
`Collect` loses its `runtime.GOOS != "linux"` return and calls
`collectUnits(ctx)`, which is the systemd collector on Linux and the service
collector on Windows. `runWorkloads` and `reportWorkloads` in
`agent/internal/sync/workloads.go` lose all three of their platform returns.
`docker.go` stays shared and ungated. It shells to the `docker` binary, which
behaves identically on Windows, so a Docker Desktop or Mirantis host reports its
containers with no new code. `DockerOK` / `DockerError` keep their existing
three-state meaning: not installed (the common case, not a fault), installed but
not responding, and running nothing.
### Collecting Windows services
`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
`%SystemRoot%\System32`, and it is running, failed, or has `StartMode=Auto`
while stopped. This mirrors the systemd collector's intent: show what an
operator installed, and show what is meant to be up but is not.
Path parsing strips surrounding quotes and trailing arguments before the
`%SystemRoot%` comparison. `"C:\Program Files\X\x.exe" -service` is one path
with one argument, and splitting naively on whitespace misfiles a substantial
share of a real fleet.
Field mapping:
| Workload field | Source |
| --- | --- |
| `Kind` | `"unit"` |
| `ID` | `Name` (the service name) |
| `Name` | `DisplayName` |
| `State` | `running` / `stopped` / `failed`, from `State` plus `ExitCode` |
| `Health`, `Image`, `Stack`, `Ports`, `Restarts` | unset |
`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
the same thing. The naming is corrected where it is read, in the UI, which knows
the server's OS.
`State` values match the ones the UI already colours, so no web change is needed
for the rows themselves.
### Protection
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,
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.
`ErrProtected` still surfaces as HTTP 409 from the API, and the reported
`Protected` flag remains a courtesy that greys the button rather than the
boundary.
### Control
`Start-Service`, `Stop-Service -Force`, `Restart-Service -Force`, under the same
90-second `controlTimeout`, with the error text taken from PowerShell's stderr.
`sc.exe` is avoided because it returns before the operation completes, which
turns a timeout into a false success. `-Force` is required because
`Stop-Service` without it refuses when other services depend on the target, and
that refusal reads to an operator as a silent no-op.
### Logs
`Get-WinEvent` with a filter hashtable over the `System` and `Application` logs,
provider names matching the service name, its display name, and
`Service Control Manager`, newest first, capped by the requested tail.
Each event is formatted as `<ISO 8601 timestamp> <Level> <Message>`, which is
the same shape `journalctl --output=short-iso` produces, so the log dialog needs
no per-platform rendering.
Service Control Manager logs every service on the host under one provider, so
its events are filtered client-side to those naming the target service.
`capLog` is shared and unchanged: 500 lines and 256KB, whichever binds first,
trimmed from the front. There is still no follow mode.
An empty result returns an empty string and no error. A service that has logged
nothing is normal, and an error there would read as a broken feature.
## Server and web
The server changes in one place: `services.ReportInventory` persists
`reboot_required`.
The web changes in three, all keyed on the same `os_info` test
`MaintenanceTab.tsx` already uses (`server.os_info?.toLowerCase().includes("windows")`)
rather than on `os_type`. `os_type` is stored and serialised but unread by
`web/` today, and introducing a second Windows test in the same component is how
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
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
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.
## Testing
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
beside the parsers as ordinary `_test.go` files, run with `go test ./...` from
`agent/`, and need no new dependency:
- `Win32_Service` JSON, including a quoted path with arguments, a
`%SystemRoot%\System32` service that must be filtered out, a stopped
`StartMode=Auto` service that must be kept, and a failed service with a
non-zero `ExitCode`.
- Update searcher JSON, including an update with no KB ID.
- `Get-WinEvent` JSON, including a Service Control Manager event for another
service that must be filtered out.
- Pending-reboot detection from registry key presence.
`capLog` and `Hash` are unchanged and gain no tests.
The invocation halves are verified by hand on a Windows host: check, apply,
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
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.
@@ -0,0 +1,286 @@
# Public status pages
Date: 2026-08-24
## Goal
Let an operator publish one or more public status pages from a Vantage
instance, at `<slug>.vantage.<tld>/status/<page-id>`, showing the state of any
monitors they choose, plus incidents and maintenance windows they author by
hand. The pages are completely public: no session, no token, no login.
Out of scope, deliberately:
- **Custom domains** (`status.customer.com`). Needs certificate provisioning and
a host-to-page lookup that bypasses `hostSlug` entirely. Its own sub-project.
- **Per-page themes.** `web/` is locked dark by design and a public page is not
the place to break that.
- **Subscriber notifications.** Email or webhook on incident updates is a
notification subsystem, and one already exists for monitors; wiring the two
together is a separate decision.
- **SLA reporting.** Uptime percentages are shown; contractual SLA calculation
with credits and exclusions is a different product.
## Current state
Everything needed to draw a status page already exists and is already scoped by
instance:
| Data | Where |
| --- | --- |
| Monitor identity and live state | `models.Monitor`, `Monitor.State` |
| Outage records | `models.Incident`, opened when a monitor flips down |
| Hourly uptime history | `models.Rollup` (`monitor_rollups`) |
| Sub-hour history | `models.MonitorSample`, TTL-expired |
| Instance from hostname | `auth.InstanceFromHost`, 60s cached |
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`,
`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where
it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with
no scope entry.
## Approach
Two new collections hold the page and the authored incidents. A single
assembly function reads them alongside the existing monitor data and emits a
purpose-built public struct. The public route is mounted outside `/api`, is
cached in Redis, and is rate limited per client address.
The redaction boundary is the assembly function, and it is the security
property of this whole feature.
## Data model
Both collections carry `instance_id` and both must be added to
`services.ScopedCollections`, or their rows outlive a deleted instance.
### `status_pages`
One document per page. It is read whole, always, so its structure is embedded
rather than joined: one page is one Mongo read is one cache fill.
```
_id, instance_id
page_id // operator-chosen slug, [a-z0-9-], 3-40 chars
title, description, logo_url
published bool
banner { enabled, level, text }
sections [ { name, entries: [ { monitor_id, display_name } ] } ]
created_at, updated_at
```
Unique index on `(instance_id, page_id)`. The slug is operator-chosen rather
than random because it is a URL handed to customers and printed on support
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
distinct 403 would confirm it exists.
Sections are page-local and unrelated to `Monitor.Group`, which is a display
label on the authenticated monitors list. One monitor may appear under "API" on
the customer page and "Edge" on the partner page, under two different display
names. That is the point of the override: a monitor's internal name is often
not a name you want published.
The banner is three fields on the page rather than a collection, because it is
one string with no lifecycle.
### `status_incidents`
Manual incidents and maintenance windows share one shape, because they share a
timeline, an impact and a set of affected components; splitting them into two
collections would duplicate all three.
```
_id, instance_id, incident_id
page_ids []string // which pages show it
kind "incident" | "maintenance"
title
impact // none | minor | major | critical
affected_monitors []string // monitor_ids
status // incident: investigating | identified | monitoring | resolved
// maintenance: scheduled | in_progress | completed
scheduled_start, scheduled_end // maintenance only
updates [ { at, status, body, author } ]
started_at, resolved_at, created_at, updated_at
```
Updates are embedded for the same reason sections are: they are few, and they
are never read apart from their incident.
`page_ids` is explicit rather than derived from `affected_monitors`. Deriving it
would be less to fill in, but adding a monitor to a page later would
retroactively republish old incidents to a new audience. An operator publishing
to customers chooses that audience.
### Auto-incidents are derived, never copied
The existing `incidents` collection remains the only writer for
monitor-detected outages. The public snapshot derives them at assembly time:
filter to the monitors on the page, last 90 days, render as display name, start,
end and duration.
`Incident.Cause` is dropped. It is where `dial tcp 10.0.0.5:5432: connect
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 —
the same argument that keeps `RefreshWorkloadsCmd` from returning workloads
inline.
### Maintenance does not rewrite uptime
During a maintenance window, affected components render as "under maintenance"
rather than down. The uptime percentage and the history bar still come from the
rollups, unmodified.
Rollups are the durable record. Bending them so a page looks better is a lie
pointed the other way, and the operator who later asks "what was our actual
availability" gets an answer that was edited for publication.
## The redaction boundary
`services.BuildStatusSnapshot(instanceID, pageID)` is the only function that
reads `monitors`, `incidents`, `monitor_rollups` and `status_incidents` on
behalf of an anonymous caller, and it emits a purpose-built struct.
**`models.Monitor` is never marshalled to a public caller.** Target URL, host,
port, method, keyword, `state.message`, `state.cert_expiry_at` and
`channel_ids` all stay behind the boundary. A field added to `Monitor` next year
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
the monitor existed, which is a distinct thing from a day it was down. No
latency, no addresses, no failure text.
## Public read path
```
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
can widen. Outside `/api` it needs none of them.
The instance is resolved from the request host through `auth.InstanceFromHost`.
A host with no instance label, an unknown slug, an unknown page and an
unpublished page all answer **404**, identically.
### The feature gate answers 200, not 403
Status pages are gated by a new `license.FeatureStatusPages = "status_pages"`,
on both the authoring routes and the public read.
The public side checks inline rather than through `RequireFeature`, which
aborts with a 403 JSON body. A public page needs to render an explanation:
```json
{ "available": false, "reason": "feature_unavailable", "title": "Acme Status" }
```
`reason` is `feature_unavailable` when the tier does not include the feature and
`licence_inactive` when the licence has lapsed. The title is included so the
page does not look broken; nothing else is.
**This is not only a server change.** The feature must be added to admin's
`plans` rows per `(deployment, tier)`, or every instance reads it as absent and
the feature ships dark.
### Cache
Redis key `vantage:status:<instance_id>:<page_id>` holds the assembled JSON with
a 30-second TTL. N visitors cost one Mongo read regardless of traffic.
Authoring writes delete the key, so an operator posting an incident update sees
it immediately rather than wondering for half a minute whether it saved.
Redis rather than Next ISR because with `replicaCount > 1` each `web` pod would
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
important property: **when Redis is unavailable, allow rather than deny.** A
status page must survive the outage it exists to report.
### Trusted proxies
Nothing calls `r.SetTrustedProxies`, so gin trusts every proxy and
`c.ClientIP()` takes `X-Forwarded-For` verbatim. That is spoofable per request,
which makes a per-address limiter decorative.
This has not mattered so far because `ClientIP()` is only used for audit
strings. It matters now, so this work adds a trusted-proxy configuration and
sets it at boot. Without it the rate limit is theatre.
## Authoring API
Under `/api`, owner or admin, behind `RequireFeature("status_pages")`, every
mutation audited:
```
GET,POST /status-pages
GET,PUT,DELETE /status-pages/:pageId
GET,POST /status-pages/:pageId/incidents
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
POST /status-pages/:pageId/incidents/:incidentId/updates
```
This adds a ninth scope resource, `status:read` and `status:write`. The entries
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 copy, so a handler whose annotation drifted fails CI.
## Frontend
`web/app/status/[pageId]/page.tsx`, **outside the `(app)` route group**, so it
inherits no sidebar, no session fetch and no auth redirect. Server-rendered
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
existing token palette covers every state it needs.
Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar
group. It is `adminOnly`, and since the whole group is, a member sees the group
disappear entirely rather than a labelled section with nothing under it.
## Testing
The snapshot tests are the ones that matter, because they are the redaction
boundary made executable:
- `BuildStatusSnapshot` output contains no target URL or host, no
`state.message`, no `incident.cause`, no `channel_ids`, no latency.
- A monitor on no page never appears in any page's snapshot.
- An unpublished page and an unknown page both 404.
- Feature absent and licence inactive both return 200 with `available: false`
and the matching `reason`.
- A cache hit performs no Mongo read; an authoring write invalidates the key.
- Slug validation: character set, length, uniqueness within an instance.
- Maintenance window renders the component as under maintenance while leaving
the uptime percentage untouched.
## Migration and rollout
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.
The feature ships dark until the `status_pages` feature is added to the plan
rows in admin.
+5 -2
View File
@@ -55,8 +55,11 @@ It writes the config to `%ProgramData%\vantage\config.yaml`, installs the agent
as a Windows service and starts it.
:::info Windows servers do not get SSH key management
Windows agents register, report inventory and run workflow steps. Managing
`authorized_keys` is a Linux-only feature.
Windows agents register, heartbeat, report inventory, run workflow steps,
serve the browser console, check and apply OS updates, and report workloads
(services and containers). Managing `authorized_keys` is a Linux-only
feature, and so is package inventory and CVE scanning — the vulnerability
feeds this project uses carry no Windows data.
:::
## 3. Watch it come up
@@ -96,8 +96,16 @@ rather than run in a half-prepared state.
## 4. Put a proxy in front
Point your reverse proxy at `web` on port `3000` and terminate TLS there. The
web app reaches the API internally, so there is no need to publish port `8080`.
Terminate TLS at your reverse proxy and route **one hostname to two backends**:
| Path | Backend |
| -------------------------------------------------------------------------------- | ------------- |
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
| everything else | `web:3000` |
Both rules are required. The web app forwards nothing to the API, so a proxy
that sends the whole hostname to `web:3000` serves the interface and answers
`404` to every request it makes — starting with the login form.
Agents connect to port `9090`. Vantage does not terminate TLS itself, so put
that port behind your proxy too, with a certificate valid for the name in
@@ -14,7 +14,7 @@ self-hosted; what differs is the term on offer, not what you get.
| | Free | Professional | Enterprise |
| --------------------- | --------- | ------------ | --------------------- |
| Servers (base) | 3 | 3 | 10 |
| Servers (base) | 3 | 5 | 10 |
| Monitors | 3 | unlimited | unlimited |
| Secret groups | 1 | unlimited | unlimited |
| Notification channels | 1 | unlimited | unlimited |
@@ -28,13 +28,14 @@ entitlement.
## Features
Three features are enabled per instance rather than bundled into a tier:
Four features are enabled per instance rather than bundled into a tier:
| Feature | What it enables |
| --------- | -------------------------------------------------------------------- |
| Browser console | The [browser console](../vantage/browser-console.md) |
| Single sign-on | [Sign-in through your identity provider](../vantage/settings.md#single-sign-on) |
| Vulnerability scanning| [Package vulnerability scanning](../vantage/vulnerabilities.md) |
| Feature | What it enables |
| ---------------------- | ------------------------------------------------------------------------------- |
| Browser console | The [browser console](../vantage/browser-console.md) |
| Single sign-on | [Sign-in through your identity provider](../vantage/settings.md#single-sign-on) |
| Vulnerability scanning | [Package vulnerability scanning](../vantage/vulnerabilities.md) |
| Status pages | [Public status pages](../vantage/status-pages.md) |
No tier includes them by default; you enable them on the instances that need
them.
+110
View File
@@ -0,0 +1,110 @@
---
id: api-tokens
title: API tokens
sidebar_label: API tokens
---
A session cookie is fine for a browser. A script, a CI job or a cron task
needs something it can hold onto instead — an API token.
## Creating one
**API Keys**, in the Access group of the sidebar. The page is reachable at
every role: any member may create and revoke their own keys, and owner and
admin additionally see every key in the instance. Give it a name, a role
(owner, admin or member) and one or more scopes, and optionally an expiry. The value is shown
once, in full, immediately after creation:
```
vt_8f2c1a9e4b6d0735a1c8e29f4b0d6e17...
```
That is the only time you will see it. Vantage stores a hash of the token,
never the value itself, so if you lose it there is no support ticket that gets
it back — create a new token and revoke the old one.
## Scopes
A token can reach only what its scopes name. There are eight resources, each
with a `:read` and a `:write` scope, and holding `:write` on a resource also
satisfies a `:read` requirement for it — you do not need to tick both.
| Resource | Covers |
| ----------- | --------------------------------------------------- |
| `servers` | Fleet list, server detail, agent commands, tags |
| `keys` | SSH key library and assignment |
| `secrets` | The vault |
| `workflows` | Steps, workflows, runs and their logs |
| `monitors` | Monitors, incidents, uptime and notification channels |
| `vulns` | Vulnerability findings, packages and scan rules |
| `workloads` | Containers and systemd units, including control actions and logs |
| `settings` | Instance settings, members, single sign-on, licence, and token management itself |
A token created with only `servers:read` can list and inspect servers but
cannot run a workflow against them, touch a key, or read a secret — each of
those needs its own scope.
## A token never outranks its owner
A token's role can be at most the role of the person who created it, and its
effective role is **recomputed on every request** as the lower of the two —
not fixed at creation. Demote the person from owner to member and every token
they hold drops to member from that request onward. Remove the person and
every token they hold stops working immediately: a token has no existence
independent of its owner.
## Expiry
An expiry is optional on a token you create. An instance can set a
**maximum key lifetime** (Settings → Integrations) that caps how far out a new
token's expiry may be set; when that cap is in place, a token with no expiry
at all is refused, so there is no way to route around the policy by leaving
the field blank.
Changing the maximum lifetime only affects tokens created afterwards. It does
not shorten, extend or invalidate a token that already exists.
## Using a token
Send it as a bearer token:
```bash
curl -H "Authorization: Bearer vt_…" https://acme.vantage.example.com/api/servers
```
Everything else about the [REST API](./rest-api.md) applies the same way it
does to a session — JSON errors, audit logging, licence gating on writes —
except that authority comes from the token's role and scopes rather than a
signed-in person's role.
## Rate limit
A token is limited to **600 requests per minute**. Going over it gets a `429`
with a `Retry-After` header naming how many seconds to wait. Cookie sessions
are not subject to this limit; it exists so a runaway script cannot take an
instance down, not as a general throttle.
## Rotating a token
1. Create the replacement token first, with the scopes and role you need.
2. Deploy it wherever the old one was used, and confirm it works.
3. Revoke the old one.
Doing it in that order means there is no gap where the credential in use has
already been deleted.
## The full reference
This page covers the token model. Every route, request and response shape is
in the generated OpenAPI reference, served by **your own instance** at
`/api/docs` — not this documentation site, since the routes and their shapes
are specific to your install. The raw document is at `/api/openapi.json`.
:::danger Not the External Secrets token
The bearer token read by `GET /api/secrets/:group/values` for the Kubernetes
External Secrets Operator is a **separate credential** — a single instance-wide
value, rotated from Settings, that reaches only that one endpoint. It is not an
API token and an API token cannot be used in its place: the two are checked by
different code, and neither substitutes for the other. See
[Secrets](../vantage/secrets.md#kubernetes-external-secrets-operator).
:::
@@ -25,6 +25,7 @@ it is absent.
| `VANTAGE_LICENSE` | no | | A licence supplied at startup, so an automated install does not have to paste one in |
| `VANTAGE_TRIVY_DB_REF` | no | `ghcr.io/aquasecurity/trivy-db:2` | Where the vulnerability database is pulled from. Point it at a mirror for an air-gapped install |
| `VANTAGE_VULNDB_DISABLED` | no | | `true` switches [vulnerability scanning](../vantage/vulnerabilities.md) off entirely. Findings already stored are still served, and still shown as stale |
| `TRUSTED_PROXIES` | no | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. The shipped Docker Compose and Helm chart default to the private RFC1918 ranges, which covers Nginx Proxy Manager on the Docker bridge network and Traefik on a Kubernetes pod CIDR. An operator whose proxy sits on a public address must set this themselves, or every visitor behind it shares one address for rate-limiting purposes. Unset entirely (outside those shipped defaults) trusts none, so the client address is the direct peer. **On a LAN-only install, narrow this to your proxy's address.** The RFC1918 default trusts every private range, so a client on 192.168.0.0/16 reaching the server directly is itself a "trusted proxy" and can put whatever it likes in `X-Forwarded-For` — and, on the public status route, in `X-Forwarded-Host`. Behind a proxy on a public address, or with no proxy at all, that is not reachable; on a flat LAN it is |
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
+17 -5
View File
@@ -9,7 +9,7 @@ sidebar_label: Ports and networking
| Port | Service | Who connects | Expose publicly |
| ------- | ----------- | -------------------------------- | --------------- |
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
| `8080` | server API | The web app | No, firewall it |
| `8080` | server API | Your reverse proxy | Not directly — proxied |
| `9090` | server gRPC | Agents | **Yes** |
| `4822` | guacd | The server | No, firewall it |
| `27017` | MongoDB | The server | No |
@@ -20,8 +20,8 @@ sidebar_label: Ports and networking
```mermaid
flowchart LR
B["Browser"] -->|HTTPS| P["Reverse proxy"]
P --> W["web :3000"]
W --> S["server :8080"]
P -->|"everything else"| W["web :3000"]
P -->|"/api /auth /public /install* /update*"| S["server :8080"]
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
S --> G["guacd :4822"]
G -->|"relayed over the :9090 stream"| A
@@ -77,8 +77,20 @@ On a private network you can skip TLS instead, by setting `tls: false` in each
## Reverse proxy notes
- Point the proxy at `web:3000`. The web app reaches the API internally, so
`8080` does not need publishing.
- **The proxy routes two backends on one hostname**, and both are required:
| Path | Backend |
| ------------------------------------------------------------- | ------------- |
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
| everything else | `web:3000` |
The web app forwards nothing to the API. Sending the whole hostname to
`web:3000` loads the interface and every request it makes answers `404`
including the login form.
- Both backends must be the **same** hostname and certificate. The browser
calls `/api` relative to the page it is on, and the session cookie is
host-only.
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
not forward upgrade headers breaks the console and nothing else.
- Workflow log streaming is a long-lived response. A short proxy read timeout
+80
View File
@@ -20,6 +20,13 @@ or is not 64 hex characters.
## Nobody can sign in
**Every request 404s and the interface loads fine.** Your reverse proxy sends
the whole hostname to `web:3000`. `/api`, `/auth`, `/public`, `/install*` and
`/update*` belong to `server:8080` and the web app forwards nothing — see
[Ports and networking](./ports-and-networking.md#reverse-proxy-notes). The
tell is `curl -si https://<your-host>/auth/bootstrap-status` returning HTML
with `x-powered-by: Next.js` instead of JSON.
**`/setup` appears when users already exist.** The server is pointed at a
different database than you think. Check the database name in `MONGO_URI`,
which is taken from the end of the URI.
@@ -95,6 +102,53 @@ instantaneous.
- The keyword no longer appears in the response body.
- Retries are `0`, so a single dropped packet flips the state.
### The check gets a 403, 429 or a CAPTCHA page
The endpoint is fine and answers a browser normally, but the monitor records a
status it never sees by hand. Something between Vantage and the service is
blocking automated traffic: a CDN, a WAF, a bot-protection product, a reverse
proxy rule, or a rate limiter. The response usually comes from that layer and
never reaches the origin at all, so nothing appears in the application's own
logs.
Two things make it hard to spot. The check runs from the control plane's or the
agent's address rather than yours, and those addresses are often datacenter
ranges that bot protection scores badly. And a browser test proves nothing,
because a browser is exactly what the blocking layer is willing to serve.
Every HTTP check Vantage makes identifies itself:
```
User-Agent: Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)
```
That string is the hook to allow the check through. In whichever product is
doing the blocking, add a rule that skips bot protection, managed rules and rate
limiting for requests carrying it — Cloudflare, AWS WAF, Azure Front Door,
Akamai, Fastly, Imperva, Sucuri, ModSecurity, nginx and HAProxy all match on a
request header. The shape of the rule is the same everywhere:
> If the host is *yours*, the path is *the one being monitored*, and the
> User-Agent contains `Vantage-Monitor`, then skip the protection.
Three details are worth getting right:
- **Match on `contains`, not equality.** The version in the string moves. An
exact match breaks silently on an upgrade, and the symptom is a monitor that
goes down on deploy day.
- **Keep the rule narrow.** Scope it to the specific host and path being
monitored. A User-Agent is not a secret — anyone can send it — so a rule that
skips protection site-wide on that string alone is a bypass you have
published.
- **Allow the source address too, where you can.** Combining the User-Agent with
the checker's IP is stronger than either alone. Find the address in your
blocking product's own event log; it is whichever client IP was blocked on the
monitored path.
If the endpoint genuinely needs authentication rather than an exception, monitor
a purpose-built health path that does not, and leave the protected paths
protected.
## Notifications are not arriving
Use the channel **Test** button. It goes through the real delivery path, so a
@@ -113,6 +167,32 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
| Instance degraded despite a valid-looking licence | It expired more than a few days ago. Pasting a new one still works, which is how you recover |
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
## A status page 404s or shows no data
**404, and it should be published.** Check the **Published** toggle on the
page's editor — an unpublished page answers *not found* for everyone,
including you, with no session exemption. Also check the host: the public URL
is `<your-instance>.vantage.<yourdomain>/status/<page-id>`, the same
per-instance subdomain everything else in Vantage uses. A wrong or missing
subdomain resolves to no instance at all, which is also a 404.
Third possibility: `/public` is not routed to the server. Check with
`curl -si https://<your-instance>.vantage.<yourdomain>/public/status/<page-id>`
— JSON is correct, HTML carrying `x-powered-by: Next.js` means the proxy sent
that prefix to the web app.
**Loads, but shows an explanation instead of components.** This is not a
fault — it is the page working as designed. It means either the licence has
lapsed (a self-hosted instance past its grace period, or a cloud instance
between billing events) or the current tier does not include the **Status
pages** feature. Fix the licence or the plan and the same link starts serving
data again with no republish needed.
**One component reads `Unknown`.** The monitor behind it was deleted while
still listed on the page. Nothing is checking it any more, so the page says so
rather than showing a stale up or down. Remove the component from the page,
or point it at a replacement monitor, in the page's editor.
## HQ portal problems
The portal is a hosted service, so problems with it are ours to fix rather than
@@ -64,6 +64,15 @@ Posts the alert as message content.
Port `465` uses implicit TLS; anything else uses STARTTLS.
### Credentials are never read back
The SMTP `password`, the Telegram `token` and the webhook, Slack and Discord
`url`s come back from `GET /api/channels` as `••••••••` — a webhook URL is the
authorisation to post to that channel, so it is treated as a credential like
the rest. Writing that value back unchanged keeps the stored one, which is what
lets you rename a channel without retyping its password. Anything else you send
is written as given, so clearing the field clears the credential.
Alert emails look like the rest of the mail Vantage sends you.
## The message
+11 -5
View File
@@ -89,10 +89,13 @@ metrics is normal rather than a fault.
### OS updates
Agents check for pending package updates hourly and report the count. From the
server page you can:
Agents check for pending package updates hourly and report the count the
machine's own package manager on Linux, the Windows Update COM API on Windows.
From the server page you can:
- **Apply updates** runs the machine's own package manager and reports back.
- **Apply updates** runs that check's install path and reports back. The agent
never reboots the machine; if one is owed, a **reboot required** badge
appears on the next inventory snapshot instead.
- **Update agent** upgrades the Vantage agent on that machine. See
[Agent updates](../operations/agent-updates.md).
@@ -107,8 +110,11 @@ Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console
## Windows servers
Windows agents register, run workflow steps and report inventory. They do not
manage `authorized_keys`.
Windows agents register, heartbeat, run workflow steps, report inventory,
check and apply OS updates, report workloads (services and containers), and
serve the browser console. They do not manage `authorized_keys`, and they are
not covered by package inventory or CVE scanning — the vulnerability feeds
this project uses carry no Windows data.
## Removing a server
+125
View File
@@ -0,0 +1,125 @@
---
id: status-pages
title: Status pages
sidebar_label: Status pages
---
A status page is a public page reporting a chosen set of monitors as up-front
components, with a 90-day history and an uptime percentage per component. It
needs no session and no token to read — anyone with the link can open it,
which is the point: it is what you hand a customer instead of an incident
email.
Requires the **Status pages** licence feature. If the licence lapses, or the
tier does not include the feature, the page keeps serving — it renders an
explanation rather than data or a broken page, so a customer who follows an
old link never sees an error.
## Creating a page
From **Status pages**, choose a page id and a title. The id is 340 characters
of lowercase letters, digits and `-`, starting and ending with a letter or
digit. It becomes part of the public URL:
```
https://<your-vantage-address>/status/<page-id>
```
On **Vantage Cloud** that address is your instance's own subdomain, so the page
is at `https://<your-instance>.vantage.hostxtra.co.uk/status/<page-id>`.
On a **self-hosted** install it is whatever address you reach Vantage on —
`https://vantage.acme.com/status/<page-id>`, or an IP and port on a LAN
install. A self-hosted install serves exactly one Vantage instance, so no
subdomain is needed to say which one you mean. The **Copy** control next to the
page address in the editor gives you the exact URL for your install, which is
the one to hand out.
**The page id cannot be changed after creation.** Once you have shared the
link, changing the id would break it, so pick something you would still be
happy with in a year — `platform`, `api`, a customer's own name for a
dedicated page.
## Draft versus published
A new page starts unpublished. Unpublished pages answer *not found* to
anyone who requests them, including you, from a browser without a session —
so you can build out the components and copy before announcing it. Toggle
**Published** when it is ready. Un-publishing later takes it back to *not
found* rather than deleting anything.
**Delete page**, in the editor header, is the only way to correct a page id you
regret — the id is fixed once created. It takes the page, its sections and its
authored incidents with it; monitors and their history are untouched. If you
only want the page off the internet, un-publish it instead.
## Sections and components
A page is organised into **sections** — arbitrary groupings such as "API" or
"Region: EU" — each holding one or more **components**. A component is a
monitor plus a **display name** you choose for this page.
The display name is never the monitor's own name unless you type it in. An
internal monitor name ("prod-db-primary-eu1") is rarely what you want a
customer reading; give it whatever name makes sense to them, and change it
for a different page without touching the monitor.
If a monitor listed on a page is later deleted, its component still appears —
reading `Unknown` rather than up or down, because nothing is checking it any
more and claiming otherwise would be a false claim of health.
## What a visitor sees
- Component name, current state (up / down / under maintenance / pending /
unknown) and a 90-day uptime percentage. **Pending** is a monitor that has
been added but has not produced a result yet; **unknown** is one nothing is
checking any more.
- A 90-day history bar per component.
- Any active incidents, upcoming maintenance, and a rolling history of both.
- An optional banner across the top of the page, for anything you want said
regardless of component state. It is one notice with one appearance — there
are no severity levels to choose between.
A visitor never sees a target URL, host or port, the check's expected status
or keyword, latency, a certificate expiry date, failure text, or which
notification channel is attached. That is a deliberate boundary, not an
oversight: nothing that would tell a stranger how your infrastructure is
reachable is on this page.
## Incidents and maintenance
Two kinds of entries appear on a page's timeline:
- **Automatic** — a monitor going down opens an incident on any page that
lists it, with no action from you. These appear the moment the monitor's
state changes and close the moment it recovers.
- **Authored** — an incident or maintenance window you create by hand, with
its own title, impact and a set of affected components you choose. You
post updates to it (Investigating → Identified → Monitoring → Resolved) as
the situation develops, and each update is timestamped and kept on the
page's history.
An authored incident is attached to one or more pages explicitly when you
create it — it does not follow a monitor onto every page that monitor happens
to be listed on.
### Scheduling maintenance
A maintenance window has a scheduled start and end (the end must be after the
start) and moves through Scheduled → In progress → Completed. While a window
is in progress and its affected components are within the scheduled time,
those components are drawn as "under maintenance" instead of up or down.
**Maintenance changes how a day is drawn, never the uptime number itself.**
The 90-day percentage is computed from what actually happened — a component
that stayed up throughout a maintenance window still shows as up in its
history, it is only the live status pill that reads "under maintenance" for
the duration.
## Delay before an update appears
A visitor's read of a page is cached for up to 30 seconds, so posting an
update or flipping Published does not necessarily change what a visitor sees
instantly — though most authoring actions invalidate that cache immediately,
so in practice it usually shows within a second or two. If a change genuinely
does not appear, reloading after 30 seconds always will.
+11 -10
View File
@@ -4,23 +4,24 @@ title: Workloads
sidebar_label: Workloads
---
A **workload** is one Docker container or one systemd service. Each Linux server
reports what it is running, and you can start, stop and restart those workloads,
and read their recent logs, without opening a console.
A **workload** is one Docker container or one service — a systemd unit on
Linux, a Windows service on Windows. Every server reports what it is running,
and you can start, stop and restart those workloads, and read their recent
logs, without opening a console.
Available on every instance. No licence feature is required.
## What gets reported
Linux servers only, reported every 60 seconds.
Every server, Linux and Windows, reported every 60 seconds.
- **Containers**: every container, running or not, with its image, published
ports, health, restart count and the compose stack it belongs to.
- **Services**: systemd units that are running, failed, or enabled but stopped.
The operating system's own units are hidden, since a typical host has hundreds
of them and they bury the ones you care about.
Windows servers report no workloads at all.
ports, health, restart count and the compose stack it belongs to. Requires
Docker (or Docker Desktop on Windows).
- **Services**: systemd units on Linux that are running, failed, or enabled but
stopped, and Windows services in the equivalent states. The operating
system's own units and platform services are hidden, since a typical host
has hundreds of them and they bury the ones you care about.
## Docker not in use is not an error
+2 -1
View File
@@ -29,6 +29,7 @@ const sidebars: SidebarsConfig = {
"vantage/vulnerabilities",
"vantage/workloads",
"vantage/notification-channels",
"vantage/status-pages",
"vantage/secrets",
"vantage/browser-console",
"vantage/audit-log",
@@ -43,7 +44,7 @@ const sidebars: SidebarsConfig = {
{
type: "category",
label: "Reference",
items: ["reference/environment-variables", "reference/rest-api", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"],
items: ["reference/environment-variables", "reference/rest-api", "reference/api-tokens", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"],
},
{
type: "category",
+3
View File
@@ -168,6 +168,9 @@ message InventoryReport {
uint64 swap_used = 7;
repeated PartitionReport partitions = 8;
string kernel = 9;
// Set on static snapshots only. The agent never reboots; it reports that one
// is owed and leaves the decision to a person or a workflow.
bool reboot_required = 10;
}
message InventoryReportResponse {
+52
View File
@@ -29,6 +29,32 @@ import (
"github.com/gin-gonic/gin"
)
// @title Vantage API
// @version 1.0
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
// @BasePath /api
// Each @securityDefinitions.apikey block below is deliberately its own
// 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
// stacked blocks sharing one group all collapse onto the first block's name.
// Three groups means three independent scans, each finding its own name.
// @securityDefinitions.apikey cookieAuth
// @in cookie
// @name km_session
// @securityDefinitions.apikey bearerAuth
// @in header
// @name Authorization
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
// @securityDefinitions.apikey esoAuth
// @in header
// @name Authorization
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
@@ -109,6 +135,10 @@ func runSchemaSetup() {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.EnsureAPITokenIndexes(); err != nil {
log.Fatalf("api token indexes: %v", err)
}
// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
// index must exist before anything inserts providers, or a concurrent
// re-run could double-insert before the index is there to refuse it.
@@ -132,6 +162,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if err := services.EnsureMonitorSampleIndexes(); err != nil {
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
}
if err := services.EnsureVulnIndexes(); err != nil {
log.Printf("warning: failed to ensure vuln indexes: %v", err)
}
@@ -140,6 +174,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure workload indexes: %v", err)
}
if err := services.EnsureStatusPageIndexes(); err != nil {
log.Printf("warning: failed to ensure status page indexes: %v", err)
}
if err := services.EnsureAuditIndexes(); err != nil {
log.Printf("warning: failed to ensure audit indexes: %v", err)
}
@@ -225,11 +263,25 @@ func serve() {
})
r := gin.New()
// Without this gin trusts every proxy and ClientIP() is whatever the
// 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.
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(corsMiddleware())
services.SetStatusRedis(auth.Redis())
api.RegisterRoutes(r)
if err := api.AssertScopeMapComplete(r); err != nil {
log.Fatalf("api scope map: %v", err)
}
srv := &http.Server{Addr: ":8080", Handler: r}
go func() {
log.Println("REST server listening on :8080")
+93 -8
View File
@@ -26,10 +26,30 @@ func viewOf(c *gin.Context, p models.AuthProvider) authProviderView {
}
}
// listAuthPresets godoc
//
// @Summary List SSO presets
// @Description Preset providers (Entra, Google, Okta, GitHub) that expand to a real issuer on save.
// @Tags auth-providers
// @Produce json
// @Success 200 {array} auth.Preset
// @Security cookieAuth
// @Security bearerAuth
// @Router /auth/presets [get]
func listAuthPresets(c *gin.Context) {
c.JSON(http.StatusOK, auth.Presets())
}
// listAuthProviders godoc
//
// @Summary List SSO providers
// @Tags auth-providers
// @Produce json
// @Success 200 {array} authProviderView
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /auth/providers [get]
func listAuthProviders(c *gin.Context) {
providers, err := services.ListAuthProviders(auth.InstanceID(c))
if err != nil {
@@ -43,6 +63,18 @@ func listAuthProviders(c *gin.Context) {
c.JSON(http.StatusOK, out)
}
// createAuthProvider godoc
//
// @Summary Create an SSO provider
// @Tags auth-providers
// @Accept json
// @Produce json
// @Param body body object{name=string,preset=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool} true "Provider parameters"
// @Success 201 {object} authProviderView
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /auth/providers [post]
func createAuthProvider(c *gin.Context) {
var body struct {
Name string `json:"name"`
@@ -80,6 +112,22 @@ func createAuthProvider(c *gin.Context) {
c.JSON(http.StatusCreated, viewOf(c, *p))
}
// updateAuthProvider godoc
//
// @Summary Update an SSO provider
// @Tags auth-providers
// @Accept json
// @Produce json
// @Param id path string true "Provider ID"
// @Param body body object{name=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool,order=int} true "Fields to update"
// @Success 200 {object} SavedResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /auth/providers/{id} [put]
func updateAuthProvider(c *gin.Context) {
var body struct {
Name *string `json:"name"`
@@ -135,9 +183,23 @@ func updateAuthProvider(c *gin.Context) {
// document was built from the old ones.
auth.EvictProvider(providerID)
services.LogEvent(instanceID, "auth_provider.update", actorFromCtx(c), "", "", existing.Name)
c.JSON(http.StatusOK, gin.H{"saved": true})
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
// deleteAuthProvider godoc
//
// @Summary Delete an SSO provider
// @Description Refused when the instance would be left with no way in (no local login and no other enabled provider).
// @Tags auth-providers
// @Produce json
// @Param id path string true "Provider ID"
// @Success 200 {object} DeletedResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /auth/providers/{id} [delete]
func deleteAuthProvider(c *gin.Context) {
instanceID := auth.InstanceID(c)
providerID := c.Param("id")
@@ -161,7 +223,7 @@ func deleteAuthProvider(c *gin.Context) {
}
auth.EvictProvider(providerID)
services.LogEvent(instanceID, "auth_provider.delete", actorFromCtx(c), "", "", existing.Name)
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// guardProviderChange asks whether the instance would still have a way in.
@@ -178,6 +240,18 @@ func guardProviderChange(instanceID string, existing *models.AuthProvider, enabl
return services.CheckLockout(services.IsLocalLoginEnabled(instanceID), n-1)
}
// ackAuthProviderNotice godoc
//
// @Summary Acknowledge a provider migration notice
// @Tags auth-providers
// @Produce json
// @Param id path string true "Provider ID"
// @Success 200 {object} AcknowledgedResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /auth/providers/{id}/ack-notice [post]
func ackAuthProviderNotice(c *gin.Context) {
instanceID := auth.InstanceID(c)
providerID := c.Param("id")
@@ -191,10 +265,21 @@ func ackAuthProviderNotice(c *gin.Context) {
return
}
services.LogEvent(instanceID, "auth_provider.ack_notice", actorFromCtx(c), "", "", existing.Name)
c.JSON(http.StatusOK, gin.H{"acknowledged": true})
c.JSON(http.StatusOK, AcknowledgedResponse{Acknowledged: true})
}
// testAuthProvider proves the configuration is reachable. It signs nobody in.
// testAuthProvider godoc
//
// @Summary Test an SSO provider's reachability
// @Description Proves the configuration is reachable. It signs nobody in.
// @Tags auth-providers
// @Produce json
// @Param id path string true "Provider ID"
// @Success 200 {object} TestProviderResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /auth/providers/{id}/test [post]
func testAuthProvider(c *gin.Context) {
instanceID := auth.InstanceID(c)
p, err := services.GetAuthProvider(instanceID, c.Param("id"))
@@ -206,15 +291,15 @@ func testAuthProvider(c *gin.Context) {
// GitHub has no discovery document. The only meaningful check without
// a user token is that credentials are present.
if p.ClientID == "" || p.ClientSecretEnc == "" {
c.JSON(http.StatusOK, gin.H{"ok": false, "message": "client ID and secret are required"})
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: "client ID and secret are required"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "credentials are configured"})
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "credentials are configured"})
return
}
if _, err := oidc.NewProvider(c.Request.Context(), p.Issuer); err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "message": err.Error()})
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "discovery document fetched"})
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "discovery document fetched"})
}
+69 -3
View File
@@ -18,15 +18,46 @@ func registerChannelRoutes(g *gin.RouterGroup) {
g.POST("/channels/:id/test", testChannel)
}
// listChannels godoc
//
// @Summary List notification channels
// @Tags channels
// @Produce json
// @Success 200 {array} models.NotificationChannel
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /channels [get]
func listChannels(c *gin.Context) {
channels, err := services.ListChannels(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, channels)
// Redacted here rather than in the service: the dispatchers read the same
// documents and need the real credentials, so the masking belongs to the
// boundary that hands them to a client.
out := make([]models.NotificationChannel, 0, len(channels))
for _, ch := range channels {
out = append(out, ch.Redacted())
}
c.JSON(http.StatusOK, out)
}
// createChannel godoc
//
// @Summary Create a notification channel
// @Tags channels
// @Accept json
// @Produce json
// @Param body body models.NotificationChannel true "Channel to create"
// @Success 201 {object} models.NotificationChannel
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /channels [post]
func createChannel(c *gin.Context) {
var ch models.NotificationChannel
if err := c.ShouldBindJSON(&ch); err != nil {
@@ -45,9 +76,23 @@ func createChannel(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
c.JSON(http.StatusCreated, created.Redacted())
}
// updateChannel godoc
//
// @Summary Update a notification channel
// @Tags channels
// @Accept json
// @Produce json
// @Param id path string true "Channel ID"
// @Param body body object{name=string,type=string,config=map[string]string,enabled=bool} true "Fields to update"
// @Success 204
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /channels/{id} [put]
func updateChannel(c *gin.Context) {
var body struct {
Name *string `json:"name"`
@@ -83,6 +128,16 @@ func updateChannel(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// deleteChannel godoc
//
// @Summary Delete a notification channel
// @Tags channels
// @Param id path string true "Channel ID"
// @Success 204
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /channels/{id} [delete]
func deleteChannel(c *gin.Context) {
if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -91,10 +146,21 @@ func deleteChannel(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// testChannel godoc
//
// @Summary Send a test notification
// @Tags channels
// @Produce json
// @Param id path string true "Channel ID"
// @Success 200 {object} StatusResponse
// @Failure 502 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /channels/{id}/test [post]
func testChannel(c *gin.Context) {
if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
c.JSON(http.StatusOK, StatusResponse{Status: "sent"})
}
+35 -4
View File
@@ -16,6 +16,22 @@ import (
"github.com/wwt/guac"
)
// 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.
// @Tags console
// @Accept json
// @Produce json
// @Param body body object{server_id=string,protocol=string,key_id=string,rdp_username=string,rdp_password=string,ssh_username=string} true "Session parameters"
// @Success 200 {object} ConsoleConnectResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /console/connect [post]
func consoleConnect(c *gin.Context) {
var body struct {
ServerID string `json:"server_id" binding:"required"`
@@ -73,10 +89,10 @@ func consoleConnect(c *gin.Context) {
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
"console session opened ("+body.Protocol+", agent-relayed)")
c.JSON(http.StatusOK, gin.H{
"session_id": sess.SessionID,
"token": token,
"ws_path": "/api/console/tunnel",
c.JSON(http.StatusOK, ConsoleConnectResponse{
SessionID: sess.SessionID,
Token: token,
WSPath: "/api/console/tunnel",
})
}
@@ -100,6 +116,21 @@ func queryIntDefault(r *http.Request, key string, def int) int {
//
// Lines are prefixed with the session ID so one attempt can be followed across
// pods, and the pod's own hostname so it is obvious which one served it.
// consoleTunnel godoc
//
// @Summary Console websocket tunnel
// @Description Upgrades the browser's connection to a websocket and joins it to guacd, relayed through the agent. Consumes the one-time session token from /console/connect.
// @Tags console
// @Param token query string true "One-time session token"
// @Success 101
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /console/tunnel [get]
func consoleTunnel(c *gin.Context) {
host, _ := os.Hostname()
+23
View File
@@ -0,0 +1,23 @@
// Package docs holds the generated OpenAPI document and the vendored Scalar
// bundle that renders it.
//
// openapi.json is generated by `swag init` and committed rather than built into
// the image: server/Dockerfile produces a scratch runtime from a Go build
// stage, and adding codegen there means putting the toolchain in the image.
// server-deploy.yml regenerates and diffs it, so an annotation edited without
// regenerating fails the build.
//
// scalar.standalone.js is vendored from
// https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.js
// and refreshed by hand. Fetched at build time it would break an air-gapped
// install; fetched at page load it would break an air-gapped install more
// visibly.
package docs
import _ "embed"
//go:embed openapi.json
var OpenAPI []byte
//go:embed scalar.standalone.js
var ScalarJS []byte
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+345 -39
View File
@@ -14,10 +14,17 @@ import (
)
func actorFromCtx(c *gin.Context) string {
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
return sess.Email
sess := auth.GetSessionFromContext(c)
if sess == nil || sess.Email == "" {
return "admin"
}
return "admin"
// The actor stays the human, because a token acts on their behalf and the
// log has to name somebody. The credential is appended so a person clicking
// and their CI job are told apart.
if sess.TokenID != "" {
return fmt.Sprintf("%s (via token:%s)", sess.Email, sess.TokenName)
}
return sess.Email
}
func RegisterRoutes(r *gin.Engine) {
@@ -40,8 +47,17 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback)
r.GET("/auth/providers", auth.HandleListPublicProviders)
// Completely public: no session, no token, no licence gate. Mounted here
// rather than under /api precisely so that none of those apply.
r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage)
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
// Scope enforcement sits between authentication and the licence gate, and
// no-ops for cookie sessions. It is mounted here rather than per route so
// a route added later is covered by where it lives, not by memory.
apiGroup.Use(RequireScopes())
apiGroup.Use(RateLimitTokens())
// Deny by default: every non-GET route under /api is gated unless it is on
// the exemption list in licence.go. A route added later is covered because
// of where it is mounted, not because someone remembered.
@@ -68,6 +84,15 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/audit", listAuditEvents)
apiGroup.GET("/tokens", listTokens)
apiGroup.GET("/tokens/scopes", listTokenScopes)
apiGroup.POST("/tokens", createToken)
apiGroup.DELETE("/tokens/:id", revokeToken)
apiGroup.GET("/openapi.json", getOpenAPI)
apiGroup.GET("/docs", getAPIDocs)
apiGroup.GET("/docs/scalar.js", getScalarJS)
settings := apiGroup.Group("/settings")
settings.Use(auth.RequireRole("owner", "admin"))
{
@@ -141,9 +166,24 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
registerStatusPageRoutes(apiGroup)
}
}
// listServers godoc
//
// @Summary List servers
// @Description Returns every server in the instance, optionally filtered by tag (repeatable, key:value).
// @Tags servers
// @Produce json
// @Param tag query []string false "Filter by tag as key:value, repeatable"
// @Success 200 {array} models.Server
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers [get]
func listServers(c *gin.Context) {
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
if err != nil {
@@ -158,6 +198,17 @@ func listServers(c *gin.Context) {
c.JSON(http.StatusOK, servers)
}
// listKnownTags godoc
//
// @Summary List known tags
// @Description Returns every tag key currently used by any server, with the values seen for each.
// @Tags servers
// @Produce json
// @Success 200 {object} map[string][]string
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/tags [get]
func listKnownTags(c *gin.Context) {
tags, err := services.KnownTags(auth.InstanceID(c))
if err != nil {
@@ -167,6 +218,22 @@ func listKnownTags(c *gin.Context) {
c.JSON(http.StatusOK, tags)
}
// putServerTags godoc
//
// @Summary Replace a server's tags
// @Description Replaces the whole tag map for a server. Last write wins.
// @Tags servers
// @Accept json
// @Produce json
// @Param id path string true "Server ID"
// @Param body body object{tags=map[string]string} true "New tag map"
// @Success 200 {object} TagsResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/tags [put]
func putServerTags(c *gin.Context) {
var body struct {
Tags map[string]string `json:"tags"`
@@ -196,9 +263,21 @@ func putServerTags(c *gin.Context) {
services.LogEvent(instanceID, "server.tags_updated", actorFromCtx(c), serverID, "",
fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags))
c.JSON(http.StatusOK, gin.H{"tags": body.Tags})
c.JSON(http.StatusOK, TagsResponse{Tags: body.Tags})
}
// createServer godoc
//
// @Summary Add a server
// @Description Creates a server record and a single-use pre-registration token (TTL 1 hour).
// @Tags servers
// @Produce json
// @Success 201 {object} CreateServerResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers [post]
func createServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
@@ -208,13 +287,26 @@ func createServer(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"server": s,
"token": token,
"server_id": s.ServerID,
c.JSON(http.StatusCreated, CreateServerResponse{
Server: s,
Token: token,
ServerID: s.ServerID,
})
}
// newServer godoc
//
// @Summary Add a server (install page)
// @Description Identical to POST /servers; also reachable by GET for the install page. Mints a new pre-registration token.
// @Tags servers
// @Produce json
// @Success 200 {object} NewServerResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/new [get]
// @Router /servers/new [post]
func newServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
@@ -238,14 +330,26 @@ func newServer(c *gin.Context) {
host, s.ServerID, token,
)
c.JSON(http.StatusOK, gin.H{
"server_id": s.ServerID,
"pre_reg_token": token,
"install_command": installCmd,
"install_command_ps": installCmdPS,
c.JSON(http.StatusOK, NewServerResponse{
ServerID: s.ServerID,
PreRegToken: token,
InstallCommand: installCmd,
InstallCommandPS: installCmdPS,
})
}
// getServer godoc
//
// @Summary Get a server
// @Description Returns a server together with its resolved key assignments.
// @Tags servers
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {object} ServerDetailResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id} [get]
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
@@ -256,16 +360,23 @@ func getServer(c *gin.Context) {
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
type serverResponse struct {
*models.Server
Keys interface{} `json:"keys"`
}
c.JSON(http.StatusOK, serverResponse{
c.JSON(http.StatusOK, ServerDetailResponse{
Server: s,
Keys: assignments,
})
}
// deleteServer godoc
//
// @Summary Delete a server
// @Tags servers
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id} [delete]
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(auth.InstanceID(c), id)
@@ -278,9 +389,24 @@ func deleteServer(c *gin.Context) {
hostname = s.Hostname
}
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// generateKey godoc
//
// @Summary Generate a key on a server
// @Description Dispatches an agent command that generates a keypair on the target server and reports it back.
// @Tags keys
// @Accept json
// @Produce json
// @Param id path string true "Server ID"
// @Param body body object{label=string,key_type=string,key_size=int,passphrase=string,comment=string} false "Key generation parameters"
// @Success 202 {object} GenerateKeyResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/generate-key [post]
func generateKey(c *gin.Context) {
id := c.Param("id")
@@ -315,13 +441,23 @@ func generateKey(c *gin.Context) {
}
services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
"server_id": s.ServerID,
c.JSON(http.StatusAccepted, GenerateKeyResponse{
Message: "key generation command sent to agent",
CommandID: cmdID,
ServerID: s.ServerID,
})
}
// listKeys godoc
//
// @Summary List keys
// @Tags keys
// @Produce json
// @Success 200 {array} models.Key
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /keys [get]
func listKeys(c *gin.Context) {
keys, err := services.ListKeys(auth.InstanceID(c))
if err != nil {
@@ -331,6 +467,19 @@ func listKeys(c *gin.Context) {
c.JSON(http.StatusOK, keys)
}
// createKey godoc
//
// @Summary Upload a key
// @Tags keys
// @Accept json
// @Produce json
// @Param body body object{label=string,public_key=string,private_key=string,passphrase=string} true "Key material"
// @Success 201 {object} models.Key
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /keys [post]
func createKey(c *gin.Context) {
var body struct {
Label string `json:"label" binding:"required"`
@@ -352,6 +501,18 @@ func createKey(c *gin.Context) {
c.JSON(http.StatusCreated, key)
}
// getPrivateKey godoc
//
// @Summary Get a key's private material
// @Description Returns the decrypted private key. Reading is a keys:read action even though the material is sensitive.
// @Tags keys
// @Produce json
// @Param id path string true "Key ID"
// @Success 200 {object} PrivateKeyResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /keys/{id}/private-key [get]
func getPrivateKey(c *gin.Context) {
id := c.Param("id")
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
@@ -359,9 +520,21 @@ func getPrivateKey(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
c.JSON(http.StatusOK, PrivateKeyResponse{PrivateKey: plaintext})
}
// getKey godoc
//
// @Summary Get a key
// @Description Returns a key together with the servers it is assigned to.
// @Tags keys
// @Produce json
// @Param id path string true "Key ID"
// @Success 200 {object} KeyDetailResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /keys/{id} [get]
func getKey(c *gin.Context) {
id := c.Param("id")
key, err := services.GetKey(auth.InstanceID(c), id)
@@ -372,16 +545,23 @@ func getKey(c *gin.Context) {
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
type keyResponse struct {
*models.Key
Assignments any `json:"assignments"`
}
c.JSON(http.StatusOK, keyResponse{
c.JSON(http.StatusOK, KeyDetailResponse{
Key: key,
Assignments: assignments,
})
}
// deleteKey godoc
//
// @Summary Delete a key
// @Tags keys
// @Produce json
// @Param id path string true "Key ID"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /keys/{id} [delete]
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(auth.InstanceID(c), id)
@@ -394,9 +574,23 @@ func deleteKey(c *gin.Context) {
label = k.Label
}
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// assignKey godoc
//
// @Summary Assign a key to a server
// @Tags keys
// @Accept json
// @Produce json
// @Param id path string true "Key ID"
// @Param body body object{server_id=string} true "Target server"
// @Success 201 {object} models.Assignment
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /keys/{id}/assign [post]
func assignKey(c *gin.Context) {
keyID := c.Param("id")
var body struct {
@@ -416,6 +610,19 @@ func assignKey(c *gin.Context) {
c.JSON(http.StatusCreated, a)
}
// revokeAssignment godoc
//
// @Summary Revoke a key assignment
// @Description Soft revocation: sets revoked_at rather than deleting, preserving audit history.
// @Tags keys
// @Produce json
// @Param id path string true "Key ID"
// @Param serverId path string true "Server ID"
// @Success 200 {object} RevokedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /keys/{id}/assign/{serverId} [delete]
func revokeAssignment(c *gin.Context) {
keyID := c.Param("id")
serverID := c.Param("serverId")
@@ -425,18 +632,42 @@ func revokeAssignment(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
}
// getLatestAgentVersion godoc
//
// @Summary Get the latest agent version
// @Description Reads the latest agent/v* tag from the Gitea release API.
// @Tags servers
// @Produce json
// @Success 200 {object} AgentVersionResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /agent/latest-version [get]
func getLatestAgentVersion(c *gin.Context) {
version, err := services.GetLatestAgentVersion()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"version": version})
c.JSON(http.StatusOK, AgentVersionResponse{Version: version})
}
// updateAgent godoc
//
// @Summary Update a server's agent
// @Description Dispatches UpdateAgentCmd to the agent, telling it to download and replace itself.
// @Tags servers
// @Produce json
// @Param id path string true "Server ID"
// @Success 202 {object} UpdateAgentResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/update-agent [post]
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
@@ -451,12 +682,25 @@ func updateAgent(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
c.JSON(http.StatusAccepted, UpdateAgentResponse{
Message: "update command sent to agent",
Version: version,
})
}
// applyUpdates godoc
//
// @Summary Apply pending OS updates on a server
// @Description Dispatches ApplyUpdatesCmd. 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
// @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)
@@ -470,9 +714,16 @@ func applyUpdates(c *gin.Context) {
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, gin.H{"message": "apply updates command sent to agent"})
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
}
// 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
// /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) {
giteaHost := "gitea.hostxtra.co.uk"
@@ -526,6 +777,21 @@ echo "vantage-agent updated to ${VERSION} and restarted."
c.String(http.StatusOK, script)
}
// listAuditEvents godoc
//
// @Summary List audit events
// @Description Every mutating API path writes an audit event. Paginated with a total, since a short page is not proof of the end of the log.
// @Tags audit
// @Produce json
// @Param q query string false "Free-text search"
// @Param category query string false "Filter by category"
// @Param limit query int false "Max events to return"
// @Param skip query int false "Events to skip"
// @Success 200 {object} AuditEventsResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /audit [get]
func listAuditEvents(c *gin.Context) {
f := services.AuditFilter{
Search: c.Query("q"),
@@ -549,9 +815,19 @@ func listAuditEvents(c *gin.Context) {
}
// An object rather than a bare array: a page is meaningless without the
// total it came from, and a short page is not proof of the end of the log.
c.JSON(http.StatusOK, gin.H{"events": events, "total": total})
c.JSON(http.StatusOK, AuditEventsResponse{Events: events, Total: total})
}
// getSettings godoc
//
// @Summary Get instance settings
// @Tags settings
// @Produce json
// @Success 200 {object} models.Settings
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /settings [get]
func getSettings(c *gin.Context) {
s, err := services.GetSettings(auth.InstanceID(c))
if err != nil {
@@ -561,17 +837,37 @@ func getSettings(c *gin.Context) {
c.JSON(http.StatusOK, s)
}
// saveSettings godoc
//
// @Summary Save instance settings
// @Description Owner and admin only. Refuses a change that would leave neither local login nor an enabled auth provider.
// @Tags settings
// @Accept json
// @Produce json
// @Param body body object{alerts=models.AlertSettings,workflow_log_retention_days=int,local_login_enabled=bool,api_token_max_days=int} true "Settings to save"
// @Success 200 {object} SavedResponse
// @Failure 400 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /settings [put]
func saveSettings(c *gin.Context) {
var body struct {
Alerts models.AlertSettings `json:"alerts"`
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
LocalLoginEnabled *bool `json:"local_login_enabled"`
APITokenMaxDays *int `json:"api_token_max_days"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil {
if body.APITokenMaxDays != nil && *body.APITokenMaxDays < 0 {
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 {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
return
@@ -580,9 +876,19 @@ func saveSettings(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
if body.APITokenMaxDays != nil {
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))
}
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
// handleInstallScript serves a dynamically generated shell script that
// downloads, verifies and installs the agent, seeded with a pre-registration
// token. Deliberately not in the generated OpenAPI document, for the same
// reason as handleUpdateScript: it is registered on the bare engine, outside
// the /api group the document's BasePath assumes, so a @Router annotation
// would publish a /api/install path that 404s.
func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
+59 -2
View File
@@ -10,6 +10,16 @@ import (
"github.com/gin-gonic/gin"
)
// listInstanceUsers godoc
//
// @Summary List instance members
// @Tags instance-users
// @Produce json
// @Success 200 {array} models.User
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /instance/users [get]
func listInstanceUsers(c *gin.Context) {
users, err := services.ListUsers(auth.InstanceID(c))
if err != nil {
@@ -23,6 +33,20 @@ func actorMayGrantOwner(c *gin.Context) bool {
return auth.Role(c) == models.RoleOwner
}
// createInstanceUser godoc
//
// @Summary Create an instance member
// @Description Only an owner can create another owner.
// @Tags instance-users
// @Accept json
// @Produce json
// @Param body body object{email=string,password=string,role=string} true "New member"
// @Success 201 {object} models.User
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /instance/users [post]
func createInstanceUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
@@ -52,6 +76,24 @@ func createInstanceUser(c *gin.Context) {
c.JSON(http.StatusCreated, u)
}
// updateInstanceUserRole godoc
//
// @Summary Change an instance member's role
// @Description A caller cannot change their own role. Only an owner can change owner roles.
// @Tags instance-users
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param body body object{role=string} true "New role"
// @Success 200 {object} OKResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /instance/users/{id}/role [put]
func updateInstanceUserRole(c *gin.Context) {
var body struct {
Role string `json:"role"`
@@ -84,9 +126,24 @@ func updateInstanceUserRole(c *gin.Context) {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
c.JSON(http.StatusOK, OKResponse{OK: true})
}
// deleteInstanceUser godoc
//
// @Summary Remove an instance member
// @Description A caller cannot remove their own account. Only an owner can remove another owner.
// @Tags instance-users
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} DeletedResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /instance/users/{id} [delete]
func deleteInstanceUser(c *gin.Context) {
instanceID, targetID := auth.InstanceID(c), c.Param("id")
if targetID == auth.UserID(c) {
@@ -107,7 +164,7 @@ func deleteInstanceUser(c *gin.Context) {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
func orgUserErrStatus(err error) int {
+30 -6
View File
@@ -116,6 +116,15 @@ type licenceUsageResponse struct {
Channels int `json:"channels"`
}
// getLicence godoc
//
// @Summary Get this instance's licence state
// @Tags licence
// @Produce json
// @Success 200 {object} licenceResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /license [get]
func getLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
st := services.GetLicenseState(instanceID)
@@ -169,6 +178,21 @@ func licencePostAllowed(instanceID string) bool {
return true
}
// postLicence godoc
//
// @Summary Set this instance's licence
// @Description Self-hosted only; a cloud instance's licence is injected by admin and this endpoint answers 409 cloud_managed. Exempt from the licence gate, since pasting a valid licence is the way out of degraded mode. Rate limited to 10 attempts per instance per hour.
// @Tags licence
// @Accept json
// @Produce json
// @Param body body object{blob=string} true "Licence key blob"
// @Success 200 {object} LicencePostResponse
// @Failure 400 {object} LicenceErrorResponse
// @Failure 409 {object} LicenceErrorResponse
// @Failure 429 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /license [post]
func postLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
@@ -210,7 +234,7 @@ func postLicence(c *gin.Context) {
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
"licence accepted (tier "+st.Tier+")")
c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
c.JSON(http.StatusOK, LicencePostResponse{State: st.Status, Tier: st.Tier, ExpiresAt: st.ExpiresAt})
}
// licenceRejectionMessage turns a machine reason into something a person can act
@@ -238,11 +262,11 @@ func limitStatus(c *gin.Context, err error) bool {
if !errors.As(err, &le) {
return false
}
c.JSON(http.StatusForbidden, gin.H{
"error": "limit_exceeded",
"limit": le.Limit,
"current": le.Current,
"max": le.Max,
c.JSON(http.StatusForbidden, LimitExceededResponse{
Error: "limit_exceeded",
Limit: le.Limit,
Current: le.Current,
Max: le.Max,
})
return true
}
+134
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
@@ -19,8 +20,19 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
g.DELETE("/monitors/:id", deleteMonitor)
g.GET("/monitors/:id/incidents", getMonitorIncidents)
g.GET("/monitors/:id/uptime", getMonitorUptime)
g.GET("/monitors/:id/samples", getMonitorSamples)
}
// listMonitors godoc
//
// @Summary List monitors
// @Tags monitors
// @Produce json
// @Success 200 {array} models.Monitor
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors [get]
func listMonitors(c *gin.Context) {
monitors, err := services.ListMonitors(auth.InstanceID(c))
if err != nil {
@@ -30,6 +42,20 @@ func listMonitors(c *gin.Context) {
c.JSON(http.StatusOK, monitors)
}
// createMonitor godoc
//
// @Summary Create a monitor
// @Tags monitors
// @Accept json
// @Produce json
// @Param body body models.Monitor true "Monitor to create"
// @Success 201 {object} models.Monitor
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors [post]
func createMonitor(c *gin.Context) {
var m models.Monitor
if err := c.ShouldBindJSON(&m); err != nil {
@@ -55,6 +81,18 @@ func createMonitor(c *gin.Context) {
c.JSON(http.StatusCreated, created)
}
// getMonitor godoc
//
// @Summary Get a monitor
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {object} models.Monitor
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id} [get]
func getMonitor(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -68,9 +106,24 @@ func getMonitor(c *gin.Context) {
c.JSON(http.StatusOK, m)
}
// updateMonitor godoc
//
// @Summary Update a monitor
// @Tags monitors
// @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"
// @Success 204
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id} [put]
func updateMonitor(c *gin.Context) {
var body struct {
Name *string `json:"name"`
Group *string `json:"group"`
Type *string `json:"type"`
Target *models.MonitorTarget `json:"target"`
IntervalSec *int `json:"interval_sec"`
@@ -87,6 +140,9 @@ func updateMonitor(c *gin.Context) {
if body.Name != nil {
upd["name"] = *body.Name
}
if body.Group != nil {
upd["group"] = *body.Group
}
if body.Type != nil {
upd["type"] = *body.Type
}
@@ -119,6 +175,16 @@ func updateMonitor(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// deleteMonitor godoc
//
// @Summary Delete a monitor
// @Tags monitors
// @Param id path string true "Monitor ID"
// @Success 204
// @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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -127,6 +193,18 @@ func deleteMonitor(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// getMonitorIncidents godoc
//
// @Summary List a monitor's incidents
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {array} models.Incident
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/incidents [get]
func getMonitorIncidents(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -145,6 +223,62 @@ func getMonitorIncidents(c *gin.Context) {
c.JSON(http.StatusOK, incidents)
}
// getMonitorSamples godoc
//
// @Summary Get a monitor's individual check results
// @Description Raw check results for the last `minutes` minutes, oldest first. Samples expire after 48 hours; use the uptime rollups for longer ranges.
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Param minutes query int false "Window in minutes (default 60, max 2880)"
// @Success 200 {array} models.MonitorSample
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/samples [get]
func getMonitorSamples(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
// Clamped rather than rejected: the window is a view setting, and the only
// honest answer past the TTL is the shorter window anyway.
minutes := 60
if raw := c.Query("minutes"); raw != "" {
if n, convErr := strconv.Atoi(raw); convErr == nil && n > 0 {
minutes = n
}
}
if max := int(services.MonitorSampleTTL.Minutes()); minutes > max {
minutes = max
}
samples, err := services.MonitorSamples(auth.InstanceID(c), c.Param("id"), time.Now().Add(-time.Duration(minutes)*time.Minute))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, samples)
}
// getMonitorUptime godoc
//
// @Summary Get a monitor's uptime rollups
// @Description Hourly rollups for the last 30 days.
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {array} models.Rollup
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/uptime [get]
func getMonitorUptime(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api/docs"
"github.com/gin-gonic/gin"
)
// scalarPage renders the reference against this instance's own spec, so "Try
// it" acts on the reader's API with the reader's session.
const scalarPage = `<!doctype html>
<html>
<head>
<title>Vantage API</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app"></div>
<script src="/api/docs/scalar.js"></script>
<script>
Scalar.createApiReference('#app', {
url: '/api/openapi.json',
theme: 'deepSpace',
})
</script>
</body>
</html>`
// getOpenAPI godoc
//
// @Summary Get the OpenAPI document
// @Description Generated from swaggo annotations at build time and committed; served verbatim.
// @Tags docs
// @Produce json
// @Success 200 {object} map[string]any
// @Security cookieAuth
// @Security bearerAuth
// @Router /openapi.json [get]
func getOpenAPI(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", docs.OpenAPI)
}
// getScalarJS godoc
//
// @Summary Get the vendored Scalar bundle
// @Description Served locally rather than from a CDN so the reference page works on an air-gapped install.
// @Tags docs
// @Produce application/javascript
// @Success 200 {string} string "javascript bundle"
// @Security cookieAuth
// @Security bearerAuth
// @Router /docs/scalar.js [get]
func getScalarJS(c *gin.Context) {
c.Data(http.StatusOK, "application/javascript; charset=utf-8", docs.ScalarJS)
}
// getAPIDocs godoc
//
// @Summary API reference page
// @Description Renders the Scalar reference against this instance's own OpenAPI document.
// @Tags docs
// @Produce html
// @Success 200 {string} string "HTML page"
// @Security cookieAuth
// @Security bearerAuth
// @Router /docs [get]
func getAPIDocs(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarPage))
}
+155
View File
@@ -0,0 +1,155 @@
package api
import (
"errors"
"log"
"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"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
)
// publicStatusRateLimit is per client address per minute. Generous enough that
// a busy page during an outage is unaffected, small enough that scanning for
// page ids is not free.
const publicStatusRateLimit = 120
// RateLimitPublicStatus counts requests per client address in a one-minute
// 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 {
return func(c *gin.Context) {
rdb := auth.Redis()
if rdb == nil {
c.Next()
return
}
window := time.Now().UTC().Unix() / 60
key := "vantage:statusrl:" + c.ClientIP() + ":" + strconv.FormatInt(window, 10)
count, err := rdb.Incr(c.Request.Context(), key).Result()
if err != nil {
c.Next()
return
}
if count == 1 {
rdb.Expire(c.Request.Context(), key, 2*time.Minute)
}
if count > publicStatusRateLimit {
c.Header("Retry-After", "60")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "too many requests",
"code": "rate_limited",
})
return
}
c.Next()
}
}
// getPublicStatusPage is the only unauthenticated read of monitor data in the
// product.
//
// It is mounted on the gin root rather than under /api on purpose: /api
// carries auth.Middleware, RequireScopes, RateLimitTokens and
// RequireActiveLicense by virtue of where it is mounted, and a public route
// there would need four exemptions, each one a hole a later change can widen.
//
// Unknown host, unknown page and unpublished page all answer the same 404.
//
// 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
// which would sit behind auth.Middleware if it did. The real address is:
//
// GET {scheme}://{instance-host}/public/status/{pageId}
//
// on the gin root, unauthenticated, rate limited per client address.
//
// @Summary Public status page
// @Tags status
// @Produce json
// @Param pageId path string true "Status page id"
// @Success 200 {object} services.StatusSnapshot
// @Failure 404 {object} ErrorResponse
// @Failure 429 {object} ErrorResponse
func getPublicStatusPage(c *gin.Context) {
pageID := c.Param("pageId")
inst, ok := publicStatusInstance(c)
if !ok {
// Every 404 on this route is indistinguishable to the caller by
// design, so the log is the only place the three reasons are told
// apart. It carries no monitor data and no page contents.
log.Printf("public status: 404 page=%q reason=no_instance", pageID)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
snap, err := services.PublicStatusSnapshot(inst.InstanceID, pageID)
if errors.Is(err, services.ErrPageNotFound) {
log.Printf("public status: 404 page=%q instance=%s reason=page_missing_or_unpublished", pageID, inst.InstanceID)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err != nil {
log.Printf("public status: 500 page=%q instance=%s: %v", pageID, inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
// Public and cacheable, but only briefly: an intermediary holding this for
// minutes would show a resolved incident as ongoing.
c.Header("Cache-Control", "public, max-age=30")
c.JSON(http.StatusOK, snap)
}
// publicStatusInstance resolves which instance a public request is for.
//
// The browser never reaches this handler directly: the request arrives from
// the Next server, which forwards the visitor's host in X-Forwarded-Host
// because the Host header cannot be set on a fetch (undici drops it silently,
// as a forbidden header name). That makes X-Forwarded-Host a tenant selector,
// 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
// 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.
func publicStatusInstance(c *gin.Context) (*models.Instance, bool) {
// Host resolution is where this route fails silently: an untrusted peer
// means X-Forwarded-Host is ignored and the request host is the Go
// service's own name, which names no slug. Log the inputs and the branch
// taken, so the 404 says which of the four it was.
host := c.Request.Host
xfh := firstForwarded(c.GetHeader("X-Forwarded-Host"))
trusted := trustedPeer(c)
if trusted && xfh != "" {
host = xfh
}
log.Printf("public status: resolve peer=%s trusted=%t request_host=%q x_forwarded_host=%q using_host=%q slug=%q",
c.RemoteIP(), trusted, c.Request.Host, xfh, host, auth.HostSlug(host))
if inst, ok := auth.InstanceForHost(host); ok {
return inst, true
}
if slug := auth.HostSlug(host); slug != "" {
// The host named an instance and that instance does not exist.
log.Printf("public status: no instance for slug=%q (host=%q)", slug, host)
return nil, false
}
if services.DeploymentMode() == license.DeploymentCloud {
log.Printf("public status: host %q names no slug and deployment is cloud, refusing to guess", host)
return nil, false
}
inst, ok := auth.SoleInstance()
if !ok {
log.Printf("public status: host %q names no slug and this deployment has no single instance", host)
}
return inst, ok
}
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"net/http"
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"github.com/gin-gonic/gin"
)
// tokenRateLimit is per token per minute. It is not the general API
// rate-limiting project: it is only enough that a runaway script cannot take an
// instance down, and cookie sessions are deliberately untouched.
const tokenRateLimit = 600
// RateLimitTokens counts requests per token in a one-minute fixed window.
//
// A fixed window rather than a sliding one because the cost of a burst at a
// boundary is a script running twice as fast for one second, and a sliding
// window is a sorted set per token for that.
func RateLimitTokens() gin.HandlerFunc {
return func(c *gin.Context) {
if !auth.IsToken(c) {
c.Next()
return
}
rdb := auth.Redis()
if rdb == nil {
c.Next()
return
}
window := time.Now().UTC().Unix() / 60
key := "vantage:tokenrate:" + auth.TokenID(c) + ":" + strconv.FormatInt(window, 10)
count, err := rdb.Incr(c.Request.Context(), key).Result()
if err != nil {
// Redis is already required for sessions, so it being down is a
// larger problem than this. Do not turn it into a second outage.
c.Next()
return
}
if count == 1 {
rdb.Expire(c.Request.Context(), key, 2*time.Minute)
}
if count > tokenRateLimit {
c.Header("Retry-After", "60")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded for this API token",
"code": "rate_limited",
})
return
}
c.Next()
}
}
+231
View File
@@ -0,0 +1,231 @@
package api
import (
"fmt"
"net/http"
"sort"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// 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
// decorator, because a route registered without a decorator would be
// unguarded. AssertScopeMapComplete refuses to boot if any /api route is
// missing here, so the failure lands at deploy rather than as a surprise 403
// in production.
//
// GET is read, everything else is write. The exceptions are written out rather
// than derived, because two of them are not obvious: reading a private key is
// still reading a key, and reading a container's logs is a write-level action
// because container output is arbitrary and cannot be masked.
var routeScopes = map[string]string{
"GET /api/license": "settings:read",
"POST /api/license": "settings:write",
"GET /api/servers": "servers:read",
"GET /api/servers/tags": "servers:read",
"POST /api/servers": "servers:write",
"GET /api/servers/new": "servers:write",
"POST /api/servers/new": "servers:write",
"GET /api/servers/:id": "servers:read",
"DELETE /api/servers/:id": "servers:write",
"POST /api/servers/:id/generate-key": "keys:write",
"POST /api/servers/:id/update-agent": "servers:write",
"POST /api/servers/:id/apply-updates": "servers:write",
"PUT /api/servers/:id/tags": "servers:write",
"GET /api/agent/latest-version": "servers:read",
"GET /api/audit": "settings:read",
"GET /api/settings": "settings:read",
"PUT /api/settings": "settings:write",
"POST /api/settings/secrets-token": "settings:write",
"GET /api/secrets": "secrets:read",
"POST /api/secrets": "secrets:write",
"GET /api/secrets/:group": "secrets:read",
"PUT /api/secrets/:group": "secrets:write",
"POST /api/secrets/:group/reveal": "secrets:read",
"DELETE /api/secrets/:group": "secrets:write",
"DELETE /api/secrets/:group/:key": "secrets:write",
"GET /api/keys": "keys:read",
"POST /api/keys": "keys:write",
"GET /api/keys/:id": "keys:read",
"GET /api/keys/:id/private-key": "keys:read",
"DELETE /api/keys/:id": "keys:write",
"POST /api/keys/:id/assign": "keys:write",
"DELETE /api/keys/:id/assign/:serverId": "keys:write",
"POST /api/console/connect": "servers:write",
"GET /api/console/tunnel": "servers:write",
// Workflow, step and run routes, registered by registerWorkflowRoutes.
"GET /api/steps": "workflows:read",
"POST /api/steps": "workflows:write",
"PUT /api/steps/:id": "workflows:write",
"DELETE /api/steps/:id": "workflows:write",
"GET /api/steps/:id/export": "workflows:read",
"POST /api/steps/import": "workflows:write",
"POST /api/steps/seed-defaults": "workflows:write",
"GET /api/steps/usage": "workflows:read",
"POST /api/steps/parse": "workflows:write",
"GET /api/workflows": "workflows:read",
"POST /api/workflows": "workflows:write",
"GET /api/workflows/:id": "workflows:read",
"PUT /api/workflows/:id": "workflows:write",
"DELETE /api/workflows/:id": "workflows:write",
"POST /api/workflows/:id/run": "workflows:write",
"GET /api/workflows/:id/runs": "workflows:read",
"PUT /api/workflows/:id/schedule": "workflows:write",
"GET /api/workflows/:id/schedule/preview": "workflows:read",
"GET /api/runs/:runId": "workflows:read",
"POST /api/runs/:runId/cancel": "workflows:write",
"GET /api/runs/:runId/servers/:serverId/logs": "workflows:read",
"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",
// Channel routes, registered by registerChannelRoutes. Channels exist to
// serve alerts, so they share the monitors scope rather than getting their
// own resource.
"GET /api/channels": "monitors:read",
"POST /api/channels": "monitors:write",
"PUT /api/channels/:id": "monitors:write",
"DELETE /api/channels/:id": "monitors:write",
"POST /api/channels/:id/test": "monitors:write",
// Instance user management and SSO configuration live on the /settings
// page in web/ (the Access group), so both share the settings scope.
"GET /api/instance/users": "settings:read",
"POST /api/instance/users": "settings:write",
"PUT /api/instance/users/:id/role": "settings:write",
"DELETE /api/instance/users/:id": "settings:write",
"GET /api/auth/providers": "settings:read",
"POST /api/auth/providers": "settings:write",
"PUT /api/auth/providers/:id": "settings:write",
"DELETE /api/auth/providers/:id": "settings:write",
"POST /api/auth/providers/:id/test": "settings:write",
"POST /api/auth/providers/:id/ack-notice": "settings:write",
"GET /api/auth/presets": "settings:read",
"GET /api/vulnerabilities": "vulns:read",
"GET /api/vulnerabilities/summary": "vulns:read",
"POST /api/vulnerabilities/rescan": "vulns:write",
"POST /api/vulnerabilities/:id/accept": "vulns:write",
"DELETE /api/vulnerabilities/:id/accept": "vulns:write",
"GET /api/servers/:id/vulnerabilities": "vulns:read",
"GET /api/servers/:id/packages": "vulns:read",
"GET /api/packages/search": "vulns:read",
"GET /api/vuln-rules": "vulns:read",
"POST /api/vuln-rules": "vulns:write",
"PUT /api/vuln-rules/:id": "vulns:write",
"DELETE /api/vuln-rules/:id": "vulns:write",
"GET /api/workloads": "workloads:read",
"GET /api/servers/:id/workloads": "workloads:read",
"POST /api/servers/:id/workloads/refresh": "workloads:read",
"POST /api/servers/:id/workloads/:wid/action": "workloads:write",
"GET /api/servers/:id/workloads/:wid/logs": "workloads:write",
"GET /api/tokens": "settings:read",
"GET /api/tokens/scopes": "settings:read",
"POST /api/tokens": "settings:write",
"DELETE /api/tokens/:id": "settings:write",
// The generated OpenAPI document and its Scalar reference page. Read-only,
// so they share the settings:read scope with the rest of the docs a token
// can already see about its own instance.
"GET /api/openapi.json": "settings:read",
"GET /api/docs": "settings:read",
"GET /api/docs/scalar.js": "settings:read",
// Status pages. Reading is status:read even though the pages themselves
// are public, because these routes read the unpublished ones too.
"GET /api/status-pages": "status:read",
"POST /api/status-pages": "status:write",
"GET /api/status-pages/:pageId": "status:read",
"PUT /api/status-pages/:pageId": "status:write",
"DELETE /api/status-pages/:pageId": "status:write",
"GET /api/status-pages/:pageId/incidents": "status:read",
"POST /api/status-pages/:pageId/incidents": "status:write",
"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",
}
// RequireScopes enforces routeScopes for token-authenticated requests and does
// nothing at all for cookie sessions, whose authority is their role.
func RequireScopes() gin.HandlerFunc {
return func(c *gin.Context) {
if !auth.IsToken(c) {
c.Next()
return
}
key := c.Request.Method + " " + c.FullPath()
required, ok := routeScopes[key]
if !ok {
// Fail closed. An unmapped route reached by a token is a route
// nobody decided the authority for.
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "this endpoint is not available to API tokens",
"code": "scope_unmapped",
})
return
}
if !services.ScopeSatisfied(auth.Scopes(c), required) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("token is missing the %q scope", required),
"code": "scope_missing",
"required_scope": required,
})
return
}
c.Next()
}
}
// AssertScopeMapComplete fails boot when a registered /api route has no scope.
//
// Without it, adding a route silently makes it unreachable by every token, and
// the report arrives as a customer asking why their script gets 403.
func AssertScopeMapComplete(r *gin.Engine) error {
var missing []string
for _, route := range r.Routes() {
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" {
continue
}
if _, ok := routeScopes[route.Method+" "+route.Path]; !ok {
missing = append(missing, route.Method+" "+route.Path)
}
}
if len(missing) > 0 {
sort.Strings(missing)
return fmt.Errorf("routes missing from the API token scope map: %s", strings.Join(missing, ", "))
}
return nil
}
+119 -7
View File
@@ -37,6 +37,19 @@ 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.
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Success 200 {object} map[string]string
// @Failure 401 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security esoAuth
// @Router /secrets/{group}/values [get]
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
@@ -58,6 +71,16 @@ func esoGetGroup(c *gin.Context) {
c.JSON(http.StatusOK, values)
}
// listSecretGroups godoc
//
// @Summary List secret groups
// @Tags secrets
// @Produce json
// @Success 200 {array} models.GroupSummary
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets [get]
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups(auth.InstanceID(c))
if err != nil {
@@ -67,6 +90,20 @@ func listSecretGroups(c *gin.Context) {
c.JSON(http.StatusOK, groups)
}
// createSecretGroup godoc
//
// @Summary Create a secret group
// @Tags secrets
// @Accept json
// @Produce json
// @Param body body object{group=string,values=map[string]string} true "Group and its initial key/value pairs"
// @Success 201 {object} GroupResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets [post]
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
@@ -98,9 +135,22 @@ func createSecretGroup(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
c.JSON(http.StatusCreated, GroupResponse{Group: body.Group})
}
// getSecretGroup godoc
//
// @Summary Get a secret group's keys
// @Description Returns the group's key metadata, not decrypted values. See POST /secrets/{group}/reveal for a value.
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Success 200 {object} SecretGroupResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group} [get]
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
@@ -112,9 +162,24 @@ func getSecretGroup(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
c.JSON(http.StatusOK, SecretGroupResponse{Group: group, Secrets: secrets})
}
// putSecretGroup godoc
//
// @Summary Replace a secret group's keys
// @Tags secrets
// @Accept json
// @Produce json
// @Param group path string true "Secret group name"
// @Param body body map[string]string true "Key/value pairs"
// @Success 200 {object} SavedResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} LimitExceededResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group} [put]
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
@@ -144,9 +209,23 @@ func putSecretGroup(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
// revealSecret godoc
//
// @Summary Reveal a secret value
// @Tags secrets
// @Accept json
// @Produce json
// @Param group path string true "Secret group name"
// @Param body body object{key=string} true "Key to reveal"
// @Success 200 {object} RevealSecretResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group}/reveal [post]
func revealSecret(c *gin.Context) {
group := c.Param("group")
var body struct {
@@ -162,9 +241,21 @@ func revealSecret(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
c.JSON(http.StatusOK, RevealSecretResponse{Value: value})
}
// deleteSecretKey godoc
//
// @Summary Delete a key from a secret group
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Param key path string true "Key name"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group}/{key} [delete]
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
@@ -173,9 +264,20 @@ func deleteSecretKey(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// deleteSecretGroup godoc
//
// @Summary Delete a secret group
// @Tags secrets
// @Produce json
// @Param group path string true "Secret group name"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /secrets/{group} [delete]
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
@@ -183,9 +285,19 @@ func deleteSecretGroup(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// rotateSecretsToken godoc
//
// @Summary Rotate the ESO read token
// @Tags settings
// @Produce json
// @Success 200 {object} SecretsTokenResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /settings/secrets-token [post]
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
if err != nil {
@@ -193,5 +305,5 @@ func rotateSecretsToken(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
c.JSON(http.StatusOK, SecretsTokenResponse{Token: token})
}
+312
View File
@@ -0,0 +1,312 @@
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"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
)
func registerStatusPageRoutes(g *gin.RouterGroup) {
// Owner or admin throughout: publishing a page is speaking to the public
// in the instance's name. The feature gate sits alongside the role gate so
// authoring and serving are gated by the same licence feature.
sp := g.Group("/status-pages")
sp.Use(auth.RequireRole("owner", "admin"), RequireFeature(license.FeatureStatusPages))
sp.GET("", listStatusPages)
sp.POST("", createStatusPage)
sp.GET("/:pageId", getStatusPage)
sp.PUT("/:pageId", updateStatusPage)
sp.DELETE("/:pageId", deleteStatusPage)
sp.GET("/:pageId/incidents", listStatusIncidents)
sp.POST("/:pageId/incidents", createStatusIncident)
sp.PUT("/:pageId/incidents/:incidentId", updateStatusIncident)
sp.DELETE("/:pageId/incidents/:incidentId", deleteStatusIncident)
sp.POST("/:pageId/incidents/:incidentId/updates", appendStatusIncidentUpdate)
}
// 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
// invalid incident status is a 400, not a 500.
func statusPageError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrPageNotFound), errors.Is(err, services.ErrIncidentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrPageIDTaken):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrInvalidPageID), errors.Is(err, services.ErrPageInvalid):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
// listStatusPages godoc
//
// @Summary List status pages
// @Tags status-pages
// @Produce json
// @Success 200 {array} models.StatusPage
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages [get]
func listStatusPages(c *gin.Context) {
pages, err := services.ListStatusPages(auth.InstanceID(c))
if err != nil {
statusPageError(c, err)
return
}
c.JSON(http.StatusOK, pages)
}
// createStatusPage godoc
//
// @Summary Create a status page
// @Tags status-pages
// @Accept json
// @Produce json
// @Param body body models.StatusPage true "Status page"
// @Success 201 {object} models.StatusPage
// @Failure 400 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages [post]
func createStatusPage(c *gin.Context) {
var p models.StatusPage
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
created, err := services.CreateStatusPage(auth.InstanceID(c), &p)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_page_created", actorFromCtx(c), "", "",
"Status page '"+created.PageID+"' created")
c.JSON(http.StatusCreated, created)
}
// getStatusPage godoc
//
// @Summary Get a status page
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Success 200 {object} models.StatusPage
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId} [get]
func getStatusPage(c *gin.Context) {
page, err := services.GetStatusPage(auth.InstanceID(c), c.Param("pageId"))
if err != nil {
statusPageError(c, err)
return
}
c.JSON(http.StatusOK, page)
}
// updateStatusPage godoc
//
// @Summary Update a status page
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param body body models.StatusPage true "Status page"
// @Success 200 {object} models.StatusPage
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId} [put]
func updateStatusPage(c *gin.Context) {
var p models.StatusPage
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := services.UpdateStatusPage(auth.InstanceID(c), c.Param("pageId"), &p)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_page_updated", actorFromCtx(c), "", "",
"Status page '"+updated.PageID+"' updated")
c.JSON(http.StatusOK, updated)
}
// deleteStatusPage godoc
//
// @Summary Delete a status page
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Success 204 "No Content"
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId} [delete]
func deleteStatusPage(c *gin.Context) {
if err := services.DeleteStatusPage(auth.InstanceID(c), c.Param("pageId")); err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_page_deleted", actorFromCtx(c), "", "",
"Status page '"+c.Param("pageId")+"' deleted")
c.Status(http.StatusNoContent)
}
// listStatusIncidents godoc
//
// @Summary List authored incidents for a status page
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Success 200 {array} models.StatusIncident
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents [get]
func listStatusIncidents(c *gin.Context) {
incs, err := services.ListStatusIncidents(auth.InstanceID(c), c.Param("pageId"))
if err != nil {
statusPageError(c, err)
return
}
c.JSON(http.StatusOK, incs)
}
// createStatusIncident godoc
//
// @Summary Create an incident or maintenance window
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param body body models.StatusIncident true "Incident"
// @Success 201 {object} models.StatusIncident
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents [post]
func createStatusIncident(c *gin.Context) {
var inc models.StatusIncident
if err := c.ShouldBindJSON(&inc); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// The page in the path is always one of the pages the incident names, so
// creating from a page cannot produce an incident that page never shows.
if !contains(inc.PageIDs, c.Param("pageId")) {
inc.PageIDs = append(inc.PageIDs, c.Param("pageId"))
}
created, err := services.CreateStatusIncident(auth.InstanceID(c), &inc)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_created", actorFromCtx(c), "", "",
"Status "+created.Kind+" '"+created.Title+"' created")
c.JSON(http.StatusCreated, created)
}
func contains(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// updateStatusIncident godoc
//
// @Summary Update an incident or maintenance window
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param incidentId path string true "Incident id"
// @Param body body models.StatusIncident true "Incident"
// @Success 200 {object} models.StatusIncident
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents/{incidentId} [put]
func updateStatusIncident(c *gin.Context) {
var inc models.StatusIncident
if err := c.ShouldBindJSON(&inc); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := services.UpdateStatusIncident(auth.InstanceID(c), c.Param("incidentId"), &inc)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_updated", actorFromCtx(c), "", "",
"Status "+updated.Kind+" '"+updated.Title+"' updated")
c.JSON(http.StatusOK, updated)
}
// deleteStatusIncident godoc
//
// @Summary Delete an incident or maintenance window
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Param incidentId path string true "Incident id"
// @Success 204 "No Content"
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents/{incidentId} [delete]
func deleteStatusIncident(c *gin.Context) {
if err := services.DeleteStatusIncident(auth.InstanceID(c), c.Param("incidentId")); err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_deleted", actorFromCtx(c), "", "",
"Status incident '"+c.Param("incidentId")+"' deleted")
c.Status(http.StatusNoContent)
}
// appendStatusIncidentUpdate godoc
//
// @Summary Post an update to an incident
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param incidentId path string true "Incident id"
// @Param body body StatusIncidentUpdateRequest true "Update"
// @Success 200 {object} models.StatusIncident
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents/{incidentId}/updates [post]
func appendStatusIncidentUpdate(c *gin.Context) {
var body StatusIncidentUpdateRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := services.AppendStatusIncidentUpdate(
auth.InstanceID(c), c.Param("incidentId"), body.Status, body.Body, actorFromCtx(c))
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_update_posted", actorFromCtx(c), "", "",
"Update posted to '"+updated.Title+"' ("+body.Status+")")
c.JSON(http.StatusOK, updated)
}
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"errors"
"fmt"
"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"
)
func elevated(c *gin.Context) bool {
r := auth.Role(c)
return r == models.RoleOwner || r == models.RoleAdmin
}
// listTokens godoc
//
// @Summary List API tokens
// @Description Returns the caller's own tokens. Owner and admin may pass all=true to see every token in the instance.
// @Tags tokens
// @Produce json
// @Param all query bool false "Include every token in the instance (owner and admin only)"
// @Success 200 {object} ListTokensResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /tokens [get]
func listTokens(c *gin.Context) {
all := c.Query("all") == "true" && elevated(c)
tokens, err := services.ListAPITokens(auth.InstanceID(c), auth.UserID(c), all)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ListTokensResponse{Tokens: tokens, All: all})
}
// listTokenScopes godoc
//
// @Summary List available token scopes
// @Description Advertises the scope vocabulary so the UI never hardcodes it.
// @Tags tokens
// @Produce json
// @Success 200 {object} TokenScopesResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /tokens/scopes [get]
func listTokenScopes(c *gin.Context) {
c.JSON(http.StatusOK, TokenScopesResponse{Scopes: services.AllScopes()})
}
// createToken godoc
//
// @Summary Create an API token
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role cannot exceed the creator's own; when the request is itself token-authenticated, its scopes cannot exceed the calling token's scopes either.
// @Tags tokens
// @Accept json
// @Produce json
// @Param body body CreateTokenRequest true "Token parameters"
// @Success 201 {object} CreateTokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 422 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @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"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 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 —
// 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
// cookie session skips this: its authority is the user's role, not a
// scope list.
if auth.IsToken(c) {
callerScopes := auth.Scopes(c)
var excess []string
for _, s := range body.Scopes {
if !services.ScopeSatisfied(callerScopes, s) {
excess = append(excess, s)
}
}
if len(excess) > 0 {
c.JSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("requested scopes exceed the calling token's own scopes: %v", excess),
"code": "scope_confinement",
"excess_scopes": excess,
})
return
}
}
tok, plaintext, err := services.CreateAPIToken(
auth.InstanceID(c), auth.UserID(c),
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
)
switch {
case errors.Is(err, services.ErrTokenNameTaken):
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "name_taken"})
return
case errors.Is(err, services.ErrTokenRoleTooHigh):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "role_too_high"})
return
case errors.Is(err, services.ErrTokenExpiryPolicy):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error(), "code": "expiry_policy"})
return
case errors.Is(err, services.ErrInvalidScope):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "invalid_scope"})
return
case errors.Is(err, services.ErrTokenInvalid):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
return
}
expiry := "no expiry"
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))
// The plaintext is returned exactly once and is not stored anywhere.
c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok})
}
// revokeToken godoc
//
// @Summary Revoke an API token
// @Tags tokens
// @Produce json
// @Param id path string true "Token ID"
// @Success 200 {object} RevokedResponse
// @Failure 401 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /tokens/{id} [delete]
func revokeToken(c *gin.Context) {
requester, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
tok, err := services.RevokeAPIToken(auth.InstanceID(c), c.Param("id"), requester)
if errors.Is(err, services.ErrTokenNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "token.revoked", actorFromCtx(c), "", "",
fmt.Sprintf("API token '%s' revoked", tok.Name))
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net"
"os"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
// TrustedProxies reads TRUSTED_PROXIES, a comma-separated list of CIDRs or
// addresses. Unset means trust none: ClientIP() is then the peer address,
// which is right for a direct exposure and means every request behind an
// un-configured proxy shares one address for rate limiting. That is a visible
// failure (one client limited) rather than an invisible one (no limit at all).
//
// This lives here rather than in main.go because the string has two consumers:
// gin's own SetTrustedProxies, which main.go calls with it, and trustedPeer
// below, which the public status page uses to decide whether to believe an
// X-Forwarded-Host. One variable, one parser.
func TrustedProxies() []string {
v := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES"))
if v == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
var (
trustedNetsOnce sync.Once
trustedNets []*net.IPNet
)
func parsedTrustedNets() []*net.IPNet {
trustedNetsOnce.Do(func() {
for _, entry := range TrustedProxies() {
if _, n, err := net.ParseCIDR(entry); err == nil {
trustedNets = append(trustedNets, n)
continue
}
// A bare address is a /32 or /128.
if ip := net.ParseIP(entry); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
trustedNets = append(trustedNets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
}
})
return trustedNets
}
// trustedPeer reports whether the immediate peer is one of the configured
// proxies.
//
// It deliberately uses RemoteIP() rather than ClientIP(): ClientIP() is the
// reconstructed *client* address, which is derived from the very headers this
// function exists to decide whether to believe. X-Forwarded-Host selects a
// tenant on the public status route, so it is only honoured when the machine
// that actually opened the connection is trusted to have set it.
func trustedPeer(c *gin.Context) bool {
nets := parsedTrustedNets()
if len(nets) == 0 {
return false
}
ip := net.ParseIP(c.RemoteIP())
if ip == nil {
return false
}
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
+248
View File
@@ -0,0 +1,248 @@
package api
import (
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// ErrorResponse is the shape every failing endpoint answers with. Some also
// carry a machine-readable code; it is omitted when absent rather than empty.
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code,omitempty"`
}
// LimitExceededResponse is what a create route answers when a licence cap
// would be exceeded.
type LimitExceededResponse struct {
Error string `json:"error"`
Limit string `json:"limit"`
Current int `json:"current"`
Max int `json:"max"`
}
// LicenceErrorResponse pairs an error with a machine-readable reason rather
// 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"`
Reason string `json:"reason,omitempty"`
}
// Small, reused acknowledgement shapes. Several unrelated handlers happen to
// answer with exactly one of these.
type DeletedResponse struct {
Deleted bool `json:"deleted"`
}
type RevokedResponse struct {
Revoked bool `json:"revoked"`
}
type SavedResponse struct {
Saved bool `json:"saved"`
}
type AcknowledgedResponse struct {
Acknowledged bool `json:"acknowledged"`
}
type OKResponse struct {
OK bool `json:"ok"`
}
type CancelledResponse struct {
Cancelled bool `json:"cancelled"`
}
type UpdatedResponse struct {
Updated bool `json:"updated"`
}
type MessageResponse struct {
Message string `json:"message"`
}
type StatusResponse struct {
Status string `json:"status"`
}
// --- servers / keys ---
type TagsResponse struct {
Tags map[string]string `json:"tags"`
}
type CreateServerResponse struct {
Server *models.Server `json:"server"`
Token string `json:"token"`
ServerID string `json:"server_id"`
}
type NewServerResponse struct {
ServerID string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
InstallCommand string `json:"install_command"`
InstallCommandPS string `json:"install_command_ps"`
}
// ServerDetailResponse is a server with its resolved key assignments.
type ServerDetailResponse struct {
*models.Server
Keys interface{} `json:"keys"`
}
type GenerateKeyResponse struct {
Message string `json:"message"`
CommandID string `json:"command_id"`
ServerID string `json:"server_id"`
}
type PrivateKeyResponse struct {
PrivateKey string `json:"private_key"`
}
// KeyDetailResponse is a key with its resolved server assignments.
type KeyDetailResponse struct {
*models.Key
Assignments any `json:"assignments"`
}
type AgentVersionResponse struct {
Version string `json:"version"`
}
type UpdateAgentResponse struct {
Message string `json:"message"`
Version string `json:"version"`
}
type AuditEventsResponse struct {
Events []models.AuditEvent `json:"events"`
Total int64 `json:"total"`
}
// --- tokens ---
type ListTokensResponse struct {
Tokens []models.APIToken `json:"tokens"`
All bool `json:"all"`
}
type TokenScopesResponse struct {
Scopes []string `json:"scopes"`
}
type CreateTokenRequest struct {
Name string `json:"name"`
Role string `json:"role"`
Scopes []string `json:"scopes"`
ExpiresInDays *int `json:"expires_in_days,omitempty"`
}
type CreateTokenResponse struct {
// Token is the plaintext, returned exactly once and stored nowhere.
Token string `json:"token"`
Record models.APIToken `json:"record"`
}
// --- secrets ---
type GroupResponse struct {
Group string `json:"group"`
}
type SecretGroupResponse struct {
Group string `json:"group"`
Secrets []models.Secret `json:"secrets"`
}
type RevealSecretResponse struct {
Value string `json:"value"`
}
type SecretsTokenResponse struct {
Token string `json:"token"`
}
// --- auth providers ---
type TestProviderResponse struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
// --- licence ---
type LicencePostResponse struct {
State license.State `json:"state"`
Tier string `json:"tier"`
ExpiresAt *time.Time `json:"expires_at"`
}
// --- vulnerabilities ---
// VulnSummaryResponse's four DB-freshness fields are only present at all when
// a vulndb_meta document exists; LastError is separately omitted from that
// group when empty, matching the handler's original conditional gin.H.
type VulnSummaryResponse struct {
Counts map[string]int `json:"counts"`
DBVersion *int `json:"db_version,omitempty"`
PulledAt *time.Time `json:"pulled_at,omitempty"`
LastFullScanAt *time.Time `json:"last_full_scan_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type QueuedResponse struct {
Queued int64 `json:"queued"`
}
type ReportedResponse struct {
Reported bool `json:"reported"`
}
// --- workflows ---
type SeedDefaultsResponse struct {
Created int `json:"created"`
Updated int `json:"updated"`
}
type RunWorkflowResponse struct {
RunID string `json:"run_id"`
}
type ScheduleResponse struct {
Schedule models.Schedule `json:"schedule"`
NextRunAt *time.Time `json:"next_run_at"`
}
type OccurrencesResponse struct {
Occurrences []time.Time `json:"occurrences"`
}
// --- console ---
type ConsoleConnectResponse struct {
SessionID string `json:"session_id"`
Token string `json:"token"`
WSPath string `json:"ws_path"`
}
// --- workloads ---
type WorkloadLogsResponse struct {
Text string `json:"text"`
Truncated bool `json:"truncated"`
}
// --- status pages ---
// StatusIncidentUpdateRequest is one post to an incident's timeline. The author
// is taken from the session, never from the body.
type StatusIncidentUpdateRequest struct {
Status string `json:"status" binding:"required"`
Body string `json:"body" binding:"required"`
}
+157 -11
View File
@@ -26,6 +26,22 @@ type vulnGroup struct {
Findings []models.VulnFinding `json:"findings"`
}
// 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.
// @Tags vulnerabilities
// @Produce json
// @Param severity query string false "Filter by severity"
// @Param state query string false "Filter by state (default open)"
// @Param server query string false "Filter by server ID"
// @Param tag query []string false "Filter by tag as key:value, repeatable"
// @Param has_fix query bool false "Filter by whether a vendor fix exists"
// @Success 200 {array} vulnGroup
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities [get]
func listVulnerabilities(c *gin.Context) {
findings, err := services.ListInstanceFindings(auth.InstanceID(c), services.FindingFilter{
Severity: c.Query("severity"),
@@ -115,6 +131,17 @@ func tagsFromQuery(c *gin.Context) map[string]string {
return out
}
// vulnerabilitySummary godoc
//
// @Summary Get vulnerability counts and database freshness
// @Description Counts travel with the database version and pull time, since a fleet scanned against a stale database must say so wherever its findings are read.
// @Tags vulnerabilities
// @Produce json
// @Success 200 {object} VulnSummaryResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/summary [get]
func vulnerabilitySummary(c *gin.Context) {
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
if err != nil {
@@ -122,24 +149,32 @@ func vulnerabilitySummary(c *gin.Context) {
return
}
resp := gin.H{"counts": counts}
resp := VulnSummaryResponse{Counts: counts}
// Database freshness travels with the counts rather than living in
// settings: a fleet scanned against a three-week-old database must say so
// wherever its findings are read, not somewhere the reader has to go and
// look for it.
if meta, err := services.GetVulnDBMeta(); err == nil && meta != nil {
resp["db_version"] = meta.DBVersion
resp["pulled_at"] = meta.PulledAt
resp["last_full_scan_at"] = meta.LastFullScanAt
if meta.LastError != "" {
resp["last_error"] = meta.LastError
}
resp.DBVersion = &meta.DBVersion
resp.PulledAt = &meta.PulledAt
resp.LastFullScanAt = &meta.LastFullScanAt
resp.LastError = meta.LastError
}
c.JSON(http.StatusOK, resp)
}
// rescanVulnerabilities godoc
//
// @Summary Queue the fleet for a vulnerability rescan
// @Tags vulnerabilities
// @Produce json
// @Success 200 {object} QueuedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/rescan [post]
func rescanVulnerabilities(c *gin.Context) {
instanceID := auth.InstanceID(c)
@@ -151,7 +186,7 @@ func rescanVulnerabilities(c *gin.Context) {
services.LogEvent(instanceID, "vuln.rescan", actorFromCtx(c), "", "",
"queued "+strconv.FormatInt(n, 10)+" server(s) for rescan")
c.JSON(http.StatusOK, gin.H{"queued": n})
c.JSON(http.StatusOK, QueuedResponse{Queued: n})
}
type acceptFindingRequest struct {
@@ -159,6 +194,22 @@ type acceptFindingRequest struct {
Until time.Time `json:"until"`
}
// acceptFinding godoc
//
// @Summary Accept a finding
// @Description Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.
// @Tags vulnerabilities
// @Accept json
// @Produce json
// @Param id path string true "Finding ID"
// @Param body body acceptFindingRequest true "Reason and expiry"
// @Success 200 {object} models.VulnFinding
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/{id}/accept [post]
func acceptFinding(c *gin.Context) {
var req acceptFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -192,6 +243,18 @@ func acceptFinding(c *gin.Context) {
c.JSON(http.StatusOK, f)
}
// unacceptFinding godoc
//
// @Summary Return an accepted finding to open
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Finding ID"
// @Success 200 {object} models.VulnFinding
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vulnerabilities/{id}/accept [delete]
func unacceptFinding(c *gin.Context) {
instanceID := auth.InstanceID(c)
actor := actorFromCtx(c)
@@ -215,6 +278,17 @@ func writeFindingError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
// listServerVulnerabilities godoc
//
// @Summary List a server's vulnerabilities
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {array} models.VulnFinding
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @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"))
if err != nil {
@@ -227,6 +301,18 @@ func listServerVulnerabilities(c *gin.Context) {
c.JSON(http.StatusOK, findings)
}
// 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.
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {object} models.ServerPackages
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/packages [get]
func getServerPackages(c *gin.Context) {
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -237,12 +323,24 @@ func getServerPackages(c *gin.Context) {
// Not a 404: an agent that has not reported yet is the normal state for
// the first hour after install, and is a different thing from a bad
// server id.
c.JSON(http.StatusOK, gin.H{"reported": false})
c.JSON(http.StatusOK, ReportedResponse{Reported: false})
return
}
c.JSON(http.StatusOK, sp)
}
// searchPackages godoc
//
// @Summary Search packages fleet-wide
// @Tags vulnerabilities
// @Produce json
// @Param name query string true "Package name"
// @Success 200 {array} services.PackageHit
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /packages/search [get]
func searchPackages(c *gin.Context) {
name := c.Query("name")
if name == "" {
@@ -257,6 +355,16 @@ func searchPackages(c *gin.Context) {
c.JSON(http.StatusOK, hits)
}
// listVulnRules godoc
//
// @Summary List vulnerability alert rules
// @Tags vulnerabilities
// @Produce json
// @Success 200 {array} models.VulnAlertRule
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules [get]
func listVulnRules(c *gin.Context) {
rules, err := services.ListVulnRules(auth.InstanceID(c))
if err != nil {
@@ -266,6 +374,18 @@ func listVulnRules(c *gin.Context) {
c.JSON(http.StatusOK, rules)
}
// createVulnRule godoc
//
// @Summary Create a vulnerability alert rule
// @Tags vulnerabilities
// @Accept json
// @Produce json
// @Param body body models.VulnAlertRule true "Rule to create"
// @Success 201 {object} models.VulnAlertRule
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules [post]
func createVulnRule(c *gin.Context) {
var r models.VulnAlertRule
if err := c.ShouldBindJSON(&r); err != nil {
@@ -284,6 +404,20 @@ func createVulnRule(c *gin.Context) {
c.JSON(http.StatusCreated, created)
}
// updateVulnRule godoc
//
// @Summary Update a vulnerability alert rule
// @Tags vulnerabilities
// @Accept json
// @Produce json
// @Param id path string true "Rule ID"
// @Param body body models.VulnAlertRule true "Rule fields"
// @Success 200 {object} StatusResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules/{id} [put]
func updateVulnRule(c *gin.Context) {
var r models.VulnAlertRule
if err := c.ShouldBindJSON(&r); err != nil {
@@ -302,9 +436,21 @@ func updateVulnRule(c *gin.Context) {
}
services.LogEvent(instanceID, "vuln.rule_updated", actorFromCtx(c), "", "", "rule "+r.Name)
c.JSON(http.StatusOK, gin.H{"status": "updated"})
c.JSON(http.StatusOK, StatusResponse{Status: "updated"})
}
// deleteVulnRule godoc
//
// @Summary Delete a vulnerability alert rule
// @Tags vulnerabilities
// @Produce json
// @Param id path string true "Rule ID"
// @Success 200 {object} StatusResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /vuln-rules/{id} [delete]
func deleteVulnRule(c *gin.Context) {
instanceID := auth.InstanceID(c)
if err := services.DeleteVulnRule(instanceID, c.Param("id")); err != nil {
@@ -317,5 +463,5 @@ func deleteVulnRule(c *gin.Context) {
}
services.LogEvent(instanceID, "vuln.rule_deleted", actorFromCtx(c), "", "", "rule "+c.Param("id"))
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
c.JSON(http.StatusOK, StatusResponse{Status: "deleted"})
}
+277 -10
View File
@@ -47,6 +47,20 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
// getServerRunLog godoc
//
// @Summary Get a run's log for one server
// @Description Streams the stored log in pages rather than loading it whole; capped at 200k lines per server-run.
// @Tags workflows
// @Produce plain
// @Param runId path string true "Run ID"
// @Param serverId path string true "Server ID"
// @Success 200 {string} string "plain-text log"
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId}/servers/{serverId}/logs [get]
func getServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
@@ -83,6 +97,20 @@ func getServerRunLog(c *gin.Context) {
// is one or two queries, small enough that no single response buffers much.
const logPageSize = 2000
// streamServerRunLog godoc
//
// @Summary Stream a run's log for one server (SSE)
// @Description Server-sent events; sends new lines every 500ms until the server's run reaches a terminal state.
// @Tags workflows
// @Produce text/event-stream
// @Param runId path string true "Run ID"
// @Param serverId path string true "Server ID"
// @Success 200 {string} string "text/event-stream"
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId}/servers/{serverId}/logs/stream [get]
func streamServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
@@ -166,6 +194,16 @@ func splitSSE(b []byte) []string {
return strings.Split(s, "\n")
}
// listSteps godoc
//
// @Summary List workflow steps
// @Tags workflows
// @Produce json
// @Success 200 {array} models.WorkflowStep
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps [get]
func listSteps(c *gin.Context) {
steps, err := services.ListSteps(auth.InstanceID(c))
if err != nil {
@@ -175,6 +213,16 @@ func listSteps(c *gin.Context) {
c.JSON(http.StatusOK, steps)
}
// stepUsage godoc
//
// @Summary Count workflows using each step
// @Tags workflows
// @Produce json
// @Success 200 {object} map[string]int
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/usage [get]
func stepUsage(c *gin.Context) {
counts, err := services.StepUsageCounts(auth.InstanceID(c))
if err != nil {
@@ -184,6 +232,19 @@ func stepUsage(c *gin.Context) {
c.JSON(http.StatusOK, counts)
}
// createStep godoc
//
// @Summary Create a workflow step
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.WorkflowStep true "Step to create"
// @Success 201 {object} models.WorkflowStep
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps [post]
func createStep(c *gin.Context) {
var s models.WorkflowStep
if err := c.ShouldBindJSON(&s); err != nil {
@@ -199,6 +260,22 @@ func createStep(c *gin.Context) {
c.JSON(http.StatusCreated, out)
}
// updateStep godoc
//
// @Summary Update a workflow step
// @Description A step with source "default" is read-only and refuses with 409, because seeding rewrites it on every boot.
// @Tags workflows
// @Accept json
// @Produce json
// @Param id path string true "Step ID"
// @Param body body models.WorkflowStep true "Step fields"
// @Success 200 {object} UpdatedResponse
// @Failure 400 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/{id} [put]
func updateStep(c *gin.Context) {
var s models.WorkflowStep
if err := c.ShouldBindJSON(&s); err != nil {
@@ -214,9 +291,22 @@ func updateStep(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
c.JSON(http.StatusOK, gin.H{"updated": true})
c.JSON(http.StatusOK, UpdatedResponse{Updated: true})
}
// deleteStep godoc
//
// @Summary Delete a workflow step
// @Description A step with source "default" is read-only and refuses with 409.
// @Tags workflows
// @Produce json
// @Param id path string true "Step ID"
// @Success 200 {object} DeletedResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/{id} [delete]
func deleteStep(c *gin.Context) {
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
if errors.Is(err, services.ErrDefaultStep) {
@@ -227,9 +317,20 @@ func deleteStep(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// exportStep godoc
//
// @Summary Export a step as a downloadable JSON document
// @Tags workflows
// @Produce json
// @Param id path string true "Step ID"
// @Success 200 {object} models.WorkflowStep
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/{id}/export [get]
func exportStep(c *gin.Context) {
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -240,6 +341,16 @@ func exportStep(c *gin.Context) {
c.Data(http.StatusOK, "application/json", b)
}
// seedDefaults godoc
//
// @Summary Sync the default step library
// @Tags workflows
// @Produce json
// @Success 200 {object} SeedDefaultsResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/seed-defaults [post]
func seedDefaults(c *gin.Context) {
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
if err != nil {
@@ -247,11 +358,23 @@ func seedDefaults(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
c.JSON(http.StatusOK, SeedDefaultsResponse{Created: created, Updated: updated})
}
const maxStepBodyBytes = 1 << 20
// importStep godoc
//
// @Summary Import a step from an exported JSON document
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.WorkflowStep true "Exported step document"
// @Success 201 {object} models.WorkflowStep
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/import [post]
func importStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
body, err := io.ReadAll(c.Request.Body)
@@ -268,6 +391,18 @@ func importStep(c *gin.Context) {
c.JSON(http.StatusCreated, out)
}
// parseStep godoc
//
// @Summary Parse a step document without saving it
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.WorkflowStep true "Step document to parse"
// @Success 200 {object} models.WorkflowStep
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/parse [post]
func parseStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
body, err := io.ReadAll(c.Request.Body)
@@ -283,6 +418,16 @@ func parseStep(c *gin.Context) {
c.JSON(http.StatusOK, s)
}
// listWorkflows godoc
//
// @Summary List workflows
// @Tags workflows
// @Produce json
// @Success 200 {array} models.Workflow
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows [get]
func listWorkflows(c *gin.Context) {
wfs, err := services.ListWorkflows(auth.InstanceID(c))
if err != nil {
@@ -292,6 +437,19 @@ func listWorkflows(c *gin.Context) {
c.JSON(http.StatusOK, wfs)
}
// createWorkflow godoc
//
// @Summary Create a workflow
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.Workflow true "Workflow to create"
// @Success 201 {object} models.Workflow
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows [post]
func createWorkflow(c *gin.Context) {
var w models.Workflow
if err := c.ShouldBindJSON(&w); err != nil {
@@ -307,6 +465,17 @@ func createWorkflow(c *gin.Context) {
c.JSON(http.StatusCreated, out)
}
// getWorkflow godoc
//
// @Summary Get a workflow
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Success 200 {object} models.Workflow
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id} [get]
func getWorkflow(c *gin.Context) {
w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -316,6 +485,20 @@ func getWorkflow(c *gin.Context) {
c.JSON(http.StatusOK, w)
}
// updateWorkflow godoc
//
// @Summary Update a workflow
// @Tags workflows
// @Accept json
// @Produce json
// @Param id path string true "Workflow ID"
// @Param body body models.Workflow true "Workflow fields"
// @Success 200 {object} models.Workflow
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id} [put]
func updateWorkflow(c *gin.Context) {
var w models.Workflow
if err := c.ShouldBindJSON(&w); err != nil {
@@ -335,15 +518,39 @@ func updateWorkflow(c *gin.Context) {
c.JSON(http.StatusOK, updated)
}
// deleteWorkflow godoc
//
// @Summary Delete a workflow
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id} [delete]
func deleteWorkflow(c *gin.Context) {
if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// runWorkflow godoc
//
// @Summary Run a workflow
// @Description Snapshots the resolved steps into a WorkflowRun and dispatches to every targeted server.
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Success 202 {object} RunWorkflowResponse
// @Failure 400 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/run [post]
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
if err != nil {
@@ -355,9 +562,21 @@ func runWorkflow(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
c.JSON(http.StatusAccepted, RunWorkflowResponse{RunID: runID})
}
// listWorkflowRuns godoc
//
// @Summary List a workflow's runs
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Param limit query int false "Max runs to return (default 50)"
// @Success 200 {array} models.WorkflowRun
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/runs [get]
func listWorkflowRuns(c *gin.Context) {
limit := int64(50)
if l := c.Query("limit"); l != "" {
@@ -373,6 +592,17 @@ func listWorkflowRuns(c *gin.Context) {
c.JSON(http.StatusOK, runs)
}
// getRun godoc
//
// @Summary Get a run
// @Tags workflows
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.WorkflowRun
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId} [get]
func getRun(c *gin.Context) {
r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
if err != nil {
@@ -382,15 +612,42 @@ func getRun(c *gin.Context) {
c.JSON(http.StatusOK, r)
}
// cancelRun godoc
//
// @Summary Cancel a run
// @Tags workflows
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} CancelledResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId}/cancel [post]
func cancelRun(c *gin.Context) {
if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
c.JSON(http.StatusOK, gin.H{"cancelled": true})
c.JSON(http.StatusOK, CancelledResponse{Cancelled: true})
}
// putWorkflowSchedule godoc
//
// @Summary Set a workflow's schedule
// @Description Standard 5-field cron and an IANA zone, both validated at save time.
// @Tags workflows
// @Accept json
// @Produce json
// @Param id path string true "Workflow ID"
// @Param body body models.Schedule true "Schedule"
// @Success 200 {object} ScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/schedule [put]
func putWorkflowSchedule(c *gin.Context) {
var body models.Schedule
if err := c.ShouldBindJSON(&body); err != nil {
@@ -415,12 +672,22 @@ func putWorkflowSchedule(c *gin.Context) {
services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next})
c.JSON(http.StatusOK, ScheduleResponse{Schedule: body, NextRunAt: next})
}
// previewWorkflowSchedule exists so the browser and the scheduler agree on
// what a cron string means. A client-side cron parser that disagrees with the
// server by one field is a bug found in production, at night.
// previewWorkflowSchedule godoc
//
// @Summary Preview the next occurrences of a cron schedule
// @Description Exists so the browser and the scheduler agree on what a cron string means.
// @Tags workflows
// @Produce json
// @Param cron query string true "5-field cron expression"
// @Param tz query string true "IANA time zone name"
// @Success 200 {object} OccurrencesResponse
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/schedule/preview [get]
func previewWorkflowSchedule(c *gin.Context) {
expr := c.Query("cron")
tz := c.Query("tz")
+80 -5
View File
@@ -18,6 +18,19 @@ import (
//
// A server that has never reported answers an empty list rather than 404: the
// agent may simply not have got there yet, and 404 reads as "no such server".
// getServerWorkloads godoc
//
// @Summary Get a server's workload snapshot
// @Description Returns the stored snapshot. A server that has never reported answers an empty list, not 404.
// @Tags workloads
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {object} models.ServerWorkloads
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads [get]
func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -47,6 +60,19 @@ func getServerWorkloads(c *gin.Context) {
// refreshServerWorkloads nudges the agent to report now. It returns no data:
// the client refetches the stored document once the agent has written it.
// refreshServerWorkloads godoc
//
// @Summary Request a fresh workload report
// @Description Nudges the agent to report now. Returns no data; the client refetches once the agent has written it.
// @Tags workloads
// @Produce json
// @Param id path string true "Server ID"
// @Success 202 {object} MessageResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads/refresh [post]
func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -63,9 +89,28 @@ func refreshServerWorkloads(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
c.JSON(http.StatusAccepted, MessageResponse{Message: "refresh requested"})
}
// controlWorkload godoc
//
// @Summary Start, stop or restart a workload
// @Description Owner and admin only. The protected set (vantage-agent.service and the agent's own container) is enforced agent-side and answers 409, not an error.
// @Tags workloads
// @Accept json
// @Produce json
// @Param id path string true "Server ID"
// @Param wid path string true "Workload ID"
// @Param body body object{action=string,kind=string} true "Action (start/stop/restart) and kind (container/unit)"
// @Success 200 {object} MessageResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads/{wid}/action [post]
func controlWorkload(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -117,9 +162,27 @@ func controlWorkload(c *gin.Context) {
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"})
c.JSON(http.StatusOK, MessageResponse{Message: body.Action + " ok"})
}
// getWorkloadLogs godoc
//
// @Summary Read a workload's logs
// @Description Owner and admin only, and audited: container output is arbitrary and cannot be masked. Capped at 500 lines and 256KB, whichever binds first.
// @Tags workloads
// @Produce json
// @Param id path string true "Server ID"
// @Param wid path string true "Workload ID"
// @Param kind query string false "container or unit (default container)"
// @Param tail query int false "Lines to return, clamped to the cap"
// @Success 200 {object} WorkloadLogsResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads/{wid}/logs [get]
func getWorkloadLogs(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -163,11 +226,23 @@ func getWorkloadLogs(c *gin.Context) {
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated})
c.JSON(http.StatusOK, WorkloadLogsResponse{Text: text, Truncated: truncated})
}
// listWorkloads answers the fleet-wide question, which is the reason the
// snapshot is stored rather than fetched on demand and discarded.
// listWorkloads godoc
//
// @Summary Search workloads fleet-wide
// @Description Answers the fleet-wide question, which is the reason the snapshot is stored rather than fetched on demand and discarded.
// @Tags workloads
// @Produce json
// @Param image query string false "Filter by image name"
// @Param stack query string false "Filter by compose stack"
// @Param state query string false "Filter by state"
// @Success 200 {array} services.WorkloadHit
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workloads [get]
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))
+67 -10
View File
@@ -23,6 +23,10 @@ var (
const instanceCacheTTL = 60 * time.Second
// soleInstanceCacheKey cannot collide with a slug: a slug is [a-z0-9-] and can
// never contain a NUL.
const soleInstanceCacheKey = "\x00sole"
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
return strings.ToLower(v)
@@ -50,25 +54,78 @@ func hostSlug(host string) string {
return parts[0]
}
// HostSlug exposes the slug rules to callers outside this package that need to
// distinguish "this host names no instance at all" from "this host names an
// instance that does not exist". It is a thin wrapper rather than a second
// implementation on purpose.
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
// slug rules and the 60s cache stay single-implementation.
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
slug := hostSlug(c.Request.Host)
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
}
instanceCacheMu.Lock()
if e, ok := instanceCache[slug]; ok && time.Since(e.at) < instanceCacheTTL {
instanceCacheMu.Unlock()
return e.instance, e.instance != nil
if inst, hit := cachedInstanceFor(slug); hit {
return inst, inst != nil
}
instanceCacheMu.Unlock()
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)
return nil, false
}
instanceCacheMu.Lock()
instanceCache[slug] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
storeInstance(slug, inst)
return inst, true
}
// 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 —
// 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
}
n, err := services.CountInstances()
if err != nil || n != 1 {
storeInstance(soleInstanceCacheKey, nil)
return nil, false
}
inst, err := services.FirstInstance()
if err != nil || inst == nil {
storeInstance(soleInstanceCacheKey, nil)
return nil, false
}
storeInstance(soleInstanceCacheKey, inst)
return inst, true
}
func cachedInstanceFor(key string) (*models.Instance, bool) {
instanceCacheMu.Lock()
defer instanceCacheMu.Unlock()
if e, ok := instanceCache[key]; ok && time.Since(e.at) < instanceCacheTTL {
return e.instance, true
}
return nil, false
}
func storeInstance(key string, inst *models.Instance) {
instanceCacheMu.Lock()
instanceCache[key] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
}
+130 -8
View File
@@ -1,8 +1,12 @@
package auth
import (
"errors"
"fmt"
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
@@ -48,17 +52,27 @@ func RequireRole(roles ...string) gin.HandlerFunc {
}
}
// Middleware authenticates a request by session cookie or by API token.
//
// Both paths end by putting a *Session in the context, which is why no handler,
// role guard, licence gate or audit call needed changing: the token path is a
// second way to arrive at the same value, not a second way through the API.
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
sess, ok := sessionFromCookie(c)
if !ok {
// A cookie that was presented and rejected has already had its
// response written by sessionFromCookie (no bearer was present to
// fall through to). Trying sessionFromToken anyway would write a
// second body onto the same response.
if c.IsAborted() {
return
}
sess, ok = sessionFromToken(c)
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
if !ok {
// sessionFromCookie and sessionFromToken have already written the
// response describing which credential failed and why.
return
}
@@ -69,6 +83,8 @@ func Middleware() gin.HandlerFunc {
c.Set(ctxSessionKey, sess)
// The host guard applies to both credential kinds. A token carries an
// instance, and the tenant boundary must not have a token-shaped hole.
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
return
@@ -77,3 +93,109 @@ func Middleware() gin.HandlerFunc {
c.Next()
}
}
// sessionFromCookie returns false without writing a response when there is no
// cookie at all, so the token path gets its turn. It writes and aborts only
// when a cookie was presented and was not usable.
func sessionFromCookie(c *gin.Context) (*Session, bool) {
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
return nil, false
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
// 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) != "" {
return nil, false
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return nil, false
}
return sess, true
}
func bearerToken(c *gin.Context) string {
const prefix = "Bearer "
h := c.GetHeader("Authorization")
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return ""
}
return h[len(prefix):]
}
func sessionFromToken(c *gin.Context) (*Session, bool) {
raw := bearerToken(c)
if raw == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return nil, false
}
tok, err := services.ResolveAPIToken(raw)
if errors.Is(err, services.ErrTokenExpired) {
// Recorded rather than only refused: an expired token still being
// presented is how a forgotten CI job becomes visible. Throttled to
// once per token per minute, or a looping job writes an unbounded
// stream of audit rows instead of one.
if services.ShouldLogExpiredTokenUse(tok.TokenID) {
services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "",
fmt.Sprintf("expired token '%s' was used", tok.Name))
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token expired", "code": "token_expired"})
return nil, false
}
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return nil, false
}
user, err := services.GetUserInInstance(tok.InstanceID, tok.UserID)
if err != nil {
// The owner is gone. DeleteUser removes tokens, so this is the
// belt-and-braces path for a row deleted some other way.
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return nil, false
}
services.TouchAPIToken(tok)
return &Session{
UserID: tok.UserID,
InstanceID: tok.InstanceID,
// Recomputed per request, so demoting the person demotes the token.
Role: services.LowerRole(user.Role, tok.Role),
Email: user.Email,
Name: user.Email,
TokenID: tok.TokenID,
TokenName: tok.Name,
Scopes: tok.Scopes,
}, true
}
// TokenID is empty for a cookie session and the token's ID for a token
// request. It is what lets audit detail record which credential acted.
func TokenID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.TokenID
}
return ""
}
func TokenName(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.TokenName
}
return ""
}
func Scopes(c *gin.Context) []string {
if s := GetSessionFromContext(c); s != nil {
return s.Scopes
}
return nil
}
// IsToken reports whether this request authenticated with an API token rather
// than a browser session.
func IsToken(c *gin.Context) bool { return TokenID(c) != "" }
+13
View File
@@ -22,6 +22,14 @@ type Session struct {
Role string `json:"role"`
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
// 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:"-"`
}
var rdb *redis.Client
@@ -51,6 +59,11 @@ func PingRedis(ctx context.Context) error {
return rdb.Ping(ctx).Err()
}
// Redis exposes the session client for callers that need a counter rather than
// a session. There is one Redis in this deployment and adding a second client
// would double the connection pool for no reason.
func Redis() *redis.Client { return rdb }
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
+5
View File
@@ -17,6 +17,10 @@ const (
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
// out. Match on a prefix, not equality: the version moves.
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
)
type Spec struct {
@@ -80,6 +84,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{Message: err.Error()}
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
+10 -9
View File
@@ -113,15 +113,16 @@ type PartitionReport struct {
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
RebootRequired bool `json:"reboot_required,omitempty"`
}
type InventoryReportResponse struct{}
+52
View File
@@ -0,0 +1,52 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// APIToken is a personal access token for the REST API.
//
// The plaintext is shown once at creation and never stored: only TokenHash,
// which is sha256 hex of the value, exactly as servers.agent_token_hash and the
// ESO read token already are. bcrypt is deliberately not used — the value is
// full-entropy random rather than a chosen password, and a per-token salt would
// force a collection scan where an indexed lookup is wanted.
//
// Role and Scopes are immutable after creation. There is no update endpoint:
// editing what a credential already deployed in CI can do, with no record of
// what it could do before, is worse than requiring a rotation.
type APIToken struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
TokenID string `bson:"token_id" json:"token_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
UserID string `bson:"user_id" json:"user_id"`
Name string `bson:"name" json:"name"`
// Hint is the first 8 characters of the plaintext, stored in clear so the
// list can identify a token without revealing it.
Hint string `bson:"hint" json:"hint"`
// TokenHash is never serialised to JSON.
TokenHash string `bson:"token_hash" json:"-"`
Role string `bson:"role" json:"role"`
Scopes []string `bson:"scopes" json:"scopes"`
// ExpiresAt nil means the token never expires. Whether that is allowed is
// a per-instance policy, settings.api_token_max_days.
ExpiresAt *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
CreatedByIP string `bson:"created_by_ip,omitempty" json:"created_by_ip,omitempty"`
// Email of the owning user, joined at read time for the list. Never stored.
UserEmail string `bson:"-" json:"user_email,omitempty"`
}
// Expired reports whether the token's expiry has passed. A nil ExpiresAt never
// expires.
func (t *APIToken) Expired(now time.Time) bool {
return t.ExpiresAt != nil && now.After(*t.ExpiresAt)
}
+44
View File
@@ -14,6 +14,28 @@ const (
ChannelTelegram = "telegram"
)
// RedactedSecret is what a channel's secret config values read as over the API.
// It is a sentinel and not merely a mask: a client may write it straight back,
// and the value it stood for is preserved. See NotificationChannel.Redacted.
const RedactedSecret = "••••••••"
// channelSecretKeys names, per channel type, the config entries that are
// credentials rather than settings. A Slack or Discord webhook URL is on this
// list because possession of the URL *is* the authorisation to post to that
// channel — there is nothing else to steal.
var channelSecretKeys = map[string][]string{
ChannelWebhook: {"url"},
ChannelSlack: {"url"},
ChannelDiscord: {"url"},
ChannelTelegram: {"token"},
ChannelSMTP: {"password"},
}
// ChannelSecretKeys reports which config keys of a channel type are secret.
func ChannelSecretKeys(channelType string) []string {
return channelSecretKeys[channelType]
}
type NotificationChannel struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
@@ -24,3 +46,25 @@ type NotificationChannel struct {
Enabled bool `bson:"enabled" json:"enabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// Redacted returns a copy with every secret config value replaced by
// RedactedSecret, for handing to a client. Nothing internal uses it: the
// dispatchers read the stored document through GetChannel/GetChannels, so the
// redaction is a property of the API boundary and cannot break delivery.
//
// A set-but-secret key keeps its key, so a caller can still tell configured
// from absent; an empty value is left empty rather than being dressed up as a
// credential that is not there.
func (c NotificationChannel) Redacted() NotificationChannel {
out := c
out.Config = make(map[string]string, len(c.Config))
for k, v := range c.Config {
out.Config[k] = v
}
for _, k := range ChannelSecretKeys(c.Type) {
if out.Config[k] != "" {
out.Config[k] = RedactedSecret
}
}
return out
}
+23 -4
View File
@@ -43,10 +43,14 @@ type MonitorState struct {
}
type Monitor struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
// Group is a display-only label. It buckets rows on the monitors page and
// has no effect on scheduling, alerting or scope; an empty group means the
// monitor is listed on its own under "Ungrouped".
Group string `bson:"group,omitempty" json:"group,omitempty"`
Type string `bson:"type" json:"type"`
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
@@ -67,6 +71,21 @@ type Incident struct {
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
}
// MonitorSample is one check result, kept only long enough to draw the
// sub-hour views of the history chart. Rollup remains the durable record: a
// sample expires by TTL, a rollup does not.
//
// It carries no message. The failure text is on the incident, and a document
// per check is the one place in this schema where a few bytes multiply by the
// check rate.
type MonitorSample struct {
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
At time.Time `bson:"at" json:"at"`
Up bool `bson:"up" json:"up"`
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
}
type Rollup struct {
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
+1
View File
@@ -39,6 +39,7 @@ type Inventory struct {
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
RebootRequired bool `bson:"reboot_required,omitempty" json:"reboot_required,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
}
+4
View File
@@ -7,3 +7,7 @@ type (
AlertSettings = shared.AlertSettings
SecretsSettings = shared.SecretsSettings
)
// APITokenMaxDays re-exports shared.APITokenMaxDays so server/internal/services
// can read the token lifetime cap without importing shared/models directly.
func APITokenMaxDays(s *Settings) int { return shared.APITokenMaxDays(s) }

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