Compare commits

..
80 Commits
Author SHA1 Message Date
mrhid6 eb6f47c8ed fix(monitors): document 403 and 404 on monitor update and delete; regenerate openapi
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 4m10s
2026-09-17 09:25:11 +00:00
mrhid6 ffc6853142 fix(web): metric monitor edit keeps for_sec 0 and allows 0 minutes 2026-09-17 09:25:02 +00:00
mrhid6 42766968e3 fix(monitors): keep reboot_required_since on a transient inventory read error 2026-09-17 09:24:42 +00:00
mrhid6 cffa7e84b2 fix(monitors): index heartbeat token hash 2026-09-17 09:24:42 +00:00
mrhid6 2701b32b5f fix(monitors): 400/404 on bad updates, clamp for_sec, reset heartbeat state on re-enable 2026-09-17 09:24:16 +00:00
mrhid6 753ba09e6e fix(mcp): filter list_incidents by the token's visible servers 2026-09-17 09:23:14 +00:00
mrhid6 7c2c5d95e3 fix(web): hand the new heartbeat token over in sessionStorage, not the URL 2026-09-17 09:23:03 +00:00
mrhid6 13e7160405 fix(monitors): rate limit heartbeats per token and kind; test limiter key and fail body cap 2026-09-17 09:22:50 +00:00
mrhid6 2afdc267f8 fix(monitors): keep a down server down while its breach lasts after for_sec is raised 2026-09-17 09:22:50 +00:00
mrhid6 8908b435a0 docs(monitors): document heartbeat and metric monitors; add heartbeat e2e 2026-09-17 09:16:53 +00:00
mrhid6 4158696388 feat(web): metric monitor rule builder and per-server state table 2026-09-17 09:11:23 +00:00
mrhid6 3cb8463dc7 feat(api): metric monitor per-server states and server-scoped incidents 2026-09-17 09:02:35 +00:00
mrhid6 2017c95a7a feat(monitors): sweep metric monitors per server with incidents per breach 2026-09-17 08:59:24 +00:00
mrhid6 4068349afe feat(monitors): validate metric monitors and confine selectors to token scope 2026-09-17 08:56:15 +00:00
mrhid6 f01375470e fix(monitors): ignore partitions reporting more used than total 2026-09-17 08:52:21 +00:00
mrhid6 12136d1c17 feat(monitors): metric rule evaluators and per-server state decisions 2026-09-17 08:40:06 +00:00
mrhid6 1a1f6998a4 feat(inventory): record when a reboot became required 2026-09-17 08:37:41 +00:00
mrhid6 42358f57cb feat(web): heartbeat monitor form, ping URL panel and token rotation 2026-09-17 08:33:29 +00:00
mrhid6 17ed0192c1 feat(monitors): public heartbeat ping endpoints, header token, log masking and sweeper 2026-09-17 08:27:34 +00:00
mrhid6 839d716a47 fix(monitors): guard heartbeat sweep against a concurrent ping 2026-09-17 08:24:13 +00:00
mrhid6 09888825a8 feat(monitors): heartbeat token, ping recording and overdue verdict 2026-09-17 08:20:16 +00:00
mrhid6 590c369d36 fix(notify): include server name in monitor alert email 2026-09-17 08:16:10 +00:00
mrhid6 64dbad1d20 refactor(monitors): extract applyTransition and add passive monitor model fields 2026-09-17 08:14:24 +00:00
mrhid6 aaad7db09d docs(plan): accept heartbeat token in X-Vantage-Token header and mask URL tokens in logs 2026-09-17 07:53:37 +00:00
mrhid6 fab9d11c7e docs(plan): fix task cross-references and error sentinel placement 2026-09-17 07:48:36 +00:00
mrhid6 73da464701 docs(plan): metric alerts and heartbeat monitors implementation plan 2026-09-17 07:48:25 +00:00
mrhid6 d59da68701 docs(spec): metric alerts and heartbeat monitors design 2026-09-17 07:40:49 +00:00
mrhid6 189bce55ba feat(runs): mask encoded and multi-line secrets in run logs
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 3m25s
Mask base64 and URL-encoded forms of secret values, each line of a
multi-line secret, and secrets loaded by earlier steps of the run.
Replace longer values first so overlapping secrets mask cleanly.
2026-09-17 07:33:20 +00:00
mrhid6 217e2dc5a9 feat(auth): prefer rather than require passkey user verification
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 2m59s
Requiring user verification made password managers such as NordPass ask
for their master password on every passkey use. Every ceremony now asks
for it as preferred and no longer rejects a result without it.

This is a deliberate trade-off: a passkey used without verification is
possession-only, so passwordless sign-in and step-up rest on the device or
vault being unlocked. The spec records the decision.
2026-09-16 15:20:31 +00:00
mrhid6 934501f4ec fix(auth): store passkey backup flags so synced passkeys verify
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 3m4s
go-webauthn refuses an assertion whose Backup Eligible flag differs from
the stored credential's. The flag was never stored, so it compared against
false and every synced passkey (iCloud Keychain, Google Password Manager,
1Password) failed with "Backup Eligible flag inconsistency" - in
passwordless sign-in, second-factor sign-in and step-up alike.

Registration now stores BackupEligible and BackupState. Rows registered
before this have no baseline, so their first verified assertion adopts the
signed flag and records it; a recorded value always stands, so a genuine
change is still refused.
2026-09-16 15:03:54 +00:00
mrhid6 b1193c59e3 fix(auth): log why a passkey ceremony was refused
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 3m3s
Every WebAuthn refusal answered "that passkey could not be verified" and
discarded the library's error, leaving a misconfigured relying party
undiagnosable. Log the stage, the derived RP ID and expected origin, the
request's Origin and X-Forwarded-Proto, and the library error with its
DevInfo. None of it is secret.
2026-09-16 14:51:55 +00:00
mrhid6 069e7e7c61 fix(api): declare MFA routes session-only so the server boots
Chart Release / chart (push) Successful in 36s
Server Deploy / deploy (push) Successful in 3m0s
The MFA routes were missing from routeScopes and serverScopedRoutes, and both
boot assertions refused to start the server. They are deliberately not
reachable by API tokens, so they go in a new sessionOnlyRoutes set rather than
receiving a scope, and are declared exempt from server scoping.

Adds a test that registers the real routes and runs both boot assertions, so
an undeclared route fails CI instead of production startup.
2026-09-16 14:40:29 +00:00
mrhid6 19383abaf8 fix(auth): final-review MFA fixes F1 F2 F3 F5 F7 and drop Ticket.Attempts
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 5m51s
- OIDC sessions minted through saveSignIn with AMR oidc and StepUpAt
- passwordless passkey uses ValidateDiscoverableLogin with owner-handle check
- TOTP replay guard burns the matched step, not the current one
- enrol-only tickets refused once the user already has a factor
- bootstrap owner session minted through mintSession
2026-09-16 14:26:42 +00:00
mrhid6 dcdfd3ce52 test(mfa): fix step-up test to assert the fresh-session path 2026-09-16 14:20:42 +00:00
mrhid6 142b99e408 test(mfa): end-to-end coverage, OpenAPI and documentation 2026-09-16 14:18:39 +00:00
mrhid6 14e9db606a fix(mfa): wire up webauthn step-up routes
The missing /me/step-up/webauthn/begin and /finish were a plan defect,
not an acceptable gap: a user whose only factor is a passkey was
offered only recovery codes for step-up, burning one every ten
minutes. Adds the handlers in package auth (session-authenticated,
not themselves behind RequireStepUp, modeled on
HandleMFAWebAuthnBegin/finishAssertion) and registers both routes
behind the same RateLimitAuth() as /me/step-up. StepUpModal now offers
"Use passkey" when the server names webauthn and the browser supports
WebAuthn.
2026-09-16 09:44:41 +00:00
mrhid6 3e341b17ec feat(web): step-up modal and MFA settings controls
Adds the global re-authentication modal for guarded routes and the
owner/admin MFA controls on the settings page.

request() in lib/api.ts now intercepts a 403 step_up_required response,
awaits re-authentication through a callback registered by StepUpModal
(lib/stepup.ts), and retries the original request exactly once. The
modal offers TOTP, recovery code and password, since the webauthn
step-up routes (/me/step-up/webauthn/begin and /finish) are not
registered server-side yet; it omits the passkey option rather than
calling a route that does not exist.

me.stepUp posts one factor to /api/me/step-up. The settings page gains
an owner-only "Require MFA" toggle and the members table gains an MFA
column and a "Reset MFA" action, both routed through the existing
PUT /api/settings and DELETE /api/org/users/:id/mfa.
2026-09-16 09:33:50 +00:00
mrhid6 32fd11cde7 feat(web): account security page and MFA enrolment wizard 2026-09-16 09:26:54 +00:00
mrhid6 26825841fa feat(web): MFA and passkey sign-in on the login page 2026-09-16 09:22:18 +00:00
mrhid6 d8597ee3ae feat(mfa): require_mfa policy, sign-in rate limit and MFA column 2026-09-16 09:14:16 +00:00
mrhid6 f87626cf17 feat(mfa): account MFA management, passkey registration and step-up 2026-09-16 09:08:24 +00:00
mrhid6 bd0639acfa fix(mfa): refuse rather than panic when RequireStepUp sees no session 2026-09-16 09:03:22 +00:00
mrhid6 e2ff0dace9 feat(mfa): step-up re-authentication on sensitive actions 2026-09-16 09:01:19 +00:00
mrhid6 d54d8971b2 feat(mfa): passwordless passkey sign-in 2026-09-16 08:58:26 +00:00
mrhid6 14a1cdb2b0 feat(mfa): passkey verification as a second factor 2026-09-16 08:48:39 +00:00
mrhid6 1445af11ab fix(mfa): mint session before deleting ticket, collapse dead invalid_code branch 2026-09-16 08:43:53 +00:00
mrhid6 3fa469c303 feat(mfa): forced TOTP enrolment at sign-in 2026-09-16 08:41:56 +00:00
mrhid6 2d75832ceb feat(mfa): second-factor sign-in with TOTP and recovery codes 2026-09-16 08:39:48 +00:00
mrhid6 dfcfd1d3e2 fix(mfa): make ticket attempt count atomic via Redis INCR 2026-09-16 08:37:07 +00:00
mrhid6 9c60adc836 feat(mfa): pending-login ticket store 2026-09-16 08:34:57 +00:00
mrhid6 330c326fb5 fix(mfa): key TOTP replay guard on time step, not the code 2026-09-16 08:33:02 +00:00
mrhid6 25541345a8 feat(mfa): TOTP secrets, recovery codes and factor lookup 2026-09-16 08:30:55 +00:00
mrhid6 b78a9b3832 feat(mfa): user_mfa and webauthn_credentials collections 2026-09-16 08:27:54 +00:00
mrhid6 9aee0a61aa chore(server): bump vantage-shared to v0.7.0 for require_mfa 2026-09-16 08:22:08 +00:00
mrhid6 0aabb664e2 docs: implementation plan for MFA on local sign-in 2026-09-16 08:15:46 +00:00
mrhid6 e0468aa7b9 docs: design for MFA on local sign-in (TOTP, passkeys, step-up) 2026-09-15 15:48:59 +00:00
mrhid6 fbcf436ef6 feat: show Ubuntu phased updates apart and leave them out of pending counts
Chart Release / chart (push) Successful in 29s
Server Deploy / deploy (push) Successful in 5m9s
apt lists phased updates as upgradable while an upgrade defers them until
Ubuntu selects the host, so a freshly patched server kept reporting pending
updates. The agent now flags them; the server stores the flag and leaves them
out of patch run counts, and the server page shows them in their own section.
2026-09-15 14:55:30 +00:00
mrhid6 0e464c4bb8 fix(web): wrap long patch output lines instead of widening the run table
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 1m14s
2026-09-15 14:27:42 +00:00
mrhid6 fc10575b08 fix: require a 2 minute boot time change before counting a patch reboot
Chart Release / chart (push) Successful in 36s
Server Deploy / deploy (push) Successful in 4m19s
Windows reports boot time as now minus uptime, which drifts by a second or so
between reports. A static report sent in the grace period before the reboot
could read as a changed boot and mark a server still owing a reboot as failed.
2026-09-15 14:09:34 +00:00
mrhid6 3f2d20868e fix(patching): final review fixes
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 6m12s
- no dispatch in the last 15 minutes of a window; no-result timeout from dispatch time
- per-server output moves to patch_run_outputs (16MB document limit)
- reboot proven by a changed boot time; RebootTimeout 45m, ResultGrace 20m
- window update and delete are server-scoped against the policies using them
- scheduler puts the claim back on an error after it, so the next tick retries
- cancelled runs with failures alert; MCP apply_updates audits per server
- apply-updates 503 body documented; openapi regenerated
- web: cleared numeric fields no longer save as 0; Run now asks for confirmation
2026-09-15 13:49:28 +00:00
mrhid6 3ecea7c39f feat(web): show patch policy coverage on servers and open the run after Apply updates 2026-09-15 13:23:19 +00:00
mrhid6 68c613fd40 feat(web): patch run detail page with per-server output 2026-09-15 12:24:17 +00:00
mrhid6 70c239021d feat(web): patching page with policies, windows and runs 2026-09-15 11:19:01 +00:00
mrhid6 3a7618f82f feat(web): maintenance window and patch policy editors 2026-09-15 10:59:06 +00:00
mrhid6 7809419202 fix(web): anchor patch agent version regex to mirror server parseVersion 2026-09-15 09:43:56 +00:00
mrhid6 59e7ef63fe feat(web): patching API client, status vocabulary and navigation 2026-09-15 09:41:07 +00:00
mrhid6 b5bbf28c63 feat: patching REST API, patching scope, run IDs from apply-updates and MCP 2026-09-15 09:31:51 +00:00
mrhid6 17c9f813fc fix: patchsched hasActiveRun must not treat a real DB error as no active run 2026-09-15 09:19:51 +00:00
mrhid6 139658864b feat: patch scheduler loop; record patch results and verify reboots from the agent stream 2026-09-15 09:17:27 +00:00
mrhid6 1bb2ba7f2b fix: patch run dispatch - per-server contexts, cancel race, result command guard
Fix round 1 review findings on the patch run service:
- advanceRun no longer runs every server's dispatch claim and failed-send
  reset on the caller's shared short context; each gets its own fresh
  patchCtx(), and a failed reset write is logged instead of discarded.
- The dispatch claim (queued/waiting_offline -> patching) now also requires
  the run to still be status running with no cancelled_at, closing a race
  where a tick that loaded the run just before CancelPatchRun wrote
  cancelled_at could still dispatch.
- RecordPatchResult's write is now guarded on command_id too, so a late
  result for a superseded command cannot land on a re-dispatched attempt.
2026-09-15 09:09:12 +00:00
mrhid6 fef886c93b feat: patch run service - dispatch, results, reboot verification, cancel, alerts, retention 2026-09-15 09:04:23 +00:00
mrhid6 3a1614066e feat: maintenance window and patch policy services
Named patch_window.go (not patch_windows.go) since the _windows.go
suffix is Go's implicit GOOS build constraint and would silently
exclude the file on non-Windows builds.
2026-09-15 08:50:36 +00:00
mrhid6 c0e26d0493 feat: patchsched fire/skip decision and window arithmetic 2026-09-15 08:44:52 +00:00
mrhid6 b60daf0461 feat: patchrun - pure state machine for patch runs
Implements the patchrun package with a pure functional state machine for managing
patch runs. Contains no database dependencies - the services layer loads a run,
asks this package what should change, and writes changes guarded by expected status.

All 14 test cases pass, covering:
- Agent version parsing and support detection
- Concurrency limits and queueing
- Window deadlines and offline handling
- Result timeouts (ResultGrace, ManualTimeout, RebootTimeout)
- Reboot verification with boot time proof
- Run finalization logic
- Summary generation for alerts
2026-09-15 08:39:21 +00:00
mrhid6 8f1ea6d5a0 feat: patch models, scoped collections and indexes; pin vantage-shared v0.5.0 2026-09-15 08:27:04 +00:00
mrhid6 e63e773cda docs: implementation plan for scheduled patching; align spec audit names, alert payload and run source 2026-09-14 15:32:47 +00:00
mrhid6 48116bf737 docs: design for maintenance windows and scheduled patching 2026-09-14 14:41:24 +00:00
mrhid6 2b4b630ae0 chore: pin vantage-shared v0.3.3
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 3m24s
2026-09-11 11:02:10 +00:00
mrhid6 ad0f583d41 chore: pin vantage-shared v0.3.2
Chart Release / chart (push) Successful in 21s
Server Deploy / deploy (push) Successful in 3m34s
2026-09-11 09:58:51 +00:00
mrhid6 5ca705c88c feat: login page says a locked instance is suspended instead of drawing the form
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 4m4s
2026-09-11 09:05:44 +00:00
139 changed files with 27632 additions and 461 deletions
+4
View File
@@ -13,6 +13,10 @@ installer/nssm.zip
installer/checksums-msi.txt
.next
*.tsbuildinfo
web/test-results
web/playwright-report
web/blob-report
web/playwright/.cache
graphify-out
docker-compose.live.yml
.claude
+184 -11
View File
@@ -173,6 +173,51 @@ Skips are recorded and surfaced, not just logged: past the 1h grace window is
`missed`, an active run is `already_running`, and a schedule that no longer
parses is disabled rather than left spinning the loop every 30 seconds forever.
### Scheduled patching
Four collections: `maintenance_windows` (cron start, IANA zone, duration),
`patch_policies` (selector, window, `all|security`, `never|if_required`,
concurrency cap, channels), `patch_runs` (one per firing or manual Apply
updates, one `servers[]` entry per target) and `patch_run_outputs` (one per
run and server, holding the package manager's output tail). The output lives
apart from the run because a large run with up to 64KB per server would pass
MongoDB's 16MB document limit; `GetPatchRun` fills `servers[].output` back in
memory so the API shape is unchanged, and the tick paths never read it. All
four are in `ScopedCollections`.
**Runs are driven by database state, not goroutines.** A run can last hours; a
goroutine-driven run is stranded at `running` when its pod dies. `patchsched`
ticks every 30s inside the housekeeping leader: it claims due policies with the
workflowsched `next_run_at` pattern, then advances every running run. Every
decision is a pure function in `internal/patchrun` (`Advance`, `ApplyResult`,
`VerifyReboot`, `Finalize`) and every write is guarded by the server run's
current status, so a result landing mid-tick is never overwritten.
**The window end never kills a package manager.** No server is dispatched in
the last `patchrun.LatestStartBeforeEnd` (15 minutes) of a window; queued and
waiting servers close at the window end as before. A server already patching
may finish past the end: its no-result timeout is its own dispatch time plus
`ManualTimeout` (2h, the agent's backstop) plus `ResultGrace` (20 minutes), for
windowed and manual runs alike. `RebootTimeout` is 45 minutes.
**Results do not cross the bus.** The pod holding the agent's stream writes
`PatchResult` straight into the run, found by `servers.command_id` and the
agent's own server ID. A reboot is settled by the first static inventory report
whose boot time differs from (is later than) `boot_time_before`, the
`inventory.boot_time` recorded when the server moved to rebooting, so host and
server clock skew does not matter. Without `boot_time_before` the report's boot
time must be later than `rebooted_at`. A report during the one-minute grace
does not count either way.
**Old agents must never receive a scope.** An agent before
`patchrun.MinAgentVersion` ignores `scope` and installs everything, so policy
runs mark it `agent_too_old` and do not dispatch. A manual Apply updates still
sends such an agent the empty command and records "no result reported".
The next window starts after `max(now, windowEnd)`, so windows never overlap,
including across a daylight-saving fall-back. `patchsched` must not import
`services`; its dependencies are injected from `main.go`.
### Server tags and workflow targeting
A server carries `tags map[string]string` - lowercase `[a-z0-9_-]`, key ≤32,
@@ -222,7 +267,16 @@ a dark ground.
### Monitors
HTTP, TCP, ICMP and TLS checks. Each monitor has a `runner`: `"server"` (executed by the server-side scheduler) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
HTTP, TCP, ICMP and TLS checks are pull-based. Each has a `runner`: `"server"` (executed by `monitorsched`) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
Two passive types are never run by `monitorsched` or agents; `metricsched` sweeps them every 30s:
- `heartbeat`: jobs call `/public/hb/<token>` (plus `/start`, `/fail`), or send the token in `X-Vantage-Token` to `/public/hb[/start|/fail]`; a URL token wins. Request logs (gin formatter via `api.MaskLogPath`, bundled nginx `log_format vantage`) mask URL tokens. Down when a ping is overdue past period + grace, a start never finishes, or `/fail` is called. The token is stored hashed; plaintext is shown on create and `POST /api/monitors/:id/rotate-token` only.
- `metric`: a tag `selector` plus a rule (`disk_pct`, `disk_free_gb`, `mem_pct`, `load_per_core`, `unit_failed`, `container_unhealthy`, `reboot_pending_days`, `agent_offline_min`) evaluated against stored inventory and workloads. State is per server in `monitor_server_states`, with one incident per breaching server (`Incident.ServerID`). Inventory older than 5 minutes is skipped. Restricted tokens may only use selectors inside their tag scope.
All three paths share `applyTransition` (services/monitortransition.go) for incidents and notifications.
**Two known gaps, deferred.** Nginx's error log (not the access log) and gin's panic recovery dump can still record a URL token even with `MaskLogPath` masking normal request logs; the `X-Vantage-Token` header avoids putting the token in the URL at all, which is the mitigation until those paths are masked too. And the bundled nginx `vantage.conf` is reproduced in `vantage-docs`' self-hosted install docs, so its `log_format` change must be mirrored there as well - the routing-change rule above already says the file must be, this extends it to the log format.
### Notification channels
@@ -337,10 +391,12 @@ 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`.
**The agent reboots a host only when a patch command asks and a reboot is
owed.** `ApplyUpdatesCmd` carries `scope`, `reboot_if_required` and
`deadline_unix`; an empty command still means "everything, no reboot". The
agent answers with `PatchResult` and, when rebooting, sends it first and then
restarts after a one-minute grace. `inventory.reboot_required` is still set on
the static snapshot every 15 minutes and at agent start.
### Package inventory and CVE findings
@@ -699,6 +755,15 @@ 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.
**Every `/api` route must be declared twice or the server refuses to boot**:
once in `routeScopes` (or `sessionOnlyRoutes`) and once in `serverScopedRoutes`.
`sessionOnlyRoutes` holds the MFA routes, which no API token may reach - a
token that could enrol a passkey or satisfy step-up would be a way around MFA.
They have no scope on purpose, and `RequireScopes` refuses any unmapped route
to a token. `TestRegisteredRoutesPassBootAssertions` registers the real routes
and runs both boot assertions, so a missing declaration fails CI rather than
production startup.
`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
@@ -710,6 +775,93 @@ reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
reference page has to work on an air-gapped install with no outbound access at
all - the same requirement licence verification already meets.
### Multi-factor authentication
Local and `hq`-sourced members can enrol TOTP and WebAuthn passkeys; OIDC users
are exempt (`auth_source == "oidc"`), since their IdP owns authentication.
**A password that checks out mints a pending-login ticket, not a session with
an `mfa_pending` flag.** The ticket is a Redis key (`km:mfa:<id>`, 5 min TTL)
referenced by a separate `km_mfa_pending` cookie; only the `/auth/mfa/*` and
`/auth/mfa/enrol/*` endpoints accept it, and it exchanges for a `km_session`
only on a completed second factor or completed enrolment. A flag on `Session`
would fail open - any route mounted under `auth.Middleware`, today's or a
future one, could serve a half-authenticated user by forgetting to check it.
A ticket fails closed: nothing under `auth.Middleware` recognises it at all,
because it is never a `*Session`. `Session` itself gained `AMR []string`
(`pwd`, `otp`, `webauthn`, `recovery`, `oidc`) and `StepUpAt *time.Time`; sign-in
counts as a step-up, so `StepUpAt` is set at session creation.
**The pending ticket's attempt counter is an atomic Redis counter
(`km:mfa:<id>:attempts`), not a field rewritten on the ticket document.** Two
requests racing to fail a guess would otherwise both read the same `attempts`
and both write it back incremented once, undercounting. `INCR` has no such
race; the fifth failure deletes the ticket.
**The TOTP replay guard is keyed on the time step, not the code**:
`km:totp:<user_id>:<step>`, `SET NX` with a 90s TTL. Keying on the code itself
would let the same 6 digits be replayed across two different steps that
happen to compute it (a 1-in-a-million collision, but a free one to close);
keying on the step means a given 30-second window can be spent exactly once,
which is what "single-use" actually means for a TOTP code.
**`user_mfa.totp_pending_enc` holds an unconfirmed TOTP secret and is
deliberately not in `vantage-shared`'s `backup.ciphertextFields["user_mfa"]`**,
which lists only `totp_secret_enc`. The confirmed secret is the one that
authenticates anyone; an abandoned setup attempt (scanned once, never
confirmed, replaced by the next `POST /me/mfa/totp/setup` call) is not worth
widening the backup contract's surface for. `vantagectl verify`'s live probe
therefore never touches it - this is intentional, not the same silent gap the
ciphertext-field mirror otherwise guards against.
**Two new collections**, both in `ScopedCollections` so an instance purge
removes them, both with a fatal index builder like `EnsureAuthIndexes`:
- `user_mfa` - one document per user who has started enrolment. Unique index
`{instance_id, user_id}`. `totp_confirmed_at: nil` means setup started but
TOTP is not active; "has MFA" means that field is set or the user owns a
passkey.
- `webauthn_credentials` - one document per passkey. Unique index
`{instance_id, credential_id}`, plus `{instance_id, user_id}`. `sign_count`
backs clone detection: a non-increasing non-zero count fails the assertion.
`require_mfa` (`models.Settings.RequireMFA bool`, `bson:"require_mfa"`)
shipped in `vantage-shared` v0.7.0 - a plain bool because absent must mean off.
Switching it on does not revoke existing sessions; they end at their normal
24h TTL, and the next sign-in enforces enrolment for anyone with no factor yet.
**WebAuthn's RP ID is the request host with any port stripped**, resolved
per-request rather than configured, the same way the org/host guard resolves
an instance from `<slug>.vantage.<tld>`. A passkey is bound to the host it was
registered on: moving a self-hosted instance to a new domain, or renaming a
cloud instance (see "A rename moves the host" above), invalidates every
passkey on it. TOTP and recovery codes are unaffected, since they carry no
host binding. The docs say so; there is no migration path for a passkey
across a host change.
**Step-up** (`auth.RequireStepUp()`) gates three existing sensitive routes -
`POST /api/secrets/:group/reveal`, `GET /api/keys/:id/private-key`,
`POST /api/console/connect` - plus the MFA-management endpoints that create or
remove a factor. It passes when `StepUpAt` is within the last **ten minutes**,
when the session's `AMR` contains `oidc` (the IdP's own session policy
governs), or **when the request authenticated with an API token**
(`TokenID != ""`). That last exemption is a known, accepted gap, not an
oversight: a token has no human present to prompt for a second factor, so a
token holding `secrets:read` or `keys:read` can reveal a secret or download a
private key with no re-authentication at all. The mitigation is scoped,
short-lived tokens, tracked separately in the gap review, not a code change
here - a later reviewer should not "fix" this silently. `POST /api/me/step-up`
takes `{totp}`, `{recovery}` or `{password}` (password only for a user with no
MFA); `POST /api/me/step-up/webauthn/begin` and `/finish` do the same with a
passkey. All three, like every unauthenticated MFA endpoint, sit behind
`RateLimitAuth()` - a fixed Redis window, 20 requests/minute per
`c.ClientIP()`, answering 429 with `Retry-After` - on the `RateLimitTokens`
pattern but for sign-in and re-authentication rather than API tokens.
Library versions: `github.com/pquerna/otp` for TOTP, `github.com/go-webauthn/webauthn`
**v0.18.1** for WebAuthn ceremonies, `qrcode` (npm) to draw the enrolment QR
client-side so an air-gapped install needs nothing external.
### The public host
**vantage.hostxtra.co.uk is not served by this repository.** The marketing site
@@ -887,7 +1039,7 @@ service Vantage {
}
```
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, `StepOutputChunk`, or `PatchResult`. `AgentMessage` now also carries `PatchResult`, the answer to `ApplyUpdatesCmd`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`, `RefreshWorkloadsCmd`, `ControlWorkloadCmd`,
`WorkloadLogsCmd`.
@@ -920,6 +1072,11 @@ POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} - no issuer, client ID or secret
GET /api/secrets/:group/values # bearer token (ESO)
POST /auth/mfa/totp /auth/mfa/recovery # second factor against a pending-login ticket
POST /auth/mfa/webauthn/begin /finish
POST /auth/passkey/begin /auth/passkey/finish # passwordless sign-in
POST /auth/mfa/enrol/totp/setup /confirm # ticket-scoped forced enrolment
POST /auth/mfa/enrol/passkey/begin /finish
```
Session-authed under `/api`:
@@ -936,7 +1093,8 @@ workflows GET,POST /steps · PUT,DELETE /steps/:id · GET /steps/:id/export
GET /runs/:runId · POST /runs/:runId/cancel
GET /runs/:runId/servers/:serverId/logs[/stream]
monitors GET,POST /monitors · GET,PUT,DELETE /monitors/:id
GET /monitors/:id/{incidents,uptime}
GET /monitors/:id/{incidents,uptime,samples,servers}
POST /monitors/:id/rotate-token
channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:id/test
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
@@ -955,11 +1113,22 @@ status-pages GET,POST /status-pages · GET,PUT,DELETE /status-pages/:pageId (ow
GET,POST /status-pages/:pageId/incidents
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
POST /status-pages/:pageId/incidents/:incidentId/updates
patching GET,POST /maintenance-windows · POST /maintenance-windows/preview
GET,PUT,DELETE /maintenance-windows/:id (writes: owner|admin)
GET,POST /patch-policies · GET,PUT,DELETE /patch-policies/:id
POST /patch-policies/:id/run-now (writes: owner|admin)
GET /patch-runs · GET /patch-runs/:runId · POST /patch-runs/:runId/cancel
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
licence GET /license · POST /license (POST: self-hosted only)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
DELETE /org/users/:id/mfa (owner|admin, step-up)
mfa GET /me/mfa · POST /me/mfa/totp/setup (step-up)
POST /me/mfa/totp/confirm · DELETE /me/mfa/totp (step-up)
POST /me/mfa/recovery/regenerate (step-up)
POST,PATCH,DELETE /me/passkeys[/begin,/finish,/:id] (step-up, except rename)
POST /me/step-up · POST /me/step-up/webauthn/begin /finish
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
@@ -1002,16 +1171,20 @@ plane, each of which this codebase enforces:
- **Disputes lock and purge instances.** HQ writes `instances.locked_at` when
an account is disputed and `instances.purge_after` only once the dispute
fails. `services.InstanceLocked` (60s cache) refuses sessions, API tokens
and agents on a locked instance, and `GetInstanceBySlug` hides it from the
host resolver. The check in `auth.Middleware` is explicit because the host
guard only runs when a host resolves. `ReapTerminatedInstances` purges once
and agents on a locked instance, and `InstanceForHost` and `SoleInstance`
resolve it to nothing. The check in `auth.Middleware` is explicit because the
host guard only runs when a host resolves. `HostLocked` is the one place that
tells "locked" from "unknown", so the login page can say access is suspended
rather than draw a form; `resolveLoginInstance` refuses a locked instance on
the single-instance fallback too, which otherwise served its sign-in page.
A locked instance's public status page stays an ordinary 404. `ReapTerminatedInstances` purges once
both fields are present and `purge_after` has passed, independent of
`FREE_INSTANCE_REAP_AFTER`, and `ReapFreeInstances` skips locked instances
so a restore finds them intact.
## 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` · `api_tokens` · `status_pages` · `status_incidents` · `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` · `monitor_server_states` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `user_mfa` · `webauthn_credentials` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth - see `server/internal/models/`.
+13
View File
@@ -12,6 +12,17 @@ map $http_upgrade $connection_upgrade {
'' close;
}
# Heartbeat ping URLs carry a credential. Log them with the token replaced;
# the header form (X-Vantage-Token) is never logged by this format.
map $request_uri $vantage_log_uri {
"~^/public/hb/(?!start(?:[/?]|$)|fail(?:[/?]|$))[^/?]+(?<hb_rest>.*)$" "/public/hb/***$hb_rest";
default $request_uri;
}
log_format vantage '$remote_addr - $remote_user [$time_local] '
'"$request_method $vantage_log_uri $server_protocol" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
upstream vantage_server {
server server:8080;
keepalive 16;
@@ -27,6 +38,8 @@ server {
listen [::]:80;
server_name _;
access_log /var/log/nginx/access.log vantage;
# Step imports and licence pastes are the largest request bodies.
client_max_body_size 10m;
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
@@ -0,0 +1,539 @@
# Maintenance windows and scheduled patching
Date: 2026-09-14
## Goal
Let an operator say "install security updates on every `env:prod` server,
Sundays 02:00 to 04:00 Europe/London, and reboot them if the OS says a reboot
is owed", and have Vantage do it, report exactly what happened per server, and
say so loudly when it did not.
Today the only patching path is **Apply updates**, which runs immediately on one
server, installs everything, never reboots, and reports nothing back: the agent
logs a failure locally and the control plane never hears of it.
Out of scope, deliberately:
- **Update rings / staged rollout.** A policy runs all its targets within one
window, bounded only by `max_concurrent`. Rings are their own feature and
build on this one.
- **Package holds and per-package allow-lists.** Scope is `all` or `security`.
- **Live-streamed patch output.** A capped output tail is stored per server;
`workflow_log_lines` is not reused.
- **Alert muting during windows.** The window is a standalone object so this can
reference it later, but v1 does not mute anything.
- **Licence gating.** Patching is free on every tier, consistent with
`applyUpdates` already being exempt from the licence gate.
## Current state
- `POST /api/servers/:id/apply-updates` calls `services.DispatchApplyUpdates`,
which sends an empty `ApplyUpdatesCmd` and returns. No result is awaited or
recorded.
- Agent `handleApplyUpdates` runs `updates.ApplyAll()` and, on success only,
sends an empty `ReportUpdates`. Output is discarded (`exec.Cmd.Run()`).
- The apt path wraps `apt-get update` **and** `apt-get upgrade` in one 5-minute
context, so a large upgrade can be killed partway. dnf, yum, zypper, pacman
and apk run with no timeout at all.
- The agent never reboots. `inventory.reboot_required` is set on the 15-minute
static snapshot, and at agent start.
- `workflowsched` already solves scheduling: 5-field cron, IANA timezone,
`next_run_at` persisted and claimed atomically, recorded skips, run inside
`bus.RunAsLeader("housekeeping", ...)`.
- `default_steps/` ships `apply_package_updates` and `reboot_server` bash steps.
They stay as they are; this feature does not use them.
## Approach
A native patch path, not a workflow convention. The agent gains a result-bearing
update command with a scope and an opt-in reboot. Three new collections hold
windows, policies and runs. A new `patchsched` loop fires policies and advances
runs from state held in MongoDB, so a leader handover mid-window loses nothing.
The manual button and the vulnerability page move onto the same run model, so
every patch Vantage performs has a record.
Rejected alternatives:
- **Build on the workflow engine** (script steps per package manager). The patch
logic would exist twice, as scripts and as the agent's `updates` package; the
engine sends one step list to every target, so a mixed Linux/Windows policy
needs per-OS branching it does not have; and the manual button would stay
fire-and-forget.
- **Native command, recorded as a `WorkflowRun`.** Saves one page, but binds
patching to `steps_snapshot` and log sequencing that do not describe it, and
breaks the rule that a run always shows the script that ran.
## Data model
All three collections carry `instance_id` and are added to
`services.ScopedCollections`, so instance purge covers them.
### `maintenance_windows`
```go
type MaintenanceWindow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
WindowID string `bson:"window_id" json:"window_id"`
Name string `bson:"name" json:"name"`
Cron string `bson:"cron" json:"cron"` // 5-field, window start
TZ string `bson:"tz" json:"tz"` // IANA name
DurationMinutes int `bson:"duration_minutes" json:"duration_minutes"` // 15..720
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
```
A window answers "when" and nothing else: no targets, no behaviour. Cron and TZ
are validated through `workflowsched.NextOccurrence`, exactly as workflow
schedules are. Deleting a window referenced by any policy is refused with 409
`window_in_use`. Editing a window recomputes `next_run_at` on every policy that
references it, in the same service call.
### `patch_policies`
```go
type PatchPolicy struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
PolicyID string `bson:"policy_id" json:"policy_id"`
Name string `bson:"name" json:"name"`
Enabled bool `bson:"enabled" json:"enabled"`
WindowID string `bson:"window_id" json:"window_id"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"`
Scope string `bson:"scope" json:"scope"` // "all" | "security"
Reboot string `bson:"reboot" json:"reboot"` // "never" | "if_required"
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // 0 = no cap
NotifyChannelIDs []string `bson:"notify_channel_ids,omitempty" json:"notify_channel_ids,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"`
LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"`
DisabledReason string `bson:"disabled_reason,omitempty" json:"disabled_reason,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
```
Targets use the workflow selector semantics unchanged: the distinct union of
named servers and tag matches, resolved through `services.ResolveTargets` at
fire time, and an empty selector matches nothing (`ErrNoTargets` on save).
`Skip` is the existing `models.Skip`.
Index: `{instance_id: 1, enabled: 1, next_run_at: 1}`.
### `patch_runs`
```go
type PatchRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
RunID string `bson:"run_id" json:"run_id"`
PolicyID string `bson:"policy_id,omitempty" json:"policy_id,omitempty"` // empty for manual
PolicyName string `bson:"policy_name,omitempty" json:"policy_name,omitempty"`
TriggeredBy string `bson:"triggered_by" json:"triggered_by"` // "schedule" | actor
Source string `bson:"source" json:"source"` // schedule | server | vulnerabilities | mcp | run_now
CancelledAt *time.Time `bson:"cancelled_at,omitempty" json:"cancelled_at,omitempty"`
Scope string `bson:"scope" json:"scope"` // snapshot
Reboot string `bson:"reboot" json:"reboot"` // snapshot
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // snapshot
WindowEnd *time.Time `bson:"window_end,omitempty" json:"window_end,omitempty"` // nil for manual
Status string `bson:"status" json:"status"` // running | succeeded | partial | failed | cancelled
StartedAt time.Time `bson:"started_at" json:"started_at"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
Servers []PatchServerRun `bson:"servers" json:"servers"`
}
type PatchServerRun struct {
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
Status string `bson:"status" json:"status"`
CommandID string `bson:"command_id,omitempty" json:"-"`
PendingBefore int `bson:"pending_before" json:"pending_before"`
PendingAfter *int `bson:"pending_after,omitempty" json:"pending_after,omitempty"`
RebootedAt *time.Time `bson:"rebooted_at,omitempty" json:"rebooted_at,omitempty"`
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
Output string `bson:"output,omitempty" json:"output,omitempty"` // tail, max 64KB
Error string `bson:"error,omitempty" json:"error,omitempty"`
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
}
```
Server run statuses:
| Status | Terminal | Meaning |
| ----------------- | -------- | -------------------------------------------------------------------- |
| `queued` | no | Waiting for a concurrency slot |
| `waiting_offline` | no | Agent not connected; retried each tick while the window is open |
| `patching` | no | Command dispatched, no result yet |
| `rebooting` | no | Agent announced a reboot; awaiting a post-boot inventory report |
| `succeeded` | yes | Patched, and rebooted and verified if a reboot was owed and allowed |
| `failed` | yes | Package manager failed, no result, or reboot not verified |
| `unsupported` | yes | Security-only requested on a host with no security metadata |
| `agent_too_old` | yes | Agent predates patch results; not dispatched |
| `missed_offline` | yes | Offline for the whole window |
| `window_closed` | yes | Still queued when the window ended |
| `cancelled` | yes | Run cancelled before this server was dispatched |
Scope, reboot and concurrency are snapshotted onto the run so editing a policy
never changes what a past run shows. `PendingBefore` is the server's
`available_updates` count at dispatch; `PendingAfter` comes from the result.
Indexes: `{instance_id: 1, policy_id: 1, started_at: -1}`,
`{instance_id: 1, "servers.server_id": 1, started_at: -1}`,
`{status: 1}` (the tick's scan), and `{"servers.command_id": 1}` (result lookup).
Runs are swept under `workflow_log_retention_days` by the existing log sweeper.
## Wire contract
Changed in `vantage-shared` (`grpc/pb` and `proto/vantage/v1/vantage.proto` in
the same commit):
```proto
message ApplyUpdatesCmd {
string scope = 1; // "" or "all" | "security"
bool reboot_if_required = 2;
int64 deadline_unix = 3; // 0 = none; the agent caps at 2h
}
message PatchResult { // new AgentMessage oneof variant: PatchResult patch_result = 8;
string command_id = 1;
string status = 2; // ok | failed | unsupported | busy
string message = 3;
string output_tail = 4; // at most 64KB, newest bytes kept
int32 pending_after = 5;
bool reboot_required = 6;
bool rebooting = 7;
}
message InventoryReport {
// existing fields ...
int64 boot_time_unix = 11; // every report
}
```
The existing comment on `InventoryReport.reboot_required` ("The agent never
reboots") is reworded in the same commit to match the new reboot rule.
An empty `ApplyUpdatesCmd` means `scope: all`, no reboot, no deadline: exactly
today's behaviour, so a new agent under an old server is unaffected.
### Old agents
An old agent ignores the new fields. Under `scope: security` it would install
**everything**, and it sends no `PatchResult`. The server therefore gates on
`servers.agent_version`:
- `agentSupportsPatchResults(version)` is a semver comparison against the first
agent release carrying this feature. Empty, unparseable and dev versions are
treated as too old.
- A policy run marks an older server `agent_too_old` without dispatching.
- A manual Apply updates on an older server still dispatches the empty command,
records the server run as `succeeded` immediately with
`error: "no result reported: agent predates patch results"`, and the UI shows
it as unverified. The button keeps working through the transition.
## Agent
`internal/updates`:
- `Apply(opts ApplyOptions) (Result, error)` replaces `ApplyAll()`.
`ApplyOptions{Scope string; Deadline time.Time}`. `Result{Output []byte;
Unsupported bool}`.
- A package-level mutex: a second `Apply` while one runs returns `ErrBusy`.
- Index refresh (`apt-get update`, and nothing else) keeps its own 5-minute
timeout. The upgrade runs under a context ending at `Deadline`, or 2h from
start when no deadline is given. The existing single 5-minute context is
removed.
- Combined stdout and stderr go to a 64KB ring buffer that keeps the newest
bytes.
- Non-interactive everywhere: `DEBIAN_FRONTEND=noninteractive` and
`-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold` for
apt, `--non-interactive` for zypper, `--noconfirm` stays for pacman.
Security-only, per package manager:
| Manager | Command | No security metadata |
| --------- | ---------------------------------------------------------------------------------------------------- | -------------------- |
| apt | temp sources list of entries whose suite ends in `-security`, from `/etc/apt/sources.list`, `*.list` and deb822 `*.sources`; `apt-get update` and `apt-get upgrade -y` with `-o Dir::Etc::SourceList=<tmp> -o Dir::Etc::SourceParts=-` | no such entries: `unsupported` |
| dnf / yum | `<pm> upgrade --security -y` | n/a |
| zypper | `zypper --non-interactive patch --category security` | n/a |
| pacman | none | always `unsupported` |
| apk | none | always `unsupported` |
| Windows | existing COM search, keeping updates whose `Categories` include Security Updates (`0FA1201D-4330-4FA8-8AE9-B877473B6441`) or Critical Updates (`E6CF1350-C01B-414D-A61F-263D14D133B4`) | n/a |
`unsupported` installs nothing. It never falls back to `all`.
apt security source filtering is a pure function over file contents
(`securitySources(files map[string]string) (list string, ok bool)`) so it is
testable on any platform.
`internal/sync` `handleApplyUpdates`:
1. `updates.Apply(...)` with the command's scope and deadline.
2. Re-run `CheckAvailable`, `ReportUpdates` the result (on success **and**
failure, so counts are fresh either way), read `RebootRequired()`.
3. If `reboot_if_required`, a reboot is owed, and at least 5 minutes remain
before the deadline: send `PatchResult{status: ok, rebooting: true, ...}` on
the command stream, then run `shutdown -r +1` (Linux) or
`shutdown /r /t 60 /c "Vantage patch policy"` (Windows). The one-minute
grace lets the result leave before the host goes down.
4. Otherwise send `PatchResult` with the outcome.
`internal/inventory` reports `boot_time_unix` on every report (Linux
`/proc/stat` `btime`, Windows via the existing collection).
The agent's documented promise changes from "never reboots a host" to "never
reboots a host unless the command explicitly asks and the OS reports a reboot
is owed". `vantage-agent/CLAUDE.md` and this repository's CLAUDE.md are updated
in the same change.
## Scheduler and run lifecycle
### `patchsched`
Runs inside the existing `RunAsLeader("housekeeping", ...)`, ticking every 30s.
It must not import `services` (the same cycle `workflowsched` avoids); its
dependencies are injected from `main.go` as `patchsched.Deps`.
Each tick does two things.
**Fire due policies.** For each enabled policy with `next_run_at <= now`, claim
it with the `workflowsched` pattern: `UpdateOne` matching `policy_id` **and**
the current `next_run_at`, setting the next occurrence. A zero match means
another process claimed it. Then `Decide(due, windowEnd, now, running, nTargets)`:
| Condition | Result |
| ----------------------------------------------------- | -------------------------------------------------- |
| window missing or no longer parses | disable policy, set `disabled_reason`, audit |
| `now >= windowEnd` or `now > due + 1h` | skip `missed` |
| a run of this policy has `status: running` | skip `already_running` |
| targets resolve to zero servers | skip `no_targets` |
| otherwise | fire |
Skips set `last_skipped` and write `patch.skipped`, as workflow skips do. Firing
creates a `patch_run` with every target `queued` (or `agent_too_old`),
`WindowEnd = due + duration`, sets `last_run_at`, and writes `patch.run_started`
with actor `schedule`.
**Advance running runs.** For each `patch_run` with `status: running`, load
connection state for its servers and apply `Advance(run, now, connected)`, a
pure function returning the transitions:
- While `now < WindowEnd` (or always, for a manual run): move `queued` and
`waiting_offline` servers to `patching` up to `MaxConcurrent` in flight
(`patching` plus `rebooting`), dispatching `ApplyUpdatesCmd` with
`deadline_unix = WindowEnd`. A dispatch that fails (503, agent offline) moves
the server to `waiting_offline`.
- When `now >= WindowEnd`: `queued` to `window_closed`, `waiting_offline` to
`missed_offline`.
- `patching` with no result past `deadline + 10m` (manual: start + 2h + 10m):
`failed`, `"no result from agent"`.
- `rebooting` with no verifying report past `RebootedAt + 20m`: `failed`,
`"did not come back within 20 minutes"`.
- When every server is terminal, `Finalize` sets the run status: `succeeded` if
all servers succeeded, `failed` if none did, otherwise `partial`.
`unsupported`, `agent_too_old`, `missed_offline` and `window_closed` are not
successes. Then notify (below) and write `patch.run_finished`.
Every transition is written with a filter on the server's current status, so a
result arriving concurrently is never overwritten by a stale tick.
### Results
`CommandStream`, on whichever pod holds the agent's stream, handles
`PatchResult` by updating the server run found by `servers.command_id`, via
`ApplyResult(serverRun, result, now)`:
- `ok` without `rebooting`: `succeeded`, `PendingAfter`, output.
- `ok` with `rebooting`: `rebooting`, `RebootedAt = now`, write `patch.reboot`.
- `failed` or `busy`: `failed` with the message.
- `unsupported`: `unsupported`.
Nothing awaits the result on the bus. The run document is the only state, so a
pod or leader change mid-window loses nothing. A result for an unknown command
ID is dropped and logged.
### Reboot verification
`ReportInventory` checks, for this server, any server run in `rebooting`, and
only on reports with `include_static` set: `reboot_required` is only computed
on static snapshots, so a metrics-only report would read as "no reboot owed".
The agent sends a static snapshot at start, so the first report after a reboot
qualifies.
`VerifyReboot(serverRun, bootTime, rebootRequired, now)`:
- `bootTime <= RebootedAt`: not yet rebooted, no change. This is why boot time
is used rather than "a report arrived": a static snapshot sent during the
one-minute grace must not count.
- `bootTime > RebootedAt` and `reboot_required` false: `succeeded`,
`VerifiedAt = now`.
- `bootTime > RebootedAt` and `reboot_required` still true: `failed`,
`"still requires a reboot after restarting"`.
### Manual runs
`POST /servers/:id/apply-updates` and the vulnerability page's Apply updates
create a one-server `patch_run` (`scope: all`, `reboot: never`, no window,
`TriggeredBy` the actor, `Source` one of `server`, `vulnerabilities` or `mcp`)
and dispatch immediately in
the handler rather than waiting for the next tick. A dispatch failure still
answers 503, and the run is recorded as `failed` so the attempt is not lost.
Timeouts and finalisation go through the same tick.
### Cancel
`POST /patch-runs/:runId/cancel` moves `queued` and `waiting_offline` servers to
`cancelled`. Servers already `patching` or `rebooting` continue: interrupting a
package manager mid-transaction is worse than letting it finish. The run
finalises as `cancelled` once the in-flight servers settle, unless none were in
flight, in which case immediately.
### Notifications
When a run finalises as `partial` or `failed`, one summary goes to each channel
in `NotifyChannelIDs` through the existing `notify` dispatch, as a new event
type `patch`: `[Vantage] Patch policy "Sunday prod" partial: 38 succeeded,
2 failed, 1 missed offline (run 3f2a...)`. The webhook payload is the existing
event shape with that summary as its message; no new payload fields. A clean
run sends nothing.
## REST API
All under `/api`, each route registered in `routeScopes` (so
`AssertScopeMapComplete` passes) and annotated for `swag`; `openapi.json` is
regenerated and committed.
```
maintenance-windows GET,POST /maintenance-windows · GET,PUT,DELETE /maintenance-windows/:id
POST /maintenance-windows/preview {cron,tz,duration_minutes} -> next 3 {start,end}
patch-policies GET,POST /patch-policies · GET,PUT,DELETE /patch-policies/:id
POST /patch-policies/:id/run-now window = now .. now + window duration
patch-runs GET /patch-runs?policy_id=&server_id=&limit= · GET /patch-runs/:runId
POST /patch-runs/:runId/cancel
servers POST /servers/:id/apply-updates 202 {run_id, message}
```
- New scope resource **`patching`** (`:read`, `:write`). Windows, policies and
runs use it. `apply-updates` stays on `servers:write`.
- Creating, editing and deleting windows and policies, and run-now, are
owner or admin (`RequireRole`). Viewing and cancelling runs are open to every
role. Apply updates keeps its current access.
- Saving a policy through a tag-restricted token is refused if its selector
reaches outside the restriction, reusing `validateWorkflowTargetScope`
unchanged. Scheduled firing passes a nil token scope, as workflow schedules do.
- `apply-updates` keeps `message` in its response for existing scripts and adds
`run_id`.
- MCP `apply_updates` returns the `run_id`. No new MCP tools.
Errors: `400` validation (`invalid_cron`, `invalid_tz`, `invalid_duration`,
`invalid_scope`, `invalid_reboot`, `no_targets`), `404` unknown window, policy
or run, `409 window_in_use`, `503` agent offline on apply-updates.
## Audit
Every event uses the `patch.` prefix so the audit page groups them under one
category: `patch.window_created|window_updated|window_deleted`,
`patch.policy_created|policy_updated|policy_deleted|policy_disabled`,
`patch.run_started`, `patch.run_finished`, `patch.skipped`, `patch.cancelled`,
`patch.reboot` (one per server Vantage reboots, naming the policy),
and the existing `updates.applied` for manual runs, now carrying the run ID.
## Frontend
`web/`, dark tokens only, no hex, pills carry shape and label.
- Sidebar: **Patching** in the Fleet group.
- `/patching`, three tabs:
- **Policies**: name, next window in its own timezone, resolved target count
via `web/lib/targets.ts`, scope and reboot chips, last-run pill, enabled
toggle. A disabled policy shows its `disabled_reason`.
- **Windows**: name, schedule in words, duration, policies using it.
- **Runs**: newest first, filter by policy.
- **Policy editor** (modal): name; window picker with inline "New window";
targets with `DualListBox` plus tag rows; scope radio, noting apk and pacman
hosts report unsupported for security-only; reboot radio, where "If required"
shows "Up to N servers may reboot during this window"; max concurrent;
notification channels. Shows "N of M targets need an agent update" linking to
the servers when any target is too old.
- **Window editor** (modal): name, presets writing cron (Nightly 02:00,
Sunday 02:00, Saturday 22:00, Monthly 1st 02:00), cron field, timezone,
duration; next 3 occurrences from `/maintenance-windows/preview`.
- `/patching/runs/[runId]`: header with status, trigger, window end and counts
per status; table of servers with status pill, updates installed
(`PendingBefore - PendingAfter`), rebooted and verified times, error; each row
expands to the output tail on the `--well` surface. Cancel while running. The
page polls while the run is `running`.
- **Server detail, OS updates panel**: "Covered by *Sunday prod*, next window
Sun 21 Sep 02:00 BST" or "Not covered by any patch policy"; last patch run
with link. Apply updates navigates to the new run.
- **Vulnerabilities**: Apply updates navigates to the new run.
## Documentation
`vantage-docs`:
- New `docs/vantage/patching.md`: windows, policies, scope per package manager
(including unsupported), reboot rule and verification, statuses table,
offline and window-close behaviour, agent version requirement.
- `vantage/servers.md`: remove the "Applying updates is not scheduled or staged"
warning, describe the run record, update the reboot sentence.
- `vantage/vulnerabilities.md`: Apply updates creates a run; link to patching.
- `hq/licensing-and-entitlements.md`: patching is available on every tier.
- `reference/api-tokens.md`: the `patching` scope.
`vantage-app/CLAUDE.md`: new "Scheduled patching" subsystem section, updated
"Inventory and OS updates", collection list, REST list, `ServerCommand` and
`AgentMessage` variants. `vantage-agent/CLAUDE.md`: the reboot promise.
## Testing
The repository tests pure functions without a database, so the logic is shaped
for that and the Mongo layer is a thin shell.
Server:
- `patchsched.Decide`: on time, late within grace, past grace, past window
end, already running, zero targets, and a DST case (`Europe/London`, last
Sunday of October, 01:30 start occurring twice; `NextOccurrence` behaviour is
asserted, not assumed).
- `patchrun.Advance`: concurrency cap never exceeded counting `rebooting`;
offline then online inside the window dispatches; offline to window end is
`missed_offline`; queued at window end is `window_closed`; nothing dispatched
after window end; no-result timeout; reboot timeout; cancelled runs dispatch
nothing; manual runs ignore window rules.
- `patchrun.ApplyResult` for each result status.
- `patchrun.VerifyReboot`: boot time before, equal to and after `RebootedAt`,
with reboot still owed and cleared.
- `patchrun.Finalize`: every mix of terminal statuses.
- `agentSupportsPatchResults`: older, equal, newer, pre-release, empty,
unparseable.
- Validation of windows and policies.
- `scopes_test` covers the new routes; a token tag-restriction case in the
style of `workflow_target_scope_test`.
Agent:
- `securitySources`: Debian and Ubuntu `.list`, Ubuntu 24.04 deb822 `.sources`,
commented lines, a file set with no security suites (`ok == false`).
- Ring buffer keeps the newest 64KB.
- Windows security category filtering, as a parser test beside `winparse_test`.
- Manual verification on Debian 12, Ubuntu 24.04, Rocky 9 and Windows Server
2022: security-only and all, reboot and verification, window close
mid-queue, agent busy.
## Rollout
1. `vantage-shared`: `ApplyUpdatesCmd` fields, `PatchResult`,
`InventoryReport.boot_time_unix`, proto and `pb` together. Release a tag.
2. `vantage-app`: bump the pin; ship collections, indexes, `patchsched`,
results handling, API, UI, MCP change and docs. With no new agents yet,
policies show every target as `agent_too_old` and the manual button works as
before, now with a run record.
3. `vantage-agent`: bump the pin, ship the agent changes, tag `agent/v*`. Set
that version as the gate constant in the server in step 2 (the gate names a
version that does not exist yet until step 3 ships, which is harmless: every
agent reads as too old until it does).
4. `vantage-docs`: publish the patching page.
@@ -0,0 +1,342 @@
# MFA for local sign-in (TOTP and passkeys) - design
Date: 2026-09-15
Gap review entry: `#r-mfa` ("Build next", effort S - revised upward, see Scope)
## Problem
Local and HQ-projected members sign in with an email address and a password and
nothing else. MFA exists only through SSO, which is a paid feature. One phished
password on a Free instance gives an attacker root script execution across the
fleet, root consoles and decrypted private keys. MFA is a hard requirement on
most security questionnaires and must ship on every tier, with no licence gate.
## Scope
In:
- TOTP as a second factor, with 10 single-use recovery codes.
- WebAuthn passkeys, both as a second factor after a password and as
passwordless sign-in (discoverable credentials, user verification preferred).
- An owner setting, `require_mfa`, that forces enrolment for password users.
- Owner/admin reset of another member's MFA.
- Step-up re-authentication before three sensitive actions: secret reveal,
private key download, console connect.
- A per-IP rate limit on every unauthenticated sign-in endpoint.
Out:
- MFA owned by Vantage HQ and projected to instances. HQ users enrol per
instance, like local users.
- Step-up for API tokens (see Known limitations).
- Forcing OIDC users through their IdP again (`prompt=login`) for step-up.
- A grace period for `require_mfa`.
- SMS or email codes.
## Who MFA applies to
| `auth_source` | Can enrol | Covered by `require_mfa` | Step-up factor |
| ------------- | --------- | ------------------------ | --------------------------- |
| `local` | yes | yes | MFA if enrolled, else password |
| `hq` | yes | yes | MFA if enrolled, else password |
| `oidc` | no | no | none - passes through |
OIDC users are exempt because their IdP owns authentication; their session
carries `amr: ["oidc"]`.
## Approach: pending-login ticket
A password that checks out no longer mints a session when the user has MFA or
must enrol. It mints a **pending-login ticket** instead: a Redis key referenced
by a separate short-lived cookie. Only the MFA endpoints accept the ticket, and
only a completed second factor (or completed enrolment) exchanges it for a
`km_session`.
The reason for this shape over "a session with an `mfa_pending` flag" is that a
half-authenticated user never becomes a `*Session` at all, so no route mounted
under `auth.Middleware` - today's or a future one - can serve them by
forgetting a check. The flag design fails open; this one fails closed.
## Data model
### `user_mfa` (control plane, new)
One document per user who has started enrolment.
| Field | Type | Notes |
| ------------------- | ----------- | ----- |
| `instance_id` | string | tenant scope |
| `user_id` | string | unique index `{instance_id, user_id}` |
| `webauthn_handle` | binary(64) | random, never the `user_id` (WebAuthn user handle) |
| `totp_secret_enc` | string | AES-256-GCM via `services.encryptString`; empty when TOTP is off |
| `totp_confirmed_at` | *time | nil means setup started but not confirmed - TOTP not active |
| `recovery_codes` | []{hash, used_at} | 10 codes, SHA-256 of the normalised code |
| `updated_at` | time | |
A user "has MFA" when `totp_confirmed_at` is set or they own at least one
passkey. A pending unconfirmed TOTP secret is replaced on the next setup call.
### `webauthn_credentials` (control plane, new)
One document per passkey.
| Field | Type | Notes |
| --------------- | -------- | ----- |
| `instance_id` | string | |
| `user_id` | string | index `{instance_id, user_id}` |
| `credential_id` | binary | unique index `{instance_id, credential_id}` |
| `public_key` | binary | COSE key; not secret |
| `sign_count` | uint32 | clone detection: a non-increasing non-zero count fails the assertion |
| `aaguid` | binary | |
| `transports` | []string | |
| `name` | string | user-editable, default from AAGUID or "Passkey" |
| `created_at`, `last_used_at` | time | |
Both collections are added to `ScopedCollections`, with a test asserting it,
so an instance purge removes them. Their index builder is fatal on failure,
like `EnsureAuthIndexes` - the unique indexes are a security property.
### `vantage-shared` changes (one release, then bump `server`'s pin)
- `models.Settings.RequireMFA bool` (`bson:"require_mfa"`). A plain bool is
correct here: absent must mean off.
- `backup.ciphertextFields["user_mfa"] = []string{"totp_secret_enc"}`. Without
it `vantagectl verify` silently skips these secrets.
### Redis keys
| Key | TTL | Holds |
| --------------------------- | ----- | ----- |
| `km:mfa:<id>` | 5 min | pending ticket: `user_id`, `instance_id`, `methods`, `enrol_only`, `attempts` |
| `km:wa:<id>` | 5 min | WebAuthn session data (challenge) for one ceremony |
| `km:totp:<user_id>:<step>` | 90 s | `SET NX` replay guard: a TOTP code works once |
| `km:rl:auth:<ip>` | 1 min | fixed-window counter for the sign-in rate limit |
The pending ticket cookie is `km_mfa_pending`: HttpOnly, SameSite=Lax, Secure
under the same rule as `SetSessionCookie`, Path `/`, MaxAge 300. It is cleared
when exchanged or when the ticket is deleted.
### `Session` additions
- `AMR []string` - how the session was authenticated: `pwd`, `otp`, `webauthn`,
`recovery`, `oidc`. An HQ user's password sign-in is `pwd` like a local one.
- `StepUpAt *time.Time` - last successful step-up. Sign-in itself counts as a
step-up, so `StepUpAt` is set at session creation.
Updating `StepUpAt` rewrites the Redis value under the same session ID, keeping
the remaining TTL (`KEEPTTL`).
## Sign-in flows
### Password, then second factor
1. `POST /auth/login` runs unchanged up to `VerifyPassword`.
2. Then:
- user is `oidc`: unreachable (no local password).
- user has MFA: create a ticket with `methods` (`totp`, `webauthn`,
`recovery` as applicable), set `km_mfa_pending`, answer
`200 {mfa_required: true, methods: [...]}`.
- user has no MFA and `require_mfa` is on: create a ticket with
`enrol_only: true`, answer `200 {enrol_required: true}`.
- otherwise: mint the session as today, `amr: ["pwd"]`.
3. The client calls one of:
- `POST /auth/mfa/totp {code}`
- `POST /auth/mfa/recovery {code}`
- `POST /auth/mfa/webauthn/begin` then `POST /auth/mfa/webauthn/finish`
4. On success the ticket is deleted, the session is minted with
`amr: ["pwd", <factor>]`, `TouchLastLogin` runs, and the response matches
today's `{ok: true}`.
Each failure increments `attempts`; the fifth deletes the ticket.
### Forced enrolment
An `enrol_only` ticket is accepted only by:
- `POST /auth/mfa/enrol/totp/setup``{secret, otpauth_uri}`
- `POST /auth/mfa/enrol/totp/confirm {code}``{recovery_codes}` and the session
- `POST /auth/mfa/enrol/passkey/begin|finish``{recovery_codes}` and the session
It is refused by the plain `/auth/mfa/*` verify endpoints and vice versa, so a
user cannot skip enrolment by presenting the ticket elsewhere.
Existing sessions are not revoked when `require_mfa` is switched on; they end
at their 24h TTL and the next sign-in enforces enrolment.
### Passwordless passkey
1. `POST /auth/passkey/begin` - resolves the instance with
`resolveLoginInstance`, applies the locked-instance and
`LocalLoginPermitted` checks exactly as `/auth/login` does, and returns
assertion options with an empty `allowCredentials` and
`userVerification: "required"`.
2. `POST /auth/passkey/finish` - looks the credential up by
`{instance_id, credential_id}`, verifies the assertion including the UV flag,
and mints the session with `amr: ["webauthn"]`.
User verification is preferred, not required. Requiring it made password
managers such as NordPass prompt for their master password on every use, so it
was relaxed on 2026-09-16 at the product owner's decision. A passkey used
without verification is possession-only; passwordless sign-in and step-up then
rest on the device or vault being unlocked, and still satisfy `require_mfa`.
The discoverable lookup is scoped by `instance_id` from the host, never by the
credential alone - the same rule that makes `users` lookups instance-scoped.
### WebAuthn relying party
- RP ID: the request host with any port removed. On cloud that is the
instance subdomain; self-hosted, the install's own host. The reverse proxy
must preserve `Host`, which the instance host guard already requires.
- Expected origin: `https://<host>` (or `http://` only when the request itself
is plain HTTP on `localhost`, for development).
- A passkey is bound to its host. Moving an instance to a new address
invalidates its passkeys; TOTP and recovery codes still work. The docs say so.
## Step-up
`auth.RequireStepUp()` is new middleware, mounted after `auth.Middleware` on:
- `POST /api/secrets/:group/reveal`
- `GET /api/keys/:id/private-key`
- `POST /api/console/connect`
- the MFA-management endpoints marked "step-up" below
It passes when any of these holds:
- the request authenticated with an API token (`TokenID != ""`);
- `AMR` contains `oidc`;
- `StepUpAt` is within the last 10 minutes.
Otherwise it answers `403 {error: "re-authentication required", code:
"step_up_required", methods: [...]}`, where `methods` is the user's MFA factors,
or `["password"]` when they have none.
`POST /api/me/step-up` accepts `{totp}`, `{recovery}` or `{password}` (password
only for a user with no MFA), and `POST /api/me/step-up/webauthn/begin|finish`
does the same with a passkey. Success sets `StepUpAt`. Failures are rate
limited by the same per-IP limiter and audited.
## Account endpoints (session-authenticated, every role)
| Method and path | Step-up | Notes |
| --------------------------------------- | ------- | ----- |
| `GET /api/me/mfa` | no | TOTP on/off, passkeys, recovery codes remaining, `require_mfa` |
| `POST /api/me/mfa/totp/setup` | yes | returns secret and `otpauth://` URI; replaces an unconfirmed one |
| `POST /api/me/mfa/totp/confirm` | no | code must verify; returns recovery codes when none exist yet |
| `DELETE /api/me/mfa/totp` | yes | refused if last factor and `require_mfa` |
| `POST /api/me/mfa/recovery/regenerate` | yes | invalidates the old set, returns 10 new codes once |
| `POST /api/me/passkeys/begin`, `/finish`| yes | registers a discoverable credential, UV required |
| `PATCH /api/me/passkeys/:id` | no | rename |
| `DELETE /api/me/passkeys/:id` | yes | refused if last factor and `require_mfa` |
| `DELETE /api/org/users/:id/mfa` | yes | owner or admin; an admin cannot reset an owner; clears TOTP, passkeys, recovery codes |
The `/me/*` endpoints refuse OIDC users with 409 `mfa_not_applicable`.
The first factor enrolled from `/account/security` issues recovery codes;
later factors do not regenerate them.
`require_mfa` is set through the existing `PUT /api/settings`, owner only for
that field (admins may save other settings).
Every handler carries swag annotations; `openapi.json` is regenerated.
## Rate limiting
There is no rate limit on `/auth/login` today. A new `RateLimitAuth()` fixed
window, on the `RateLimitTokens` pattern, applies to `/auth/login`,
`/auth/mfa/*`, `/auth/passkey/*` and `/api/me/step-up*`: 20 requests per minute
per `c.ClientIP()`, answering 429 with `Retry-After`. The ticket's 5-attempt
cap still bounds guesses per ticket; the IP limit bounds tickets per attacker.
## Audit events
`mfa.enrolled` (factor), `mfa.removed` (factor), `mfa.reset` (target user),
`mfa.recovery_used`, `mfa.recovery_regenerated`, `mfa.failed` (factor, at
sign-in), `step_up.ok`, `step_up.failed`, `settings.require_mfa` (on/off).
Details never contain codes, secrets or credential IDs.
## UI (`web/`)
- **`/login`**
- "Sign in with passkey" button when `window.PublicKeyCredential` exists and
local login is enabled.
- After the password: a second-factor step with a 6-digit code input,
"Use passkey", and "Use a recovery code".
- `enrol_required`: an inline wizard - choose TOTP (QR code, secret as text,
confirm code) or passkey, then recovery codes shown once with copy and an
"I have saved these" checkbox before continuing.
- `mfa_ticket_expired` returns the form to the password step with a message.
- **`/account/security`** - new page, every role, linked from the sidebar user
menu. TOTP status and setup/remove, passkey list with rename and remove,
recovery codes remaining and regenerate. Hidden content with an explanation
for OIDC users.
- **`/settings`** - owner-only "Require MFA for password sign-in" toggle; the
users table gains an MFA column and a "Reset MFA" action.
- **`StepUpModal`** - `request()` in `web/lib/api.ts` catches 403 with
`code: "step_up_required"`, opens the modal with the offered methods, and on
success retries the original request once. Callers of the three sensitive
actions need no changes.
- QR codes are drawn client-side (the `qrcode` npm package); nothing is fetched
from outside, so air-gapped installs work.
## Errors
| Situation | Answer |
| ---------------------------------- | ------ |
| missing or expired ticket | 401 `mfa_ticket_expired` |
| wrong code | 401 `invalid_code`, `attempts_left` |
| fifth wrong code | ticket deleted, 401 `mfa_ticket_expired` |
| replayed TOTP code | 401 `invalid_code` |
| WebAuthn verification failure | 401 `invalid_assertion` |
| origin or RP ID mismatch | 400 `origin_mismatch` |
| removing last factor under policy | 409 `mfa_required_by_policy` |
| rate limited | 429 with `Retry-After` |
| Redis unavailable | 503; sign-in already depends on Redis |
TOTP: SHA-1, 6 digits, 30 s, ±1 step, issuer = instance name, account = email.
## Libraries
- `github.com/pquerna/otp` - TOTP generation and validation.
- `github.com/go-webauthn/webauthn` - WebAuthn ceremonies.
- `qrcode` (npm) - QR rendering in the browser.
## Testing
Go unit tests:
- TOTP verify, skew window, replay guard.
- Recovery codes: normalisation, single use, regenerate invalidates the old set.
- Ticket state machine: TTL, attempt cap, `enrol_only` scope in both directions.
- Login branching: no MFA, MFA, `require_mfa` without MFA, OIDC-exempt.
- `RequireStepUp`: inside and outside the window, token exemption, OIDC exemption.
- Last-factor refusal under `require_mfa`; admin cannot reset an owner.
- Passwordless passkey honours locked instances and `LocalLoginPermitted`.
- `user_mfa` and `webauthn_credentials` present in `ScopedCollections`.
- WebAuthn ceremonies against a software authenticator fixture.
Playwright:
- TOTP enrol and sign-in, with codes generated in the test.
- Passkey enrol, second-factor sign-in, passwordless sign-in and step-up using
Chrome's virtual authenticator (`WebAuthn.addVirtualAuthenticator` over CDP).
- Forced enrolment when `require_mfa` is on.
- Step-up modal on secret reveal, then no prompt within 10 minutes.
## Known limitations
- **API tokens bypass step-up.** A token with `secrets:read` or `keys:read` can
reveal secrets and download private keys without re-authentication. Tokens
have no human present to prompt. Scoped, short-lived tokens are the mitigation
and are tracked separately in the gap review.
- OIDC users are never prompted for step-up; their IdP's session policy governs.
- HQ users on several instances enrol once per instance.
- Passkeys stop working if an instance changes host.
## Documentation
- `CLAUDE.md`: a "Multi-factor authentication" subsystem section covering the
ticket design, step-up, the shared-module changes and `ciphertextFields`.
- `vantage-docs`: enrolling, recovery codes, owner reset, `require_mfa`,
passkeys bound to the host.
- Gap review: mark `#r-mfa` shipped, and the MFA row in the comparison table.
@@ -0,0 +1,186 @@
# Metric alerts and heartbeat monitors
Date: 2026-09-17
Source: competitive gap review, "Alerts on agent metrics" (effort M) and "Heartbeat (push) monitors" (effort S).
## Goal
1. Alert on data agents already report (disk, memory, load, failed units, unhealthy containers, reboot pending, agent offline), targeted by tag so one rule covers the fleet.
2. Alert when a push-based job (backup, cron) stops pinging, reports failure, or starts and never finishes.
Both reuse the existing monitor model, states, incidents, notification channels, groups and status pages.
## Out of scope
- Scheduled workflows pinging a heartbeat automatically.
- Metric history graphs (separate gap review item).
- Agent-side evaluation. No agent release is required.
## Data model
New constants in `models/monitor.go`: `MonitorMetric = "metric"`, `MonitorHeartbeat = "heartbeat"`.
`MonitorTarget` gains:
| Field | Type | Used by | Meaning |
|---|---|---|---|
| `Selector` | `map[string]string` | metric | Server tag selector. Empty means the whole org fleet. |
| `Metric` | `string` | metric | One of the metric kinds below. |
| `Threshold` | `float64` | metric | Breach threshold, unit depends on kind. |
| `Mount` | `string` | metric (disk kinds) | Mountpoint. Empty means any mount breaches. |
| `PeriodSec` | `int` | heartbeat | Expected time between pings. |
| `GraceSec` | `int` | heartbeat | Extra time allowed before overdue, and the max run time after `/start`. |
`Monitor` gains `ForSec int` (metric): the condition must hold continuously this long before a server goes down. 0 means on first evaluation.
`Monitor` gains `HeartbeatTokenHash string` (`json:"-"`), SHA-256 of the ping token. The plaintext token is returned only on create and on rotate.
`MonitorState` gains, for heartbeats: `LastPingAt *time.Time`, `StartedAt *time.Time`.
`Incident` gains `ServerID string` (`omitempty`). Empty for every non-metric monitor.
`Inventory` gains `RebootRequiredSince *time.Time`, set by `StoreInventory` when `reboot_required` turns true and unset when it turns false.
New collection `monitor_server_states`, one document per (metric monitor, matching server):
```go
type MonitorServerState struct {
InstanceID string // org
MonitorID string
ServerID string
Status string // up | down | pending
BreachSince *time.Time // first evaluation where the condition held; nil when clear
Value float64 // last evaluated value, for display
Message string // e.g. "/var 94.2% used"
UpdatedAt time.Time
}
```
Unique index on `(monitor_id, server_id)`.
### Metric kinds
| Kind | Source | Breach when | Threshold unit |
|---|---|---|---|
| `disk_pct` | `inventory.partitions` | used/total*100 >= threshold on `Mount` (or any mount) | percent |
| `disk_free_gb` | `inventory.partitions` | (total-used)/1e9 <= threshold | GB |
| `mem_pct` | `inventory.memory` | used/total*100 >= threshold | percent |
| `load_per_core` | `inventory.cpu.load1 / cores` | >= threshold | ratio |
| `unit_failed` | workloads, kind `unit` | any unit state `failed` (threshold unused) | - |
| `container_unhealthy` | workloads, kind `container` | any health `unhealthy` (threshold unused) | - |
| `reboot_pending_days` | `inventory.reboot_required_since` | now - since >= threshold days | days |
| `agent_offline_min` | `server.last_seen` | now - last_seen >= threshold minutes | minutes |
Validation on create/update: `Metric` is a known kind; `Threshold` > 0 for kinds that use it; `disk_pct` and `mem_pct` threshold <= 100; `Mount`, if set, is an absolute path; `Selector` keys and values follow existing tag rules. `IntervalSec`, `Runner` and `Retries` are ignored for metric and heartbeat monitors and stored as `runner = "server"`, so `ListServerScheduledMonitors` must exclude these two types from the pull scheduler.
Heartbeat validation: `PeriodSec` >= 60, `GraceSec` >= 0 (default 300 when unset).
## Shared transition logic
Extract from `ingestResult` in `services/monitors.go`:
```go
func applyTransition(ctx context.Context, m *models.Monitor, serverID, prev, next, message string, now time.Time)
```
It opens an incident (with `ServerID`) on a change to down, resolves the matching open incident (same `monitor_id` and `server_id`) on down to up, and calls `notifyTransition`. `ingestResult` calls it with `serverID = ""`. Behaviour of existing monitor types does not change.
`notify.Event` gains `ServerName string`. Channel formatters include it when set ("disk on web-01: /var 94.2% used").
## Metric evaluation
New package `server/internal/metricsched`, started from `main.go` beside `monitorsched`. Like monitors, it runs regardless of licence state.
Every 30 seconds, for each enabled metric monitor:
1. Resolve servers with `ListServersFiltered(instanceID, selector)`.
2. Load existing `monitor_server_states` for the monitor.
3. For each server:
- For kinds other than `agent_offline_min`: skip (keep previous state) when `inventory.metrics_at` is older than 5 minutes or missing. A dead agent must not flap other alerts; `agent_offline_min` covers it.
- Evaluate the kind to get `(breach bool, value float64, message string)`.
- Breach: set `BreachSince` if nil. If `now - BreachSince >= ForSec`, next = down, otherwise next = pending when previously up or new.
- No breach: `BreachSince = nil`, next = up.
- Upsert the state doc. On a status change call `applyTransition` with the server ID. A new server's first state is never treated as a transition from down.
4. State docs for servers no longer matched (tag removed, server deleted): resolve any open incident without notifying, and delete the doc.
5. Roll up the parent `Monitor.State`: status = down if any child down, else pending if any pending, else up (no matching servers means up). Message is "N of M servers breaching". `last_check_at = now`. Write one `MonitorSample` (up = no child down, latency 0) and increment the hourly rollup, so uptime graphs and status pages work unchanged.
Deleting a metric monitor deletes its `monitor_server_states`.
Evaluators are pure functions `func(kind string, t models.MonitorTarget, srv models.Server, wls []models.Workload, now time.Time) (bool, float64, string)` so they can be table-tested without Mongo. Workloads are loaded only for monitors whose kind needs them.
## Heartbeats
### Public endpoints
Registered outside `/api`, unauthenticated, no scope declarations needed:
- `GET|POST /public/hb/:token`: success ping
- `GET|POST /public/hb/:token/start`: run started
- `GET|POST /public/hb/:token/fail`: run failed
Mounted under /public because every deployment already routes that prefix to the server. The token may instead be sent in the `X-Vantage-Token` header to `/public/hb`, `/public/hb/start` or `/public/hb/fail`; a URL token wins when both are present. The server's request log and the bundled nginx access log mask the URL token.
Lookup is by SHA-256 of the token. Unknown token or disabled monitor gives 404. Rate limit is one accepted request per second per token (in-process, Redis-backed if a limiter helper already exists); excess gives 429. The response body is `OK`. Request bodies over 1 KB are truncated; only `/fail` uses the body.
Behaviour:
- **ping**: `LastPingAt = now`. If `StartedAt` is set, duration = now - StartedAt, write a sample with `LatencyMs = duration`, then clear `StartedAt`. Otherwise write a sample with latency 0. Next status is up; call `applyTransition` if changed.
- **start**: `StartedAt = now`. No status change.
- **fail**: clear `StartedAt`, write a down sample, next status is down immediately with message `reported failure: <body>` (body defaults to empty). The failure is also recorded as `LastPingAt` so the overdue timer restarts from it.
All state writes go through one conditional update per request, so concurrent pings don't lose updates.
### Overdue sweep
The same 30-second `metricsched` loop checks enabled heartbeat monitors:
- New monitor with no ping yet: pending, never down. The overdue clock starts at the first ping.
- `now > LastPingAt + PeriodSec + GraceSec`: down, message `no ping since <time>`.
- `StartedAt != nil && now > StartedAt + GraceSec`: down, message `started <time>, never finished`.
Recovery happens only through a ping.
### Token management
- Create returns `heartbeat_token` and `heartbeat_url` once.
- `POST /api/monitors/:id/rotate-token` issues a new token and invalidates the old one. It needs `routeScopes` (`monitors:write`) and `serverScopedRoutes` entries.
- The UI shows the URL only right after create or rotate, with a copy button and curl examples for ping, start and fail.
## API
- Existing create/update monitor endpoints accept the new types and fields. OpenAPI annotations are updated.
- `GET /api/monitors/:id/servers` returns `monitor_server_states` for a metric monitor, with hostnames. It needs `routeScopes` (`monitors:read`) and `serverScopedRoutes` entries, and filters rows to servers visible to the acting token.
- A tag-scoped token may create or update a metric monitor only when its selector includes every key/value of the token's scope. Otherwise the response is 403.
- The MCP `create_monitor` tool accepts the new types.
## Web UI
- Monitor form: a type picker adds Metric and Heartbeat. Metric shows a selector editor (reuse the tag selector component), a kind dropdown, a threshold with a unit label, mount (disk kinds only) and "for N minutes". Heartbeat shows period and grace.
- Monitors list: metric rows show "N of M servers breaching". Heartbeat rows show last ping as relative time.
- Metric detail page: a per-server table with server, status, value, since and message, linking to the server page. The incident list shows the server name.
- Heartbeat detail page: URL panel (after create or rotate), rotate button with confirm, last ping, and duration in the latency chart.
## Error handling
- Evaluator panics or bad inventory data: recover per monitor, log, and leave states unchanged.
- Mongo errors in the sweep: log and continue to the next monitor. The next tick retries.
- Notification failures are logged only, as today.
## Testing
- Table tests for every evaluator: breach, clear, mount filter, missing inventory, zero totals.
- Sweep tests (Mongo test helper): the for-duration gate, pending to down, stale inventory skipped, a server leaving the selector resolves its incident silently, parent rollup counts.
- Heartbeat handler tests: ping/start/fail transitions, duration sample, unknown token 404, rate limit 429, fail body truncation.
- Overdue tests: never pinged stays pending, overdue goes down, start without finish goes down, ping recovers.
- `applyTransition` regression: existing http monitor incident open/resolve unchanged.
- `go test ./internal/api/` including `TestRegisteredRoutesPassBootAssertions`.
- Playwright: create a heartbeat, curl a ping, see up.
## Phases
1. Shared `applyTransition` refactor, heartbeat type, public endpoints, overdue sweep, rotate route, UI. Ships alone.
2. Metric type, `RebootRequiredSince`, `monitor_server_states`, evaluators, sweep, servers route, UI.
## Documentation
Update the Monitors section of `CLAUDE.md` and add a user guide page in vantage-docs for both types.
+39 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
@@ -23,7 +24,10 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/metricsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
@@ -136,6 +140,10 @@ func runSchemaSetup() {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.EnsureMFAIndexes(); err != nil {
log.Fatalf("failed to ensure mfa indexes: %v", err)
}
if err := services.EnsureAPITokenIndexes(); err != nil {
log.Fatalf("api token indexes: %v", err)
}
@@ -167,6 +175,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
}
if err := services.EnsureMonitorServerStateIndexes(); err != nil {
log.Printf("warning: %v", err)
}
if err := services.EnsureVulnIndexes(); err != nil {
log.Printf("warning: failed to ensure vuln indexes: %v", err)
}
@@ -179,6 +191,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure status page indexes: %v", err)
}
if err := services.EnsurePatchIndexes(); err != nil {
log.Printf("warning: patch indexes: %v", err)
}
if err := services.EnsureAuditIndexes(); err != nil {
log.Printf("warning: failed to ensure audit indexes: %v", err)
}
@@ -215,6 +231,7 @@ func serve() {
log.Fatalf("failed to connect to Redis: %v", err)
}
log.Println("connected to Redis")
services.RedisClient = auth.Redis()
// The bus carries agent commands and step results between replicas. It is
// not optional even on a single-replica deployment: dispatch takes the same
@@ -248,11 +265,23 @@ func serve() {
services.StartAuditSweeper(jobCtx)
services.StartReaper(jobCtx)
monitorsched.Start(jobCtx)
metricsched.Start(jobCtx)
workflowsched.Start(jobCtx, workflowsched.Deps{
TriggerWorkflow: services.TriggerWorkflow,
LogEvent: services.LogEvent,
})
patchsched.Start(jobCtx, patchsched.Deps{
LookupWindow: services.LookupWindow,
CountTargets: services.CountPolicyTargets,
StartPolicyRun: func(p models.PatchPolicy, windowEnd time.Time) error {
_, err := services.StartPolicyRun(p, windowEnd, models.PatchSourceSchedule, "schedule")
return err
},
AdvanceRuns: services.AdvancePatchRuns,
LogEvent: services.LogEvent,
})
vulnsched.Start(jobCtx, vulnsched.Deps{
LogEvent: services.LogEvent,
SendDigest: services.SendVulnDigest,
@@ -283,7 +312,16 @@ func serve() {
log.Fatalf("trusted proxies: %v", err)
}
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{"/api/console/tunnel"},
// Heartbeat URLs carry a credential; the request log must not.
Formatter: func(p gin.LogFormatterParams) string {
return fmt.Sprintf("[GIN] %v | %3d | %13v | %15s | %-7s %#v\n%s",
p.TimeStamp.Format("2006/01/02 - 15:04:05"),
p.StatusCode, p.Latency, p.ClientIP, p.Method,
api.MaskLogPath(p.Path), p.ErrorMessage)
},
}))
r.Use(corsMiddleware())
services.SetStatusRedis(auth.Redis())
+13 -1
View File
@@ -6,12 +6,14 @@ require (
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57
github.com/coreos/go-oidc/v3 v3.21.0
github.com/gin-gonic/gin v1.12.0
github.com/go-webauthn/webauthn v0.18.1
github.com/google/uuid v1.6.0
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/opencontainers/image-spec v1.1.1
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.22.0
github.com/robfig/cron/v3 v3.0.1
github.com/wwt/guac v1.3.2
@@ -23,12 +25,19 @@ require (
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.62.0 // indirect
github.com/samber/lo v1.53.0 // indirect
@@ -37,7 +46,10 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.0 // indirect
@@ -46,7 +58,7 @@ require (
)
require (
gitea.hostxtra.co.uk/vantage/vantage-shared v0.3.0
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0
github.com/bytedance/sonic v1.15.3 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+26 -2
View File
@@ -1,9 +1,11 @@
gitea.hostxtra.co.uk/vantage/vantage-shared v0.3.0 h1:6nSWrXsulzSCMEcr/2viu+uMyUdNOBfZ1JYtdiIzJH4=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.3.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0 h1:wwXvHfDKZB44EEj6BXl9O68hLC3kfPz3eak9wvupIRA=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -23,6 +25,8 @@ github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
@@ -43,6 +47,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0=
github.com/go-webauthn/webauthn v0.18.1/go.mod h1:s4rZTQnKHWxIh6G3yEGqlxvtiLceA1jigll8FpqSgQ8=
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -53,6 +63,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
@@ -97,7 +111,11 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -135,12 +153,16 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -152,6 +174,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// The two boot assertions only run against a real engine in main.go, so a
// route added without its declarations compiled, passed every unit test and
// then refused to start in production. This registers the real routes and
// runs both assertions, so that failure lands in CI instead.
func TestRegisteredRoutesPassBootAssertions(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r)
if err := AssertScopeMapComplete(r); err != nil {
t.Fatalf("scope map: %v", err)
}
var routes []string
for _, route := range r.Routes() {
if strings.HasPrefix(route.Path, "/api/") {
routes = append(routes, route.Method+" "+route.Path)
}
}
if err := AssertServerScopeMapComplete(routes); err != nil {
t.Fatalf("server scope map: %v", err)
}
}
// A route cannot be both session-only and token-reachable: an entry in both
// maps would silently hand the MFA routes to any token holding that scope.
func TestSessionOnlyRoutesHaveNoTokenScope(t *testing.T) {
for route := range sessionOnlyRoutes {
if scope, ok := routeScopes[route]; ok {
t.Errorf("%s is session-only but routeScopes grants it to tokens with %q", route, scope)
}
}
}
File diff suppressed because it is too large Load Diff
+80 -17
View File
@@ -42,7 +42,25 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
r.POST("/auth/bootstrap", auth.HandleBootstrap)
r.POST("/auth/login", auth.HandleLocalLogin)
// Every unauthenticated sign-in and enrolment step lives behind
// RateLimitAuth: without it, the five-attempt cap on a single ticket is
// trivially sidestepped by starting a fresh sign-in each time.
authGroup := r.Group("", RateLimitAuth())
{
authGroup.POST("/auth/login", auth.HandleLocalLogin)
authGroup.POST("/auth/mfa/totp", auth.HandleMFATOTP)
authGroup.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
authGroup.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin)
authGroup.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish)
authGroup.POST("/auth/passkey/begin", auth.HandlePasskeyLoginBegin)
authGroup.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish)
authGroup.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
authGroup.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
authGroup.POST("/auth/mfa/enrol/passkey/begin", auth.HandleEnrolPasskeyBegin)
authGroup.POST("/auth/mfa/enrol/passkey/finish", auth.HandleEnrolPasskeyFinish)
}
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
@@ -53,6 +71,14 @@ func RegisterRoutes(r *gin.Engine) {
// rather than under /api precisely so that none of those apply.
r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage)
// Ping endpoints for heartbeat monitors. The token is in the URL or the
// X-Vantage-Token header; see resolveHeartbeat for the shapes. Rate
// limiting is per token inside the handler.
for _, p := range []string{"/public/hb", "/public/hb/:a", "/public/hb/:a/:b"} {
r.GET(p, handleHeartbeat)
r.POST(p, handleHeartbeat)
}
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
// Scope enforcement sits between authentication and the licence gate, and
@@ -91,6 +117,20 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/tokens", createToken)
apiGroup.DELETE("/tokens/:id", revokeToken)
apiGroup.GET("/me/mfa", getMyMFA)
apiGroup.POST("/me/mfa/totp/setup", auth.RequireStepUp(), setupTOTP)
apiGroup.POST("/me/mfa/totp/confirm", confirmTOTP)
apiGroup.DELETE("/me/mfa/totp", auth.RequireStepUp(), removeTOTP)
apiGroup.POST("/me/mfa/recovery/regenerate", auth.RequireStepUp(), regenerateRecoveryCodes)
apiGroup.POST("/me/passkeys/begin", auth.RequireStepUp(), auth.HandleRegisterPasskeyBegin)
apiGroup.POST("/me/passkeys/finish", auth.RequireStepUp(), auth.HandleRegisterPasskeyFinish)
apiGroup.PATCH("/me/passkeys/:id", renamePasskey)
apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey)
apiGroup.POST("/me/step-up", RateLimitAuth(), stepUp)
apiGroup.POST("/me/step-up/webauthn/begin", RateLimitAuth(), auth.HandleStepUpWebAuthnBegin)
apiGroup.POST("/me/step-up/webauthn/finish", RateLimitAuth(), auth.HandleStepUpWebAuthnFinish)
apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA)
apiGroup.GET("/openapi.json", getOpenAPI)
apiGroup.GET("/docs", getAPIDocs)
apiGroup.GET("/docs/scalar.js", getScalarJS)
@@ -107,19 +147,19 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/secrets", createSecretGroup)
apiGroup.GET("/secrets/:group", getSecretGroup)
apiGroup.PUT("/secrets/:group", putSecretGroup)
apiGroup.POST("/secrets/:group/reveal", revealSecret)
apiGroup.POST("/secrets/:group/reveal", auth.RequireStepUp(), revealSecret)
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
apiGroup.GET("/keys/:id", getKey)
apiGroup.GET("/keys/:id/private-key", getPrivateKey)
apiGroup.GET("/keys/:id/private-key", auth.RequireStepUp(), getPrivateKey)
apiGroup.DELETE("/keys/:id", deleteKey)
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
apiGroup.POST("/console/connect", auth.RequireStepUp(), RequireFeature("console"), consoleConnect)
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
// MCP is mounted inside /api so that bearer auth, rate limiting, licence
@@ -187,6 +227,7 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
registerStatusPageRoutes(apiGroup)
registerPatchingRoutes(apiGroup)
}
}
@@ -748,30 +789,41 @@ func updateAgent(c *gin.Context) {
// applyUpdates godoc
//
// @Summary Apply pending OS updates on a server
// @Description Dispatches ApplyUpdatesCmd. Exempt from the licence gate: security patching is never paywalled.
// @Description Starts a manual patch run (all updates, no reboot) and returns its ID. Exempt from the licence gate: security patching is never paywalled.
// @Tags servers
// @Produce json
// @Param id path string true "Server ID"
// @Success 202 {object} MessageResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Param id path string true "Server ID"
// @Param source query string false "vulnerabilities when started from the vulnerabilities page"
// @Success 202 {object} ApplyUpdatesResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ApplyUpdatesErrorResponse "agent offline; the attempt is recorded as run_id"
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/apply-updates [post]
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
instanceID := auth.InstanceID(c)
s, err := services.GetServerScoped(instanceID, c.Param("id"), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DispatchApplyUpdates(s.ServerID); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
source := models.PatchSourceServer
if c.Query("source") == models.PatchSourceVulnerabilities {
source = models.PatchSourceVulnerabilities
}
run, err := services.StartManualRun(instanceID, s, actorFromCtx(c), source)
if errors.Is(err, services.ErrAgentOffline) {
// The attempt is still recorded, so it has a run ID to show.
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error(), "run_id": run.RunID})
return
}
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "updates.applied", actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("package update run %s started on %s", run.RunID, s.Hostname))
c.JSON(http.StatusAccepted, ApplyUpdatesResponse{Message: "apply updates command sent to agent", RunID: run.RunID})
}
// handleUpdateScript serves a dynamically generated shell script that
@@ -914,6 +966,7 @@ func saveSettings(c *gin.Context) {
Alerts models.AlertSettings `json:"alerts"`
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
LocalLoginEnabled *bool `json:"local_login_enabled"`
RequireMFA *bool `json:"require_mfa"`
APITokenMaxDays *int `json:"api_token_max_days"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -924,7 +977,13 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil {
// The MFA requirement gates every future sign-in, so only an owner may
// change it; an admin can still save the rest of this endpoint's settings.
if body.RequireMFA != nil && auth.Role(c) != models.RoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change the MFA requirement"})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.RequireMFA, body.APITokenMaxDays); err != nil {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
return
@@ -937,6 +996,10 @@ func saveSettings(c *gin.Context) {
services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "",
fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays))
}
if body.RequireMFA != nil {
services.LogEvent(auth.InstanceID(c), "settings.require_mfa", actorFromCtx(c), "", "",
fmt.Sprintf("enabled=%v", *body.RequireMFA))
}
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
+176
View File
@@ -0,0 +1,176 @@
package api
import (
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// HeartbeatTokenHeader carries the ping token for callers who want it out of
// URLs, and so out of their own proxies' access logs.
const HeartbeatTokenHeader = "X-Vantage-Token"
const heartbeatPathPrefix = "/public/hb/"
func heartbeatKind(seg string) string {
switch seg {
case "":
return services.HeartbeatPing
case "start":
return services.HeartbeatStart
case "fail":
return services.HeartbeatFail
}
return ""
}
// resolveHeartbeat maps the three route shapes onto a token and a kind. a and b
// are the first and second path segments after /public/hb. A token in the URL
// wins over the header, so a URL copied from the UI behaves the same no matter
// what headers a client adds.
func resolveHeartbeat(header, a, b string) (string, string, bool) {
var token, kindSeg string
switch {
// a is a URL token unless a header is present, no second segment follows
// and a is itself a kind word (the header routes /public/hb/start|fail).
case a != "" && (header == "" || b != "" || heartbeatKind(a) == ""):
token, kindSeg = a, b
case header != "":
token, kindSeg = header, a
default:
return "", "", false
}
kind := heartbeatKind(kindSeg)
if token == "" || kind == "" {
return "", "", false
}
return token, kind, true
}
// MaskLogPath hides a heartbeat token in a request path before it is logged.
// "start" and "fail" directly under the prefix are the header-token routes and
// carry no secret, so they are left readable.
func MaskLogPath(path string) string {
rest, found := strings.CutPrefix(path, heartbeatPathPrefix)
if !found {
return path
}
end := strings.IndexAny(rest, "/?")
if end < 0 {
end = len(rest)
}
seg := rest[:end]
if seg == "" || seg == "start" || seg == "fail" {
return path
}
return heartbeatPathPrefix + "***" + rest[end:]
}
// heartbeatLimitKey buckets requests by token, kind and wall-clock second. The
// kind is part of the key so a fast job's success ping is not rejected for
// landing in the same second as its /start, which would leave the run marked
// started and page as never finished. The token is hashed so Redis never holds
// the secret.
func heartbeatLimitKey(token, kind string, now time.Time) string {
return "vantage:hbrl:" + services.HashHeartbeatToken(token) + ":" + kind + ":" + strconv.FormatInt(now.Unix(), 10)
}
// readHeartbeatBody keeps up to MaxHeartbeatBody bytes of a /fail body as the
// incident message. Other kinds carry no message, so their bodies are ignored.
func readHeartbeatBody(kind string, r io.Reader) string {
if kind != services.HeartbeatFail || r == nil {
return ""
}
b, _ := io.ReadAll(io.LimitReader(r, services.MaxHeartbeatBody))
return string(b)
}
func writeHeartbeatLimited(c *gin.Context) {
c.Header("Retry-After", "1")
c.String(http.StatusTooManyRequests, "too many requests")
}
// heartbeatAllowed admits one request per token and kind per second. A cron job pinging
// in a loop should not become a write per request, and a leaked URL should not
// be a way to hammer Mongo. Like the status page limiter it allows when Redis
// is down: a missed ping pages someone. It runs inside the handler rather than
// as middleware because the token may come from a header.
func heartbeatAllowed(c *gin.Context, token, kind string) bool {
rdb := auth.Redis()
if rdb == nil {
return true
}
key := heartbeatLimitKey(token, kind, time.Now())
count, err := rdb.Incr(c.Request.Context(), key).Result()
if err != nil {
return true
}
if count == 1 {
rdb.Expire(c.Request.Context(), key, 2*time.Second)
}
return count <= 1
}
// handleHeartbeat records a push from a job. It is mounted on the gin root
// under /public for the same reasons as the status page (see
// getPublicStatusPage): no session, no token, no licence gate, and /public is
// already routed to this server by every deployment.
//
// Unknown token, disabled monitor and an unresolvable path all answer the same
// 404.
func handleHeartbeat(c *gin.Context) {
token, kind, ok := resolveHeartbeat(c.GetHeader(HeartbeatTokenHeader), c.Param("a"), c.Param("b"))
if !ok {
c.String(http.StatusNotFound, "not found")
return
}
if !heartbeatAllowed(c, token, kind) {
writeHeartbeatLimited(c)
return
}
err := services.RecordHeartbeat(token, kind, readHeartbeatBody(kind, c.Request.Body), time.Now())
if errors.Is(err, services.ErrHeartbeatNotFound) {
c.String(http.StatusNotFound, "not found")
return
}
if err != nil {
log.Printf("heartbeat: %v", err)
c.String(http.StatusInternalServerError, "error")
return
}
c.String(http.StatusOK, "OK")
}
// rotateHeartbeatToken godoc
//
// @Summary Rotate a heartbeat monitor's ping token
// @Description Issues a new token and invalidates the old ping URL immediately. The token is returned only in this response.
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {object} object{heartbeat_token=string}
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/rotate-token [post]
func rotateHeartbeatToken(c *gin.Context) {
tok, err := services.RotateHeartbeatToken(auth.InstanceID(c), c.Param("id"))
if errors.Is(err, services.ErrHeartbeatNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "heartbeat monitor not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"heartbeat_token": tok})
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestResolveHeartbeat(t *testing.T) {
cases := []struct {
name, header, a, b string
token, kind string
ok bool
}{
{"url ping", "", "tok", "", "tok", "ping", true},
{"url start", "", "tok", "start", "tok", "start", true},
{"url fail", "", "tok", "fail", "tok", "fail", true},
{"url bad kind", "", "tok", "explode", "", "", false},
{"header ping", "htok", "", "", "htok", "ping", true},
{"header start", "htok", "start", "", "htok", "start", true},
{"header fail", "htok", "fail", "", "htok", "fail", true},
{"url token wins over header", "htok", "tok", "", "tok", "ping", true},
{"url token and kind win over header", "htok", "tok", "fail", "tok", "fail", true},
{"nothing", "", "", "", "", "", false},
// Without a header, /public/hb/start is a token called "start": it
// resolves, and the lookup answers 404 like any unknown token.
{"bare start without header", "", "start", "", "start", "ping", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
tok, kind, ok := resolveHeartbeat(c.header, c.a, c.b)
if tok != c.token || kind != c.kind || ok != c.ok {
t.Fatalf("got (%q,%q,%v), want (%q,%q,%v)", tok, kind, ok, c.token, c.kind, c.ok)
}
})
}
}
// A ping URL is a credential. Anything that logs request paths must see a
// masked one.
func TestMaskLogPath(t *testing.T) {
cases := map[string]string{
"/public/hb/abc123": "/public/hb/***",
"/public/hb/abc123/fail": "/public/hb/***/fail",
"/public/hb/abc123?x=1": "/public/hb/***?x=1",
"/public/hb/start": "/public/hb/start",
"/public/hb/fail": "/public/hb/fail",
"/public/hb": "/public/hb",
"/public/status/page": "/public/status/page",
"/api/monitors/abc/uptime": "/api/monitors/abc/uptime",
}
for in, want := range cases {
if got := MaskLogPath(in); got != want {
t.Errorf("MaskLogPath(%q) = %q, want %q", in, got, want)
}
}
}
func TestHeartbeatUnresolvableIs404(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/public/hb/:a/:b", handleHeartbeat)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/public/hb/abc/explode", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
}
func TestHeartbeatLimitKey(t *testing.T) {
now := time.Unix(1700000000, 0)
start := heartbeatLimitKey("tok", services.HeartbeatStart, now)
ping := heartbeatLimitKey("tok", services.HeartbeatPing, now)
if start == ping {
t.Fatalf("start and ping share a limiter key: %s", start)
}
if ping != heartbeatLimitKey("tok", services.HeartbeatPing, now.Add(500*time.Millisecond)) {
t.Fatal("same kind in the same second should share a key")
}
if strings.Contains(ping, "tok") {
t.Fatalf("key leaks the plaintext token: %s", ping)
}
}
func TestReadHeartbeatBody(t *testing.T) {
long := strings.Repeat("x", services.MaxHeartbeatBody+500)
if got := readHeartbeatBody(services.HeartbeatFail, strings.NewReader(long)); len(got) != services.MaxHeartbeatBody {
t.Fatalf("fail body not capped: %d", len(got))
}
if got := readHeartbeatBody(services.HeartbeatPing, strings.NewReader("hi")); got != "" {
t.Fatalf("ping body should be ignored, got %q", got)
}
if got := readHeartbeatBody(services.HeartbeatFail, nil); got != "" {
t.Fatalf("nil body: %q", got)
}
}
func TestHeartbeatRateLimitedWritesRetryAfter(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
writeHeartbeatLimited(c)
if w.Code != http.StatusTooManyRequests || w.Header().Get("Retry-After") != "1" {
t.Fatalf("got %d retry-after %q", w.Code, w.Header().Get("Retry-After"))
}
}
+20 -1
View File
@@ -20,13 +20,32 @@ import (
// @Security cookieAuth
// @Security bearerAuth
// @Router /instance/users [get]
// instanceUserResponse wraps a member with whether they hold an MFA factor,
// for the settings page's member column and reset action. A wrapper rather
// than a field on models.User because User is shared with Vantage HQ.
type instanceUserResponse struct {
models.User `bson:",inline"`
MFAEnabled bool `json:"mfa_enabled"`
}
func listInstanceUsers(c *gin.Context) {
users, err := services.ListUsers(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
// One aggregate over two small collections beats N round trips for a
// member list that renders on every settings page load.
enabled, err := services.UsersWithMFA(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]instanceUserResponse, 0, len(users))
for _, u := range users {
out = append(out, instanceUserResponse{User: u, MFAEnabled: enabled[u.UserID]})
}
c.JSON(http.StatusOK, out)
}
func actorMayGrantOwner(c *gin.Context) bool {
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// oidcUser refuses MFA management for a user whose IdP owns authentication.
func oidcUser(c *gin.Context) bool {
u, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
return err == nil && u.AuthSource == models.AuthOIDC
}
// getMyMFA reports this user's factors.
//
// @Summary Get my MFA status
// @Tags mfa
// @Produce json
// @Success 200 {object} object{totp_enabled=bool,passkeys=[]models.WebAuthnCredential,recovery_remaining=int,require_mfa=bool,applicable=bool}
// @Router /me/mfa [get]
func getMyMFA(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
m, err := services.GetUserMFA(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
passkeys, err := services.ListPasskeys(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"totp_enabled": m != nil && m.TOTPConfirmedAt != nil,
"passkeys": passkeys,
"recovery_remaining": services.RecoveryCodesRemaining(m),
"require_mfa": services.RequireMFAForInstance(instanceID),
"applicable": !oidcUser(c),
})
}
// setupTOTP issues a new unconfirmed secret.
//
// @Summary Start TOTP setup
// @Tags mfa
// @Produce json
// @Success 200 {object} object{secret=string,otpauth_uri=string}
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/mfa/totp/setup [post]
func setupTOTP(c *gin.Context) {
if oidcUser(c) {
c.JSON(http.StatusConflict, gin.H{"error": "your identity provider manages sign-in", "code": "mfa_not_applicable"})
return
}
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
issuer := "Vantage"
if inst, err := services.GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
issuer = inst.Name
}
secret, uri, err := services.StartTOTPSetup(instanceID, userID, issuer, auth.GetSessionFromContext(c).Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
}
// confirmTOTP activates the pending secret and issues recovery codes if this
// is the user's first factor.
//
// @Summary Confirm TOTP setup
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/mfa/totp/confirm [post]
func confirmTOTP(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
if err := services.ConfirmTOTP(instanceID, userID, body.Code); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that code is not valid", "code": "invalid_code"})
return
}
services.LogEvent(instanceID, "mfa.enrolled", actorFromCtx(c), "", "", "factor=totp")
m, _ := services.GetUserMFA(instanceID, userID)
if services.RecoveryCodesRemaining(m) > 0 {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
codes, err := services.IssueRecoveryCodes(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
}
// removeTOTP drops the TOTP factor. Step-up guarded at the route.
//
// @Summary Remove TOTP
// @Tags mfa
// @Produce json
// @Success 204
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/mfa/totp [delete]
func removeTOTP(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorTOTP); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
return
}
if err := services.RemoveTOTP(instanceID, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=totp")
c.Status(http.StatusNoContent)
}
// regenerateRecoveryCodes invalidates the old set. Step-up guarded.
//
// @Summary Regenerate recovery codes
// @Tags mfa
// @Produce json
// @Success 200 {object} object{recovery_codes=[]string}
// @Router /me/mfa/recovery/regenerate [post]
func regenerateRecoveryCodes(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
codes, err := services.IssueRecoveryCodes(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.recovery_regenerated", actorFromCtx(c), "", "", "")
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
}
// renamePasskey and deletePasskey work on the hex credential ID.
//
// @Summary Rename a passkey
// @Tags mfa
// @Accept json
// @Produce json
// @Param id path string true "Credential ID"
// @Param body body object{name=string} true "New name"
// @Success 204
// @Router /me/passkeys/{id} [patch]
func renamePasskey(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
err := services.RenamePasskey(auth.InstanceID(c), auth.UserID(c), c.Param("id"), body.Name)
if errors.Is(err, services.ErrNoPasskey) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// @Summary Delete a passkey
// @Tags mfa
// @Produce json
// @Param id path string true "Credential ID"
// @Success 204
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/passkeys/{id} [delete]
func deletePasskey(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorWebAuthn); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
return
}
err := services.DeletePasskey(instanceID, userID, c.Param("id"))
if errors.Is(err, services.ErrNoPasskey) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=webauthn")
c.Status(http.StatusNoContent)
}
// resetUserMFA lets an owner or admin clear somebody else's factors.
//
// @Summary Reset another member's MFA
// @Tags mfa
// @Produce json
// @Param id path string true "User ID"
// @Success 204
// @Failure 403 {object} object{error=string}
// @Router /org/users/{id}/mfa [delete]
func resetUserMFA(c *gin.Context) {
instanceID := auth.InstanceID(c)
target, err := services.GetUserInInstance(instanceID, c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such member"})
return
}
// An admin resetting an owner's MFA would be a promotion path: clear the
// factor, phish the password, hold the instance.
if auth.Role(c) != models.RoleOwner && target.Role == models.RoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can reset an owner's MFA"})
return
}
if err := services.ClearMFA(instanceID, target.UserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.reset", actorFromCtx(c), "", "", "target="+target.Email)
c.Status(http.StatusNoContent)
}
// stepUp re-authenticates the current session.
//
// @Summary Re-authenticate before a sensitive action
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{totp=string,recovery=string,password=string} true "One factor"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/step-up [post]
func stepUp(c *gin.Context) {
sess := auth.GetSessionFromContext(c)
var body struct {
TOTP string `json:"totp"`
Recovery string `json:"recovery"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
return
}
var err error
switch {
case body.TOTP != "":
err = services.VerifyTOTPCode(sess.InstanceID, sess.UserID, body.TOTP)
case body.Recovery != "":
err = services.UseRecoveryCode(sess.InstanceID, sess.UserID, body.Recovery)
case body.Password != "":
// Password is offered only to a user with no MFA at all; accepting it
// from an enrolled user would demote step-up to what they already did.
has, herr := services.HasMFA(sess.InstanceID, sess.UserID)
if herr != nil || has {
err = services.ErrBadCode
} else {
u, uerr := services.GetUserInInstance(sess.InstanceID, sess.UserID)
if uerr != nil || !services.VerifyPassword(u, body.Password) {
err = services.ErrBadCode
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
return
}
if err != nil {
services.LogEvent(sess.InstanceID, "step_up.failed", actorFromCtx(c), "", "", "")
c.JSON(http.StatusUnauthorized, gin.H{"error": "that did not verify", "code": "invalid_code"})
return
}
if err := auth.TouchStepUpFromRequest(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
return
}
services.LogEvent(sess.InstanceID, "step_up.ok", actorFromCtx(c), "", "", "")
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+90 -2
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"net/http"
"strconv"
"time"
@@ -21,6 +22,8 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
g.GET("/monitors/:id/incidents", getMonitorIncidents)
g.GET("/monitors/:id/uptime", getMonitorUptime)
g.GET("/monitors/:id/samples", getMonitorSamples)
g.GET("/monitors/:id/servers", getMonitorServers)
g.POST("/monitors/:id/rotate-token", rotateHeartbeatToken)
}
// listMonitors godoc
@@ -85,6 +88,14 @@ func createMonitor(c *gin.Context) {
}
created, err := services.CreateMonitor(auth.InstanceID(c), &m, auth.ServerScope(c))
if err != nil {
if errors.Is(err, services.ErrInvalidMonitor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, services.ErrMonitorOutOfScope) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -131,9 +142,11 @@ func getMonitor(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "Monitor ID"
// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string,for_sec=int} true "Fields to update"
// @Success 204
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
@@ -149,6 +162,7 @@ func updateMonitor(c *gin.Context) {
Retries *int `json:"retries"`
Enabled *bool `json:"enabled"`
ChannelIDs *[]string `json:"channel_ids"`
ForSec *int `json:"for_sec"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -182,11 +196,26 @@ func updateMonitor(c *gin.Context) {
if body.ChannelIDs != nil {
upd["channel_ids"] = *body.ChannelIDs
}
if body.ForSec != nil {
upd["for_sec"] = *body.ForSec
}
if len(upd) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd, auth.ServerScope(c)); err != nil {
if errors.Is(err, services.ErrInvalidMonitor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, services.ErrMonitorOutOfScope) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if errors.Is(err, services.ErrMonitorNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -199,12 +228,17 @@ func updateMonitor(c *gin.Context) {
// @Tags monitors
// @Param id path string true "Monitor ID"
// @Success 204
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id} [delete]
func deleteMonitor(c *gin.Context) {
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id"), auth.ServerScope(c)); err != nil {
if errors.Is(err, services.ErrMonitorOutOfScope) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -238,9 +272,63 @@ func getMonitorIncidents(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
incidents = services.FilterByVisibleServer(incidents, func(i models.Incident) string { return i.ServerID }, visible, restricted)
c.JSON(http.StatusOK, incidents)
}
// getMonitorServers godoc
//
// @Summary List a metric monitor's per-server states
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {array} models.MonitorServerState
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/servers [get]
func getMonitorServers(c *gin.Context) {
instanceID := auth.InstanceID(c)
m, err := services.GetMonitor(instanceID, c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil || m.Type != models.MonitorMetric {
c.JSON(http.StatusNotFound, gin.H{"error": "metric monitor not found"})
return
}
states, err := services.ListMonitorServerStates(instanceID, m.MonitorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visible, restricted, err := services.VisibleServerIDs(instanceID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
states = services.FilterByVisibleServer(states, func(s models.MonitorServerState) string { return s.ServerID }, visible, restricted)
servers, err := services.ListServersFiltered(instanceID, m.Target.Selector)
if err == nil {
names := map[string]string{}
for _, s := range servers {
names[s.ServerID] = s.Hostname
}
for i := range states {
states[i].Hostname = names[states[i].ServerID]
}
}
c.JSON(http.StatusOK, states)
}
// getMonitorSamples godoc
//
// @Summary Get a monitor's individual check results
+455
View File
@@ -0,0 +1,455 @@
package api
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// registerPatchingRoutes mounts maintenance windows, patch policies and patch
// runs. Free on every tier: security patching is never paywalled, so no
// RequireFeature here. Writes that decide what reboots and when are owner or
// admin; watching and cancelling a run is open to every role.
func registerPatchingRoutes(g *gin.RouterGroup) {
admin := auth.RequireRole("owner", "admin")
g.GET("/maintenance-windows", listWindows)
g.POST("/maintenance-windows", admin, createWindow)
g.POST("/maintenance-windows/preview", previewWindow)
g.GET("/maintenance-windows/:id", getWindow)
g.PUT("/maintenance-windows/:id", admin, updateWindow)
g.DELETE("/maintenance-windows/:id", admin, deleteWindow)
g.GET("/patch-policies", listPolicies)
g.POST("/patch-policies", admin, createPolicy)
g.GET("/patch-policies/:id", getPolicy)
g.PUT("/patch-policies/:id", admin, updatePolicy)
g.DELETE("/patch-policies/:id", admin, deletePolicy)
g.POST("/patch-policies/:id/run-now", admin, runPolicyNow)
g.GET("/patch-runs", listPatchRuns)
g.GET("/patch-runs/:runId", getPatchRun)
g.POST("/patch-runs/:runId/cancel", cancelPatchRun)
}
// patchError maps every patching service error once.
func patchError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrWindowNotFound), errors.Is(err, services.ErrPolicyNotFound), errors.Is(err, services.ErrPatchRunNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWindowInUse), errors.Is(err, services.ErrPatchRunActive), errors.Is(err, services.ErrPatchRunFinished):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWindowInvalid), errors.Is(err, services.ErrPolicyInvalid),
errors.Is(err, services.ErrNoTargets), errors.Is(err, services.ErrInvalidTag),
errors.Is(err, services.ErrWorkflowTargetOutOfScope):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
// listWindows godoc
//
// @Summary List maintenance windows
// @Tags patching
// @Produce json
// @Success 200 {array} models.MaintenanceWindow
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows [get]
func listWindows(c *gin.Context) {
ws, err := services.ListWindows(auth.InstanceID(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, ws)
}
// createWindow godoc
//
// @Summary Create a maintenance window
// @Tags patching
// @Accept json
// @Produce json
// @Param body body models.MaintenanceWindow true "Window"
// @Success 201 {object} models.MaintenanceWindow
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows [post]
func createWindow(c *gin.Context) {
var body models.MaintenanceWindow
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := services.CreateWindow(auth.InstanceID(c), body)
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_created", actorFromCtx(c), "", "",
fmt.Sprintf("maintenance window %s: %s %s for %d minutes", w.Name, w.Cron, w.TZ, w.DurationMinutes))
c.JSON(http.StatusCreated, w)
}
// previewWindow godoc
//
// @Summary Preview the next three maintenance windows
// @Description Computed by the scheduler's own code, so the editor and the scheduler agree.
// @Tags patching
// @Accept json
// @Produce json
// @Param body body WindowPreviewRequest true "Schedule"
// @Success 200 {array} services.WindowSpan
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/preview [post]
func previewWindow(c *gin.Context) {
var body WindowPreviewRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
spans, err := services.PreviewWindow(body.Cron, body.TZ, body.DurationMinutes, time.Now(), 3)
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, spans)
}
// getWindow godoc
//
// @Summary Get a maintenance window
// @Tags patching
// @Produce json
// @Param id path string true "Window ID"
// @Success 200 {object} models.MaintenanceWindow
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [get]
func getWindow(c *gin.Context) {
w, err := services.GetWindow(auth.InstanceID(c), c.Param("id"))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, w)
}
// updateWindow godoc
//
// @Summary Update a maintenance window
// @Description Moves the next run of every enabled policy using it.
// @Tags patching
// @Accept json
// @Produce json
// @Param id path string true "Window ID"
// @Param body body models.MaintenanceWindow true "Window"
// @Success 200 {object} models.MaintenanceWindow
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [put]
func updateWindow(c *gin.Context) {
var body models.MaintenanceWindow
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.CheckWindowScope(auth.InstanceID(c), c.Param("id"), auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
w, err := services.UpdateWindow(auth.InstanceID(c), c.Param("id"), body)
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_updated", actorFromCtx(c), "", "",
fmt.Sprintf("maintenance window %s: %s %s for %d minutes", w.Name, w.Cron, w.TZ, w.DurationMinutes))
c.JSON(http.StatusOK, w)
}
// deleteWindow godoc
//
// @Summary Delete a maintenance window
// @Tags patching
// @Param id path string true "Window ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse "window_in_use"
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [delete]
func deleteWindow(c *gin.Context) {
if err := services.CheckWindowScope(auth.InstanceID(c), c.Param("id"), auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if err := services.DeleteWindow(auth.InstanceID(c), c.Param("id")); err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_deleted", actorFromCtx(c), "", "", "maintenance window "+c.Param("id")+" deleted")
c.Status(http.StatusNoContent)
}
// listPolicies godoc
//
// @Summary List patch policies
// @Tags patching
// @Produce json
// @Success 200 {array} models.PatchPolicy
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies [get]
func listPolicies(c *gin.Context) {
ps, err := services.ListPolicies(auth.InstanceID(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, ps)
}
// createPolicy godoc
//
// @Summary Create a patch policy
// @Tags patching
// @Accept json
// @Produce json
// @Param body body models.PatchPolicy true "Policy"
// @Success 201 {object} models.PatchPolicy
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies [post]
func createPolicy(c *gin.Context) {
var body models.PatchPolicy
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p, err := services.CreatePolicy(auth.InstanceID(c), body, auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.policy_created", actorFromCtx(c), "", "",
fmt.Sprintf("patch policy %s: scope %s, reboot %s, enabled %v", p.Name, p.Scope, p.Reboot, p.Enabled))
c.JSON(http.StatusCreated, p)
}
// getPolicy godoc
//
// @Summary Get a patch policy
// @Tags patching
// @Produce json
// @Param id path string true "Policy ID"
// @Success 200 {object} models.PatchPolicy
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [get]
func getPolicy(c *gin.Context) {
p, err := services.GetPolicy(auth.InstanceID(c), c.Param("id"))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, p)
}
// updatePolicy godoc
//
// @Summary Update a patch policy
// @Tags patching
// @Accept json
// @Produce json
// @Param id path string true "Policy ID"
// @Param body body models.PatchPolicy true "Policy"
// @Success 200 {object} models.PatchPolicy
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [put]
func updatePolicy(c *gin.Context) {
var body models.PatchPolicy
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p, err := services.UpdatePolicy(auth.InstanceID(c), c.Param("id"), body, auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.policy_updated", actorFromCtx(c), "", "",
fmt.Sprintf("patch policy %s: scope %s, reboot %s, enabled %v", p.Name, p.Scope, p.Reboot, p.Enabled))
c.JSON(http.StatusOK, p)
}
// deletePolicy godoc
//
// @Summary Delete a patch policy
// @Tags patching
// @Param id path string true "Policy ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [delete]
func deletePolicy(c *gin.Context) {
instanceID := auth.InstanceID(c)
p, err := services.GetPolicy(instanceID, c.Param("id"))
if err != nil {
patchError(c, err)
return
}
if err := services.CheckPolicyScope(instanceID, *p, auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if err := services.DeletePolicy(instanceID, p.PolicyID); err != nil {
patchError(c, err)
return
}
services.LogEvent(instanceID, "patch.policy_deleted", actorFromCtx(c), "", "", "patch policy "+p.Name+" deleted")
c.Status(http.StatusNoContent)
}
// runPolicyNow godoc
//
// @Summary Run a patch policy now
// @Description Opens a window of the policy's usual length starting now.
// @Tags patching
// @Produce json
// @Param id path string true "Policy ID"
// @Success 202 {object} models.PatchRun
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id}/run-now [post]
func runPolicyNow(c *gin.Context) {
run, err := services.StartRunNow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c), auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusAccepted, run)
}
// listPatchRuns godoc
//
// @Summary List patch runs
// @Tags patching
// @Produce json
// @Param policy_id query string false "Filter by policy"
// @Param server_id query string false "Filter by server"
// @Param limit query int false "At most 200, default 50"
// @Success 200 {array} models.PatchRun
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs [get]
func listPatchRuns(c *gin.Context) {
instanceID := auth.InstanceID(c)
limit, _ := strconv.ParseInt(c.Query("limit"), 10, 64)
runs, err := services.ListPatchRuns(instanceID, c.Query("policy_id"), c.Query("server_id"), limit)
if err != nil {
patchError(c, err)
return
}
out := runs[:0]
for i := range runs {
before := len(runs[i].Servers)
if err := services.ScopePatchRun(instanceID, &runs[i], auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if before == 0 || len(runs[i].Servers) > 0 {
out = append(out, runs[i])
}
}
c.JSON(http.StatusOK, out)
}
// getPatchRun godoc
//
// @Summary Get a patch run, with per-server output
// @Tags patching
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.PatchRun
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs/{runId} [get]
func getPatchRun(c *gin.Context) {
run, ok := scopedRun(c)
if ok {
c.JSON(http.StatusOK, run)
}
}
// cancelPatchRun godoc
//
// @Summary Cancel a patch run
// @Description Stops further dispatch. Servers already patching finish.
// @Tags patching
// @Param runId path string true "Run ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs/{runId}/cancel [post]
func cancelPatchRun(c *gin.Context) {
run, ok := scopedRun(c)
if !ok {
return
}
if err := services.CancelPatchRun(auth.InstanceID(c), run.RunID); err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.cancelled", actorFromCtx(c), "", "", "patch run "+run.RunID+" cancelled")
c.Status(http.StatusNoContent)
}
// scopedRun loads a run for a caller. A tag-restricted token that cannot see
// every server in the run gets a 404, the same answer as a run that does not
// exist, rather than a partial record it could act on.
func scopedRun(c *gin.Context) (*models.PatchRun, bool) {
instanceID := auth.InstanceID(c)
run, err := services.GetPatchRun(instanceID, c.Param("runId"))
if err != nil {
patchError(c, err)
return nil, false
}
before := len(run.Servers)
if err := services.ScopePatchRun(instanceID, run, auth.ServerScope(c)); err != nil {
patchError(c, err)
return nil, false
}
if len(run.Servers) != before {
patchError(c, services.ErrPatchRunNotFound)
return nil, false
}
return run, true
}
@@ -0,0 +1,34 @@
package api
import "testing"
// Every patching route must carry a scope and a server-scope declaration, or
// boot fails. Asserting the exact scope here keeps a copy-paste of
// "patching:read" onto a write route from slipping through.
func TestPatchingRouteScopes(t *testing.T) {
want := map[string]string{
"GET /api/maintenance-windows": "patching:read",
"POST /api/maintenance-windows": "patching:write",
"POST /api/maintenance-windows/preview": "patching:read",
"GET /api/maintenance-windows/:id": "patching:read",
"PUT /api/maintenance-windows/:id": "patching:write",
"DELETE /api/maintenance-windows/:id": "patching:write",
"GET /api/patch-policies": "patching:read",
"POST /api/patch-policies": "patching:write",
"GET /api/patch-policies/:id": "patching:read",
"PUT /api/patch-policies/:id": "patching:write",
"DELETE /api/patch-policies/:id": "patching:write",
"POST /api/patch-policies/:id/run-now": "patching:write",
"GET /api/patch-runs": "patching:read",
"GET /api/patch-runs/:runId": "patching:read",
"POST /api/patch-runs/:runId/cancel": "patching:write",
}
for route, scope := range want {
if got := routeScopes[route]; got != scope {
t.Errorf("%s: scope %q, want %q", route, got, scope)
}
if _, ok := serverScopedRoutes[route]; !ok {
t.Errorf("%s: missing from serverScopedRoutes", route)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"fmt"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// authRateLimit is per client address per minute. It bounds how many tickets an
// attacker can start; the ticket's own five-attempt cap bounds guesses inside
// one. Neither alone is enough.
const authRateLimit = 20
// RateLimitAuth guards every unauthenticated sign-in endpoint. Without it, the
// per-ticket cap is trivially sidestepped by starting a new sign-in each time.
func RateLimitAuth() gin.HandlerFunc {
return func(c *gin.Context) {
rdb := services.RedisClient
if rdb == nil {
c.Next()
return
}
window := time.Now().Unix() / 60
key := fmt.Sprintf("km:rl:auth:%s:%d", c.ClientIP(), window)
ctx := c.Request.Context()
n, err := rdb.Incr(ctx, key).Result()
if err != nil {
// A limiter that cannot reach Redis must not lock out sign-in: fail
// open rather than turn a Redis blip into a second outage.
c.Next()
return
}
if n == 1 {
rdb.Expire(ctx, key, time.Minute)
}
if n > authRateLimit {
c.Header("Retry-After", "60")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "too many sign-in attempts; try again in a minute",
})
return
}
c.Next()
}
}
+55 -8
View File
@@ -99,14 +99,16 @@ var routeScopes = map[string]string{
"GET /api/runs/:runId/servers/:serverId/logs/stream": "workflows:read",
// Monitor and incident routes, registered by registerMonitorRoutes.
"GET /api/monitors": "monitors:read",
"POST /api/monitors": "monitors:write",
"GET /api/monitors/:id": "monitors:read",
"PUT /api/monitors/:id": "monitors:write",
"DELETE /api/monitors/:id": "monitors:write",
"GET /api/monitors/:id/incidents": "monitors:read",
"GET /api/monitors/:id/uptime": "monitors:read",
"GET /api/monitors/:id/samples": "monitors:read",
"GET /api/monitors": "monitors:read",
"POST /api/monitors": "monitors:write",
"GET /api/monitors/:id": "monitors:read",
"PUT /api/monitors/:id": "monitors:write",
"DELETE /api/monitors/:id": "monitors:write",
"GET /api/monitors/:id/incidents": "monitors:read",
"GET /api/monitors/:id/uptime": "monitors:read",
"GET /api/monitors/:id/samples": "monitors:read",
"GET /api/monitors/:id/servers": "monitors:read",
"POST /api/monitors/:id/rotate-token": "monitors:write",
// Channel routes, registered by registerChannelRoutes. Channels exist to
// serve alerts, so they share the monitors scope rather than getting their
@@ -175,6 +177,23 @@ var routeScopes = map[string]string{
"PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write",
// Scheduled patching: maintenance windows, patch policies and patch runs.
"GET /api/maintenance-windows": "patching:read",
"POST /api/maintenance-windows": "patching:write",
"POST /api/maintenance-windows/preview": "patching:read",
"GET /api/maintenance-windows/:id": "patching:read",
"PUT /api/maintenance-windows/:id": "patching:write",
"DELETE /api/maintenance-windows/:id": "patching:write",
"GET /api/patch-policies": "patching:read",
"POST /api/patch-policies": "patching:write",
"GET /api/patch-policies/:id": "patching:read",
"PUT /api/patch-policies/:id": "patching:write",
"DELETE /api/patch-policies/:id": "patching:write",
"POST /api/patch-policies/:id/run-now": "patching:write",
"GET /api/patch-runs": "patching:read",
"GET /api/patch-runs/:runId": "patching:read",
"POST /api/patch-runs/:runId/cancel": "patching:write",
}
// RequireScopes enforces routeScopes for token-authenticated requests and does
@@ -221,6 +240,30 @@ var routesOutsideAPIGroup = map[string]bool{
"GET /api/secrets/:group/values": true,
}
// sessionOnlyRoutes are /api routes that an API token must never reach, so they
// deliberately have no entry in routeScopes. RequireScopes already refuses any
// unmapped route to a token; this set is what tells AssertScopeMapComplete the
// absence is a decision rather than an omission.
//
// Every entry manages the caller's own second factors or proves the caller is
// present. A token that could enrol a passkey, regenerate recovery codes or
// satisfy step-up would turn one leaked token into a way around MFA itself.
var sessionOnlyRoutes = map[string]bool{
"GET /api/me/mfa": true,
"POST /api/me/mfa/totp/setup": true,
"POST /api/me/mfa/totp/confirm": true,
"DELETE /api/me/mfa/totp": true,
"POST /api/me/mfa/recovery/regenerate": true,
"POST /api/me/passkeys/begin": true,
"POST /api/me/passkeys/finish": true,
"PATCH /api/me/passkeys/:id": true,
"DELETE /api/me/passkeys/:id": true,
"POST /api/me/step-up": true,
"POST /api/me/step-up/webauthn/begin": true,
"POST /api/me/step-up/webauthn/finish": true,
"DELETE /api/org/users/:id/mfa": true,
}
// AssertScopeMapComplete fails boot when a registered /api route has no scope.
//
// Without it, adding a route silently makes it unreachable by every token, and
@@ -236,6 +279,10 @@ func AssertScopeMapComplete(r *gin.Engine) error {
if routesOutsideAPIGroup[route.Method+" "+route.Path] {
continue
}
// Session-only routes are refused to every token by having no scope.
if sessionOnlyRoutes[route.Method+" "+route.Path] {
continue
}
if _, ok := routeScopes[route.Method+" "+route.Path]; !ok {
missing = append(missing, route.Method+" "+route.Path)
}
+62 -7
View File
@@ -292,13 +292,25 @@ var serverScopedRoutes = map[string]scopeDecl{
// then act on.
"DELETE /api/monitors/:id": fleetWide,
// A monitor's incidents, uptime rollups and recent samples are all about
// the monitored endpoint - status, latency, timestamps - and carry no
// server identifier at all; the runner is a field of the monitor
// document, which these do not return.
"GET /api/monitors/:id/incidents": exempt,
"GET /api/monitors/:id/uptime": exempt,
"GET /api/monitors/:id/samples": exempt,
// Rotating a ping token touches no server and returns only the token.
"POST /api/monitors/:id/rotate-token": fleetWide,
// Uptime rollups and recent samples are about the monitored endpoint -
// status, latency, timestamps - and carry no server identifier at all;
// the runner is a field of the monitor document, which these do not
// return.
"GET /api/monitors/:id/uptime": exempt,
"GET /api/monitors/:id/samples": exempt,
// A metric monitor's incidents carry server_id, so a restricted token
// must not see an incident for a server outside its scope. Handled by
// services.FilterByVisibleServer rather than by the route classification
// itself.
"GET /api/monitors/:id/incidents": scoped,
// Per-server metric state names servers, so it is filtered to the ones the
// credential can see.
"GET /api/monitors/:id/servers": scoped,
// ---- notification channels ----
@@ -398,6 +410,49 @@ var serverScopedRoutes = map[string]scopeDecl{
// Each tool touching server data applies the caller's selector itself.
"POST /api/mcp": exempt,
"GET /api/mcp": exempt,
// Maintenance windows are a cron expression, a zone and a duration. They
// name no server and return no server data, so reading and creating one
// is exempt. Changing or deleting one moves or stops the patching of
// every policy using it, so each is refused when any of those policies
// targets servers outside the token's tag restriction.
"GET /api/maintenance-windows": exempt,
"POST /api/maintenance-windows": exempt,
"POST /api/maintenance-windows/preview": exempt,
"GET /api/maintenance-windows/:id": exempt,
"PUT /api/maintenance-windows/:id": scoped,
"DELETE /api/maintenance-windows/:id": scoped,
// Reading a policy returns its selector (server IDs and tag pairs) and no
// hostname, inventory or state, the same data a workflow's targets carry.
"GET /api/patch-policies": exempt,
"GET /api/patch-policies/:id": exempt,
// Writes and run-now act on the policy's targets, so each is refused when
// those targets reach outside the token's tag restriction.
"POST /api/patch-policies": scoped,
"PUT /api/patch-policies/:id": scoped,
"DELETE /api/patch-policies/:id": scoped,
"POST /api/patch-policies/:id/run-now": scoped,
// Runs name hostnames; ScopePatchRun removes servers the token cannot see.
"GET /api/patch-runs": scoped,
"GET /api/patch-runs/:runId": scoped,
"POST /api/patch-runs/:runId/cancel": scoped,
// Multi-factor authentication. These act on the caller's own factors (or,
// for the reset, on another member's), never on a server, and no API token
// can reach them at all - see sessionOnlyRoutes.
"GET /api/me/mfa": exempt,
"POST /api/me/mfa/totp/setup": exempt,
"POST /api/me/mfa/totp/confirm": exempt,
"DELETE /api/me/mfa/totp": exempt,
"POST /api/me/mfa/recovery/regenerate": exempt,
"POST /api/me/passkeys/begin": exempt,
"POST /api/me/passkeys/finish": exempt,
"PATCH /api/me/passkeys/:id": exempt,
"DELETE /api/me/passkeys/:id": exempt,
"POST /api/me/step-up": exempt,
"POST /api/me/step-up/webauthn/begin": exempt,
"POST /api/me/step-up/webauthn/finish": exempt,
"DELETE /api/org/users/:id/mfa": exempt,
}
// AssertServerScopeMapComplete refuses to boot when any registered /api route
+1 -1
View File
@@ -16,7 +16,7 @@ func TestServerScopeMapCoversEveryScopedRoute(t *testing.T) {
if _, ok := routeScopes[r]; ok {
continue
}
if routesOutsideAPIGroup[r] {
if routesOutsideAPIGroup[r] || sessionOnlyRoutes[r] {
continue
}
t.Errorf("route %q is declared in serverScopedRoutes but is not a registered route", r)
+23
View File
@@ -297,3 +297,26 @@ type StatusIncidentUpdateRequest struct {
Status string `json:"status" binding:"required"`
Body string `json:"body" binding:"required"`
}
// --- patching ---
// ApplyUpdatesResponse keeps the message existing scripts read and adds the
// run that records what happened.
type ApplyUpdatesResponse struct {
Message string `json:"message"`
RunID string `json:"run_id,omitempty"`
}
// ApplyUpdatesErrorResponse is the 503 body of apply-updates: the agent is
// offline, and the attempt is still recorded as a run.
type ApplyUpdatesErrorResponse struct {
Error string `json:"error"`
RunID string `json:"run_id,omitempty"`
}
// WindowPreviewRequest is the body of POST /maintenance-windows/preview.
type WindowPreviewRequest struct {
Cron string `json:"cron"`
TZ string `json:"tz"`
DurationMinutes int `json:"duration_minutes"`
}
+34 -10
View File
@@ -68,17 +68,40 @@ func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
return InstanceForHost(c.Request.Host)
}
// instanceBySlug is a variable so tests can stub the Mongo read.
var instanceBySlug = services.GetInstanceBySlugIncludingLocked
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
//
// An instance Vantage HQ has locked under a dispute resolves to nothing, so
// every caller refuses it without knowing locks exist. HostLocked is the one
// place that may tell the two apart, for the login page's message.
func InstanceForHost(host string) (*models.Instance, bool) {
slug := hostSlug(host)
if slug == "" {
inst := slugInstance(hostSlug(host))
if inst == nil || inst.LockedAt != nil {
return nil, false
}
if inst, hit := cachedInstanceFor(slug); hit {
return inst, inst != nil
}
return inst, true
}
inst, err := services.GetInstanceBySlug(slug)
// HostLocked reports whether the host names an instance Vantage HQ has locked.
// It shares InstanceForHost's cache, so a lock and an unlock are seen within
// the same 60 seconds by both.
func HostLocked(host string) bool {
inst := slugInstance(hostSlug(host))
return inst != nil && inst.LockedAt != nil
}
// slugInstance reads the instance a slug names, locked or not, through the
// cache.
func slugInstance(slug string) *models.Instance {
if slug == "" {
return nil
}
if inst, hit := cachedInstanceFor(slug); hit {
return inst
}
inst, err := instanceBySlug(slug)
if err != nil || inst == nil {
// Negative entries are cached too. Without them an unknown but
// well-formed host costs a Mongo query per anonymous request, which
@@ -86,10 +109,10 @@ func InstanceForHost(host string) (*models.Instance, bool) {
// round trip is itself a timing oracle separating "no such instance"
// from "instance exists, page does not".
storeInstance(slug, nil)
return nil, false
return nil
}
storeInstance(slug, inst)
return inst, true
return inst
}
// SoleInstance resolves the one instance of a deployment that has exactly one.
@@ -99,7 +122,7 @@ func InstanceForHost(host string) (*models.Instance, bool) {
// more than one instance exists.
func SoleInstance() (*models.Instance, bool) {
if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit {
return inst, inst != nil
return inst, inst != nil && inst.LockedAt == nil
}
n, err := services.CountInstances()
if err != nil || n != 1 {
@@ -112,7 +135,8 @@ func SoleInstance() (*models.Instance, bool) {
return nil, false
}
storeInstance(soleInstanceCacheKey, inst)
return inst, true
// A locked sole instance is refused like any other locked instance.
return inst, inst.LockedAt == nil
}
func cachedInstanceFor(key string) (*models.Instance, bool) {
@@ -0,0 +1,56 @@
package auth
import (
"errors"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// stubSlugs replaces the slug lookup and empties the host cache for one test.
func stubSlugs(t *testing.T, bySlug map[string]*models.Instance) {
t.Helper()
prev := instanceBySlug
instanceBySlug = func(slug string) (*models.Instance, error) {
if inst, ok := bySlug[slug]; ok {
return inst, nil
}
return nil, errors.New("not found")
}
instanceCacheMu.Lock()
instanceCache = map[string]cachedInstance{}
instanceCacheMu.Unlock()
t.Cleanup(func() {
instanceBySlug = prev
instanceCacheMu.Lock()
instanceCache = map[string]cachedInstance{}
instanceCacheMu.Unlock()
})
}
func TestLockedHostResolvesToNothingButReportsLocked(t *testing.T) {
at := time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC)
stubSlugs(t, map[string]*models.Instance{
"acme": {InstanceID: "i-acme", Slug: "acme", LockedAt: &at},
"open": {InstanceID: "i-open", Slug: "open"},
})
if _, ok := InstanceForHost("acme.vantage.example.com"); ok {
t.Fatal("a locked instance must not resolve")
}
if !HostLocked("acme.vantage.example.com") {
t.Fatal("a locked instance's host must report locked")
}
if inst, ok := InstanceForHost("open.vantage.example.com"); !ok || inst.InstanceID != "i-open" {
t.Fatalf("unlocked instance: got %v, %v", inst, ok)
}
if HostLocked("open.vantage.example.com") {
t.Fatal("an unlocked instance is not locked")
}
if HostLocked("missing.vantage.example.com") || HostLocked("example.com") {
t.Fatal("a host naming no instance is not locked")
}
}
+76 -12
View File
@@ -1,6 +1,7 @@
package auth
import (
"errors"
"fmt"
"net/http"
@@ -31,10 +32,18 @@ func SetSessionCookie(c *gin.Context, sessionID string) {
//
// Anything else is refused rather than guessed. Picking an instance on someone's
// behalf is how you sign them into the wrong tenant.
//
// An instance Vantage HQ has locked answers ErrInstanceLocked on both paths.
// Without the check on the fallback, a single-instance deployment whose host
// resolver hides the locked instance fell through to "the sole instance" and
// served its sign-in page anyway.
func resolveLoginInstance(c *gin.Context) (string, error) {
if inst, ok := InstanceFromHost(c); ok {
return inst.InstanceID, nil
}
if HostLocked(c.Request.Host) {
return "", ErrInstanceLocked
}
n, err := services.CountInstances()
if err != nil {
return "", err
@@ -48,9 +57,22 @@ func resolveLoginInstance(c *gin.Context) (string, error) {
if err != nil {
return "", err
}
if inst.LockedAt != nil {
return "", ErrInstanceLocked
}
return inst.InstanceID, nil
}
// ErrInstanceLocked is a sign-in to an instance Vantage HQ has locked under an
// account dispute. The login page shows it as a message in place of the form.
var ErrInstanceLocked = errors.New("access to this instance is suspended")
// loginLocked reports whether this request's sign-in target is locked.
func loginLocked(c *gin.Context) bool {
_, err := resolveLoginInstance(c)
return errors.Is(err, ErrInstanceLocked)
}
func HandleLocalLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
@@ -61,6 +83,10 @@ func HandleLocalLogin(c *gin.Context) {
return
}
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -76,16 +102,48 @@ func HandleLocalLogin(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
requireMFA := services.RequireMFAForInstance(instanceID)
hasMFA, err := services.HasMFA(u.InstanceID, u.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.JSON(http.StatusOK, gin.H{"ok": true})
switch loginDecision(hasMFA, requireMFA) {
case "session":
if err := mintSession(c, u, []string{"pwd"}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
case "verify":
methods, err := services.MFAMethods(u.InstanceID, u.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
return
}
id, err := CreateTicket(c.Request.Context(), &Ticket{
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, Methods: methods,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetPendingCookie(c, id)
c.JSON(http.StatusOK, gin.H{"mfa_required": true, "methods": methods})
case "enrol":
id, err := CreateTicket(c.Request.Context(), &Ticket{
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, EnrolOnly: true,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetPendingCookie(c, id)
c.JSON(http.StatusOK, gin.H{"enrol_required": true})
}
}
// HandleListPublicProviders is unauthenticated: it is what the login page reads
@@ -101,6 +159,10 @@ func HandleListPublicProviders(c *gin.Context) {
out := []publicProvider{}
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusOK, gin.H{"local_enabled": false, "providers": out, "locked": true})
return
}
if err != nil {
// An unresolvable instance is not an error the login page can act on:
// it still has to render a password form. Answer the safe shape.
@@ -127,6 +189,12 @@ func HandleBootstrapStatus(c *gin.Context) {
err error
instName string
)
// Checked first: a locked instance must not fall through to the global
// user count, which answers needs_setup false and draws the sign-in form.
if loginLocked(c) {
c.JSON(http.StatusOK, gin.H{"needs_setup": false, "locked": true})
return
}
if inst, ok := InstanceFromHost(c); ok {
n, err = services.CountInstanceUsers(inst.InstanceID)
instName = inst.Name
@@ -191,14 +259,10 @@ func HandleBootstrap(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
if err := mintSession(c, u, []string{"pwd"}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{
"instance": inst,
"slug": inst.Slug,
+230
View File
@@ -0,0 +1,230 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// enrolTicketStillValid refuses an enrol-only ticket once the user has any
// factor. Otherwise someone holding only the password who started sign-in
// before the real user enrolled could, within the ticket's lifetime, add
// their own passkey and replace the user's recovery codes. Answered as an
// expired ticket, the single indistinguishable error, and the ticket is
// destroyed. A lookup failure is treated the same way: fail closed.
//
// It runs before the factor is written, not in finishEnrolment: by then the
// factor being enrolled already counts, so HasMFA would always be true.
func enrolTicketStillValid(c *gin.Context, t *Ticket, ticketID string) bool {
has, err := services.HasMFA(t.InstanceID, t.UserID)
if err != nil || has {
_ = DeleteTicket(c.Request.Context(), ticketID)
abortTicketExpired(c)
return false
}
return true
}
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
// from, before they hold a session. Only an enrol-only ticket reaches it.
//
// @Summary Start forced TOTP enrolment during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{secret=string,otpauth_uri=string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/totp/setup [post]
func HandleEnrolTOTPSetup(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
inst, err := services.GetInstance(t.InstanceID)
issuer := "Vantage"
if err == nil && inst != nil && inst.Name != "" {
issuer = inst.Name
}
secret, uri, err := services.StartTOTPSetup(t.InstanceID, t.UserID, issuer, t.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
}
// HandleEnrolTOTPConfirm finishes forced enrolment and signs the user in.
//
// @Summary Confirm forced TOTP enrolment and sign in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/totp/confirm [post]
func HandleEnrolTOTPConfirm(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
if err := services.ConfirmTOTP(t.InstanceID, t.UserID, body.Code); err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
})
return
}
finishEnrolment(c, t, ticketID, services.FactorTOTP)
}
// finishEnrolment issues recovery codes, mints the session and audits, so the
// TOTP and passkey enrolment paths cannot drift apart.
func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
codes, err := services.IssueRecoveryCodes(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue recovery codes"})
return
}
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
}
// HandleEnrolPasskeyBegin starts forced passkey enrolment during sign-in.
// Identical to HandleRegisterPasskeyBegin except the user comes from the
// enrol-only ticket rather than a session, since none exists yet.
//
// @Summary Begin forced passkey enrolment during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/passkey/begin [post]
func HandleEnrolPasskeyBegin(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
existing, err := services.ListPasskeys(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
lib := make([]webauthn.Credential, 0, len(existing))
for _, cr := range existing {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
options, sessionData, err := w.BeginRegistration(
waUser{handle: handle, name: t.Email, credentials: lib},
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleEnrolPasskeyFinish stores the new credential and finishes forced
// enrolment, minting the session that HandleEnrolTOTPConfirm also produces.
//
// @Summary Complete forced passkey enrolment and sign in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/passkey/finish [post]
func HandleEnrolPasskeyFinish(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
var body struct {
CeremonyID string `json:"ceremony_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
return
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that enrolment expired", "code": "mfa_ticket_expired"})
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
return
}
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
if err != nil {
logWebAuthnFailure(c, "enrolment registration", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, tr := range parsed.Response.Transports {
transports = append(transports, string(tr))
}
if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name,
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
return
}
finishEnrolment(c, t, ticketID, services.FactorWebAuthn)
}
+136
View File
@@ -0,0 +1,136 @@
package auth
import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// loginDecision is the branch a verified password takes.
func loginDecision(hasMFA, requireMFA bool) string {
switch {
case hasMFA:
return "verify"
case requireMFA:
return "enrol"
default:
return "session"
}
}
// newSession builds the session a sign-in produces. Sign-in counts as a
// step-up, so StepUpAt is always the moment of sign-in.
func newSession(u *models.User, amr []string, now time.Time) *Session {
return &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
AMR: amr, StepUpAt: &now,
}
}
// oidcSession is the session an SSO callback mints. AMR "oidc" is what exempts
// it from step-up: the IdP owns authentication policy, and these users have
// no password or local factor to step up with.
func oidcSession(u *models.User, name string, now time.Time) *Session {
s := newSession(u, []string{"oidc"}, now)
s.Name = name
return s
}
// mintSession is the single place a session is created from a user, so every
// path records amr and step-up freshness the same way.
func mintSession(c *gin.Context, u *models.User, amr []string) error {
return saveSignIn(c, newSession(u, amr, time.Now()))
}
// saveSignIn persists a sign-in session and sets its cookie. The pending-MFA
// cookie is cleared because a completed sign-in supersedes any ticket.
func saveSignIn(c *gin.Context, sess *Session) error {
sessionID, err := SaveSession(c.Request.Context(), sess)
if err != nil {
return err
}
_ = services.TouchLastLogin(sess.UserID)
ClearPendingCookie(c)
SetSessionCookie(c, sessionID)
return nil
}
// HandleMFATOTP completes a sign-in with a TOTP code.
//
// @Summary Complete sign-in with a TOTP code
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/totp [post]
func HandleMFATOTP(c *gin.Context) {
handleMFAVerify(c, services.FactorTOTP)
}
// HandleMFARecovery completes a sign-in with a recovery code.
//
// @Summary Complete sign-in with a recovery code
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Recovery code"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/recovery [post]
func HandleMFARecovery(c *gin.Context) {
handleMFAVerify(c, services.FactorRecovery)
}
func handleMFAVerify(c *gin.Context, factor string) {
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
var err error
switch factor {
case services.FactorTOTP:
err = services.VerifyTOTPCode(t.InstanceID, t.UserID, body.Code)
case services.FactorRecovery:
err = services.UseRecoveryCode(t.InstanceID, t.UserID, body.Code)
}
if err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor="+factor)
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
})
return
}
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
if factor == services.FactorRecovery {
services.LogEvent(t.InstanceID, "mfa.recovery_used", u.Email, "", "", "")
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+27
View File
@@ -0,0 +1,27 @@
package auth
import "testing"
// The whole point of the feature is in this table: a user with MFA is never
// handed a session by a password alone, and require_mfa turns "no factor" into
// forced enrolment rather than a free pass.
func TestLoginDecision(t *testing.T) {
cases := []struct {
name string
hasMFA bool
requireMFA bool
want string
}{
{"no mfa, not required", false, false, "session"},
{"no mfa, required", false, true, "enrol"},
{"has mfa, not required", true, false, "verify"},
{"has mfa, required", true, true, "verify"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := loginDecision(tc.hasMFA, tc.requireMFA); got != tc.want {
t.Fatalf("loginDecision(%v,%v) = %q, want %q", tc.hasMFA, tc.requireMFA, got, tc.want)
}
})
}
}
+180
View File
@@ -0,0 +1,180 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
ticketTTL = 5 * time.Minute
maxTicketAttempts = 5
ticketPrefix = "km:mfa:"
pendingCookieName = "km_mfa_pending"
)
// ErrTicketExpired covers every unusable ticket - missing, timed out, or
// destroyed by too many wrong codes. They are one message on purpose: which of
// the three it was tells an attacker whether the password was right.
var ErrTicketExpired = errors.New("this sign-in attempt has expired; start again")
// Ticket is a password that verified but has not yet become a session. It is
// deliberately NOT a Session: nothing half-authenticated may reach a route
// under Middleware, and the way to guarantee that is for it never to be the
// type those routes read.
type Ticket struct {
UserID string `json:"user_id"`
InstanceID string `json:"instance_id"`
Email string `json:"email"`
Methods []string `json:"methods"`
EnrolOnly bool `json:"enrol_only"`
}
type ticketScope int
const (
scopeVerify ticketScope = iota
scopeEnrol
)
func (t *Ticket) allows(s ticketScope) bool {
if t.EnrolOnly {
return s == scopeEnrol
}
return s == scopeVerify
}
func attemptsLeft(attempts int) int {
if attempts >= maxTicketAttempts {
return 0
}
return maxTicketAttempts - attempts
}
// attemptsKey is the counter backing a ticket's brute-force cap. It is a
// separate key rather than a field on the ticket JSON so INCR can make the
// count atomic: two requests racing on the same ticket must each cost one
// attempt, not both read the same count and both write count+1.
func attemptsKey(id string) string {
return ticketPrefix + id + ":attempts"
}
func CreateTicket(ctx context.Context, t *Ticket) (string, error) {
id, err := randomHex(32)
if err != nil {
return "", err
}
data, err := json.Marshal(t)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, ticketPrefix+id, data, ticketTTL).Err(); err != nil {
return "", err
}
return id, nil
}
func LoadTicket(ctx context.Context, id string) (*Ticket, error) {
data, err := rdb.Get(ctx, ticketPrefix+id).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrTicketExpired
}
if err != nil {
return nil, err
}
var t Ticket
if err := json.Unmarshal(data, &t); err != nil {
return nil, ErrTicketExpired
}
return &t, nil
}
// FailTicket records a wrong code and returns how many attempts remain. The
// count is kept in its own INCR-backed key rather than the ticket JSON: a
// read-modify-write on the JSON lets requests racing on the same ticket all
// read the same count and all write count+1, which bypasses the cap instead
// of costing one attempt each. At zero the ticket is destroyed rather than
// left to time out.
func FailTicket(ctx context.Context, id string) (int, error) {
// Confirms the ticket exists first, so a missing/expired/destroyed ticket
// still answers with the one indistinguishable ErrTicketExpired rather
// than incrementing a counter for an id nobody holds.
if _, err := LoadTicket(ctx, id); err != nil {
return 0, err
}
key := attemptsKey(id)
n, err := rdb.Incr(ctx, key).Result()
if err != nil {
return 0, err
}
if n == 1 {
// Only the creator of the counter sets its expiry, so a later
// increment never extends it past the ticket's own window.
if err := rdb.Expire(ctx, key, ticketTTL).Err(); err != nil {
return 0, err
}
}
left := attemptsLeft(int(n))
if left == 0 {
_ = DeleteTicket(ctx, id)
return 0, nil
}
return left, nil
}
func DeleteTicket(ctx context.Context, id string) error {
return rdb.Del(ctx, ticketPrefix+id, attemptsKey(id)).Err()
}
func SetPendingCookie(c *gin.Context, id string) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: pendingCookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(ticketTTL.Seconds()),
})
}
func ClearPendingCookie(c *gin.Context) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: pendingCookieName, Value: "", Path: "/",
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
// ticketFromRequest resolves the pending ticket and enforces its scope. It
// writes the response and returns false when the ticket is unusable.
func ticketFromRequest(c *gin.Context, scope ticketScope) (*Ticket, string, bool) {
cookie, err := c.Request.Cookie(pendingCookieName)
if err != nil || cookie.Value == "" {
abortTicketExpired(c)
return nil, "", false
}
t, err := LoadTicket(c.Request.Context(), cookie.Value)
if err != nil {
abortTicketExpired(c)
return nil, "", false
}
if !t.allows(scope) {
abortTicketExpired(c)
return nil, "", false
}
return t, cookie.Value, true
}
func abortTicketExpired(c *gin.Context) {
ClearPendingCookie(c)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": ErrTicketExpired.Error(), "code": "mfa_ticket_expired",
})
}
+50
View File
@@ -0,0 +1,50 @@
package auth
import "testing"
// The ticket's attempt cap is the only per-ticket brute-force guard: five
// wrong codes must destroy it rather than let an attacker keep guessing
// against a single stolen password.
func TestAttemptsLeftCountsDownAndHitsZero(t *testing.T) {
cases := []struct {
attempts int
want int
}{
{0, 5}, {1, 4}, {4, 1}, {5, 0}, {9, 0},
}
for _, tc := range cases {
if got := attemptsLeft(tc.attempts); got != tc.want {
t.Errorf("attemptsLeft(%d) = %d, want %d", tc.attempts, got, tc.want)
}
}
}
// attemptsKey must derive deterministically from the ticket id and stay
// distinct from the ticket's own key, since FailTicket relies on INCR against
// it being the sole writer of the attempt count.
func TestAttemptsKeyIsDerivedFromTicketID(t *testing.T) {
got := attemptsKey("abc123")
want := "km:mfa:abc123:attempts"
if got != want {
t.Errorf("attemptsKey(%q) = %q, want %q", "abc123", got, want)
}
if attemptsKey("abc123") == ticketPrefix+"abc123" {
t.Error("attempts key must not collide with the ticket's own key")
}
}
// An enrol-only ticket exists because the instance requires MFA the user does
// not have. It must not satisfy a verification endpoint, and a verification
// ticket must not reach the enrolment endpoints - each would skip the other's
// purpose.
func TestTicketScopeIsEnforcedInBothDirections(t *testing.T) {
verify := &Ticket{Methods: []string{"totp"}}
enrol := &Ticket{EnrolOnly: true}
if !verify.allows(scopeVerify) || verify.allows(scopeEnrol) {
t.Error("a verification ticket must allow only verification")
}
if !enrol.allows(scopeEnrol) || enrol.allows(scopeVerify) {
t.Error("an enrolment ticket must allow only enrolment")
}
}
+6 -6
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
@@ -88,6 +89,10 @@ func loadProvider(instanceID, providerID string) (*models.AuthProvider, string,
func HandleSSOStart(c *gin.Context) {
inst, ok := InstanceFromHost(c)
if !ok {
if HostLocked(c.Request.Host) {
c.Redirect(http.StatusFound, "/login?error=instance_locked")
return
}
c.Redirect(http.StatusFound, "/login?error=unknown_host")
return
}
@@ -221,15 +226,10 @@ func completeSSOLogin(c *gin.Context, instanceID, email, name string) {
}
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: name,
})
if err != nil {
if err := saveSignIn(c, oidcSession(u, name, time.Now())); err != nil {
c.Redirect(http.StatusFound, "/login?error=session_failed")
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.Redirect(http.StatusFound, "/")
}
+110
View File
@@ -0,0 +1,110 @@
package auth
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// HandlePasskeyLoginBegin starts a passwordless sign-in.
//
// It repeats every gate /auth/login applies - instance resolution, the locked
// instance refusal, and the local-login setting - because this is a second
// front door, and a front door that skips the locks is not a shortcut.
//
// @Summary Begin passwordless passkey sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 403 {object} object{error=string}
// @Router /auth/passkey/begin [post]
func HandlePasskeyLoginBegin(c *gin.Context) {
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !services.LocalLoginPermitted(instanceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
// Discoverable login: no allowCredentials, so the authenticator offers
// whichever resident credential it holds for this RP ID.
options, sessionData, err := w.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandlePasskeyLoginFinish verifies a discoverable assertion and mints a
// session. A user-verified passkey is possession plus a PIN or biometric, so it
// satisfies require_mfa on its own.
//
// @Summary Complete passwordless passkey sign-in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/passkey/finish [post]
func HandlePasskeyLoginFinish(c *gin.Context) {
instanceID, err := resolveLoginInstance(c)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "sign-in is not available here"})
return
}
if !services.LocalLoginPermitted(instanceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
return
}
// finishAssertion is given no userID or email, so it resolves the owning
// user from the credential ID scoped to this instance and validates with
// the library's discoverable path, refusing a user handle that is not
// that owner's.
cred, err := finishAssertion(c, instanceID, "", "")
if err != nil {
logWebAuthnFailure(c, "passwordless assertion", err)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that passkey could not be verified", "code": "invalid_assertion",
})
return
}
stored, err := services.GetPasskeyByCredentialID(instanceID, cred.ID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
u, err := services.GetUserInInstance(instanceID, stored.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{services.FactorWebAuthn}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = services.TouchPasskey(instanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+20
View File
@@ -31,6 +31,14 @@ type Session struct {
TokenName string `json:"-"`
Scopes []string `json:"-"`
TokenScope map[string]string `json:"-"`
// AMR records how this session authenticated: pwd, otp, webauthn,
// recovery, oidc. Step-up reads it to exempt OIDC sessions, whose IdP owns
// authentication policy.
AMR []string `json:"amr,omitempty"`
// StepUpAt is the last successful re-authentication. Sign-in counts as one.
StepUpAt *time.Time `json:"step_up_at,omitempty"`
}
var rdb *redis.Client
@@ -103,3 +111,15 @@ func GetSession(ctx context.Context, id string) (*Session, error) {
func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
// TouchStepUp records a fresh re-authentication without disturbing the
// session's remaining lifetime.
func TouchStepUp(ctx context.Context, id string, sess *Session) error {
now := time.Now()
sess.StepUpAt = &now
data, err := json.Marshal(sess)
if err != nil {
return err
}
return rdb.Set(ctx, sessionPrefix+id, data, redis.KeepTTL).Err()
}
+70
View File
@@ -0,0 +1,70 @@
package auth
import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// StepUpWindow is how long one re-authentication covers. Ten minutes is long
// enough to open several consoles in a row and short enough that a walked-away
// laptop is not a fleet-wide credential.
const StepUpWindow = 10 * time.Minute
func stepUpFresh(sess *Session, now time.Time) bool {
if sess == nil {
return false
}
// An API token authenticates per request and has no human to prompt.
if sess.TokenID != "" {
return true
}
for _, a := range sess.AMR {
if a == "oidc" {
return true
}
}
return sess.StepUpAt != nil && now.Sub(*sess.StepUpAt) < StepUpWindow
}
// RequireStepUp guards the actions that hand out credentials rather than
// describe them: secret reveal, private key download, console connect.
//
// It answers a machine-readable code rather than a bare 403 so web/ can open
// the re-authentication modal and retry the original request.
func RequireStepUp() gin.HandlerFunc {
return func(c *gin.Context) {
sess := GetSessionFromContext(c)
if stepUpFresh(sess, time.Now()) {
c.Next()
return
}
if sess == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
methods, err := services.MFAMethods(sess.InstanceID, sess.UserID)
if err != nil || len(methods) == 0 {
methods = []string{services.FactorPassword}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "re-authentication required",
"code": "step_up_required",
"methods": methods,
})
}
}
// TouchStepUpFromRequest records a fresh step-up against the session the
// request arrived on. It lives in auth because the cookie name and the Redis
// key are this package's business.
func TouchStepUpFromRequest(c *gin.Context) error {
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
return err
}
sess := GetSessionFromContext(c)
return TouchStepUp(c.Request.Context(), cookie.Value, sess)
}
+76
View File
@@ -0,0 +1,76 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/gin-gonic/gin"
)
func TestStepUpFresh(t *testing.T) {
now := time.Now()
ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
cases := []struct {
name string
sess *Session
want bool
}{
{"just signed in", &Session{StepUpAt: ago(time.Minute)}, true},
{"nine minutes ago", &Session{StepUpAt: ago(9 * time.Minute)}, true},
{"eleven minutes ago", &Session{StepUpAt: ago(11 * time.Minute)}, false},
{"never", &Session{}, false},
// An API token has no human to prompt; the spec exempts it and records
// the bypass as a known limitation.
{"api token", &Session{TokenID: "tok_1"}, true},
// An OIDC session's IdP owns authentication policy.
{"oidc session", &Session{AMR: []string{"oidc"}, StepUpAt: ago(time.Hour)}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stepUpFresh(tc.sess, now); got != tc.want {
t.Fatalf("stepUpFresh = %v, want %v", got, tc.want)
}
})
}
}
// A missing session must refuse rather than panic dereferencing sess.
// auth.Middleware guarantees a non-nil session on every route today, but
// RequireStepUp must not rely on that holding forever.
func TestRequireStepUpNilSessionRefusesWithoutPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/console/connect", nil)
RequireStepUp()(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestOIDCSessionIsExemptFromStepUp(t *testing.T) {
now := time.Now()
u := &models.User{UserID: "u1", InstanceID: "i1", Role: "member", Email: "a@example.com"}
s := oidcSession(u, "Ann", now.Add(-24*time.Hour))
if !stepUpFresh(s, now) {
t.Fatal("an OIDC session must be exempt from step-up however old its sign-in")
}
if s.StepUpAt == nil || s.Name != "Ann" || s.UserID != "u1" || s.InstanceID != "i1" {
t.Fatalf("oidc session missing fields: %+v", s)
}
}
func TestSignInSessionIsFreshAtSignIn(t *testing.T) {
now := time.Now()
s := newSession(&models.User{UserID: "u1"}, []string{"pwd"}, now)
if !stepUpFresh(s, now) {
t.Fatal("a session is fresh at the moment of sign-in")
}
}
+507
View File
@@ -0,0 +1,507 @@
package auth
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"log"
"net"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/redis/go-redis/v9"
)
const (
ceremonyPrefix = "km:wa:"
ceremonyTTL = 5 * time.Minute
)
// rpConfig derives the relying party from the request. The RP ID is the host
// without its port - WebAuthn forbids a port there - while the origin keeps it.
//
// This is why the reverse proxy must preserve Host: a proxy rewriting it makes
// every passkey on the instance fail to verify, with no error that says so.
func rpConfig(c *gin.Context) (string, string) {
host := c.Request.Host
rpID := host
if h, _, err := net.SplitHostPort(host); err == nil {
rpID = h
}
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
// Only development is ever plain HTTP; WebAuthn permits it on localhost.
scheme = "http"
}
return rpID, scheme + "://" + host
}
// logWebAuthnFailure records why a ceremony was refused. The response stays
// deliberately vague, so without this the only evidence of a misconfigured
// relying party - most often a proxy that terminates TLS without passing
// X-Forwarded-Proto: https - is a user reporting that their passkey "could not
// be verified". Nothing logged here is secret: RP ID, origins, and the
// library's error, whose DevInfo names expected and received values.
func logWebAuthnFailure(c *gin.Context, stage string, err error) {
rpID, origin := rpConfig(c)
detail := err.Error()
var perr *protocol.Error
if errors.As(err, &perr) && perr.DevInfo != "" {
detail += " (" + perr.DevInfo + ")"
}
log.Printf("webauthn: %s refused: rp_id=%q expected_origin=%q request_origin=%q x_forwarded_proto=%q: %s",
stage, rpID, origin, c.GetHeader("Origin"), c.GetHeader("X-Forwarded-Proto"), detail)
}
func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
rpID, origin := rpConfig(c)
return webauthn.New(&webauthn.Config{
RPDisplayName: "Vantage",
RPID: rpID,
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
// Preferred, not required, and not checked on the result. Requiring
// it made password managers such as NordPass ask for their master
// password on every use, which other sites avoid by preferring it.
// The cost is deliberate: an authenticator that skips verification
// makes a passkey possession-only, so passwordless sign-in and
// step-up then rest on the device or vault being unlocked.
UserVerification: protocol.VerificationPreferred,
},
})
}
// waUser adapts our records to the library's interface. The handle is random
// and per-user: a resident credential hands its user handle to any origin that
// asks, so the user ID must not be it.
type waUser struct {
handle []byte
name string
credentials []webauthn.Credential
}
func (u waUser) WebAuthnID() []byte { return u.handle }
func (u waUser) WebAuthnName() string { return u.name }
func (u waUser) WebAuthnDisplayName() string { return u.name }
func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func toLibCredential(c models.WebAuthnCredential) webauthn.Credential {
return webauthn.Credential{
ID: c.CredentialID,
PublicKey: c.PublicKey,
AttestationType: "none",
Flags: webauthn.CredentialFlags{
BackupEligible: c.BackupEligible != nil && *c.BackupEligible,
BackupState: c.BackupState,
},
Authenticator: webauthn.Authenticator{
AAGUID: c.AAGUID,
SignCount: c.SignCount,
},
}
}
// backupEligibleFor is the eligibility to validate an assertion against.
//
// A recorded value always stands, so the library still refuses a credential
// whose eligibility changed. A row registered before the flag was stored has no
// baseline at all, and comparing against a zero value refused every synced
// passkey; for those the flag in the signed authenticator data is adopted, and
// TouchPasskey records it after the assertion verifies.
func backupEligibleFor(stored *bool, observed bool) bool {
if stored != nil {
return *stored
}
return observed
}
func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) {
id, err := randomHex(32)
if err != nil {
return "", err
}
blob, err := json.Marshal(data)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil {
return "", err
}
return id, nil
}
// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so
// it is deleted as it is read.
func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) {
blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrTicketExpired
}
if err != nil {
return nil, err
}
var data webauthn.SessionData
if err := json.Unmarshal(blob, &data); err != nil {
return nil, ErrTicketExpired
}
return &data, nil
}
// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in.
//
// @Summary Begin passkey verification during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/webauthn/begin [post]
func HandleMFAWebAuthnBegin(c *gin.Context) {
t, _, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
creds, err := services.ListPasskeys(t.InstanceID, t.UserID)
if err != nil || len(creds) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
lib := make([]webauthn.Credential, 0, len(creds))
for _, cr := range creds {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib},
webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleMFAWebAuthnFinish verifies the assertion and signs the user in.
//
// @Summary Complete sign-in with a passkey
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/webauthn/finish [post]
func HandleMFAWebAuthnFinish(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email)
if err != nil {
logWebAuthnFailure(c, "second-factor assertion", err)
left, ferr := FailTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn")
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left,
})
return
}
_ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// HandleRegisterPasskeyBegin starts registration for the signed-in user.
//
// @Summary Begin passkey registration
// @Tags mfa
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Router /me/passkeys/begin [post]
func HandleRegisterPasskeyBegin(c *gin.Context) {
sess := GetSessionFromContext(c)
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
lib := make([]webauthn.Credential, 0, len(existing))
for _, cr := range existing {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
options, sessionData, err := w.BeginRegistration(
waUser{handle: handle, name: sess.Email, credentials: lib},
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleRegisterPasskeyFinish stores the new credential.
//
// @Summary Complete passkey registration
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Router /me/passkeys/finish [post]
func HandleRegisterPasskeyFinish(c *gin.Context) {
sess := GetSessionFromContext(c)
var body struct {
CeremonyID string `json:"ceremony_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
return
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"})
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
return
}
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
return
}
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
if err != nil {
logWebAuthnFailure(c, "registration", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, t := range parsed.Response.Transports {
transports = append(transports, string(t))
}
if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name,
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
return
}
services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn")
// A first factor earns recovery codes; later ones do not reissue them.
m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID)
if services.RecoveryCodesRemaining(m) == 0 {
codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID)
if err == nil {
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// HandleStepUpWebAuthnBegin offers an assertion challenge for step-up
// re-authentication: the signed-in user proving it is still them before a
// guarded action, rather than a pending ticket proving it during sign-in.
//
// @Summary Begin passkey step-up
// @Tags mfa
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 400 {object} object{error=string}
// @Router /me/step-up/webauthn/begin [post]
func HandleStepUpWebAuthnBegin(c *gin.Context) {
sess := GetSessionFromContext(c)
creds, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
if err != nil || len(creds) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
return
}
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
lib := make([]webauthn.Credential, 0, len(creds))
for _, cr := range creds {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib},
webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleStepUpWebAuthnFinish verifies the assertion and records a fresh
// step-up for the current session. Unlike HandleMFAWebAuthnFinish this does
// not mint a session: the caller is already signed in, this only proves they
// still hold the passkey. finishAssertion is called with the session's own
// user ID, so a credential belonging to somebody else is refused rather than
// stepping up this session on their behalf.
//
// @Summary Complete passkey step-up
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/step-up/webauthn/finish [post]
func HandleStepUpWebAuthnFinish(c *gin.Context) {
sess := GetSessionFromContext(c)
cred, err := finishAssertion(c, sess.InstanceID, sess.UserID, sess.Email)
if err != nil {
logWebAuthnFailure(c, "step-up assertion", err)
services.LogEvent(sess.InstanceID, "step_up.failed", sess.Email, "", "", "factor=webauthn")
c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"})
return
}
_ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
if err := TouchStepUpFromRequest(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
return
}
services.LogEvent(sess.InstanceID, "step_up.ok", sess.Email, "", "", "factor=webauthn")
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
// step-up, so the verification rules (backup eligibility, clone detection,
// instance scope) exist once.
func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) {
var body struct {
CeremonyID string `json:"ceremony_id"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
return nil, errors.New("assertion required")
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
return nil, err
}
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential))
if err != nil {
return nil, err
}
stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID)
if err != nil {
return nil, err
}
if userID != "" && stored.UserID != userID {
return nil, errors.New("credential belongs to another user")
}
handle, err := services.WebAuthnHandle(instanceID, stored.UserID)
if err != nil {
return nil, err
}
w, err := webAuthnFor(c)
if err != nil {
return nil, err
}
libCred := toLibCredential(*stored)
libCred.Flags.BackupEligible = backupEligibleFor(stored.BackupEligible,
parsed.Response.AuthenticatorData.Flags.HasBackupEligible())
user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{libCred}}
var cred *webauthn.Credential
if userID == "" {
// Discoverable (passwordless) ceremony: the session carries no user,
// so the library asks us to resolve the authenticator's user handle.
// The only acceptable answer is the credential's own stored owner in
// this instance; any other handle is refused.
cred, err = w.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
if !discoverableHandleMatches(handle, userHandle) {
return nil, errors.New("user handle does not match the credential owner")
}
return user, nil
}, *sessionData, parsed)
} else {
cred, err = w.ValidateLogin(user, *sessionData, parsed)
}
if err != nil {
return nil, err
}
// A counter that fails to advance is the library's clone signal. Zero on
// both sides means the authenticator does not keep one, which is normal.
if cred.Authenticator.CloneWarning {
return nil, errors.New("authenticator may be cloned")
}
return cred, nil
}
// discoverableHandleMatches reports whether the user handle an authenticator
// returned belongs to the credential's stored owner. Empty never matches.
func discoverableHandleMatches(ownerHandle, userHandle []byte) bool {
return len(ownerHandle) > 0 && subtle.ConstantTimeCompare(ownerHandle, userHandle) == 1
}
+90
View File
@@ -0,0 +1,90 @@
package auth
import (
"bytes"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it
// silently makes every existing passkey unusable - so the port-stripping and
// scheme rules are pinned here.
func TestRPConfig(t *testing.T) {
cases := []struct {
name, host, proto string
wantID, wantOrig string
}{
{"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"},
{"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"},
{"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil)
c.Request.Host = tc.host
if tc.proto != "" {
c.Request.Header.Set("X-Forwarded-Proto", tc.proto)
}
id, origin := rpConfig(c)
if id != tc.wantID || origin != tc.wantOrig {
t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig)
}
})
}
}
func TestDiscoverableHandleMatches(t *testing.T) {
owner := bytes.Repeat([]byte{7}, 64)
if !discoverableHandleMatches(owner, bytes.Repeat([]byte{7}, 64)) {
t.Fatal("the owner's own handle must match")
}
other := bytes.Repeat([]byte{7}, 64)
other[63] = 8
if discoverableHandleMatches(owner, other) {
t.Fatal("another user's handle must not match")
}
if discoverableHandleMatches(nil, nil) || discoverableHandleMatches(owner, nil) {
t.Fatal("an empty handle must never match")
}
}
// Synced passkeys (iCloud Keychain, Google Password Manager, 1Password) report
// backup eligibility, and go-webauthn refuses any assertion whose flag differs
// from the stored credential's. Not storing it made every synced passkey fail
// with "Backup Eligible flag inconsistency".
func TestLibCredentialCarriesStoredBackupFlags(t *testing.T) {
yes := true
lc := toLibCredential(models.WebAuthnCredential{BackupEligible: &yes, BackupState: true})
if !lc.Flags.BackupEligible || !lc.Flags.BackupState {
t.Fatalf("flags not carried: %+v", lc.Flags)
}
}
func TestBackupEligibleFor(t *testing.T) {
yes, no := true, false
cases := []struct {
name string
stored *bool
observed bool
want bool
}{
// Registered before the flag was recorded: no baseline, adopt it.
{"legacy row, synced passkey", nil, true, true},
{"legacy row, hardware key", nil, false, false},
// Recorded: the stored value stands, so the library still refuses a
// credential whose eligibility genuinely changed.
{"recorded eligible", &yes, true, true},
{"recorded not eligible, now claims eligible", &no, true, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := backupEligibleFor(tc.stored, tc.observed); got != tc.want {
t.Fatalf("backupEligibleFor = %v, want %v", got, tc.want)
}
})
}
}
+10
View File
@@ -103,6 +103,7 @@ func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdates
Name: u.Name,
CurrentVersion: u.CurrentVersion,
NewVersion: u.NewVersion,
Phased: u.Phased,
}
}
if err := services.StoreAvailableUpdates(srv.ServerID, pkgs); err != nil {
@@ -236,6 +237,12 @@ func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryRe
if err := services.StoreInventory(srv.ServerID, req); err != nil {
log.Printf("store inventory for %s: %v", srv.ServerID, err)
}
// Only static snapshots compute reboot_required, so only they can settle a
// reboot. The agent sends one at start, so the first report after a
// reboot qualifies.
if req.IncludeStatic && req.BootTimeUnix > 0 {
services.VerifyPatchReboots(srv.InstanceID, srv.ServerID, time.Unix(req.BootTimeUnix, 0), req.RebootRequired)
}
return &pb.InventoryReportResponse{}, nil
}
@@ -330,6 +337,9 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
if m.WorkloadLogsResult != nil {
services.WorkloadResults.Deliver(m.WorkloadLogsResult)
}
if m.PatchResult != nil {
services.RecordPatchResult(srv.InstanceID, srv.ServerID, m.PatchResult)
}
if m.StepResult != nil {
services.StepResults.Deliver(m.StepResult)
}
+37 -4
View File
@@ -118,6 +118,25 @@ func buildMonitor(args map[string]any) (models.Monitor, error) {
if b, ok := rawTarget["insecure"].(bool); ok {
target.Insecure = b
}
target.Metric = stringArg(rawTarget, "metric")
target.Mount = stringArg(rawTarget, "mount")
if n, ok := rawTarget["threshold"].(float64); ok {
target.Threshold = n
}
if n, ok := rawTarget["period_sec"].(float64); ok {
target.PeriodSec = int(n)
}
if n, ok := rawTarget["grace_sec"].(float64); ok {
target.GraceSec = int(n)
}
if sel, ok := rawTarget["selector"].(map[string]any); ok {
target.Selector = map[string]string{}
for k, v := range sel {
if s, ok := v.(string); ok {
target.Selector[k] = s
}
}
}
switch monitorType {
case models.MonitorHTTP, models.MonitorTLS:
@@ -131,6 +150,9 @@ func buildMonitor(args map[string]any) (models.Monitor, error) {
if monitorType == models.MonitorTCP && target.Port == 0 {
return models.Monitor{}, fmt.Errorf("target.port is required for a tcp monitor")
}
case models.MonitorHeartbeat, models.MonitorMetric:
// Full validation (validateHeartbeat / validateMetric) runs inside
// services.CreateMonitor; nothing further is required here.
default:
return models.Monitor{}, fmt.Errorf("unknown monitor type %q", monitorType)
}
@@ -150,6 +172,10 @@ func buildMonitor(args map[string]any) (models.Monitor, error) {
if n, ok := args["interval_sec"].(float64); ok && int(n) > 0 {
interval = int(n)
}
forSec := 0
if n, ok := args["for_sec"].(float64); ok && int(n) > 0 {
forSec = int(n)
}
return models.Monitor{
Name: name,
@@ -157,6 +183,7 @@ func buildMonitor(args map[string]any) (models.Monitor, error) {
Type: monitorType,
Target: target,
IntervalSec: interval,
ForSec: forSec,
// Never armed on creation. A monitor that started enabled would begin
// alerting real people the moment a model invented it, and creating
// must stay a separate decision from acting.
@@ -257,10 +284,11 @@ func init() {
Name: "create_monitor",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the monitor.", Required: true},
{Name: "type", Type: ArgString, Description: "Check type: http, tcp, icmp or tls.", Required: true},
{Name: "target", Type: ArgObject, Description: "What to check. http/tls take url; tcp/icmp take host, and tcp also port. Optional: method, keyword, expected_status, tls_warn_days, insecure.", Required: true},
{Name: "type", Type: ArgString, Description: "Check type: http, tcp, icmp, tls, heartbeat or metric.", Required: true},
{Name: "target", Type: ArgObject, Description: "What to check. http/tls take url; tcp/icmp take host, and tcp also port. Optional: method, keyword, expected_status, tls_warn_days, insecure. heartbeat takes period_sec and optional grace_sec; metric takes metric (disk_pct, disk_free_gb, mem_pct, load_per_core, unit_failed, container_unhealthy, reboot_pending_days, agent_offline_min), threshold, optional mount and selector (tag map).", Required: true},
{Name: "group", Type: ArgString, Description: "Optional group name to file the monitor under."},
{Name: "interval_sec", Type: ArgInteger, Description: "Seconds between checks; defaults to 60."},
{Name: "for_sec", Type: ArgInteger, Description: "For a metric monitor, how long the condition must hold before a server counts as down."},
},
Write: true,
Scope: "monitors:write",
@@ -277,12 +305,17 @@ func init() {
return nil, fmt.Errorf("could not create the monitor: %w", err)
}
LogCreated(c, "monitor", created.MonitorID, created.Name)
return map[string]any{
out := map[string]any{
"monitor_id": created.MonitorID,
"name": created.Name,
"enabled": false,
"note": "Created disabled. Enable it in Vantage to start checking.",
}, nil
}
if created.HeartbeatToken != "" {
out["heartbeat_token"] = created.HeartbeatToken
out["heartbeat_token_note"] = "Shown once."
}
return out, nil
},
})
}
+7
View File
@@ -187,6 +187,12 @@ func init() {
}
}
// Metric incidents name a server, so a server-scoped token sees only
// the ones on servers it can see, matching the REST endpoint.
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not resolve visible servers: %w", err)
}
out := []incidentSummary{}
for _, mid := range monitorIDs {
if len(out) >= int(limit) {
@@ -196,6 +202,7 @@ func init() {
if err != nil {
return nil, fmt.Errorf("could not list incidents: %w", err)
}
incidents = services.FilterByVisibleServer(incidents, func(i models.Incident) string { return i.ServerID }, visible, restricted)
name := mid
if monitorNames != nil {
if n, ok := monitorNames[mid]; ok {
+4 -2
View File
@@ -80,6 +80,8 @@ type pendingUpdate struct {
Package string `json:"package"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
// Phased is an Ubuntu phased update apt defers until the host is selected.
Phased bool `json:"phased,omitempty"`
}
type listPendingUpdatesResult struct {
@@ -390,7 +392,7 @@ func init() {
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased,
})
}
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
@@ -411,7 +413,7 @@ func init() {
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased,
})
}
}
+20 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
@@ -156,16 +157,16 @@ type updateBatchResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
RunIDs map[string]string `json:"run_ids,omitempty"` // server ID -> patch run ID
}
// apply_updates. The REST route (internal/api/handlers.go's applyUpdates) is
// per-server: POST /servers/:id/apply-updates resolves one server with
// services.GetServerScoped and calls services.DispatchApplyUpdates(serverID).
// There is no fleet-wide variant of that service call to invoke once, so this
// tool resolves the requested targets through ResolveTargetsScoped exactly as
// the brief describes, then calls the same DispatchApplyUpdates the REST route
// calls, once per resolved server - the identical dispatch, just looped
// instead of hardcoded to one server_id from the URL.
// services.GetServerScoped and calls services.StartManualRun. There is no
// fleet-wide variant of that service call to invoke once, so this tool
// resolves the requested targets through ResolveTargetsScoped, then calls the
// same StartManualRun once per resolved server and writes the same
// updates.applied audit event per server with its run ID.
func init() {
All().Register(Tool{
Name: "apply_updates",
@@ -178,9 +179,9 @@ func init() {
Write: true,
Scope: "servers:write",
Description: "Apply pending OS package updates on real servers, selected by " +
"server_ids and/or tags. This installs packages on real machines right now and " +
"cannot be undone from here. A server may need a reboot afterward, which this " +
"tool does not do.",
"server_ids and/or tags. Starts one manual patch run per server. This installs " +
"packages on real machines right now and cannot be undone from here. A server " +
"may need a reboot afterward, which this tool does not do.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
@@ -192,12 +193,20 @@ func init() {
}
result := updateBatchResult{Servers: len(targets), Failed: map[string]string{}}
for _, srv := range targets {
if err := services.DispatchApplyUpdates(srv.ServerID); err != nil {
for i := range targets {
srv := targets[i]
run, err := services.StartManualRun(c.InstanceID, &srv, "mcp:"+c.TokenName, models.PatchSourceMCP)
if err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
services.LogEvent(c.InstanceID, "updates.applied", "mcp:"+c.TokenName, srv.ServerID, "",
fmt.Sprintf("package update run %s started on %s", run.RunID, srv.Hostname))
result.Succeeded = append(result.Succeeded, srv.ServerID)
if result.RunIDs == nil {
result.RunIDs = map[string]string{}
}
result.RunIDs[srv.ServerID] = run.RunID
}
if len(result.Failed) == 0 {
result.Failed = nil
+41
View File
@@ -0,0 +1,41 @@
// Package metricsched sweeps passive monitors - heartbeats and, from phase 2,
// metric monitors - on a fixed tick. Nothing here runs a check: it reads what
// pings and agents already delivered and decides what is overdue or breaching.
package metricsched
import (
"context"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
const tick = 30 * time.Second
func Start(ctx context.Context) {
go func() {
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
sweep(now)
}
}
}()
}
// sweep recovers per sweep so one bad document cannot stop alerting for the
// whole instance until the next deploy.
func sweep(now time.Time) {
defer func() {
if r := recover(); r != nil {
log.Printf("metricsched: sweep panic: %v", r)
}
}()
services.SweepHeartbeats(now)
services.SweepMetricMonitors(now)
}
+46 -4
View File
@@ -7,12 +7,21 @@ import (
)
const (
MonitorHTTP = "http"
MonitorTCP = "tcp"
MonitorICMP = "icmp"
MonitorTLS = "tls"
MonitorHTTP = "http"
MonitorTCP = "tcp"
MonitorICMP = "icmp"
MonitorTLS = "tls"
MonitorMetric = "metric"
MonitorHeartbeat = "heartbeat"
)
// IsPassiveMonitor reports whether a monitor type is evaluated from data that
// arrives (a ping, an agent report) rather than by running a check. Passive
// monitors are never handed to monitorsched or to an agent.
func IsPassiveMonitor(t string) bool {
return t == MonitorMetric || t == MonitorHeartbeat
}
const (
StatusUp = "up"
StatusDown = "down"
@@ -38,6 +47,14 @@ type MonitorTarget struct {
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"`
// Metric monitors.
Selector map[string]string `bson:"selector,omitempty" json:"selector,omitempty"`
Metric string `bson:"metric,omitempty" json:"metric,omitempty"`
Threshold float64 `bson:"threshold,omitempty" json:"threshold,omitempty"`
Mount string `bson:"mount,omitempty" json:"mount,omitempty"`
// Heartbeat monitors.
PeriodSec int `bson:"period_sec,omitempty" json:"period_sec,omitempty"`
GraceSec int `bson:"grace_sec,omitempty" json:"grace_sec,omitempty"`
}
type MonitorState struct {
@@ -48,6 +65,8 @@ type MonitorState struct {
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
Fails int `bson:"fails" json:"fails"`
LastNotifiedAt *time.Time `bson:"last_notified_at,omitempty" json:"last_notified_at,omitempty"`
LastPingAt *time.Time `bson:"last_ping_at,omitempty" json:"last_ping_at,omitempty"`
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
}
type Monitor struct {
@@ -68,6 +87,13 @@ type Monitor struct {
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
State MonitorState `bson:"state" json:"state"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
// ForSec is how long a metric condition must hold before a server is down.
ForSec int `bson:"for_sec,omitempty" json:"for_sec,omitempty"`
// HeartbeatTokenHash is the SHA-256 of the ping token. The token itself is
// shown once, on create or rotate, and never stored.
HeartbeatTokenHash string `bson:"heartbeat_token_hash,omitempty" json:"-"`
// HeartbeatToken is the plaintext token, set only on the create response.
HeartbeatToken string `bson:"-" json:"heartbeat_token,omitempty"`
}
type Incident struct {
@@ -77,6 +103,22 @@ type Incident struct {
StartedAt time.Time `bson:"started_at" json:"started_at"`
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
// ServerID is set only for metric monitors, which keep one incident per
// breaching server.
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
}
// MonitorServerState is one metric monitor's view of one matching server.
type MonitorServerState struct {
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"-" json:"hostname,omitempty"`
Status string `bson:"status" json:"status"`
BreachSince *time.Time `bson:"breach_since,omitempty" json:"breach_since,omitempty"`
Value float64 `bson:"value" json:"value"`
Message string `bson:"message,omitempty" json:"message,omitempty"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// MonitorSample is one check result, kept only long enough to draw the
+116
View File
@@ -0,0 +1,116 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
PatchScopeAll = "all"
PatchScopeSecurity = "security"
PatchRebootNever = "never"
PatchRebootIfRequired = "if_required"
PatchRunRunning = "running"
PatchRunSucceeded = "succeeded"
PatchRunPartial = "partial"
PatchRunFailed = "failed"
PatchRunCancelled = "cancelled"
PatchSrvQueued = "queued"
PatchSrvWaitingOffline = "waiting_offline"
PatchSrvPatching = "patching"
PatchSrvRebooting = "rebooting"
PatchSrvSucceeded = "succeeded"
PatchSrvFailed = "failed"
PatchSrvUnsupported = "unsupported"
PatchSrvAgentTooOld = "agent_too_old"
PatchSrvMissedOffline = "missed_offline"
PatchSrvWindowClosed = "window_closed"
PatchSrvCancelled = "cancelled"
PatchSourceSchedule = "schedule"
PatchSourceRunNow = "run_now"
PatchSourceServer = "server"
PatchSourceVulnerabilities = "vulnerabilities"
PatchSourceMCP = "mcp"
)
// MaintenanceWindow answers "when" and nothing else. Policies reference it by
// ID, so one window can later serve alert muting and status page maintenance
// without a second definition of the same Sunday morning.
type MaintenanceWindow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
WindowID string `bson:"window_id" json:"window_id"`
Name string `bson:"name" json:"name"`
Cron string `bson:"cron" json:"cron"` // 5-field, window start
TZ string `bson:"tz" json:"tz"` // IANA name
DurationMinutes int `bson:"duration_minutes" json:"duration_minutes"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type PatchPolicy struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
PolicyID string `bson:"policy_id" json:"policy_id"`
Name string `bson:"name" json:"name"`
Enabled bool `bson:"enabled" json:"enabled"`
WindowID string `bson:"window_id" json:"window_id"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"`
Scope string `bson:"scope" json:"scope"`
Reboot string `bson:"reboot" json:"reboot"`
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // 0 = no cap
NotifyChannelIDs []string `bson:"notify_channel_ids,omitempty" json:"notify_channel_ids,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"`
LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"`
DisabledReason string `bson:"disabled_reason,omitempty" json:"disabled_reason,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// PatchRun is one firing of a policy, or one manual Apply updates. Scope,
// reboot and concurrency are copied from the policy at fire time so editing
// the policy never rewrites what a past run shows.
type PatchRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
RunID string `bson:"run_id" json:"run_id"`
PolicyID string `bson:"policy_id,omitempty" json:"policy_id,omitempty"`
PolicyName string `bson:"policy_name,omitempty" json:"policy_name,omitempty"`
TriggeredBy string `bson:"triggered_by" json:"triggered_by"`
Source string `bson:"source" json:"source"`
Scope string `bson:"scope" json:"scope"`
Reboot string `bson:"reboot" json:"reboot"`
MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"`
WindowEnd *time.Time `bson:"window_end,omitempty" json:"window_end,omitempty"`
Status string `bson:"status" json:"status"`
CancelledAt *time.Time `bson:"cancelled_at,omitempty" json:"cancelled_at,omitempty"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
Servers []PatchServerRun `bson:"servers" json:"servers"`
}
type PatchServerRun struct {
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
Status string `bson:"status" json:"status"`
CommandID string `bson:"command_id,omitempty" json:"-"`
PendingBefore int `bson:"pending_before" json:"pending_before"`
PendingAfter *int `bson:"pending_after,omitempty" json:"pending_after,omitempty"`
RebootedAt *time.Time `bson:"rebooted_at,omitempty" json:"rebooted_at,omitempty"`
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
// BootTimeBefore is the host's reported boot time when the reboot was
// announced. A later report with a different boot time proves the
// restart without comparing the host clock to the server clock.
BootTimeBefore *time.Time `bson:"boot_time_before,omitempty" json:"-"`
Output string `bson:"output,omitempty" json:"output,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
}
+8
View File
@@ -10,6 +10,9 @@ type PackageUpdate struct {
Name string `bson:"name" json:"name"`
CurrentVersion string `bson:"current_version,omitempty" json:"current_version,omitempty"`
NewVersion string `bson:"new_version" json:"new_version"`
// Phased is an Ubuntu phased update the host is not yet selected for: apt
// lists it but an upgrade defers it, so pending counts leave it out.
Phased bool `bson:"phased,omitempty" json:"phased,omitempty"`
}
type CPUInfo struct {
@@ -42,6 +45,11 @@ type Inventory struct {
RebootRequired bool `bson:"reboot_required,omitempty" json:"reboot_required,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
// BootTime is the host's last reported boot time, stored on every report
// that carries one so a patch reboot can be proven by a changed boot.
BootTime *time.Time `bson:"boot_time,omitempty" json:"boot_time,omitempty"`
// RebootRequiredSince is when the host first reported a pending reboot.
RebootRequiredSince *time.Time `bson:"reboot_required_since,omitempty" json:"reboot_required_since,omitempty"`
}
type Server struct {
+4
View File
@@ -11,3 +11,7 @@ type (
// 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) }
// RequireMFA re-exports shared.RequireMFA so services can read the MFA policy
// without importing shared/models directly.
func RequireMFA(s *Settings) bool { return shared.RequireMFA(s) }
+73
View File
@@ -0,0 +1,73 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// UserMFA is one user's multi-factor enrolment. It is a separate collection
// rather than fields on User because User lives in vantage-shared, which Vantage
// HQ also writes: MFA is a control-plane concern per instance.
type UserMFA struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
UserID string `bson:"user_id" json:"-"`
// WebAuthnHandle is a random 64 bytes, never the user ID: the handle is
// returned to any origin that asks a resident credential for it.
WebAuthnHandle []byte `bson:"webauthn_handle" json:"-"`
// TOTPSecretEnc is AES-256-GCM hex via services.encryptString. Mirrored in
// vantage-shared's backup.ciphertextFields - change one, change the other.
TOTPSecretEnc string `bson:"totp_secret_enc,omitempty" json:"-"`
// TOTPPendingEnc holds a secret from an unconfirmed StartTOTPSetup call.
// Transient: never mirrored into vantage-shared's backup.ciphertextFields.
TOTPPendingEnc string `bson:"totp_pending_enc,omitempty" json:"-"`
// TOTPConfirmedAt nil means setup was started but never confirmed, which
// does not count as an enrolled factor.
TOTPConfirmedAt *time.Time `bson:"totp_confirmed_at,omitempty" json:"totp_confirmed_at,omitempty"`
RecoveryCodes []RecoveryCode `bson:"recovery_codes,omitempty" json:"-"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// RecoveryCode stores only a SHA-256 hash: a leaked database yields no working
// codes, exactly as api_tokens and agent tokens do.
type RecoveryCode struct {
Hash string `bson:"hash"`
UsedAt *time.Time `bson:"used_at,omitempty"`
}
// WebAuthnCredential is one passkey. Nothing here is secret - a public key is
// public - so no field is encrypted.
type WebAuthnCredential struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
UserID string `bson:"user_id" json:"-"`
CredentialID []byte `bson:"credential_id" json:"-"`
PublicKey []byte `bson:"public_key" json:"-"`
SignCount uint32 `bson:"sign_count" json:"-"`
AAGUID []byte `bson:"aaguid" json:"-"`
Transports []string `bson:"transports,omitempty" json:"transports,omitempty"`
// BackupEligible is whether the credential can sync between devices. It
// never changes for a real credential, and go-webauthn refuses an assertion
// whose flag differs from this one, so it must be stored. Nil only on rows
// registered before it was recorded; see auth.backupEligibleFor.
BackupEligible *bool `bson:"backup_eligible,omitempty" json:"-"`
// BackupState is whether it is currently synced. It may change.
BackupState bool `bson:"backup_state" json:"-"`
// CredentialIDHex is the browser-facing identifier for rename and delete.
// The raw bytes never reach a URL.
CredentialIDHex string `bson:"credential_id_hex" json:"id"`
Name string `bson:"name" json:"name"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
}
+13 -2
View File
@@ -11,8 +11,13 @@ import (
// monitor check. MonitorName carries the hostname in that case.
const TypeServer = "server"
// TypePatch marks a patch run summary. Like a vulnerability digest it is a
// headline, not a transition, so title() adds no verb.
const TypePatch = "patch"
type Event struct {
MonitorName string
ServerName string
Type string
OldStatus string
NewStatus string
@@ -26,7 +31,7 @@ func (e Event) title() string {
verb = "is DOWN"
}
var s string
if e.Type == TypeVuln {
if e.Type == TypeVuln || e.Type == TypePatch {
// A digest is not a transition. MonitorName already carries the whole
// headline ("12 new critical across 4 servers"), so no verb applies.
s = fmt.Sprintf("[Vantage] %s", e.MonitorName)
@@ -38,7 +43,13 @@ func (e Event) title() string {
}
s = fmt.Sprintf("[Vantage] Server %s %s", e.MonitorName, verb)
} else {
s = fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
name := e.MonitorName
if e.ServerName != "" {
name = fmt.Sprintf("%s (%s) on %s", e.MonitorName, e.Type, e.ServerName)
} else {
name = fmt.Sprintf("%s (%s)", e.MonitorName, e.Type)
}
s = fmt.Sprintf("[Vantage] %s %s", name, verb)
}
if e.Message != "" {
s += ": " + e.Message
@@ -0,0 +1,24 @@
package notify
import (
"strings"
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func TestTitleIncludesServerName(t *testing.T) {
ev := Event{MonitorName: "Disk full", Type: models.MonitorMetric, NewStatus: models.StatusDown, ServerName: "web-01", Message: "/var 94.2% used"}
got := ev.title()
want := "[Vantage] Disk full (metric) on web-01 is DOWN: /var 94.2% used"
if got != want {
t.Fatalf("title = %q, want %q", got, want)
}
}
func TestTitleWithoutServerNameUnchanged(t *testing.T) {
ev := Event{MonitorName: "Site", Type: models.MonitorHTTP, NewStatus: models.StatusUp}
if got := ev.title(); strings.Contains(got, " on ") || got != "[Vantage] Site (http) recovered" {
t.Fatalf("title = %q", got)
}
}
+6 -2
View File
@@ -33,14 +33,18 @@ func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
if target == "" {
return fmt.Errorf("webhook: missing url")
}
return postJSON(target, map[string]any{
payload := map[string]any{
"monitor": ev.MonitorName,
"type": ev.Type,
"old_status": ev.OldStatus,
"new_status": ev.NewStatus,
"message": ev.Message,
"time": ev.Time.Format(time.RFC3339),
})
}
if ev.ServerName != "" {
payload["server_name"] = ev.ServerName
}
return postJSON(target, payload)
}
func dispatchDiscord(ch models.NotificationChannel, ev Event) error {
+17
View File
@@ -0,0 +1,17 @@
package notify
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// A patch summary is not a transition: it must not read "is DOWN" or
// "recovered".
func TestPatchEventTitle(t *testing.T) {
ev := Event{MonitorName: `Patch policy "Sunday prod" partial`, Type: TypePatch, NewStatus: models.PatchRunPartial, Message: "38 succeeded, 2 failed"}
want := `[Vantage] Patch policy "Sunday prod" partial: 38 succeeded, 2 failed`
if got := ev.title(); got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
+11 -1
View File
@@ -27,7 +27,7 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
}
return sender.SendMonitorAlert(to, mail.MonitorEvent{
MonitorName: ev.MonitorName,
MonitorName: smtpAlertName(ev),
Type: ev.Type,
OldStatus: ev.OldStatus,
NewStatus: ev.NewStatus,
@@ -36,3 +36,13 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
Down: ev.NewStatus == models.StatusDown,
})
}
// smtpAlertName is the name shown in the alert email. mail.MonitorEvent (in
// vantage-shared) has no ServerName field, so the server is folded into the
// name the same way title() folds it into the notification title.
func smtpAlertName(ev Event) string {
if ev.ServerName == "" {
return ev.MonitorName
}
return fmt.Sprintf("%s on %s", ev.MonitorName, ev.ServerName)
}
+17
View File
@@ -0,0 +1,17 @@
package notify
import "testing"
func TestSMTPAlertNameIncludesServerName(t *testing.T) {
ev := Event{MonitorName: "Disk full", ServerName: "web-01"}
if got, want := smtpAlertName(ev), "Disk full on web-01"; got != want {
t.Fatalf("smtpAlertName = %q, want %q", got, want)
}
}
func TestSMTPAlertNameWithoutServerNameUnchanged(t *testing.T) {
ev := Event{MonitorName: "Site"}
if got, want := smtpAlertName(ev), "Site"; got != want {
t.Fatalf("smtpAlertName = %q, want %q", got, want)
}
}
+275
View File
@@ -0,0 +1,275 @@
// Package patchrun is the patch run state machine as pure functions. Nothing
// here touches the database: the services layer loads a run, asks this
// package what should change, and writes that change guarded by the status it
// expected. That split is what makes the rules testable without MongoDB.
package patchrun
import (
"fmt"
"strconv"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
// MinAgentVersion is the first agent release that honours ApplyUpdatesCmd's
// scope and answers with a PatchResult. An older agent ignores the scope and
// installs everything, so a policy must never dispatch to one.
const MinAgentVersion = "1.4.0"
const (
// ResultGrace covers what the agent does after the upgrade and before it
// answers: a pending-update re-check of up to 10 minutes (Windows Update
// search) plus a 2 minute reboot check, with room to spare.
ResultGrace = 20 * time.Minute
// ManualTimeout is the agent's backstop for one started upgrade, counted
// from its own start. The window end never stops a running upgrade.
ManualTimeout = 2 * time.Hour
// RebootTimeout is how long a rebooting server has to send a post-boot
// inventory report. Windows cumulative updates routinely take over 20.
RebootTimeout = 45 * time.Minute
// LatestStartBeforeEnd is the tail of a window in which no server starts
// patching: a late start would run long past the window end.
LatestStartBeforeEnd = 15 * time.Minute
)
// AgentSupportsPatchResults compares major.minor.patch. Empty, "dev" and
// anything unparseable count as too old, and a pre-release of exactly the
// minimum version counts as older than it, as semver orders them.
func AgentSupportsPatchResults(version string) bool {
have, pre, ok := parseVersion(version)
if !ok {
return false
}
want, _, _ := parseVersion(MinAgentVersion)
for i := 0; i < 3; i++ {
if have[i] != want[i] {
return have[i] > want[i]
}
}
return !pre
}
func parseVersion(v string) ([3]int, bool, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
v, _, _ = strings.Cut(v, "+")
core, pre, hasPre := strings.Cut(v, "-")
parts := strings.Split(core, ".")
if len(parts) != 3 {
return out, false, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false, false
}
out[i] = n
}
return out, hasPre && pre != "", true
}
// IsTerminal reports whether a server run has finished.
func IsTerminal(status string) bool {
switch status {
case models.PatchSrvQueued, models.PatchSrvWaitingOffline, models.PatchSrvPatching, models.PatchSrvRebooting:
return false
}
return true
}
// Transition is one change Advance wants made. The caller writes it guarded
// by From, so a result that arrived meanwhile is never overwritten. Dispatch
// means: send ApplyUpdatesCmd, and set To only once the command is on its way.
type Transition struct {
ServerID string
From string
To string
Error string
Dispatch bool
}
// Advance returns what should change on this tick. connected says which
// agents hold a command stream right now.
func Advance(run models.PatchRun, now time.Time, connected map[string]bool) []Transition {
if run.Status != models.PatchRunRunning {
return nil
}
windowOpen := run.WindowEnd == nil || now.Before(*run.WindowEnd)
// In the last LatestStartBeforeEnd of a window nothing new starts: queued
// and waiting servers simply wait, and close at WindowEnd as usual.
mayStart := run.WindowEnd == nil || now.Before(run.WindowEnd.Add(-LatestStartBeforeEnd))
inFlight := 0
for _, s := range run.Servers {
if s.Status == models.PatchSrvPatching || s.Status == models.PatchSrvRebooting {
inFlight++
}
}
var out []Transition
for _, s := range run.Servers {
switch s.Status {
case models.PatchSrvQueued, models.PatchSrvWaitingOffline:
switch {
case run.CancelledAt != nil:
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvCancelled})
case !windowOpen:
to := models.PatchSrvWindowClosed
if s.Status == models.PatchSrvWaitingOffline {
to = models.PatchSrvMissedOffline
}
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: to})
case !mayStart:
// The window tail: no dispatch, no transition.
case run.MaxConcurrent > 0 && inFlight >= run.MaxConcurrent:
// No slot this tick.
case !connected[s.ServerID]:
if s.Status == models.PatchSrvQueued {
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvWaitingOffline})
}
default:
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvPatching, Dispatch: true})
inFlight++
}
case models.PatchSrvPatching:
if now.After(resultDeadline(run, s)) {
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvFailed, Error: "no result from agent"})
}
case models.PatchSrvRebooting:
if s.RebootedAt != nil && now.After(s.RebootedAt.Add(RebootTimeout)) {
out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvFailed, Error: fmt.Sprintf("did not come back within %d minutes", int(RebootTimeout.Minutes()))})
}
}
}
return out
}
// resultDeadline is the same for windowed and manual runs: the agent lets a
// started upgrade finish past the window end, so the window end says nothing
// about when a result is due. The base is the server's dispatch time.
func resultDeadline(run models.PatchRun, s models.PatchServerRun) time.Time {
start := run.StartedAt
if s.StartedAt != nil {
start = *s.StartedAt
}
return start.Add(ManualTimeout + ResultGrace)
}
// ApplyResult folds an agent's PatchResult into the server run. It only acts
// on a server that is patching; anything else is a late or duplicate result.
func ApplyResult(s models.PatchServerRun, r *pb.PatchResult, now time.Time) (models.PatchServerRun, bool) {
if s.Status != models.PatchSrvPatching {
return s, false
}
s.Output = r.OutputTail
if r.PendingAfter >= 0 {
v := int(r.PendingAfter)
s.PendingAfter = &v
}
switch r.Status {
case pb.PatchStatusOK:
if r.Rebooting {
s.Status = models.PatchSrvRebooting
s.RebootedAt = &now
return s, true
}
s.Status = models.PatchSrvSucceeded
case pb.PatchStatusUnsupported:
s.Status = models.PatchSrvUnsupported
s.Error = r.Message
default:
s.Status = models.PatchSrvFailed
s.Error = r.Message
if s.Error == "" {
s.Error = "agent reported a failure"
}
}
s.FinishedAt = &now
return s, true
}
// VerifyReboot settles a rebooting server from a static inventory report.
// When the boot time reported before the reboot is known, a later boot time
// is the proof: both come from the host clock, so skew against the server
// clock does not matter. Otherwise only a boot time later than the reboot
// command counts. Either way a snapshot sent during the one-minute grace
// period, before the host went down, does not.
func VerifyReboot(s models.PatchServerRun, bootTime time.Time, rebootRequired bool, now time.Time) (models.PatchServerRun, bool) {
if s.Status != models.PatchSrvRebooting || s.RebootedAt == nil {
return s, false
}
proven := bootTime.After(*s.RebootedAt)
if s.BootTimeBefore != nil {
proven = bootTime.After(s.BootTimeBefore.Add(BootTimeTolerance))
}
if !proven {
return s, false
}
if rebootRequired {
s.Status = models.PatchSrvFailed
s.Error = "still requires a reboot after restarting"
} else {
s.Status = models.PatchSrvSucceeded
s.VerifiedAt = &now
}
s.FinishedAt = &now
return s, true
}
// BootTimeTolerance is how far a reported boot time must move before it counts
// as a reboot. Windows reports boot time as now minus uptime, which drifts by a
// second or so between reports; a real reboot moves it by far more than this.
const BootTimeTolerance = 2 * time.Minute
// Finalize says whether the run is over and how it ended. Only succeeded
// counts as success: unsupported, agent_too_old and the window outcomes did
// not patch anything.
func Finalize(run models.PatchRun) (string, bool) {
ok := 0
for _, s := range run.Servers {
if !IsTerminal(s.Status) {
return "", false
}
if s.Status == models.PatchSrvSucceeded {
ok++
}
}
switch {
case run.CancelledAt != nil:
return models.PatchRunCancelled, true
case len(run.Servers) > 0 && ok == len(run.Servers):
return models.PatchRunSucceeded, true
case ok == 0:
return models.PatchRunFailed, true
default:
return models.PatchRunPartial, true
}
}
var summaryOrder = []struct{ status, label string }{
{models.PatchSrvSucceeded, "succeeded"},
{models.PatchSrvFailed, "failed"},
{models.PatchSrvUnsupported, "unsupported"},
{models.PatchSrvAgentTooOld, "need an agent update"},
{models.PatchSrvMissedOffline, "missed offline"},
{models.PatchSrvWindowClosed, "window closed"},
{models.PatchSrvCancelled, "cancelled"},
}
// Summary is the one-line count used in alerts, e.g. "38 succeeded, 2 failed".
func Summary(run models.PatchRun) string {
counts := map[string]int{}
for _, s := range run.Servers {
counts[s.Status]++
}
var parts []string
for _, o := range summaryOrder {
if n := counts[o.status]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, o.label))
}
}
return strings.Join(parts, ", ")
}
+341
View File
@@ -0,0 +1,341 @@
package patchrun
import (
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
var t0 = time.Date(2026, 9, 20, 2, 0, 0, 0, time.UTC)
func tp(t time.Time) *time.Time { return &t }
func srv(id, status string) models.PatchServerRun {
return models.PatchServerRun{ServerID: id, Hostname: id, Status: status}
}
func windowRun(max int, servers ...models.PatchServerRun) models.PatchRun {
return models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, WindowEnd: tp(t0.Add(2 * time.Hour)), MaxConcurrent: max, Servers: servers}
}
func find(ts []Transition, id string) *Transition {
for i := range ts {
if ts[i].ServerID == id {
return &ts[i]
}
}
return nil
}
func TestAgentSupportsPatchResults(t *testing.T) {
cases := map[string]bool{
"1.4.0": true, "v1.4.0": true, "1.4.1": true, "1.10.0": true, "2.0.0": true,
"1.3.5": false, "1.4.0-rc1": false, "1.5.0-rc1": true,
"": false, "dev": false, "1.4": false, "x.y.z": false,
}
for v, want := range cases {
if got := AgentSupportsPatchResults(v); got != want {
t.Errorf("%q: got %v, want %v", v, got, want)
}
}
}
func TestAdvanceRespectsConcurrencyCountingRebooting(t *testing.T) {
run := windowRun(2,
srv("a", models.PatchSrvRebooting),
srv("b", models.PatchSrvQueued),
srv("c", models.PatchSrvQueued),
)
run.Servers[0].RebootedAt = tp(t0)
ts := Advance(run, t0.Add(time.Minute), map[string]bool{"b": true, "c": true})
if tr := find(ts, "b"); tr == nil || !tr.Dispatch || tr.To != models.PatchSrvPatching {
t.Fatalf("b should dispatch, got %+v", tr)
}
if tr := find(ts, "c"); tr != nil {
t.Fatalf("c must wait for a slot, got %+v", tr)
}
}
func TestAdvanceUnlimitedWhenZero(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued), srv("b", models.PatchSrvQueued))
ts := Advance(run, t0, map[string]bool{"a": true, "b": true})
if len(ts) != 2 || !ts[0].Dispatch || !ts[1].Dispatch {
t.Fatalf("both should dispatch: %+v", ts)
}
}
func TestAdvanceOfflineWaitsThenDispatches(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued))
ts := Advance(run, t0, map[string]bool{})
if len(ts) != 1 || ts[0].To != models.PatchSrvWaitingOffline || ts[0].Dispatch {
t.Fatalf("offline should wait: %+v", ts)
}
run.Servers[0].Status = models.PatchSrvWaitingOffline
if ts := Advance(run, t0.Add(time.Minute), map[string]bool{}); len(ts) != 0 {
t.Fatalf("still offline must be a no-op, got %+v", ts)
}
ts = Advance(run, t0.Add(2*time.Minute), map[string]bool{"a": true})
if len(ts) != 1 || !ts[0].Dispatch {
t.Fatalf("back online should dispatch: %+v", ts)
}
}
func TestAdvanceWindowCloses(t *testing.T) {
run := windowRun(1, srv("q", models.PatchSrvQueued), srv("w", models.PatchSrvWaitingOffline))
ts := Advance(run, t0.Add(2*time.Hour), map[string]bool{"q": true, "w": true})
if tr := find(ts, "q"); tr == nil || tr.To != models.PatchSrvWindowClosed || tr.Dispatch {
t.Fatalf("queued at window end: %+v", tr)
}
if tr := find(ts, "w"); tr == nil || tr.To != models.PatchSrvMissedOffline {
t.Fatalf("waiting at window end: %+v", tr)
}
}
// A windowed run times out from the server's own dispatch time, not from the
// window end: a server dispatched late in the window may finish past it.
func TestAdvanceNoResultTimeoutFromDispatch(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvPatching))
run.Servers[0].StartedAt = tp(t0.Add(90 * time.Minute))
deadline := t0.Add(90*time.Minute + ManualTimeout + ResultGrace)
if ts := Advance(run, run.WindowEnd.Add(ResultGrace+time.Minute), nil); len(ts) != 0 {
t.Fatalf("past WindowEnd+grace but inside the dispatch timeout: %+v", ts)
}
if ts := Advance(run, deadline.Add(-time.Minute), nil); len(ts) != 0 {
t.Fatalf("inside the dispatch timeout: %+v", ts)
}
ts := Advance(run, deadline.Add(time.Minute), nil)
if len(ts) != 1 || ts[0].To != models.PatchSrvFailed || ts[0].Error == "" {
t.Fatalf("past the dispatch timeout: %+v", ts)
}
}
// Without a server StartedAt the run's own start is the base.
func TestAdvanceNoResultTimeoutFallsBackToRunStart(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvPatching))
if ts := Advance(run, t0.Add(2*time.Hour+19*time.Minute), nil); len(ts) != 0 {
t.Fatalf("inside timeout: %+v", ts)
}
if ts := Advance(run, t0.Add(2*time.Hour+21*time.Minute), nil); len(ts) != 1 || ts[0].To != models.PatchSrvFailed {
t.Fatalf("past timeout: %+v", ts)
}
}
func TestAdvanceManualRunTimeout(t *testing.T) {
run := models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, Servers: []models.PatchServerRun{srv("a", models.PatchSrvPatching)}}
run.Servers[0].StartedAt = tp(t0)
if ts := Advance(run, t0.Add(2*time.Hour+19*time.Minute), nil); len(ts) != 0 {
t.Fatalf("manual inside timeout: %+v", ts)
}
if ts := Advance(run, t0.Add(2*time.Hour+21*time.Minute), nil); len(ts) != 1 || ts[0].To != models.PatchSrvFailed {
t.Fatalf("manual past timeout: %+v", ts)
}
}
func TestTimingConstants(t *testing.T) {
if ResultGrace != 20*time.Minute || RebootTimeout != 45*time.Minute || LatestStartBeforeEnd != 15*time.Minute {
t.Fatalf("ResultGrace=%v RebootTimeout=%v LatestStartBeforeEnd=%v", ResultGrace, RebootTimeout, LatestStartBeforeEnd)
}
}
// No server starts patching in the last 15 minutes of a window: it would
// either be cut short or run long past the window end.
func TestAdvanceNoDispatchInWindowTail(t *testing.T) {
run := windowRun(0, srv("q", models.PatchSrvQueued), srv("w", models.PatchSrvWaitingOffline), srv("o", models.PatchSrvQueued))
cutoff := run.WindowEnd.Add(-LatestStartBeforeEnd)
online := map[string]bool{"q": true, "w": true}
for _, at := range []time.Time{cutoff, cutoff.Add(time.Minute), run.WindowEnd.Add(-time.Second)} {
if ts := Advance(run, at, online); len(ts) != 0 {
t.Fatalf("at %v: nothing may change in the window tail, got %+v", at.Sub(t0), ts)
}
}
}
func TestAdvanceDispatchJustBeforeWindowTail(t *testing.T) {
run := windowRun(0, srv("q", models.PatchSrvQueued))
at := run.WindowEnd.Add(-LatestStartBeforeEnd - time.Second)
ts := Advance(run, at, map[string]bool{"q": true})
if len(ts) != 1 || !ts[0].Dispatch {
t.Fatalf("dispatch must be allowed just before the tail: %+v", ts)
}
}
// A manual run has no window and no tail.
func TestAdvanceManualRunHasNoTail(t *testing.T) {
run := models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, Servers: []models.PatchServerRun{srv("a", models.PatchSrvQueued)}}
if ts := Advance(run, t0.Add(10*time.Hour), map[string]bool{"a": true}); len(ts) != 1 || !ts[0].Dispatch {
t.Fatalf("manual run must dispatch: %+v", ts)
}
}
func TestAdvanceRebootTimeout(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvRebooting))
run.Servers[0].RebootedAt = tp(t0)
if ts := Advance(run, t0.Add(44*time.Minute), nil); len(ts) != 0 {
t.Fatalf("inside reboot timeout: %+v", ts)
}
ts := Advance(run, t0.Add(46*time.Minute), nil)
if len(ts) != 1 || ts[0].To != models.PatchSrvFailed || ts[0].Error != "did not come back within 45 minutes" {
t.Fatalf("past reboot timeout: %+v", ts)
}
}
func TestAdvanceCancelledDispatchesNothing(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued), srv("b", models.PatchSrvWaitingOffline), srv("c", models.PatchSrvPatching))
run.CancelledAt = tp(t0)
ts := Advance(run, t0.Add(time.Minute), map[string]bool{"a": true, "b": true})
for _, id := range []string{"a", "b"} {
if tr := find(ts, id); tr == nil || tr.To != models.PatchSrvCancelled || tr.Dispatch {
t.Errorf("%s: %+v", id, tr)
}
}
if find(ts, "c") != nil {
t.Error("an in-flight server must be left to finish")
}
}
func TestAdvanceIgnoresFinishedRun(t *testing.T) {
run := windowRun(0, srv("a", models.PatchSrvQueued))
run.Status = models.PatchRunSucceeded
if ts := Advance(run, t0, map[string]bool{"a": true}); ts != nil {
t.Fatalf("got %+v", ts)
}
}
func TestApplyResult(t *testing.T) {
now := t0.Add(10 * time.Minute)
cases := []struct {
r pb.PatchResult
want string
}{
{pb.PatchResult{Status: pb.PatchStatusOK, PendingAfter: 0}, models.PatchSrvSucceeded},
{pb.PatchResult{Status: pb.PatchStatusOK, Rebooting: true}, models.PatchSrvRebooting},
{pb.PatchResult{Status: pb.PatchStatusFailed, Message: "apt broke"}, models.PatchSrvFailed},
{pb.PatchResult{Status: pb.PatchStatusBusy, Message: "busy"}, models.PatchSrvFailed},
{pb.PatchResult{Status: pb.PatchStatusUnsupported, Message: "no metadata"}, models.PatchSrvUnsupported},
}
for _, c := range cases {
got, ok := ApplyResult(srv("a", models.PatchSrvPatching), &c.r, now)
if !ok || got.Status != c.want {
t.Errorf("%s: got %s ok=%v, want %s", c.r.Status, got.Status, ok, c.want)
}
if c.want == models.PatchSrvRebooting && (got.RebootedAt == nil || got.FinishedAt != nil) {
t.Errorf("rebooting must set RebootedAt and leave FinishedAt nil: %+v", got)
}
}
if _, ok := ApplyResult(srv("a", models.PatchSrvSucceeded), &pb.PatchResult{Status: pb.PatchStatusOK}, now); ok {
t.Error("a result for a server not patching must be ignored")
}
got, _ := ApplyResult(srv("a", models.PatchSrvPatching), &pb.PatchResult{Status: pb.PatchStatusOK, PendingAfter: -1}, now)
if got.PendingAfter != nil {
t.Error("PendingAfter -1 means unknown and must stay nil")
}
}
func TestVerifyReboot(t *testing.T) {
s := srv("a", models.PatchSrvRebooting)
s.RebootedAt = tp(t0)
now := t0.Add(5 * time.Minute)
if _, ok := VerifyReboot(s, t0.Add(-time.Hour), false, now); ok {
t.Error("boot before the reboot command is not proof")
}
if _, ok := VerifyReboot(s, t0, false, now); ok {
t.Error("boot equal to the reboot command is not proof")
}
got, ok := VerifyReboot(s, t0.Add(2*time.Minute), false, now)
if !ok || got.Status != models.PatchSrvSucceeded || got.VerifiedAt == nil {
t.Errorf("clean reboot: %+v", got)
}
got, ok = VerifyReboot(s, t0.Add(2*time.Minute), true, now)
if !ok || got.Status != models.PatchSrvFailed || got.Error == "" {
t.Errorf("still owed: %+v", got)
}
if _, ok := VerifyReboot(srv("a", models.PatchSrvPatching), t0.Add(time.Hour), false, now); ok {
t.Error("only rebooting servers verify")
}
}
// With the boot time recorded before the reboot, a changed boot time is the
// proof, whatever the skew between the host clock and the server clock.
func TestVerifyRebootChangedBoot(t *testing.T) {
s := srv("a", models.PatchSrvRebooting)
s.RebootedAt = tp(t0)
s.BootTimeBefore = tp(t0.Add(-10 * 24 * time.Hour))
now := t0.Add(5 * time.Minute)
// The host clock runs 10 minutes slow: its new boot time reads earlier
// than the server's RebootedAt, yet the boot did change.
got, ok := VerifyReboot(s, t0.Add(-8*time.Minute), false, now)
if !ok || got.Status != models.PatchSrvSucceeded {
t.Fatalf("changed boot behind a slow clock must be proven: %+v ok=%v", got, ok)
}
// A fast host clock with an unchanged boot is not proof.
if _, ok := VerifyReboot(s, *s.BootTimeBefore, false, now); ok {
t.Error("an unchanged boot time is not proof")
}
if _, ok := VerifyReboot(s, s.BootTimeBefore.Add(-time.Minute), false, now); ok {
t.Error("an earlier boot time is not proof")
}
}
// Windows derives boot time from now minus uptime, so it drifts by a second or
// so between reports. A report sent in the grace period before the reboot
// must not read as a reboot, or a server still owing one is marked failed.
func TestVerifyRebootIgnoresBootTimeDrift(t *testing.T) {
s := srv("a", models.PatchSrvRebooting)
s.RebootedAt = tp(t0)
s.BootTimeBefore = tp(t0.Add(-10 * 24 * time.Hour))
now := t0.Add(30 * time.Second)
for _, drift := range []time.Duration{time.Second, BootTimeTolerance} {
if _, ok := VerifyReboot(s, s.BootTimeBefore.Add(drift), true, now); ok {
t.Errorf("a boot time drifted by %s is not proof of a reboot", drift)
}
}
got, ok := VerifyReboot(s, s.BootTimeBefore.Add(BootTimeTolerance+time.Second), false, now)
if !ok || got.Status != models.PatchSrvSucceeded {
t.Fatalf("a boot time past the tolerance is a reboot: %+v ok=%v", got, ok)
}
}
func TestFinalize(t *testing.T) {
mk := func(statuses ...string) models.PatchRun {
r := windowRun(0)
for i, s := range statuses {
r.Servers = append(r.Servers, srv(string(rune('a'+i)), s))
}
return r
}
cases := []struct {
run models.PatchRun
want string
done bool
}{
{mk(models.PatchSrvSucceeded, models.PatchSrvSucceeded), models.PatchRunSucceeded, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvFailed), models.PatchRunPartial, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvUnsupported), models.PatchRunPartial, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvAgentTooOld), models.PatchRunPartial, true},
{mk(models.PatchSrvMissedOffline, models.PatchSrvWindowClosed), models.PatchRunFailed, true},
{mk(models.PatchSrvSucceeded, models.PatchSrvPatching), "", false},
{mk(), models.PatchRunFailed, true},
}
for i, c := range cases {
got, done := Finalize(c.run)
if got != c.want || done != c.done {
t.Errorf("case %d: got %q/%v, want %q/%v", i, got, done, c.want, c.done)
}
}
cancelled := mk(models.PatchSrvSucceeded, models.PatchSrvCancelled)
cancelled.CancelledAt = tp(t0)
if got, done := Finalize(cancelled); got != models.PatchRunCancelled || !done {
t.Errorf("cancelled: %q/%v", got, done)
}
}
func TestSummary(t *testing.T) {
r := windowRun(0,
srv("a", models.PatchSrvSucceeded), srv("b", models.PatchSrvSucceeded),
srv("c", models.PatchSrvFailed), srv("d", models.PatchSrvMissedOffline))
if got := Summary(r); got != "2 succeeded, 1 failed, 1 missed offline" {
t.Fatalf("got %q", got)
}
}
+55
View File
@@ -0,0 +1,55 @@
// Package patchsched fires patch policies at the start of their maintenance
// window and advances running patch runs. Like workflowsched it runs under the
// housekeeping leader lock and must not import services: services imports
// this package for NextStart and WindowEnd.
package patchsched
import (
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
)
type Decision string
const (
Fire Decision = "fire"
SkipMissed Decision = "missed"
SkipRunning Decision = "already_running"
SkipNoTargets Decision = "no_targets"
)
// Decide is the whole fire or skip rule for one due policy. Missed comes
// first, as in workflowsched: a stale occurrence is recorded as missed
// whatever else is true. A window that has already closed is missed even
// inside the hour of grace, because there is no time left to patch in.
func Decide(due, windowEnd, now time.Time, runActive bool, targets int) Decision {
if !now.Before(windowEnd) || now.Sub(due) > workflowsched.GraceWindow {
return SkipMissed
}
if runActive {
return SkipRunning
}
if targets == 0 {
return SkipNoTargets
}
return Fire
}
func WindowEnd(start time.Time, durationMinutes int) time.Time {
return start.Add(time.Duration(durationMinutes) * time.Minute)
}
// NextStart is the first window start strictly after from. Callers pass
// Later(now, currentWindowEnd) so windows never overlap, including across a
// daylight-saving fall-back where the same wall-clock time occurs twice.
func NextStart(cron, tz string, from time.Time) (time.Time, error) {
return workflowsched.NextOccurrence(cron, tz, from)
}
func Later(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
+66
View File
@@ -0,0 +1,66 @@
package patchsched
import (
"testing"
"time"
)
var due = time.Date(2026, 9, 20, 2, 0, 0, 0, time.UTC)
func TestDecide(t *testing.T) {
end := due.Add(2 * time.Hour)
cases := []struct {
name string
now time.Time
end time.Time
running bool
targets int
want Decision
}{
{"on time", due, end, false, 3, Fire},
{"late within grace", due.Add(59 * time.Minute), end, false, 3, Fire},
{"past grace", due.Add(61 * time.Minute), end, false, 3, SkipMissed},
{"window already over", due.Add(20 * time.Minute), due.Add(15 * time.Minute), false, 3, SkipMissed},
{"missed wins over running", due.Add(2 * time.Hour), end, true, 3, SkipMissed},
{"previous run active", due, end, true, 3, SkipRunning},
{"no targets", due, end, false, 0, SkipNoTargets},
}
for _, c := range cases {
if got := Decide(due, c.end, c.now, c.running, c.targets); got != c.want {
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
}
}
}
// Europe/London falls back on 25 October 2026, so 01:30 happens twice. The
// next window is computed from the end of the current one, so a two-hour
// window starting at the first 01:30 cannot fire again at the second.
func TestNextStartAcrossFallBack(t *testing.T) {
loc, _ := time.LoadLocation("Europe/London")
from := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
first, err := NextStart("30 1 * * 0", "Europe/London", from)
if err != nil {
t.Fatal(err)
}
if d := first.In(loc); d.Day() != 25 || d.Month() != time.October || d.Hour() != 1 || d.Minute() != 30 {
t.Fatalf("first = %s", d)
}
end := WindowEnd(first, 120)
next, err := NextStart("30 1 * * 0", "Europe/London", Later(first.Add(time.Minute), end))
if err != nil {
t.Fatal(err)
}
if d := next.In(loc); d.Day() != 1 || d.Month() != time.November || d.Hour() != 1 || d.Minute() != 30 {
t.Fatalf("next = %s, want 2026-11-01 01:30 London", d)
}
}
func TestWindowEndAndLater(t *testing.T) {
if got := WindowEnd(due, 90); !got.Equal(due.Add(90 * time.Minute)) {
t.Fatalf("WindowEnd = %s", got)
}
a, b := due, due.Add(time.Second)
if !Later(a, b).Equal(b) || !Later(b, a).Equal(b) {
t.Fatal("Later must return the later time")
}
}
+159
View File
@@ -0,0 +1,159 @@
package patchsched
import (
"context"
"errors"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const tickInterval = 30 * time.Second
// Deps are injected from main.go: services imports this package, so this
// package cannot import services.
type Deps struct {
// LookupWindow returns nil, nil when the window no longer exists.
LookupWindow func(instanceID, windowID string) (*models.MaintenanceWindow, error)
CountTargets func(p models.PatchPolicy) (int, error)
StartPolicyRun func(p models.PatchPolicy, windowEnd time.Time) error
AdvanceRuns func(ctx context.Context)
LogEvent func(instanceID, eventType, actor, serverID, keyID, details string)
}
// Start runs until ctx is cancelled, inside bus.RunAsLeader("housekeeping").
// Each tick fires due policies, then advances every running run.
func Start(ctx context.Context, deps Deps) {
go func() {
t := time.NewTicker(tickInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
fireDue(ctx, deps, time.Now())
deps.AdvanceRuns(ctx)
}
}
}()
}
func fireDue(ctx context.Context, deps Deps, now time.Time) {
cur, err := db.Col("patch_policies").Find(ctx, bson.M{"enabled": true, "next_run_at": bson.M{"$lte": now}})
if err != nil {
log.Printf("patchsched: find due: %v", err)
return
}
var due []models.PatchPolicy
if err := cur.All(ctx, &due); err != nil {
log.Printf("patchsched: decode due: %v", err)
return
}
for _, p := range due {
if ctx.Err() != nil {
return
}
process(ctx, deps, p, now)
}
}
func process(ctx context.Context, deps Deps, p models.PatchPolicy, now time.Time) {
if p.NextRunAt == nil {
return
}
due := *p.NextRunAt
w, err := deps.LookupWindow(p.InstanceID, p.WindowID)
if err != nil {
log.Printf("patchsched: policy %s: load window: %v", p.PolicyID, err)
return // a database error is retried next tick, not treated as "gone"
}
if w == nil {
disable(ctx, deps, p, "its maintenance window no longer exists")
return
}
end := WindowEnd(due, w.DurationMinutes)
next, err := NextStart(w.Cron, w.TZ, Later(now, end))
if err != nil {
disable(ctx, deps, p, "its maintenance window schedule is no longer valid: "+err.Error())
return
}
// The claim, as in workflowsched: matching the current next_run_at means a
// second process reaching this policy matches nothing.
res, err := db.Col("patch_policies").UpdateOne(ctx,
bson.M{"policy_id": p.PolicyID, "next_run_at": due},
bson.M{"$set": bson.M{"next_run_at": next}})
if err != nil || res.MatchedCount == 0 {
return
}
n, err := deps.CountTargets(p)
if err != nil {
retryLater(ctx, deps, p, err, due, next, now)
return
}
active, err := hasActiveRun(ctx, p)
if err != nil {
retryLater(ctx, deps, p, err, due, next, now)
return
}
switch d := Decide(due, end, now, active, n); d {
case Fire:
if err := deps.StartPolicyRun(p, end); err != nil {
retryLater(ctx, deps, p, err, due, next, now)
return
}
_, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID},
bson.M{"$set": bson.M{"last_run_at": now}, "$unset": bson.M{"last_skipped": ""}})
default:
recordSkip(ctx, deps, p, string(d), due, now)
}
}
func hasActiveRun(ctx context.Context, p models.PatchPolicy) (bool, error) {
err := db.Col("patch_runs").FindOne(ctx,
bson.M{"instance_id": p.InstanceID, "policy_id": p.PolicyID, "status": models.PatchRunRunning},
options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
if err == nil {
return true, nil
}
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
return false, err
}
// retryLater handles an error after the claim. The claim is put back, guarded
// on the value just written so a concurrent edit to the policy is not undone,
// and the next tick retries the same occurrence. Decide's missed rule bounds
// the retries: once the occurrence is too late it is skipped as missed.
func retryLater(ctx context.Context, deps Deps, p models.PatchPolicy, cause error, due, next, now time.Time) {
if _, err := db.Col("patch_policies").UpdateOne(ctx,
bson.M{"policy_id": p.PolicyID, "next_run_at": next},
bson.M{"$set": bson.M{"next_run_at": due}}); err != nil {
log.Printf("patchsched: policy %s: put back claim: %v", p.PolicyID, err)
}
recordSkip(ctx, deps, p, "error: "+cause.Error(), due, now)
}
func recordSkip(ctx context.Context, deps Deps, p models.PatchPolicy, reason string, due, at time.Time) {
_, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID},
bson.M{"$set": bson.M{"last_skipped": models.Skip{Reason: reason, Due: due, At: at}}})
deps.LogEvent(p.InstanceID, "patch.skipped", "schedule", "", "",
"patch policy "+p.Name+" skipped "+due.Format(time.RFC3339)+": "+reason)
}
func disable(ctx context.Context, deps Deps, p models.PatchPolicy, reason string) {
_, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID}, bson.M{
"$set": bson.M{"enabled": false, "disabled_reason": reason},
"$unset": bson.M{"next_run_at": ""},
})
deps.LogEvent(p.InstanceID, "patch.policy_disabled", "schedule", "", "", "patch policy "+p.Name+" disabled: "+reason)
}
+25
View File
@@ -41,3 +41,28 @@ func EnsureAuthIndexes() error {
}
return nil
}
// EnsureMFAIndexes is fatal on failure like EnsureAuthIndexes, and for the same
// reason: these unique indexes are a security property, not an optimisation. A
// duplicate (instance_id, user_id) would make "this user's factors" ambiguous,
// and a duplicate credential_id would let an assertion resolve to two users.
func EnsureMFAIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := db.Col("user_mfa").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
_, err := db.Col("webauthn_credentials").Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "credential_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}},
})
return err
}
+237
View File
@@ -0,0 +1,237 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const (
HeartbeatPing = "ping"
HeartbeatStart = "start"
HeartbeatFail = "fail"
)
// MaxHeartbeatBody bounds what a /fail request can put into an incident cause
// and a notification. A job's stderr can be megabytes; the first kilobyte is
// what a human reads.
const MaxHeartbeatBody = 1024
const defaultHeartbeatGraceSec = 300
var ErrHeartbeatNotFound = errors.New("heartbeat not found")
// ErrInvalidMonitor marks a validation failure, which handlers answer with 400.
var ErrInvalidMonitor = errors.New("invalid monitor")
func NewHeartbeatToken() (string, string, error) {
raw := make([]byte, 24)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
tok := base64.RawURLEncoding.EncodeToString(raw)
return tok, HashHeartbeatToken(tok), nil
}
func HashHeartbeatToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func validateHeartbeat(t *models.MonitorTarget) error {
if t.PeriodSec < 60 {
return fmt.Errorf("%w: period_sec must be at least 60", ErrInvalidMonitor)
}
if t.GraceSec < 0 {
return fmt.Errorf("%w: grace_sec must not be negative", ErrInvalidMonitor)
}
if t.GraceSec == 0 {
t.GraceSec = defaultHeartbeatGraceSec
}
return nil
}
func failMessage(body string) string {
body = strings.TrimSpace(body)
if len(body) > MaxHeartbeatBody {
body = body[:MaxHeartbeatBody]
}
if body == "" {
return "reported failure"
}
return "reported failure: " + body
}
// heartbeatVerdict decides whether a heartbeat is overdue. A heartbeat that has
// never pinged is never down: the clock starts at the first ping, so creating
// one before the job is deployed does not page anyone.
func heartbeatVerdict(m models.Monitor, now time.Time) (bool, string) {
grace := time.Duration(m.Target.GraceSec) * time.Second
if m.State.StartedAt != nil && now.After(m.State.StartedAt.Add(grace)) {
return true, fmt.Sprintf("started %s, never finished", m.State.StartedAt.UTC().Format(time.RFC3339))
}
if m.State.LastPingAt == nil {
return false, ""
}
deadline := m.State.LastPingAt.Add(time.Duration(m.Target.PeriodSec)*time.Second + grace)
if now.After(deadline) {
return true, fmt.Sprintf("no ping since %s", m.State.LastPingAt.UTC().Format(time.RFC3339))
}
return false, ""
}
// RecordHeartbeat applies one ping. The state change is a single
// FindOneAndUpdate on the token hash, so two pings racing each other cannot
// both read the old state and lose one of the writes.
func RecordHeartbeat(token, kind, body string, now time.Time) error {
ctx, cancel := monCtx()
defer cancel()
filter := bson.M{"heartbeat_token_hash": HashHeartbeatToken(token), "type": models.MonitorHeartbeat, "enabled": true}
set := bson.M{"state.last_check_at": now}
unset := bson.M{}
var next, message string
switch kind {
case HeartbeatStart:
set["state.started_at"] = now
case HeartbeatPing:
set["state.last_ping_at"] = now
set["state.status"] = models.StatusUp
set["state.message"] = ""
unset["state.started_at"] = ""
next = models.StatusUp
case HeartbeatFail:
message = failMessage(body)
set["state.last_ping_at"] = now
set["state.status"] = models.StatusDown
set["state.message"] = message
unset["state.started_at"] = ""
next = models.StatusDown
default:
return fmt.Errorf("unknown heartbeat kind %q", kind)
}
upd := bson.M{"$set": set}
if len(unset) > 0 {
upd["$unset"] = unset
}
// ReturnDocument Before: the previous status and started_at are what the
// transition and the duration need.
var before models.Monitor
err := db.Col("monitors").FindOneAndUpdate(ctx, filter, upd,
options.FindOneAndUpdate().SetReturnDocument(options.Before)).Decode(&before)
if errors.Is(err, mongo.ErrNoDocuments) {
return ErrHeartbeatNotFound
}
if err != nil {
return err
}
if kind == HeartbeatStart {
return nil
}
latency := 0
if kind == HeartbeatPing && before.State.StartedAt != nil {
latency = int(now.Sub(*before.State.StartedAt).Milliseconds())
}
if latency > 0 {
db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": before.MonitorID}, bson.M{"$set": bson.M{"state.latency_ms": latency}})
}
recordSample(ctx, &before, kind == HeartbeatPing, latency, now)
applyTransition(ctx, &before, "", "", before.State.Status, next, message, now)
return nil
}
// SweepHeartbeats marks overdue heartbeats down. Recovery only ever comes from
// a ping, so the sweep never moves anything up.
func SweepHeartbeats(now time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{
"type": models.MonitorHeartbeat, "enabled": true,
"state.status": bson.M{"$ne": models.StatusDown},
})
if err != nil {
log.Printf("heartbeats: list: %v", err)
return
}
var monitors []models.Monitor
if err := cur.All(ctx, &monitors); err != nil {
log.Printf("heartbeats: decode: %v", err)
return
}
for i := range monitors {
m := &monitors[i]
down, msg := heartbeatVerdict(*m, now)
if !down {
continue
}
// Guarded on the exact fields the verdict was computed from, so a
// ping landing between the read and this write (which changes
// last_ping_at or started_at without necessarily changing status)
// makes the filter match nothing rather than overwriting a monitor
// that is no longer overdue.
res, err := db.Col("monitors").UpdateOne(ctx,
sweepGuardFilter(*m),
bson.M{"$set": bson.M{"state.status": models.StatusDown, "state.message": msg, "state.last_check_at": now},
"$unset": bson.M{"state.started_at": ""}})
if err != nil || res.ModifiedCount == 0 {
continue
}
recordSample(ctx, m, false, 0, now)
applyTransition(ctx, m, "", "", m.State.Status, models.StatusDown, msg, now)
}
}
// sweepGuardFilter is the optimistic-concurrency filter for SweepHeartbeats'
// update: it pins monitor_id, status, last_ping_at and started_at to the
// values the verdict was computed from, so the update only applies when
// nothing about the ping state changed underneath the sweep. last_ping_at is
// absent before the first ping and started_at is $unset on every ping, so an
// unset field is pinned with $exists:false rather than equality to nil.
func sweepGuardFilter(m models.Monitor) bson.M {
filter := bson.M{"monitor_id": m.MonitorID, "state.status": m.State.Status}
if m.State.LastPingAt != nil {
filter["state.last_ping_at"] = *m.State.LastPingAt
} else {
filter["state.last_ping_at"] = bson.M{"$exists": false}
}
if m.State.StartedAt != nil {
filter["state.started_at"] = *m.State.StartedAt
} else {
filter["state.started_at"] = bson.M{"$exists": false}
}
return filter
}
func RotateHeartbeatToken(instanceID, monitorID string) (string, error) {
ctx, cancel := monCtx()
defer cancel()
tok, hash, err := NewHeartbeatToken()
if err != nil {
return "", err
}
res, err := db.Col("monitors").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "instance_id": instanceID, "type": models.MonitorHeartbeat},
bson.M{"$set": bson.M{"heartbeat_token_hash": hash}})
if err != nil {
return "", err
}
if res.MatchedCount == 0 {
return "", ErrHeartbeatNotFound
}
return tok, nil
}
+117
View File
@@ -0,0 +1,117 @@
package services
import (
"reflect"
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
func hbMonitor(status string, lastPing, started *time.Time) models.Monitor {
return models.Monitor{
Type: models.MonitorHeartbeat,
Target: models.MonitorTarget{PeriodSec: 3600, GraceSec: 300},
State: models.MonitorState{Status: status, LastPingAt: lastPing, StartedAt: started},
}
}
func TestHeartbeatVerdict(t *testing.T) {
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
at := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
cases := []struct {
name string
m models.Monitor
wantDown bool
wantMsg string
}{
{"never pinged stays pending", hbMonitor(models.StatusPending, nil, nil), false, ""},
{"within period", hbMonitor(models.StatusUp, at(30*time.Minute), nil), false, ""},
{"inside grace", hbMonitor(models.StatusUp, at(62*time.Minute), nil), false, ""},
{"overdue", hbMonitor(models.StatusUp, at(66*time.Minute), nil), true, "no ping since"},
{"started inside grace", hbMonitor(models.StatusUp, at(10*time.Minute), at(4*time.Minute)), false, ""},
{"started never finished", hbMonitor(models.StatusUp, at(10*time.Minute), at(6*time.Minute)), true, "never finished"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
down, msg := heartbeatVerdict(c.m, now)
if down != c.wantDown || !strings.Contains(msg, c.wantMsg) {
t.Fatalf("got (%v,%q), want (%v, contains %q)", down, msg, c.wantDown, c.wantMsg)
}
})
}
}
func TestNewHeartbeatTokenHashes(t *testing.T) {
tok, hash, err := NewHeartbeatToken()
if err != nil {
t.Fatal(err)
}
if len(tok) < 32 {
t.Fatalf("token too short: %d", len(tok))
}
if hash != HashHeartbeatToken(tok) || hash == tok {
t.Fatal("hash must be the SHA-256 of the token and differ from it")
}
}
func TestValidateHeartbeat(t *testing.T) {
tg := models.MonitorTarget{PeriodSec: 59}
if err := validateHeartbeat(&tg); err == nil {
t.Fatal("period under 60 must be rejected")
}
tg = models.MonitorTarget{PeriodSec: 60}
if err := validateHeartbeat(&tg); err != nil || tg.GraceSec != 300 {
t.Fatalf("grace should default to 300, got %d err %v", tg.GraceSec, err)
}
tg = models.MonitorTarget{PeriodSec: 60, GraceSec: -1}
if err := validateHeartbeat(&tg); err == nil {
t.Fatal("negative grace must be rejected")
}
}
func TestSweepGuardFilterPinsFields(t *testing.T) {
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
// Both set: filter pins status, last_ping_at and started_at to their
// exact values.
m := hbMonitor(models.StatusUp, &now, &now)
m.MonitorID = "mon-1"
got := sweepGuardFilter(m)
want := bson.M{
"monitor_id": "mon-1",
"state.status": models.StatusUp,
"state.last_ping_at": now,
"state.started_at": now,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v, want %+v", got, want)
}
// Both unset: filter requires the fields to still be absent.
m2 := hbMonitor(models.StatusPending, nil, nil)
m2.MonitorID = "mon-2"
got2 := sweepGuardFilter(m2)
want2 := bson.M{
"monitor_id": "mon-2",
"state.status": models.StatusPending,
"state.last_ping_at": bson.M{"$exists": false},
"state.started_at": bson.M{"$exists": false},
}
if !reflect.DeepEqual(got2, want2) {
t.Fatalf("got %+v, want %+v", got2, want2)
}
}
func TestTruncateHeartbeatBody(t *testing.T) {
long := strings.Repeat("x", MaxHeartbeatBody+50)
if got := failMessage(long); len(got) != len("reported failure: ")+MaxHeartbeatBody {
t.Fatalf("len = %d", len(got))
}
if got := failMessage(" "); got != "reported failure" {
t.Fatalf("empty body message = %q", got)
}
}
+15
View File
@@ -35,6 +35,21 @@ func GetInstanceBySlug(slug string) (*models.Instance, error) {
return &o, nil
}
// GetInstanceBySlugIncludingLocked is GetInstanceBySlug without the lock
// filter. Only the host resolver calls it, so it can tell "no such instance"
// from "instance locked by Vantage HQ" and let the login page say which; it
// still refuses a locked instance itself.
func GetInstanceBySlugIncludingLocked(slug string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func ListInstanceIDs() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+47 -1
View File
@@ -2,11 +2,15 @@ package services
import (
"context"
"errors"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func StoreInventory(serverID string, r *pb.InventoryReport) error {
@@ -23,7 +27,13 @@ func StoreInventory(serverID string, r *pb.InventoryReport) error {
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
}
set["inventory.swap_used_bytes"] = r.SwapUsed
// Kept current so a patch reboot can be proven by a changed boot time,
// independent of clock skew between the host and the control plane.
if r.BootTimeUnix > 0 {
set["inventory.boot_time"] = time.Unix(r.BootTimeUnix, 0).UTC()
}
var unset bson.M
if r.IncludeStatic {
set["inventory.static_at"] = now
set["inventory.swap_total_bytes"] = r.SwapTotal
@@ -44,8 +54,44 @@ func StoreInventory(serverID string, r *pb.InventoryReport) error {
})
}
set["inventory.partitions"] = parts
var prev struct {
Inventory struct {
RebootRequired bool `bson:"reboot_required"`
RebootRequiredSince *time.Time `bson:"reboot_required_since"`
} `bson:"inventory"`
}
err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID},
options.FindOne().SetProjection(bson.M{"inventory.reboot_required": 1, "inventory.reboot_required_since": 1})).Decode(&prev)
// On a transient read error the previous stamp is unknown. Leaving it
// alone beats resetting it to now, which would restart "pending for N
// days" and hide a long-overdue reboot.
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
log.Printf("inventory: read reboot state for %s: %v", serverID, err)
} else if since, clear := rebootSinceUpdate(prev.Inventory.RebootRequired, prev.Inventory.RebootRequiredSince, r.RebootRequired, now); since != nil {
set["inventory.reboot_required_since"] = *since
} else if clear {
unset = bson.M{"inventory.reboot_required_since": ""}
}
}
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
upd := bson.M{"$set": set}
if unset != nil {
upd["$unset"] = unset
}
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, upd)
return err
}
// rebootSinceUpdate decides how reboot_required_since changes. The stamp is
// kept from the first report that needed a reboot, so "pending for 7 days"
// means seven days, not seven days since the last static report.
func rebootSinceUpdate(prevRequired bool, prevSince *time.Time, nowRequired bool, now time.Time) (*time.Time, bool) {
if !nowRequired {
return nil, prevSince != nil || prevRequired
}
if prevSince == nil {
return &now, false
}
return nil, false
}
@@ -0,0 +1,24 @@
package services
import (
"testing"
"time"
)
func TestRebootSinceUpdate(t *testing.T) {
now := time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC)
earlier := now.Add(-72 * time.Hour)
if set, unset := rebootSinceUpdate(false, nil, true, now); set == nil || !set.Equal(now) || unset {
t.Fatal("turning on must stamp now")
}
if set, unset := rebootSinceUpdate(true, &earlier, true, now); set != nil || unset {
t.Fatal("staying on must keep the original stamp")
}
if set, _ := rebootSinceUpdate(true, nil, true, now); set == nil {
t.Fatal("on with no stamp (pre-upgrade data) must stamp now")
}
if set, unset := rebootSinceUpdate(true, &earlier, false, now); set != nil || !unset {
t.Fatal("turning off must clear the stamp")
}
}
@@ -0,0 +1,20 @@
package services
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func TestFilterByVisibleServer(t *testing.T) {
incs := []models.Incident{{IncidentID: "a"}, {IncidentID: "b", ServerID: "s1"}, {IncidentID: "c", ServerID: "s2"}}
id := func(i models.Incident) string { return i.ServerID }
if got := FilterByVisibleServer(incs, id, nil, false); len(got) != 3 {
t.Fatalf("unrestricted keeps all, got %d", len(got))
}
got := FilterByVisibleServer(incs, id, map[string]bool{"s1": true}, true)
if len(got) != 2 || got[0].IncidentID != "a" || got[1].IncidentID != "b" {
t.Fatalf("restricted keeps serverless and visible only, got %+v", got)
}
}
+215
View File
@@ -0,0 +1,215 @@
package services
import (
"errors"
"fmt"
"path"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// ErrMonitorOutOfScope marks a metric monitor whose selector reaches beyond a
// restricted token's tag scope, on create, update or delete.
var ErrMonitorOutOfScope = errors.New("selector is outside this credential's tag scope")
// selectorWithinScope holds when every server the selector can match is also
// inside the token's scope - true exactly when the selector pins every pair
// the scope does.
func selectorWithinScope(sel, tokenScope map[string]string) bool {
for k, v := range tokenScope {
if sel[k] != v {
return false
}
}
return true
}
const (
MetricDiskPct = "disk_pct"
MetricDiskFreeGB = "disk_free_gb"
MetricMemPct = "mem_pct"
MetricLoadPerCore = "load_per_core"
MetricUnitFailed = "unit_failed"
MetricContainerUnhealthy = "container_unhealthy"
MetricRebootPendingDays = "reboot_pending_days"
MetricAgentOfflineMin = "agent_offline_min"
)
// metricStaleAfter is how old an agent's metrics can be before a rule stops
// judging them. A dead agent's last report must not hold an alert open or
// clear one; agent_offline_min is the rule for a dead agent.
const metricStaleAfter = 5 * time.Minute
func metricNeedsWorkloads(kind string) bool {
return kind == MetricUnitFailed || kind == MetricContainerUnhealthy
}
func metricUsesThreshold(kind string) bool {
return !metricNeedsWorkloads(kind)
}
func validateMetric(m *models.Monitor) error {
t := &m.Target
switch t.Metric {
case MetricDiskPct, MetricDiskFreeGB, MetricMemPct, MetricLoadPerCore,
MetricUnitFailed, MetricContainerUnhealthy, MetricRebootPendingDays, MetricAgentOfflineMin:
default:
return fmt.Errorf("%w: unknown metric %q", ErrInvalidMonitor, t.Metric)
}
if metricUsesThreshold(t.Metric) && t.Threshold <= 0 {
return fmt.Errorf("%w: threshold must be greater than 0", ErrInvalidMonitor)
}
if (t.Metric == MetricDiskPct || t.Metric == MetricMemPct) && t.Threshold > 100 {
return fmt.Errorf("%w: threshold must be 100 or less", ErrInvalidMonitor)
}
if t.Mount != "" && !path.IsAbs(t.Mount) {
return fmt.Errorf("%w: mount must be an absolute path", ErrInvalidMonitor)
}
if m.ForSec < 0 {
m.ForSec = 0
}
return nil
}
// EvaluateMetric judges one server against one rule. ok=false means there is
// nothing trustworthy to judge - stale metrics, a mount that does not exist on
// this host - and the caller keeps the previous state rather than guessing.
func EvaluateMetric(t models.MonitorTarget, srv models.Server, wls []models.Workload, now time.Time) (bool, float64, string, bool) {
if t.Metric == MetricAgentOfflineMin {
if srv.LastSeen == nil {
return false, 0, "", false
}
mins := now.Sub(*srv.LastSeen).Minutes()
return mins >= t.Threshold, mins, fmt.Sprintf("agent not seen for %.0f minutes", mins), true
}
inv := srv.Inventory
if inv == nil || inv.MetricsAt == nil || now.Sub(*inv.MetricsAt) > metricStaleAfter {
return false, 0, "", false
}
switch t.Metric {
case MetricDiskPct, MetricDiskFreeGB:
return evalDisk(t, inv.Partitions)
case MetricMemPct:
if inv.Memory.TotalBytes == 0 {
return false, 0, "", false
}
pct := float64(inv.Memory.UsedBytes) / float64(inv.Memory.TotalBytes) * 100
return pct >= t.Threshold, pct, fmt.Sprintf("memory %.1f%% used", pct), true
case MetricLoadPerCore:
if inv.CPU.Cores == 0 {
return false, 0, "", false
}
v := inv.CPU.Load1 / float64(inv.CPU.Cores)
return v >= t.Threshold, v, fmt.Sprintf("load %.2f per core", v), true
case MetricRebootPendingDays:
if !inv.RebootRequired || inv.RebootRequiredSince == nil {
return false, 0, "", true
}
days := now.Sub(*inv.RebootRequiredSince).Hours() / 24
return days >= t.Threshold, days, fmt.Sprintf("reboot pending for %.0f days", days), true
case MetricUnitFailed:
return evalWorkloads(wls, "unit", func(w models.Workload) bool { return w.State == "failed" }, "failed")
case MetricContainerUnhealthy:
return evalWorkloads(wls, "container", func(w models.Workload) bool { return w.Health == "unhealthy" }, "unhealthy")
}
return false, 0, "", false
}
// evalDisk reports the worst matching partition, so "any mount" names the one
// that is actually full.
func evalDisk(t models.MonitorTarget, parts []models.Partition) (bool, float64, string, bool) {
found := false
var worstBreach bool
var worstVal float64
var worstMsg string
for _, p := range parts {
if p.TotalBytes == 0 || p.UsedBytes > p.TotalBytes || (t.Mount != "" && p.Mountpoint != t.Mount) {
continue
}
var breach bool
var val float64
var msg string
if t.Metric == MetricDiskPct {
val = float64(p.UsedBytes) / float64(p.TotalBytes) * 100
breach = val >= t.Threshold
msg = fmt.Sprintf("%s %.1f%% used", p.Mountpoint, val)
} else {
val = float64(p.TotalBytes-p.UsedBytes) / 1e9
breach = val <= t.Threshold
msg = fmt.Sprintf("%s %.1f GB free", p.Mountpoint, val)
}
worse := !found ||
(t.Metric == MetricDiskPct && val > worstVal) ||
(t.Metric == MetricDiskFreeGB && val < worstVal)
if worse {
worstBreach, worstVal, worstMsg = breach, val, msg
}
found = true
}
if !found {
return false, 0, "", false
}
return worstBreach, worstVal, worstMsg, true
}
func evalWorkloads(wls []models.Workload, kind string, bad func(models.Workload) bool, word string) (bool, float64, string, bool) {
var names []string
for _, w := range wls {
if w.Kind == kind && bad(w) {
names = append(names, w.Name)
}
}
if len(names) == 0 {
return false, 0, "", true
}
return true, float64(len(names)), fmt.Sprintf("%s %s: %s", kind, word, strings.Join(names, ", ")), true
}
// nextServerState applies the "for N seconds" gate. BreachSince is the first
// sweep that saw the condition, so the gate measures continuous breach and a
// single clear sweep restarts it.
func nextServerState(prev *models.MonitorServerState, breach bool, forSec int, now time.Time) (string, *time.Time) {
if !breach {
return models.StatusUp, nil
}
since := now
if prev != nil && prev.BreachSince != nil {
since = *prev.BreachSince
}
// A server already down stays down while the breach lasts, even if for_sec
// was raised meanwhile. Dropping it to pending would never resolve the open
// incident, because only an up transition closes it.
if prev != nil && prev.Status == models.StatusDown {
return models.StatusDown, &since
}
if now.Sub(since) >= time.Duration(forSec)*time.Second {
return models.StatusDown, &since
}
return models.StatusPending, &since
}
func rollupParent(states []models.MonitorServerState) (string, string) {
if len(states) == 0 {
return models.StatusUp, "no matching servers"
}
down, pending := 0, 0
for _, s := range states {
switch s.Status {
case models.StatusDown:
down++
case models.StatusPending:
pending++
}
}
msg := fmt.Sprintf("%d of %d servers breaching", down, len(states))
switch {
case down > 0:
return models.StatusDown, msg
case pending > 0:
return models.StatusPending, msg
}
return models.StatusUp, msg
}
@@ -0,0 +1,130 @@
package services
import (
"errors"
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
var evalNow = time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
func fresh() *time.Time { t := evalNow.Add(-time.Minute); return &t }
func srvWith(inv models.Inventory) models.Server {
if inv.MetricsAt == nil {
inv.MetricsAt = fresh()
}
ls := evalNow.Add(-30 * time.Second)
return models.Server{ServerID: "s1", Hostname: "web-01", Inventory: &inv, LastSeen: &ls}
}
func TestEvaluateMetric(t *testing.T) {
disk := models.Inventory{Partitions: []models.Partition{
{Mountpoint: "/", TotalBytes: 100e9, UsedBytes: 50e9},
{Mountpoint: "/var", TotalBytes: 100e9, UsedBytes: 95e9},
}}
stale := evalNow.Add(-10 * time.Minute)
since := evalNow.Add(-8 * 24 * time.Hour)
oldSeen := evalNow.Add(-20 * time.Minute)
cases := []struct {
name string
t models.MonitorTarget
srv models.Server
wls []models.Workload
wantBreach bool
wantOK bool
msgHas string
}{
{"disk pct any mount breaches", models.MonitorTarget{Metric: MetricDiskPct, Threshold: 90}, srvWith(disk), nil, true, true, "/var"},
{"disk pct named mount clear", models.MonitorTarget{Metric: MetricDiskPct, Threshold: 90, Mount: "/"}, srvWith(disk), nil, false, true, "/"},
{"disk pct missing mount skips", models.MonitorTarget{Metric: MetricDiskPct, Threshold: 90, Mount: "/data"}, srvWith(disk), nil, false, false, ""},
{"disk free gb breaches", models.MonitorTarget{Metric: MetricDiskFreeGB, Threshold: 10}, srvWith(disk), nil, true, true, "GB free"},
{"zero total partition ignored", models.MonitorTarget{Metric: MetricDiskPct, Threshold: 1}, srvWith(models.Inventory{Partitions: []models.Partition{{Mountpoint: "/proc"}}}), nil, false, false, ""},
{"used over total partition ignored", models.MonitorTarget{Metric: MetricDiskFreeGB, Threshold: 10}, srvWith(models.Inventory{Partitions: []models.Partition{{Mountpoint: "/", TotalBytes: 100e9, UsedBytes: 150e9}}}), nil, false, false, ""},
{"mem pct", models.MonitorTarget{Metric: MetricMemPct, Threshold: 80}, srvWith(models.Inventory{Memory: models.MemInfo{TotalBytes: 100, UsedBytes: 85}}), nil, true, true, "memory"},
{"load per core", models.MonitorTarget{Metric: MetricLoadPerCore, Threshold: 1.5}, srvWith(models.Inventory{CPU: models.CPUInfo{Cores: 4, Load1: 8}}), nil, true, true, "load"},
{"load no cores skips", models.MonitorTarget{Metric: MetricLoadPerCore, Threshold: 1.5}, srvWith(models.Inventory{CPU: models.CPUInfo{Load1: 8}}), nil, false, false, ""},
{"stale inventory skips", models.MonitorTarget{Metric: MetricMemPct, Threshold: 1}, srvWith(models.Inventory{MetricsAt: &stale, Memory: models.MemInfo{TotalBytes: 100, UsedBytes: 99}}), nil, false, false, ""},
{"unit failed", models.MonitorTarget{Metric: MetricUnitFailed}, srvWith(models.Inventory{}), []models.Workload{{Kind: "unit", Name: "backup.service", State: "failed"}}, true, true, "backup.service"},
{"container unhealthy clear", models.MonitorTarget{Metric: MetricContainerUnhealthy}, srvWith(models.Inventory{}), []models.Workload{{Kind: "container", Name: "db", Health: "healthy"}}, false, true, ""},
{"reboot pending days", models.MonitorTarget{Metric: MetricRebootPendingDays, Threshold: 7}, srvWith(models.Inventory{RebootRequired: true, RebootRequiredSince: &since}), nil, true, true, "reboot"},
{"reboot not required", models.MonitorTarget{Metric: MetricRebootPendingDays, Threshold: 7}, srvWith(models.Inventory{}), nil, false, true, ""},
{"agent offline ignores stale inventory", models.MonitorTarget{Metric: MetricAgentOfflineMin, Threshold: 10},
func() models.Server { s := srvWith(models.Inventory{MetricsAt: &stale}); s.LastSeen = &oldSeen; return s }(), nil, true, true, "not seen"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
breach, _, msg, ok := EvaluateMetric(c.t, c.srv, c.wls, evalNow)
if breach != c.wantBreach || ok != c.wantOK || !strings.Contains(msg, c.msgHas) {
t.Fatalf("got breach=%v ok=%v msg=%q", breach, ok, msg)
}
})
}
}
func TestNextServerState(t *testing.T) {
past := func(d time.Duration) *time.Time { v := evalNow.Add(-d); return &v }
st, since := nextServerState(nil, true, 300, evalNow)
if st != models.StatusPending || since == nil || !since.Equal(evalNow) {
t.Fatalf("new breach: %s %v", st, since)
}
st, _ = nextServerState(&models.MonitorServerState{Status: models.StatusPending, BreachSince: past(2 * time.Minute)}, true, 300, evalNow)
if st != models.StatusPending {
t.Fatalf("under for_sec should stay pending, got %s", st)
}
st, since = nextServerState(&models.MonitorServerState{Status: models.StatusPending, BreachSince: past(6 * time.Minute)}, true, 300, evalNow)
if st != models.StatusDown || !since.Equal(*past(6 * time.Minute)) {
t.Fatalf("over for_sec should be down keeping since, got %s %v", st, since)
}
st, _ = nextServerState(nil, true, 0, evalNow)
if st != models.StatusDown {
t.Fatalf("for_sec 0 is down immediately, got %s", st)
}
// Raising for_sec mid-breach must not demote a down server to pending:
// pending never resolves an incident, so it would stay open forever.
st, since = nextServerState(&models.MonitorServerState{Status: models.StatusDown, BreachSince: past(2 * time.Minute)}, true, 3600, evalNow)
if st != models.StatusDown || !since.Equal(*past(2 * time.Minute)) {
t.Fatalf("down with larger for_sec should stay down keeping since, got %s %v", st, since)
}
st, since = nextServerState(&models.MonitorServerState{Status: models.StatusDown, BreachSince: past(time.Hour)}, false, 300, evalNow)
if st != models.StatusUp || since != nil {
t.Fatalf("clear should be up with no since, got %s %v", st, since)
}
}
func TestRollupParent(t *testing.T) {
if s, m := rollupParent(nil); s != models.StatusUp || m != "no matching servers" {
t.Fatalf("empty: %s %q", s, m)
}
states := []models.MonitorServerState{{Status: models.StatusUp}, {Status: models.StatusPending}, {Status: models.StatusDown}}
if s, m := rollupParent(states); s != models.StatusDown || m != "1 of 3 servers breaching" {
t.Fatalf("got %s %q", s, m)
}
if s, _ := rollupParent(states[:2]); s != models.StatusPending {
t.Fatalf("pending wins over up, got %s", s)
}
}
func TestValidateMetric(t *testing.T) {
bad := []models.MonitorTarget{
{Metric: "cpu_magic", Threshold: 1},
{Metric: MetricDiskPct, Threshold: 0},
{Metric: MetricDiskPct, Threshold: 101},
{Metric: MetricDiskPct, Threshold: 90, Mount: "var"},
}
for _, tg := range bad {
m := models.Monitor{Type: models.MonitorMetric, Target: tg}
if err := validateMetric(&m); !errors.Is(err, ErrInvalidMonitor) {
t.Errorf("%+v: want ErrInvalidMonitor, got %v", tg, err)
}
}
ok := models.Monitor{Type: models.MonitorMetric, ForSec: -5, Target: models.MonitorTarget{Metric: MetricUnitFailed}}
if err := validateMetric(&ok); err != nil || ok.ForSec != 0 {
t.Fatalf("unit_failed needs no threshold and negative for_sec clamps to 0: %v %d", err, ok.ForSec)
}
}
@@ -0,0 +1,27 @@
package services
import "testing"
// A restricted token must not be able to create a rule that watches servers it
// cannot see: the per-server table and incident messages would disclose them.
func TestSelectorWithinScope(t *testing.T) {
scope := map[string]string{"env": "prod"}
cases := []struct {
sel map[string]string
want bool
}{
{nil, false},
{map[string]string{"env": "dev"}, false},
{map[string]string{"role": "web"}, false},
{map[string]string{"env": "prod"}, true},
{map[string]string{"env": "prod", "role": "web"}, true},
}
for _, c := range cases {
if got := selectorWithinScope(c.sel, scope); got != c.want {
t.Errorf("sel %v: got %v want %v", c.sel, got, c.want)
}
}
if !selectorWithinScope(nil, nil) {
t.Error("an unrestricted credential may use any selector, including the whole fleet")
}
}
+153
View File
@@ -0,0 +1,153 @@
package services
import (
"context"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureMonitorServerStateIndexes declares the one key every read and upsert
// uses. Unique, so a double sweep across a leader handover cannot fork a
// server's state into two documents.
func EnsureMonitorServerStateIndexes() error {
_, err := db.Col("monitor_server_states").Indexes().CreateOne(context.Background(), mongo.IndexModel{
Keys: bson.D{{Key: "monitor_id", Value: 1}, {Key: "server_id", Value: 1}},
Options: options.Index().SetUnique(true),
})
if err != nil {
log.Printf("warning: monitor_server_states indexes: %v", err)
}
// Every heartbeat ping looks its monitor up by token hash. Sparse because
// only heartbeat monitors carry one.
_, err = db.Col("monitors").Indexes().CreateOne(context.Background(), mongo.IndexModel{
Keys: bson.D{{Key: "heartbeat_token_hash", Value: 1}},
Options: options.Index().SetSparse(true),
})
if err != nil {
log.Printf("warning: monitors heartbeat_token_hash index: %v", err)
}
return nil
}
func ListMonitorServerStates(instanceID, monitorID string) ([]models.MonitorServerState, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitor_server_states").Find(ctx, bson.M{"instance_id": instanceID, "monitor_id": monitorID})
if err != nil {
return nil, err
}
out := []models.MonitorServerState{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// ponytail: every metric monitor re-lists its servers each sweep; cache the
// fleet per instance per sweep if large fleets show up in the sweep log.
func SweepMetricMonitors(now time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{"type": models.MonitorMetric, "enabled": true})
if err != nil {
log.Printf("metrics: list: %v", err)
return
}
var monitors []models.Monitor
if err := cur.All(ctx, &monitors); err != nil {
log.Printf("metrics: decode: %v", err)
return
}
for i := range monitors {
sweepOneMetric(ctx, &monitors[i], now)
}
}
func sweepOneMetric(ctx context.Context, m *models.Monitor, now time.Time) {
defer func() {
if r := recover(); r != nil {
log.Printf("metrics: monitor %s panic: %v", m.MonitorID, r)
}
}()
servers, err := ListServersFiltered(m.InstanceID, m.Target.Selector)
if err != nil {
log.Printf("metrics: servers for %s: %v", m.MonitorID, err)
return
}
prevStates, err := ListMonitorServerStates(m.InstanceID, m.MonitorID)
if err != nil {
log.Printf("metrics: states for %s: %v", m.MonitorID, err)
return
}
prevByServer := map[string]*models.MonitorServerState{}
for i := range prevStates {
prevByServer[prevStates[i].ServerID] = &prevStates[i]
}
col := db.Col("monitor_server_states")
current := make([]models.MonitorServerState, 0, len(servers))
matched := map[string]bool{}
for _, srv := range servers {
matched[srv.ServerID] = true
prev := prevByServer[srv.ServerID]
var wls []models.Workload
if metricNeedsWorkloads(m.Target.Metric) {
if sw, err := GetWorkloads(m.InstanceID, srv.ServerID); err == nil && sw != nil {
wls = sw.Workloads
}
}
breach, value, msg, ok := EvaluateMetric(m.Target, srv, wls, now)
if !ok {
if prev != nil {
current = append(current, *prev)
}
continue
}
status, since := nextServerState(prev, breach, m.ForSec, now)
st := models.MonitorServerState{
InstanceID: m.InstanceID, MonitorID: m.MonitorID, ServerID: srv.ServerID,
Status: status, BreachSince: since, Value: value, Message: msg, UpdatedAt: now,
}
if _, err := col.ReplaceOne(ctx, bson.M{"monitor_id": m.MonitorID, "server_id": srv.ServerID}, st,
options.Replace().SetUpsert(true)); err != nil {
log.Printf("metrics: save state %s/%s: %v", m.MonitorID, srv.ServerID, err)
continue
}
current = append(current, st)
// A server seen for the first time has no previous status to leave, so
// it can open an incident but never announce a recovery.
prevStatus := models.StatusPending
if prev != nil {
prevStatus = prev.Status
}
applyTransition(ctx, m, srv.ServerID, srv.Hostname, prevStatus, status, msg, now)
}
// Servers that left the selector, or the fleet, did not recover: their
// incidents close quietly and their state goes.
for _, prev := range prevStates {
if matched[prev.ServerID] {
continue
}
if prev.Status == models.StatusDown {
resolveIncident(ctx, m, prev.ServerID, now)
}
col.DeleteOne(ctx, bson.M{"monitor_id": m.MonitorID, "server_id": prev.ServerID})
}
status, msg := rollupParent(current)
db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": m.MonitorID}, bson.M{"$set": bson.M{
"state.status": status, "state.message": msg, "state.last_check_at": now,
}})
recordSample(ctx, m, status != models.StatusDown, 0, now)
}
+489
View File
@@ -0,0 +1,489 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/redis/go-redis/v9"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var (
ErrNoMFA = errors.New("no multi-factor authentication is enrolled")
ErrBadCode = errors.New("that code is not valid")
ErrCodeReplayed = errors.New("that code has already been used")
)
// Factor names travel to the browser, which chooses which prompt to draw.
const (
FactorTOTP = "totp"
FactorWebAuthn = "webauthn"
FactorRecovery = "recovery"
FactorPassword = "password"
)
const recoveryCodeCount = 10
// RedisClient is set by main.go after auth.InitRedis succeeds. services must
// not import auth: auth already imports services, and Go has no import
// cycles.
var RedisClient *redis.Client
func mfaCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// UsersWithMFA returns the set of user IDs in this instance holding a factor.
// One query per collection, not one per member, so the member list stays
// cheap however many users an instance has.
func UsersWithMFA(instanceID string) (map[string]bool, error) {
ctx, cancel := mfaCtx()
defer cancel()
out := map[string]bool{}
cur, err := db.Col("user_mfa").Find(ctx,
bson.M{"instance_id": instanceID, "totp_confirmed_at": bson.M{"$exists": true}})
if err != nil {
return nil, err
}
var rows []models.UserMFA
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
for _, r := range rows {
out[r.UserID] = true
}
var userIDs []string
if err := db.Col("webauthn_credentials").Distinct(ctx, "user_id",
bson.M{"instance_id": instanceID}).Decode(&userIDs); err != nil {
return nil, err
}
for _, id := range userIDs {
out[id] = true
}
return out, nil
}
// GenerateRecoveryCodes returns the codes to show the user once, and the
// hashed records to store. The plaintext is never persisted.
func GenerateRecoveryCodes() ([]string, []models.RecoveryCode, error) {
plain := make([]string, 0, recoveryCodeCount)
stored := make([]models.RecoveryCode, 0, recoveryCodeCount)
for i := 0; i < recoveryCodeCount; i++ {
b := make([]byte, 5)
if _, err := rand.Read(b); err != nil {
return nil, nil, err
}
// Crockford-ish base32 without padding: 8 characters, no case to get
// wrong when read off paper.
code := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
plain = append(plain, code)
stored = append(stored, models.RecoveryCode{Hash: HashRecoveryCode(code)})
}
return plain, stored, nil
}
// NormaliseRecoveryCode makes a code typed off paper comparable: no case, no
// spaces, no dashes.
func NormaliseRecoveryCode(code string) string {
r := strings.NewReplacer(" ", "", "-", "", "\t", "")
return strings.ToLower(r.Replace(strings.TrimSpace(code)))
}
func HashRecoveryCode(code string) string {
sum := sha256.Sum256([]byte(NormaliseRecoveryCode(code)))
return hex.EncodeToString(sum[:])
}
// ConsumeRecoveryCode reports which unused code matches, if any. It does not
// write: the caller marks the index used so the database update and the audit
// event stay in one place.
func ConsumeRecoveryCode(codes []models.RecoveryCode, input string, now time.Time) (int, bool) {
want := HashRecoveryCode(input)
for i, c := range codes {
if c.UsedAt != nil {
continue
}
if subtle.ConstantTimeCompare([]byte(c.Hash), []byte(want)) == 1 {
return i, true
}
}
return 0, false
}
func GetUserMFA(instanceID, userID string) (*models.UserMFA, error) {
ctx, cancel := mfaCtx()
defer cancel()
var m models.UserMFA
err := db.Col("user_mfa").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&m)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func CountPasskeys(instanceID, userID string) (int64, error) {
ctx, cancel := mfaCtx()
defer cancel()
return db.Col("webauthn_credentials").CountDocuments(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
}
// hasFactor is the single definition of "this user has MFA". An unconfirmed
// TOTP secret does not count: a half-finished setup must not lock anyone out.
func hasFactor(m *models.UserMFA, passkeys int) bool {
if passkeys > 0 {
return true
}
return m != nil && m.TOTPConfirmedAt != nil
}
// MFAMethods lists the factors a user can present, newest-friendly order. An
// empty slice means they have no MFA at all.
func MFAMethods(instanceID, userID string) ([]string, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return nil, err
}
methods := []string{}
if passkeys > 0 {
methods = append(methods, FactorWebAuthn)
}
if m != nil && m.TOTPConfirmedAt != nil {
methods = append(methods, FactorTOTP)
}
if len(methods) > 0 && m != nil {
for _, c := range m.RecoveryCodes {
if c.UsedAt == nil {
methods = append(methods, FactorRecovery)
break
}
}
}
return methods, nil
}
func HasMFA(instanceID, userID string) (bool, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return false, err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return false, err
}
return hasFactor(m, int(passkeys)), nil
}
// WebAuthnHandle returns the user's stable random handle, creating the user_mfa
// document on first use.
func WebAuthnHandle(instanceID, userID string) ([]byte, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
if m != nil && len(m.WebAuthnHandle) > 0 {
return m.WebAuthnHandle, nil
}
handle := make([]byte, 64)
if _, err := rand.Read(handle); err != nil {
return nil, err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$set": bson.M{"updated_at": time.Now()},
"$setOnInsert": bson.M{"webauthn_handle": handle},
},
options.UpdateOne().SetUpsert(true))
if err != nil {
return nil, err
}
// Re-read: a concurrent caller may have won the upsert.
m, err = GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
return m.WebAuthnHandle, nil
}
// StartTOTPSetup writes an unconfirmed secret and returns it with its otpauth
// URI. An existing unconfirmed secret is replaced; a confirmed one is not
// touched until ConfirmTOTP succeeds against the new secret.
func StartTOTPSetup(instanceID, userID, issuer, account string) (string, string, error) {
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: account})
if err != nil {
return "", "", err
}
enc, err := encryptString(key.Secret())
if err != nil {
return "", "", err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{"$set": bson.M{"totp_pending_enc": enc, "updated_at": time.Now()}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// ConfirmTOTP promotes the pending secret to the live one.
func ConfirmTOTP(instanceID, userID, code string) error {
ctx, cancel := mfaCtx()
defer cancel()
var raw struct {
Pending string `bson:"totp_pending_enc"`
}
if err := db.Col("user_mfa").FindOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&raw); err != nil {
return ErrNoMFA
}
if raw.Pending == "" {
return ErrNoMFA
}
secret, err := decryptString(raw.Pending)
if err != nil {
return err
}
if !totp.Validate(code, secret) {
return ErrBadCode
}
now := time.Now()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$set": bson.M{"totp_secret_enc": raw.Pending, "totp_confirmed_at": now, "updated_at": now},
"$unset": bson.M{"totp_pending_enc": ""},
})
return err
}
// VerifyTOTPCode checks a code against the confirmed secret and burns it, so
// the same code cannot be replayed inside its 30-second window by an attacker
// who shoulder-surfed it.
func VerifyTOTPCode(instanceID, userID, code string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
if m == nil || m.TOTPConfirmedAt == nil || m.TOTPSecretEnc == "" {
return ErrNoMFA
}
secret, err := decryptString(m.TOTPSecretEnc)
if err != nil {
return err
}
step, ok := matchTOTPStep(code, secret, time.Now())
if !ok {
return ErrBadCode
}
return burnTOTPStep(userID, step)
}
// matchTOTPStep returns the 30-second time step the code was generated for,
// trying the current step and one either side (the accepted skew). The replay
// guard must burn this step, not the current one: burning the current step
// would let a code accepted as s-1 or s+1 be accepted again one step later.
func matchTOTPStep(code, secret string, now time.Time) (int64, bool) {
cur := now.Unix() / 30
for _, step := range []int64{cur - 1, cur, cur + 1} {
want, err := totp.GenerateCodeCustom(secret, time.Unix(step*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return step, true
}
}
return 0, false
}
// ClearMFA removes every factor. Used by an owner or admin reset.
func ClearMFA(instanceID, userID string) error {
ctx, cancel := mfaCtx()
defer cancel()
if _, err := db.Col("user_mfa").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID}); err != nil {
return err
}
_, err := db.Col("webauthn_credentials").DeleteMany(ctx,
bson.M{"instance_id": instanceID, "user_id": userID})
return err
}
// UseRecoveryCode consumes one unused code, marking it used by index so a
// concurrent second attempt with the same code finds it spent.
func UseRecoveryCode(instanceID, userID, input string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
if m == nil || len(m.RecoveryCodes) == 0 {
return ErrNoMFA
}
idx, ok := ConsumeRecoveryCode(m.RecoveryCodes, input, time.Now())
if !ok {
return ErrBadCode
}
ctx, cancel := mfaCtx()
defer cancel()
now := time.Now()
res, err := db.Col("user_mfa").UpdateOne(ctx,
bson.M{
"instance_id": instanceID, "user_id": userID,
"recovery_codes." + strconv.Itoa(idx) + ".used_at": bson.M{"$exists": false},
},
bson.M{"$set": bson.M{
"recovery_codes." + strconv.Itoa(idx) + ".used_at": now,
"updated_at": now,
}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrBadCode
}
return nil
}
// RequireMFAForInstance reads the policy, defaulting to off on any error: a
// database blip must not lock an entire instance out of its own control plane.
func RequireMFAForInstance(instanceID string) bool {
s, err := GetSettings(instanceID)
if err != nil {
return false
}
return models.RequireMFA(s)
}
// burnTOTPStep makes a step single-use for 90 seconds - longer than the +-1
// step window it could still validate in. Keyed on the time step the code
// matched, never on the code itself: a raw code sitting in a Redis key name
// would be a currently-valid credential readable by anything that can list
// keys. Redis is already required for sessions.
func burnTOTPStep(userID string, step int64) error {
if RedisClient == nil {
return nil
}
ctx, cancel := mfaCtx()
defer cancel()
key := "km:totp:" + userID + ":" + strconv.FormatInt(step, 10)
ok, err := RedisClient.SetNX(ctx, key, 1, 90*time.Second).Result()
if err != nil {
return err
}
if !ok {
return ErrCodeReplayed
}
return nil
}
// IssueRecoveryCodes replaces the user's set and returns the plaintext once.
// Callers must not persist or log the return value.
func IssueRecoveryCodes(instanceID, userID string) ([]string, error) {
plain, stored, err := GenerateRecoveryCodes()
if err != nil {
return nil, err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{"$set": bson.M{"recovery_codes": stored, "updated_at": time.Now()}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return nil, err
}
return plain, nil
}
var ErrMFARequiredByPolicy = errors.New("this instance requires multi-factor authentication; add another factor before removing this one")
// canRemoveFactor is pure so the rule is testable without a database.
// removing is FactorTOTP or FactorWebAuthn, and for a passkey it means one of
// the counted passkeys.
func canRemoveFactor(requireMFA, hasTOTP bool, passkeys int, removing string) error {
if !requireMFA {
return nil
}
remaining := 0
if hasTOTP && removing != FactorTOTP {
remaining++
}
switch removing {
case FactorWebAuthn:
remaining += passkeys - 1
default:
remaining += passkeys
}
if remaining > 0 {
return nil
}
return ErrMFARequiredByPolicy
}
// CheckCanRemoveFactor reads the current state and applies the rule.
func CheckCanRemoveFactor(instanceID, userID, removing string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return err
}
return canRemoveFactor(RequireMFAForInstance(instanceID), m != nil && m.TOTPConfirmedAt != nil, int(passkeys), removing)
}
// RemoveTOTP clears only the TOTP factor, leaving passkeys and recovery codes.
func RemoveTOTP(instanceID, userID string) error {
ctx, cancel := mfaCtx()
defer cancel()
_, err := db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$unset": bson.M{"totp_secret_enc": "", "totp_confirmed_at": "", "totp_pending_enc": ""},
"$set": bson.M{"updated_at": time.Now()},
})
return err
}
// RecoveryCodesRemaining counts unused codes for the account page.
func RecoveryCodesRemaining(m *models.UserMFA) int {
if m == nil {
return 0
}
n := 0
for _, c := range m.RecoveryCodes {
if c.UsedAt == nil {
n++
}
}
return n
}
@@ -0,0 +1,31 @@
package services
import "testing"
// Removing a factor under an MFA policy must leave at least one behind, or the
// user locks themselves out of an instance that will then demand enrolment
// they cannot complete without signing in.
func TestCanRemoveFactor(t *testing.T) {
cases := []struct {
name string
requireMFA bool
totp bool
passkeys int
removing string
wantRefusal bool
}{
{"policy off, last factor", false, true, 0, FactorTOTP, false},
{"policy on, totp plus passkey, drop totp", true, true, 1, FactorTOTP, false},
{"policy on, last totp", true, true, 0, FactorTOTP, true},
{"policy on, last passkey", true, false, 1, FactorWebAuthn, true},
{"policy on, two passkeys, drop one", true, false, 2, FactorWebAuthn, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := canRemoveFactor(tc.requireMFA, tc.totp, tc.passkeys, tc.removing)
if tc.wantRefusal != (err != nil) {
t.Fatalf("canRemoveFactor refusal = %v, want %v", err != nil, tc.wantRefusal)
}
})
}
}
@@ -0,0 +1,31 @@
package services
import "testing"
// A tenant-scoped collection missing from ScopedCollections outlives its
// instance when the instance is purged - here that means a former customer's
// TOTP secrets and passkeys stay in the database forever.
func TestMFACollectionsAreScoped(t *testing.T) {
for _, name := range []string{"user_mfa", "webauthn_credentials"} {
found := false
for _, got := range ScopedCollections {
if got == name {
found = true
break
}
}
if !found {
t.Errorf("%s is not in ScopedCollections", name)
}
}
}
// Per-server metric state is tenant data and must be purged with its instance.
func TestMonitorServerStatesAreScoped(t *testing.T) {
for _, got := range ScopedCollections {
if got == "monitor_server_states" {
return
}
}
t.Error("monitor_server_states is not in ScopedCollections")
}
+115
View File
@@ -0,0 +1,115 @@
package services
import (
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
func TestGenerateRecoveryCodesReturnsTenUniqueHashedCodes(t *testing.T) {
plain, stored, err := GenerateRecoveryCodes()
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(plain) != 10 || len(stored) != 10 {
t.Fatalf("want 10 codes, got %d plain and %d stored", len(plain), len(stored))
}
seen := map[string]bool{}
for i, p := range plain {
if seen[p] {
t.Fatalf("duplicate code %q", p)
}
seen[p] = true
if stored[i].Hash == p {
t.Fatal("code stored in plaintext")
}
if stored[i].Hash != HashRecoveryCode(p) {
t.Fatalf("stored hash does not match HashRecoveryCode for code %d", i)
}
if stored[i].UsedAt != nil {
t.Fatal("fresh code marked used")
}
}
}
func TestConsumeRecoveryCodeIsSingleUseAndFormatTolerant(t *testing.T) {
plain, stored, _ := GenerateRecoveryCodes()
now := time.Now()
idx, ok := ConsumeRecoveryCode(stored, plain[3], now)
if !ok || idx != 3 {
t.Fatalf("want index 3 consumed, got idx=%d ok=%v", idx, ok)
}
used := now
stored[3].UsedAt = &used
if _, ok := ConsumeRecoveryCode(stored, plain[3], now); ok {
t.Fatal("a used recovery code was accepted a second time")
}
// Users retype codes with different case and stray dashes or spaces.
messy := " " + strings.ToUpper(plain[4]) + " "
if _, ok := ConsumeRecoveryCode(stored, messy, now); !ok {
t.Fatal("normalisation rejected a valid code")
}
}
func TestConsumeRecoveryCodeRejectsUnknownCode(t *testing.T) {
_, stored, _ := GenerateRecoveryCodes()
if _, ok := ConsumeRecoveryCode(stored, "not-a-real-code", time.Now()); ok {
t.Fatal("unknown code accepted")
}
}
func TestHasFactorRequiresConfirmedTOTPOrAPasskey(t *testing.T) {
now := time.Now()
cases := []struct {
name string
mfa *models.UserMFA
passkeys int
want bool
}{
{"nothing", nil, 0, false},
{"unconfirmed totp only", &models.UserMFA{TOTPSecretEnc: "ab"}, 0, false},
{"confirmed totp", &models.UserMFA{TOTPSecretEnc: "ab", TOTPConfirmedAt: &now}, 0, true},
{"passkey only", &models.UserMFA{}, 1, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := hasFactor(tc.mfa, tc.passkeys); got != tc.want {
t.Fatalf("hasFactor = %v, want %v", got, tc.want)
}
})
}
}
func TestMatchTOTPStepReturnsTheStepTheCodeWasGeneratedFor(t *testing.T) {
const secret = "JBSWY3DPEHPK3PXP"
now := time.Unix(1_800_000_015, 0) // mid-step, well clear of a boundary
cur := now.Unix() / 30
for _, off := range []int64{-1, 0, 1} {
code, err := totp.GenerateCodeCustom(secret, time.Unix((cur+off)*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
t.Fatal(err)
}
step, ok := matchTOTPStep(code, secret, now)
if !ok || step != cur+off {
t.Fatalf("offset %d: got step %d ok=%v, want %d", off, step, ok, cur+off)
}
}
far, _ := totp.GenerateCodeCustom(secret, time.Unix((cur+5)*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if _, ok := matchTOTPStep(far, secret, now); ok {
t.Fatal("a code from five steps ahead must not match")
}
if _, ok := matchTOTPStep("000000x", secret, now); ok {
t.Fatal("a malformed code must not match")
}
}
@@ -37,6 +37,7 @@ var ScopedCollections = []string{
"incidents",
"monitor_rollups",
"monitor_samples",
"monitor_server_states",
"notification_channels",
"console_sessions",
"audit_logs",
@@ -48,6 +49,12 @@ var ScopedCollections = []string{
"server_workloads",
"status_pages",
"status_incidents",
"maintenance_windows",
"patch_policies",
"patch_runs",
"patch_run_outputs",
"user_mfa",
"webauthn_credentials",
}
// collectionRenames maps the two collections whose names change. Ordered so the
+159 -68
View File
@@ -103,7 +103,8 @@ func ListServerScheduledMonitors() ([]models.Monitor, error) {
func listMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
filter := bson.M{"runner": runner, "enabled": true}
filter := bson.M{"runner": runner, "enabled": true,
"type": bson.M{"$nin": []string{models.MonitorMetric, models.MonitorHeartbeat}}}
if instanceID != "" {
filter["instance_id"] = instanceID
}
@@ -187,6 +188,32 @@ func CreateMonitor(instanceID string, m *models.Monitor, tokenScope map[string]s
if m.Runner == "" {
m.Runner = models.RunnerServer
}
if m.Type == models.MonitorHeartbeat {
if err := validateHeartbeat(&m.Target); err != nil {
return nil, err
}
tok, hash, err := NewHeartbeatToken()
if err != nil {
return nil, err
}
m.HeartbeatTokenHash = hash
m.HeartbeatToken = tok
// A heartbeat is never run, so neither where nor how often applies.
m.Runner = models.RunnerServer
m.IntervalSec = 0
}
if m.Type == models.MonitorMetric {
if err := validateMetric(m); err != nil {
return nil, err
}
if !selectorWithinScope(m.Target.Selector, tokenScope) {
return nil, ErrMonitorOutOfScope
}
// A metric monitor is evaluated by sweeping stored server inventory,
// never run against a single named agent.
m.Runner = models.RunnerServer
m.IntervalSec = 0
}
m.State = models.MonitorState{Status: models.StatusPending}
if _, err := db.Col("monitors").InsertOne(ctx, m); err != nil {
return nil, err
@@ -198,6 +225,50 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M, tokenScope map[stri
ctx, cancel := monCtx()
defer cancel()
existing, err := GetMonitor(instanceID, monitorID)
if err != nil {
return err
}
if existing == nil {
return ErrMonitorNotFound
}
unset, err := prepareMonitorUpdate(existing, upd)
if err != nil {
return err
}
// Editing any field of a metric monitor the token could not have created is
// refused before any write, so a restricted token cannot rename or disable
// a fleet-wide rule it cannot fully see.
if existing.Type == models.MonitorMetric && !selectorWithinScope(existing.Target.Selector, tokenScope) {
return ErrMonitorOutOfScope
}
if raw, present := upd["target"]; present && models.IsPassiveMonitor(existing.Type) {
tg, ok := raw.(models.MonitorTarget)
if !ok {
return fmt.Errorf("%w: target must be an object", ErrInvalidMonitor)
}
switch existing.Type {
case models.MonitorHeartbeat:
if err := validateHeartbeat(&tg); err != nil {
return err
}
case models.MonitorMetric:
forSec := existing.ForSec
if v, ok := upd["for_sec"].(int); ok {
forSec = v
}
probe := models.Monitor{Type: existing.Type, Target: tg, ForSec: forSec}
if err := validateMetric(&probe); err != nil {
return err
}
if !selectorWithinScope(tg.Selector, tokenScope) {
return ErrMonitorOutOfScope
}
}
upd["target"] = tg
}
if raw, present := upd["channel_ids"]; present {
ids, ok := raw.([]string)
if !ok {
@@ -231,13 +302,68 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M, tokenScope map[stri
upd["runner"] = models.RunnerServer
}
}
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}, bson.M{"$set": upd})
change := bson.M{"$set": upd}
if len(unset) > 0 {
change["$unset"] = unset
}
_, err = db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}, change)
return err
}
func DeleteMonitor(instanceID, monitorID string) error {
// ErrMonitorNotFound is returned when an update names a monitor that does not
// exist in the instance.
var ErrMonitorNotFound = errors.New("monitor not found")
// prepareMonitorUpdate applies the checks and rewrites that need no database:
// refusing a type change to or from a passive type, clamping for_sec, and
// resetting a heartbeat's ping state when it is re-enabled. It returns the
// fields to $unset.
func prepareMonitorUpdate(existing *models.Monitor, upd bson.M) (bson.M, error) {
if raw, present := upd["type"]; present {
if t, _ := raw.(string); t != existing.Type && (models.IsPassiveMonitor(t) || models.IsPassiveMonitor(existing.Type)) {
bad := existing.Type
if models.IsPassiveMonitor(t) {
bad = t
}
return nil, fmt.Errorf("%w: type cannot be changed to or from %s", ErrInvalidMonitor, bad)
}
}
// A negative gate means nothing; treat it as "alert immediately", as
// create does.
if v, ok := upd["for_sec"].(int); ok && v < 0 {
upd["for_sec"] = 0
}
unset := bson.M{}
// A heartbeat disabled for a while still holds its last ping, so the next
// sweep after re-enabling would page at once. Start it over as if new.
if on, _ := upd["enabled"].(bool); on && !existing.Enabled && existing.Type == models.MonitorHeartbeat {
unset["state.last_ping_at"] = ""
unset["state.started_at"] = ""
upd["state.status"] = models.StatusPending
}
return unset, nil
}
// DeleteMonitor deletes a monitor and its associated data. For a metric
// monitor, deletion is refused when its selector reaches beyond tokenScope -
// the same out-of-scope check applied on create and update - so a restricted
// token cannot remove a fleet-wide rule it could not have made. Other monitor
// types delete as before, unaffected by tokenScope.
func DeleteMonitor(instanceID, monitorID string, tokenScope map[string]string) error {
ctx, cancel := monCtx()
defer cancel()
existing, err := GetMonitor(instanceID, monitorID)
if err != nil {
return err
}
if existing == nil {
return nil
}
if existing.Type == models.MonitorMetric && !selectorWithinScope(existing.Target.Selector, tokenScope) {
return ErrMonitorOutOfScope
}
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
if err != nil {
return err
@@ -249,6 +375,7 @@ func DeleteMonitor(instanceID, monitorID string) error {
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
db.Col("monitor_samples").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
db.Col("monitor_server_states").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
return nil
}
@@ -342,24 +469,7 @@ func ingestResult(instanceID, runner, monitorID string, res checker.Result) erro
now := time.Now()
prev := m.State.Status
retries := m.Retries
if retries < 1 {
retries = 1
}
newStatus := prev
fails := m.State.Fails
if res.Up {
fails = 0
newStatus = models.StatusUp
} else {
fails++
if fails >= retries {
newStatus = models.StatusDown
} else if prev == "" || prev == models.StatusPending {
newStatus = models.StatusPending
}
}
newStatus, fails := decideStatus(prev, m.State.Fails, m.Retries, res.Up)
state := bson.M{
"state.status": newStatus,
@@ -375,56 +485,36 @@ func ingestResult(instanceID, runner, monitorID string, res checker.Result) erro
return err
}
bucket := now.Truncate(time.Hour)
up := 0
if res.Up {
up = 1
}
recordSample(ctx, m, res.Up, res.LatencyMs, now)
/* The sample is the same result at full resolution, expiring by TTL. It is
written next to the rollup rather than instead of it: the rollup is what
survives, the sample is what the sub-hour views read. */
db.Col("monitor_samples").InsertOne(ctx, models.MonitorSample{
InstanceID: m.InstanceID,
MonitorID: monitorID,
At: now,
Up: res.Up,
LatencyMs: res.LatencyMs,
})
db.Col("monitor_rollups").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "period_start": bucket},
bson.M{
"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)},
"$setOnInsert": bson.M{"instance_id": m.InstanceID},
},
options.UpdateOne().SetUpsert(true))
if newStatus != prev {
switch newStatus {
case models.StatusDown:
inc := models.Incident{
InstanceID: m.InstanceID,
IncidentID: uuid.NewString(),
MonitorID: monitorID,
StartedAt: now,
Cause: res.Message,
}
db.Col("incidents").InsertOne(ctx, inc)
notifyTransition(m, newStatus, res.Message)
case models.StatusUp:
if prev == models.StatusDown {
db.Col("incidents").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "instance_id": m.InstanceID, "resolved_at": nil},
bson.M{"$set": bson.M{"resolved_at": now}})
notifyTransition(m, newStatus, res.Message)
}
}
}
applyTransition(ctx, m, "", "", prev, newStatus, res.Message, now)
return nil
}
func notifyTransition(m *models.Monitor, newStatus, message string) {
// recordSample writes one result at full resolution and folds it into the
// hourly rollup, the two records every history view reads.
func recordSample(ctx context.Context, m *models.Monitor, up bool, latencyMs int, now time.Time) {
u := 0
if up {
u = 1
}
db.Col("monitor_samples").InsertOne(ctx, models.MonitorSample{
InstanceID: m.InstanceID,
MonitorID: m.MonitorID,
At: now,
Up: up,
LatencyMs: latencyMs,
})
db.Col("monitor_rollups").UpdateOne(ctx,
bson.M{"monitor_id": m.MonitorID, "period_start": now.Truncate(time.Hour)},
bson.M{
"$inc": bson.M{"checks": 1, "up_count": u, "sum_latency": int64(latencyMs)},
"$setOnInsert": bson.M{"instance_id": m.InstanceID},
},
options.UpdateOne().SetUpsert(true))
}
func notifyTransition(m *models.Monitor, serverName, oldStatus, newStatus, message string) {
if len(m.ChannelIDs) == 0 {
return
}
@@ -435,8 +525,9 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
}
ev := notify.Event{
MonitorName: m.Name,
ServerName: serverName,
Type: m.Type,
OldStatus: m.State.Status,
OldStatus: oldStatus,
NewStatus: newStatus,
Message: message,
Time: time.Now(),
+45
View File
@@ -0,0 +1,45 @@
package services
import (
"errors"
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestPrepareMonitorUpdate(t *testing.T) {
hb := &models.Monitor{Type: models.MonitorHeartbeat, Enabled: false}
if _, err := prepareMonitorUpdate(hb, bson.M{"type": models.MonitorHTTP}); !errors.Is(err, ErrInvalidMonitor) {
t.Fatalf("type change should be ErrInvalidMonitor, got %v", err)
}
upd := bson.M{"enabled": true}
unset, err := prepareMonitorUpdate(hb, upd)
if err != nil {
t.Fatal(err)
}
if _, ok := unset["state.last_ping_at"]; !ok {
t.Fatalf("re-enable should clear last_ping_at, got %v", unset)
}
if _, ok := unset["state.started_at"]; !ok {
t.Fatalf("re-enable should clear started_at, got %v", unset)
}
if upd["state.status"] != models.StatusPending {
t.Fatalf("re-enable should set pending, got %v", upd)
}
on := &models.Monitor{Type: models.MonitorHeartbeat, Enabled: true}
if unset, _ := prepareMonitorUpdate(on, bson.M{"enabled": true}); len(unset) != 0 {
t.Fatalf("already enabled should not reset state, got %v", unset)
}
upd = bson.M{"for_sec": -5}
if _, err := prepareMonitorUpdate(&models.Monitor{Type: models.MonitorMetric}, upd); err != nil {
t.Fatal(err)
}
if upd["for_sec"] != 0 {
t.Fatalf("negative for_sec should clamp to 0, got %v", upd["for_sec"])
}
}
@@ -0,0 +1,78 @@
package services
import (
"context"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// decideStatus is the pull-check retry state machine: a success is always up,
// failures below the retry count leave the status alone (or pending when there
// is none yet), and reaching the count is down.
func decideStatus(prev string, fails, retries int, up bool) (string, int) {
if retries < 1 {
retries = 1
}
if up {
return models.StatusUp, 0
}
fails++
if fails >= retries {
return models.StatusDown, fails
}
if prev == "" || prev == models.StatusPending {
return models.StatusPending, fails
}
return prev, fails
}
// applyTransition is the one place a status change becomes an incident and a
// notification. serverID is empty for every monitor except a metric monitor,
// which keeps one incident per server so each breach opens and resolves on its
// own.
func applyTransition(ctx context.Context, m *models.Monitor, serverID, serverName, prev, next, message string, now time.Time) {
if next == prev {
return
}
switch next {
case models.StatusDown:
inc := models.Incident{
InstanceID: m.InstanceID,
IncidentID: uuid.NewString(),
MonitorID: m.MonitorID,
ServerID: serverID,
StartedAt: now,
Cause: message,
}
if _, err := db.Col("incidents").InsertOne(ctx, inc); err != nil {
log.Printf("monitors: open incident for %s: %v", m.MonitorID, err)
}
notifyTransition(m, serverName, prev, next, message)
case models.StatusUp:
if prev != models.StatusDown {
return
}
resolveIncident(ctx, m, serverID, now)
notifyTransition(m, serverName, prev, next, message)
}
}
// resolveIncident closes the open incident for a monitor (and server, for a
// metric monitor) without notifying. It is also used when a server stops
// matching a metric monitor's selector: nothing recovered, so nobody is told.
func resolveIncident(ctx context.Context, m *models.Monitor, serverID string, now time.Time) {
filter := bson.M{"monitor_id": m.MonitorID, "instance_id": m.InstanceID, "resolved_at": nil}
if serverID != "" {
filter["server_id"] = serverID
} else {
filter["server_id"] = bson.M{"$exists": false}
}
if _, err := db.Col("incidents").UpdateMany(ctx, filter, bson.M{"$set": bson.M{"resolved_at": now}}); err != nil {
log.Printf("monitors: resolve incident for %s: %v", m.MonitorID, err)
}
}
@@ -0,0 +1,36 @@
package services
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// decideStatus is the retry state machine lifted out of ingestResult. These
// cases pin its existing behaviour so the refactor cannot change it.
func TestDecideStatus(t *testing.T) {
cases := []struct {
name string
prev string
fails int
retries int
up bool
wantState string
wantFails int
}{
{"up resets fails", models.StatusDown, 3, 2, true, models.StatusUp, 0},
{"first failure below retries is pending", models.StatusPending, 0, 3, false, models.StatusPending, 1},
{"failure below retries keeps up", models.StatusUp, 0, 3, false, models.StatusUp, 1},
{"failure reaching retries is down", models.StatusUp, 2, 3, false, models.StatusDown, 3},
{"retries below one treated as one", models.StatusUp, 0, 0, false, models.StatusDown, 1},
{"empty prev failing below retries is pending", "", 0, 2, false, models.StatusPending, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, fails := decideStatus(c.prev, c.fails, c.retries, c.up)
if got != c.wantState || fails != c.wantFails {
t.Fatalf("got (%s,%d), want (%s,%d)", got, fails, c.wantState, c.wantFails)
}
})
}
}
+118
View File
@@ -0,0 +1,118 @@
package services
import (
"encoding/hex"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var ErrNoPasskey = errors.New("no such passkey")
func ListPasskeys(instanceID, userID string) ([]models.WebAuthnCredential, error) {
ctx, cancel := mfaCtx()
defer cancel()
cur, err := db.Col("webauthn_credentials").Find(ctx,
bson.M{"instance_id": instanceID, "user_id": userID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
out := []models.WebAuthnCredential{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// GetPasskeyByCredentialID resolves a credential inside one instance. The
// instance scope is not optional: an unscoped lookup would let a credential
// registered on one tenant assert on another.
func GetPasskeyByCredentialID(instanceID string, credID []byte) (*models.WebAuthnCredential, error) {
ctx, cancel := mfaCtx()
defer cancel()
var c models.WebAuthnCredential
err := db.Col("webauthn_credentials").FindOne(ctx,
bson.M{"instance_id": instanceID, "credential_id": credID}).Decode(&c)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNoPasskey
}
if err != nil {
return nil, err
}
return &c, nil
}
func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string, backupEligible, backupState bool) error {
ctx, cancel := mfaCtx()
defer cancel()
if name == "" {
name = "Passkey"
}
_, err := db.Col("webauthn_credentials").InsertOne(ctx, models.WebAuthnCredential{
InstanceID: instanceID,
UserID: userID,
CredentialID: credID,
CredentialIDHex: hex.EncodeToString(credID),
PublicKey: publicKey,
AAGUID: aaguid,
SignCount: signCount,
Transports: transports,
BackupEligible: &backupEligible,
BackupState: backupState,
Name: name,
CreatedAt: time.Now(),
})
return err
}
// TouchPasskey records use and the new signature counter. A counter that fails
// to advance can mean a cloned authenticator, so the caller checks it before
// calling this.
func TouchPasskey(instanceID string, credID []byte, signCount uint32, backupEligible, backupState bool) error {
ctx, cancel := mfaCtx()
defer cancel()
now := time.Now()
_, err := db.Col("webauthn_credentials").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "credential_id": credID},
bson.M{"$set": bson.M{
"sign_count": signCount, "last_used_at": now,
// Records eligibility on rows that predate it; unchanged otherwise,
// because an assertion only succeeds when it matched.
"backup_eligible": backupEligible, "backup_state": backupState,
}})
return err
}
func RenamePasskey(instanceID, userID, credIDHex, name string) error {
ctx, cancel := mfaCtx()
defer cancel()
res, err := db.Col("webauthn_credentials").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex},
bson.M{"$set": bson.M{"name": name}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrNoPasskey
}
return nil
}
func DeletePasskey(instanceID, userID, credIDHex string) error {
ctx, cancel := mfaCtx()
defer cancel()
res, err := db.Col("webauthn_credentials").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrNoPasskey
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package services
import (
"context"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsurePatchIndexes builds the patching indexes. The command_id index is the
// one that matters: every PatchResult is matched to its server run through it.
func EnsurePatchIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := db.Col("maintenance_windows").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "window_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
if _, err := db.Col("patch_policies").Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "policy_id", Value: 1}}, Options: options.Index().SetUnique(true)},
{Keys: bson.D{{Key: "enabled", Value: 1}, {Key: "next_run_at", Value: 1}}},
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "window_id", Value: 1}}},
}); err != nil {
return err
}
if _, err := db.Col("patch_run_outputs").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "run_id", Value: 1}, {Key: "server_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
_, err := db.Col("patch_runs").Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "run_id", Value: 1}}, Options: options.Index().SetUnique(true)},
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "policy_id", Value: 1}, {Key: "started_at", Value: -1}}},
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "servers.server_id", Value: 1}, {Key: "started_at", Value: -1}}},
{Keys: bson.D{{Key: "status", Value: 1}}},
{Keys: bson.D{{Key: "servers.command_id", Value: 1}}},
})
return err
}
+254
View File
@@ -0,0 +1,254 @@
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var (
ErrPolicyInvalid = errors.New("invalid patch policy")
ErrPolicyNotFound = errors.New("patch policy not found")
)
// ValidatePolicy checks a policy without touching the database. The window's
// existence and the token's tag scope are checked by Create and Update.
func ValidatePolicy(p models.PatchPolicy) error {
if n := strings.TrimSpace(p.Name); n == "" || len(n) > 100 {
return fmt.Errorf("%w: name must be 1 to 100 characters", ErrPolicyInvalid)
}
if p.WindowID == "" {
return fmt.Errorf("%w: a maintenance window is required", ErrPolicyInvalid)
}
if p.Scope != models.PatchScopeAll && p.Scope != models.PatchScopeSecurity {
return fmt.Errorf("%w: scope must be %q or %q", ErrPolicyInvalid, models.PatchScopeAll, models.PatchScopeSecurity)
}
if p.Reboot != models.PatchRebootNever && p.Reboot != models.PatchRebootIfRequired {
return fmt.Errorf("%w: reboot must be %q or %q", ErrPolicyInvalid, models.PatchRebootNever, models.PatchRebootIfRequired)
}
if p.MaxConcurrent < 0 || p.MaxConcurrent > 1000 {
return fmt.Errorf("%w: max concurrent must be between 0 and 1000", ErrPolicyInvalid)
}
// Same rule as workflows: an empty selector matches nothing, and saying so
// at save time beats a policy that silently patches nobody every Sunday.
if len(p.TargetServerIDs) == 0 && len(p.TargetTags) == 0 {
return ErrNoTargets
}
return ValidateTags(p.TargetTags)
}
func nextRunFor(p models.PatchPolicy, w models.MaintenanceWindow, now time.Time) *time.Time {
if !p.Enabled {
return nil
}
start, err := patchsched.NextStart(w.Cron, w.TZ, now)
if err != nil {
return nil
}
return &start
}
func ListPolicies(instanceID string) ([]models.PatchPolicy, error) {
ctx, cancel := patchCtx()
defer cancel()
cur, err := db.Col("patch_policies").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"name": 1}))
if err != nil {
return nil, err
}
out := []models.PatchPolicy{}
return out, cur.All(ctx, &out)
}
func GetPolicy(instanceID, policyID string) (*models.PatchPolicy, error) {
ctx, cancel := patchCtx()
defer cancel()
var p models.PatchPolicy
err := db.Col("patch_policies").FindOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrPolicyNotFound
}
return &p, err
}
// preparePolicy runs every check a save needs and returns the policy's window.
func preparePolicy(instanceID string, p *models.PatchPolicy, tokenScope map[string]string) (*models.MaintenanceWindow, error) {
p.Name = strings.TrimSpace(p.Name)
if p.TargetServerIDs == nil {
p.TargetServerIDs = []string{}
}
if err := ValidatePolicy(*p); err != nil {
return nil, err
}
w, err := GetWindow(instanceID, p.WindowID)
if errors.Is(err, ErrWindowNotFound) {
return nil, fmt.Errorf("%w: that maintenance window does not exist", ErrPolicyInvalid)
}
if err != nil {
return nil, err
}
// validateTargetServers returns a plain "target server X not found"; wrap
// it so the handler can answer 400 without matching on text.
if err := validateTargetServers(instanceID, p.TargetServerIDs, tokenScope); err != nil {
return nil, fmt.Errorf("%w: %v", ErrPolicyInvalid, err)
}
if err := validateWorkflowTargetScope(instanceID, p.TargetServerIDs, p.TargetTags, tokenScope); err != nil {
return nil, err
}
return w, nil
}
func CreatePolicy(instanceID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error) {
w, err := preparePolicy(instanceID, &p, tokenScope)
if err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
now := time.Now()
p.InstanceID, p.PolicyID = instanceID, uuid.New().String()
p.CreatedAt, p.UpdatedAt = now, now
p.NextRunAt = nextRunFor(p, *w, now)
p.LastRunAt, p.LastSkipped, p.DisabledReason = nil, nil, ""
if _, err := db.Col("patch_policies").InsertOne(ctx, p); err != nil {
return nil, err
}
return &p, nil
}
func UpdatePolicy(instanceID, policyID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error) {
// Missing policy answers 404 before any validation error.
if _, err := GetPolicy(instanceID, policyID); err != nil {
return nil, err
}
w, err := preparePolicy(instanceID, &p, tokenScope)
if err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
now := time.Now()
set := bson.M{
"name": p.Name, "enabled": p.Enabled, "window_id": p.WindowID,
"target_server_ids": p.TargetServerIDs, "target_tags": p.TargetTags,
"scope": p.Scope, "reboot": p.Reboot, "max_concurrent": p.MaxConcurrent,
"notify_channel_ids": p.NotifyChannelIDs, "updated_at": now,
}
update := bson.M{"$set": set}
if next := nextRunFor(p, *w, now); next != nil {
set["next_run_at"] = *next
// Re-enabling is the operator's answer to whatever disabled it.
update["$unset"] = bson.M{"disabled_reason": ""}
} else {
update["$unset"] = bson.M{"next_run_at": ""}
}
if _, err := db.Col("patch_policies").UpdateOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID}, update); err != nil {
return nil, err
}
return GetPolicy(instanceID, policyID)
}
func DeletePolicy(instanceID, policyID string) error {
ctx, cancel := patchCtx()
defer cancel()
res, err := db.Col("patch_policies").DeleteOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrPolicyNotFound
}
return nil
}
// recomputePolicySchedules moves every enabled policy on this window to the
// window's next start.
func recomputePolicySchedules(ctx context.Context, w models.MaintenanceWindow) error {
start, err := patchsched.NextStart(w.Cron, w.TZ, time.Now())
if err != nil {
return err
}
_, err = db.Col("patch_policies").UpdateMany(ctx,
bson.M{"instance_id": w.InstanceID, "window_id": w.WindowID, "enabled": true},
bson.M{"$set": bson.M{"next_run_at": start}})
return err
}
// CountPolicyTargets resolves the selector as a run would, at this moment.
func CountPolicyTargets(p models.PatchPolicy) (int, error) {
servers, err := ResolveTargets(p.InstanceID, p.TargetServerIDs, p.TargetTags)
if errors.Is(err, ErrNoTargets) {
return 0, nil
}
return len(servers), err
}
var ErrPatchRunActive = errors.New("a run of this policy is already in progress")
// PoliciesForWindow lists every policy that uses a window, enabled or not.
func PoliciesForWindow(instanceID, windowID string) ([]models.PatchPolicy, error) {
ctx, cancel := patchCtx()
defer cancel()
cur, err := db.Col("patch_policies").Find(ctx, bson.M{"instance_id": instanceID, "window_id": windowID})
if err != nil {
return nil, err
}
out := []models.PatchPolicy{}
return out, cur.All(ctx, &out)
}
// CheckWindowScope refuses a tag-restricted token changing a window when any
// policy using it targets servers outside its restriction: moving or
// deleting the window moves or stops those servers' patching.
func CheckWindowScope(instanceID, windowID string, tokenScope map[string]string) error {
if len(tokenScope) == 0 {
return nil
}
ps, err := PoliciesForWindow(instanceID, windowID)
if err != nil {
return err
}
for _, p := range ps {
if err := CheckPolicyScope(instanceID, p, tokenScope); err != nil {
return err
}
}
return nil
}
// CheckPolicyScope refuses a tag-restricted token acting on a policy whose
// targets reach outside its restriction, with the workflow rule unchanged.
func CheckPolicyScope(instanceID string, p models.PatchPolicy, tokenScope map[string]string) error {
return validateWorkflowTargetScope(instanceID, p.TargetServerIDs, p.TargetTags, tokenScope)
}
// StartRunNow opens a window of the policy's usual length starting now. It is
// how an operator tests a policy on a Tuesday afternoon.
func StartRunNow(instanceID, policyID, actor string, tokenScope map[string]string) (*models.PatchRun, error) {
p, err := GetPolicy(instanceID, policyID)
if err != nil {
return nil, err
}
if err := CheckPolicyScope(instanceID, *p, tokenScope); err != nil {
return nil, err
}
w, err := GetWindow(instanceID, p.WindowID)
if err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
if db.Col("patch_runs").FindOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID, "status": models.PatchRunRunning}).Err() == nil {
return nil, ErrPatchRunActive
}
return StartPolicyRun(*p, patchsched.WindowEnd(time.Now(), w.DurationMinutes), models.PatchSourceRunNow, actor)
}
+623
View File
@@ -0,0 +1,623 @@
package services
import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchrun"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrAgentOffline is declared in consoleproxy.go and reused here: both mean
// the same thing, the target's agent is not on the command stream.
var (
ErrPatchRunNotFound = errors.New("patch run not found")
ErrPatchRunFinished = errors.New("patch run has already finished")
)
const patchRunsCol = "patch_runs"
// patchRunOutputsCol holds each server's output tail, one document per
// (run_id, server_id). Kept out of the run document because a large run with
// up to 64KB of output per server would pass MongoDB's 16MB document limit.
const patchRunOutputsCol = "patch_run_outputs"
// storePatchOutput upserts one server's output tail for a run.
func storePatchOutput(ctx context.Context, instanceID, runID, serverID, output string) error {
_, err := db.Col(patchRunOutputsCol).UpdateOne(ctx,
bson.M{"instance_id": instanceID, "run_id": runID, "server_id": serverID},
bson.M{"$set": bson.M{"output": output, "updated_at": time.Now()}},
options.UpdateOne().SetUpsert(true))
return err
}
// fillPatchOutputs puts each server's stored output back on the run, so the
// API response carries it as before. A run written before outputs moved out
// keeps the output it has inline.
func fillPatchOutputs(ctx context.Context, run *models.PatchRun) error {
cur, err := db.Col(patchRunOutputsCol).Find(ctx, bson.M{"instance_id": run.InstanceID, "run_id": run.RunID})
if err != nil {
return err
}
var outs []struct {
ServerID string `bson:"server_id"`
Output string `bson:"output"`
}
if err := cur.All(ctx, &outs); err != nil {
return err
}
byServer := make(map[string]string, len(outs))
for _, o := range outs {
byServer[o.ServerID] = o.Output
}
for i := range run.Servers {
if out, ok := byServer[run.Servers[i].ServerID]; ok {
run.Servers[i].Output = out
}
}
return nil
}
// serverBootTime is the boot time the server last reported, or nil.
func serverBootTime(ctx context.Context, instanceID, serverID string) *time.Time {
var doc struct {
Inventory struct {
BootTime *time.Time `bson:"boot_time"`
} `bson:"inventory"`
}
if err := db.Col("servers").FindOne(ctx, bson.M{"instance_id": instanceID, "server_id": serverID},
options.FindOne().SetProjection(bson.M{"inventory.boot_time": 1})).Decode(&doc); err != nil {
return nil
}
return doc.Inventory.BootTime
}
func newServerRun(s models.Server, now time.Time) models.PatchServerRun {
r := models.PatchServerRun{ServerID: s.ServerID, Hostname: s.Hostname, Status: models.PatchSrvQueued, PendingBefore: InstallableUpdateCount(s.AvailableUpdates)}
if !patchrun.AgentSupportsPatchResults(s.AgentVersion) {
v := s.AgentVersion
if v == "" {
v = "unknown"
}
r.Status = models.PatchSrvAgentTooOld
r.Error = fmt.Sprintf("agent %s predates patch results; update it to %s or later", v, patchrun.MinAgentVersion)
r.FinishedAt = &now
}
return r
}
// StartPolicyRun records a run for a policy and dispatches its first batch
// immediately. The run document is written before any command leaves, so a
// fast agent's result always finds it.
func StartPolicyRun(p models.PatchPolicy, windowEnd time.Time, source, actor string) (*models.PatchRun, error) {
targets, err := ResolveTargets(p.InstanceID, p.TargetServerIDs, p.TargetTags)
if err != nil {
return nil, err
}
now := time.Now()
run := models.PatchRun{
InstanceID: p.InstanceID, RunID: uuid.New().String(), PolicyID: p.PolicyID, PolicyName: p.Name,
TriggeredBy: actor, Source: source, Scope: p.Scope, Reboot: p.Reboot, MaxConcurrent: p.MaxConcurrent,
WindowEnd: &windowEnd, Status: models.PatchRunRunning, StartedAt: now,
}
for _, s := range targets {
run.Servers = append(run.Servers, newServerRun(s, now))
}
ctx, cancel := patchCtx()
defer cancel()
if _, err := db.Col(patchRunsCol).InsertOne(ctx, run); err != nil {
return nil, err
}
LogEvent(p.InstanceID, "patch.run_started", actor, "", "",
fmt.Sprintf("patch policy %s started on %d servers (%s, reboot %s)", p.Name, len(run.Servers), p.Scope, p.Reboot))
advanceRun(ctx, run.RunID)
return &run, nil
}
// StartManualRun is Apply updates on one server. An agent too old to answer
// still gets the command it always got, and the run says it cannot know the
// outcome rather than claiming one.
func StartManualRun(instanceID string, srv *models.Server, actor, source string) (*models.PatchRun, error) {
now := time.Now()
sr := newServerRun(*srv, now)
legacy := sr.Status == models.PatchSrvAgentTooOld
sr.Status, sr.Error, sr.FinishedAt, sr.StartedAt = models.PatchSrvPatching, "", nil, &now
run := models.PatchRun{
InstanceID: instanceID, RunID: uuid.New().String(), TriggeredBy: actor, Source: source,
Scope: models.PatchScopeAll, Reboot: models.PatchRebootNever, Status: models.PatchRunRunning, StartedAt: now,
}
ctx, cancel := patchCtx()
defer cancel()
var dispatchErr error
if !Dispatcher.IsConnected(srv.ServerID) {
dispatchErr = ErrAgentOffline
}
if dispatchErr == nil && legacy {
dispatchErr = DispatchApplyUpdates(srv.ServerID)
if dispatchErr == nil {
sr.Status = models.PatchSrvSucceeded
sr.Error = "no result reported: agent predates patch results"
sr.FinishedAt = &now
}
}
if dispatchErr != nil {
sr.Status, sr.Error, sr.FinishedAt = models.PatchSrvFailed, dispatchErr.Error(), &now
} else if !legacy {
sr.CommandID = uuid.New().String()
}
run.Servers = []models.PatchServerRun{sr}
if _, err := db.Col(patchRunsCol).InsertOne(ctx, run); err != nil {
return nil, err
}
if dispatchErr == nil && !legacy {
if err := dispatchPatch(srv.ServerID, sr.CommandID, run); err != nil {
_, _ = setServer(ctx, run.RunID, srv.ServerID, models.PatchSrvPatching,
bson.M{"status": models.PatchSrvFailed, "error": err.Error(), "finished_at": time.Now()})
dispatchErr = ErrAgentOffline
}
}
finalizeRun(ctx, run.RunID)
if dispatchErr != nil {
return &run, ErrAgentOffline
}
return &run, nil
}
func dispatchPatch(serverID, commandID string, run models.PatchRun) error {
cmd := &pb.ApplyUpdatesCmd{Scope: run.Scope, RebootIfRequired: run.Reboot == models.PatchRebootIfRequired}
if run.WindowEnd != nil {
cmd.DeadlineUnix = run.WindowEnd.Unix()
}
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, ApplyUpdates: cmd})
}
// setServer updates one server run, but only if it is still in status from.
// That guard is what stops a tick from overwriting a result that landed while
// the tick was working.
func setServer(ctx context.Context, runID, serverID, from string, set bson.M) (bool, error) {
fields := bson.M{}
for k, v := range set {
fields["servers.$."+k] = v
}
res, err := db.Col(patchRunsCol).UpdateOne(ctx,
bson.M{"run_id": runID, "servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "status": from}}},
bson.M{"$set": fields})
if err != nil {
return false, err
}
return res.MatchedCount > 0, nil
}
// setServerForCommand is setServer scoped to one command: a late result from
// an older, already-superseded command (say, a fresh dispatch to the same
// server after a failed send) must not land on the new attempt.
func setServerForCommand(ctx context.Context, runID, serverID, commandID, from string, set bson.M) (bool, error) {
fields := bson.M{}
for k, v := range set {
fields["servers.$."+k] = v
}
res, err := db.Col(patchRunsCol).UpdateOne(ctx,
bson.M{"run_id": runID, "servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "status": from, "command_id": commandID}}},
bson.M{"$set": fields})
if err != nil {
return false, err
}
return res.MatchedCount > 0, nil
}
// claimServerForDispatch atomically moves one server from queued/waiting_offline
// to patching, guarded on the run still being running and not cancelled: a
// tick that loaded the run before CancelPatchRun wrote cancelled_at must not
// dispatch to it.
func claimServerForDispatch(ctx context.Context, runID, serverID, from, commandID string, now time.Time) (bool, error) {
res, err := db.Col(patchRunsCol).UpdateOne(ctx,
bson.M{
"run_id": runID,
"status": models.PatchRunRunning,
"cancelled_at": bson.M{"$exists": false},
"servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "status": from}},
},
bson.M{"$set": bson.M{
"servers.$.status": models.PatchSrvPatching,
"servers.$.command_id": commandID,
"servers.$.started_at": now,
}})
if err != nil {
return false, err
}
return res.MatchedCount > 0, nil
}
// loadRun reads a run for the tick and result paths, which never need output,
// so any inline output left on an old document is not read either.
func loadRun(ctx context.Context, filter bson.M) (*models.PatchRun, error) {
var run models.PatchRun
err := db.Col(patchRunsCol).FindOne(ctx, filter, options.FindOne().SetProjection(bson.M{"servers.output": 0})).Decode(&run)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrPatchRunNotFound
}
return &run, err
}
// AdvancePatchRuns is the scheduler's second job each tick.
func AdvancePatchRuns(ctx context.Context) {
cur, err := db.Col(patchRunsCol).Find(ctx, bson.M{"status": models.PatchRunRunning}, options.Find().SetProjection(bson.M{"run_id": 1}))
if err != nil {
log.Printf("patch runs: find running: %v", err)
return
}
var ids []struct {
RunID string `bson:"run_id"`
}
if err := cur.All(ctx, &ids); err != nil {
log.Printf("patch runs: decode running: %v", err)
return
}
for _, r := range ids {
if ctx.Err() != nil {
return
}
advanceRun(ctx, r.RunID)
}
}
func advanceRun(ctx context.Context, runID string) {
run, err := loadRun(ctx, bson.M{"run_id": runID})
if err != nil {
log.Printf("patch run %s: load: %v", runID, err)
return
}
now := time.Now()
connected := map[string]bool{}
for _, s := range run.Servers {
if !patchrun.IsTerminal(s.Status) {
connected[s.ServerID] = Dispatcher.IsConnected(s.ServerID)
}
}
for _, tr := range patchrun.Advance(*run, now, connected) {
if tr.Dispatch {
// Each server's claim and any reset-after-failed-send get their
// own fresh, short-lived context rather than sharing the caller's:
// advanceRun can dispatch to many servers in one tick, each ack
// waiting up to Dispatcher's own timeout, and a caller-supplied
// context (StartPolicyRun's patchCtx, or a leader context that
// AdvancePatchRuns loses partway through) would otherwise expire
// mid-loop and silently drop a later server's writes.
cmdID := uuid.New().String()
claimCtx, claimCancel := patchCtx()
ok, err := claimServerForDispatch(claimCtx, runID, tr.ServerID, tr.From, cmdID, now)
claimCancel()
if err != nil {
log.Printf("patch run %s: server %s: claim: %v", runID, tr.ServerID, err)
continue
}
if !ok {
continue
}
if err := dispatchPatch(tr.ServerID, cmdID, *run); err != nil {
// The agent dropped between the connection check and the send.
// Back to waiting: the next tick tries again while the window
// is open.
resetCtx, resetCancel := patchCtx()
if _, rerr := setServerForCommand(resetCtx, runID, tr.ServerID, cmdID, models.PatchSrvPatching,
bson.M{"status": models.PatchSrvWaitingOffline, "command_id": ""}); rerr != nil {
log.Printf("patch run %s: server %s: reset after failed dispatch: %v", runID, tr.ServerID, rerr)
}
resetCancel()
}
continue
}
set := bson.M{"status": tr.To}
if tr.Error != "" {
set["error"] = tr.Error
}
if patchrun.IsTerminal(tr.To) {
set["finished_at"] = now
}
if _, err := setServer(ctx, runID, tr.ServerID, tr.From, set); err != nil {
log.Printf("patch run %s: server %s: %v", runID, tr.ServerID, err)
}
}
finalizeRun(ctx, runID)
}
func finalizeRun(ctx context.Context, runID string) {
run, err := loadRun(ctx, bson.M{"run_id": runID})
if err != nil || run.Status != models.PatchRunRunning {
return
}
status, done := patchrun.Finalize(*run)
if !done {
return
}
now := time.Now()
res, err := db.Col(patchRunsCol).UpdateOne(ctx,
bson.M{"run_id": runID, "status": models.PatchRunRunning},
bson.M{"$set": bson.M{"status": status, "finished_at": now}})
if err != nil || res.MatchedCount == 0 {
return // finalised by someone else
}
run.Status = status
name := run.PolicyName
if name == "" {
name = "manual update"
}
LogEvent(run.InstanceID, "patch.run_finished", run.TriggeredBy, "", "",
fmt.Sprintf("patch run %s (%s) %s: %s", runID, name, status, patchrun.Summary(*run)))
if status == models.PatchRunPartial || status == models.PatchRunFailed ||
(status == models.PatchRunCancelled && hasBadOutcome(*run)) {
notifyPatchRun(*run)
}
}
// hasBadOutcome reports whether any server failed or was not reached. A
// cancelled run with such a result still alerts: cancelling does not make a
// failure somebody else's problem.
func hasBadOutcome(run models.PatchRun) bool {
for _, s := range run.Servers {
switch s.Status {
case models.PatchSrvFailed, models.PatchSrvMissedOffline, models.PatchSrvWindowClosed:
return true
}
}
return false
}
func notifyPatchRun(run models.PatchRun) {
if run.PolicyID == "" {
return // a manual run was watched by the person who clicked
}
p, err := GetPolicy(run.InstanceID, run.PolicyID)
if err != nil || len(p.NotifyChannelIDs) == 0 {
return
}
chs, err := GetChannels(run.InstanceID, p.NotifyChannelIDs)
if err != nil {
log.Printf("patch run %s: channels: %v", run.RunID, err)
return
}
ev := notify.Event{
MonitorName: fmt.Sprintf("Patch policy %q %s", run.PolicyName, run.Status),
Type: notify.TypePatch,
NewStatus: run.Status,
Message: fmt.Sprintf("%s (run %s)", patchrun.Summary(run), run.RunID),
Time: time.Now(),
}
for _, ch := range chs {
if err := notify.Dispatch(ch, ev); err != nil {
log.Printf("patch run %s: notify %s: %v", run.RunID, ch.Name, err)
}
}
}
// RecordPatchResult is called by whichever pod holds the agent's stream. The
// filter names this agent's own server, so one agent cannot answer for
// another's command.
func RecordPatchResult(instanceID, serverID string, r *pb.PatchResult) {
ctx, cancel := patchCtx()
defer cancel()
run, err := loadRun(ctx, bson.M{"instance_id": instanceID,
"servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "command_id": r.CommandId}}})
if err != nil {
log.Printf("patch result %s from %s matches no run: %v", r.CommandId, serverID, err)
return
}
for _, s := range run.Servers {
if s.ServerID != serverID || s.CommandID != r.CommandId {
continue
}
updated, ok := patchrun.ApplyResult(s, r, time.Now())
if !ok {
return
}
// The output goes to patch_run_outputs, not the run document.
set := bson.M{"status": updated.Status, "error": updated.Error}
if updated.PendingAfter != nil {
set["pending_after"] = *updated.PendingAfter
}
if updated.RebootedAt != nil {
set["rebooted_at"] = *updated.RebootedAt
}
if updated.Status == models.PatchSrvRebooting {
// The boot time before the reboot: a later report with a
// different one proves the restart.
if bt := serverBootTime(ctx, instanceID, serverID); bt != nil {
set["boot_time_before"] = *bt
}
}
if updated.FinishedAt != nil {
set["finished_at"] = *updated.FinishedAt
}
ok, err := setServerForCommand(ctx, run.RunID, serverID, r.CommandId, models.PatchSrvPatching, set)
if err != nil {
log.Printf("patch run %s: server %s: record result: %v", run.RunID, serverID, err)
}
if !ok {
return
}
if err := storePatchOutput(ctx, instanceID, run.RunID, serverID, updated.Output); err != nil {
log.Printf("patch run %s: server %s: store output: %v", run.RunID, serverID, err)
}
if updated.Status == models.PatchSrvRebooting {
LogEvent(instanceID, "patch.reboot", run.TriggeredBy, serverID, "",
fmt.Sprintf("%s rebooting for patch policy %s", s.Hostname, run.PolicyName))
}
}
finalizeRun(ctx, run.RunID)
}
// VerifyPatchReboots settles any rebooting server run for this server from a
// static inventory report.
func VerifyPatchReboots(instanceID, serverID string, bootTime time.Time, rebootRequired bool) {
ctx, cancel := patchCtx()
defer cancel()
cur, err := db.Col(patchRunsCol).Find(ctx, bson.M{"instance_id": instanceID, "status": models.PatchRunRunning,
"servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "status": models.PatchSrvRebooting}}})
if err != nil {
return
}
var runs []models.PatchRun
if err := cur.All(ctx, &runs); err != nil {
return
}
now := time.Now()
for _, run := range runs {
for _, s := range run.Servers {
if s.ServerID != serverID {
continue
}
updated, ok := patchrun.VerifyReboot(s, bootTime, rebootRequired, now)
if !ok {
continue
}
set := bson.M{"status": updated.Status, "error": updated.Error, "finished_at": now}
if updated.VerifiedAt != nil {
set["verified_at"] = *updated.VerifiedAt
}
_, _ = setServer(ctx, run.RunID, serverID, models.PatchSrvRebooting, set)
}
finalizeRun(ctx, run.RunID)
}
}
// CancelPatchRun stops further dispatch. Servers already patching finish:
// killing a package manager mid-transaction is worse than letting it end.
func CancelPatchRun(instanceID, runID string) error {
ctx, cancel := patchCtx()
defer cancel()
res, err := db.Col(patchRunsCol).UpdateOne(ctx,
bson.M{"instance_id": instanceID, "run_id": runID, "status": models.PatchRunRunning, "cancelled_at": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"cancelled_at": time.Now()}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
if _, err := GetPatchRun(instanceID, runID); err != nil {
return err
}
return ErrPatchRunFinished
}
advanceRun(ctx, runID)
return nil
}
func GetPatchRun(instanceID, runID string) (*models.PatchRun, error) {
ctx, cancel := patchCtx()
defer cancel()
var run models.PatchRun
err := db.Col(patchRunsCol).FindOne(ctx, bson.M{"instance_id": instanceID, "run_id": runID}).Decode(&run)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrPatchRunNotFound
}
if err != nil {
return nil, err
}
if err := fillPatchOutputs(ctx, &run); err != nil {
return nil, err
}
return &run, nil
}
// ListPatchRuns omits output: a list of fifty runs would otherwise carry up
// to 64KB per server.
func ListPatchRuns(instanceID, policyID, serverID string, limit int64) ([]models.PatchRun, error) {
ctx, cancel := patchCtx()
defer cancel()
filter := bson.M{"instance_id": instanceID}
if policyID != "" {
filter["policy_id"] = policyID
}
if serverID != "" {
filter["servers.server_id"] = serverID
}
if limit <= 0 || limit > 200 {
limit = 50
}
cur, err := db.Col(patchRunsCol).Find(ctx, filter, options.Find().
SetSort(bson.M{"started_at": -1}).SetLimit(limit).SetProjection(bson.M{"servers.output": 0}))
if err != nil {
return nil, err
}
out := []models.PatchRun{}
return out, cur.All(ctx, &out)
}
// ScopePatchRun removes servers outside a tag-restricted token's reach, so a
// run record cannot name a host the caller could not otherwise see.
func ScopePatchRun(instanceID string, run *models.PatchRun, tokenScope map[string]string) error {
if len(tokenScope) == 0 {
return nil
}
ids := make([]string, 0, len(run.Servers))
for _, s := range run.Servers {
ids = append(ids, s.ServerID)
}
visible, err := ResolveTargetsScoped(instanceID, ids, nil, tokenScope)
if err != nil && !errors.Is(err, ErrNoTargets) {
return err
}
keep := map[string]bool{}
for _, s := range visible {
keep[s.ServerID] = true
}
kept := run.Servers[:0]
for _, s := range run.Servers {
if keep[s.ServerID] {
kept = append(kept, s)
}
}
run.Servers = kept
return nil
}
// sweepPatchRuns deletes finished runs past their instance's workflow log
// retention: they are the same kind of record, and one setting governs both.
func sweepPatchRuns() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cur, err := db.Col(patchRunsCol).Find(ctx, bson.M{"finished_at": bson.M{"$ne": nil}},
options.Find().SetProjection(bson.M{"run_id": 1, "instance_id": 1, "finished_at": 1}))
if err != nil {
log.Printf("patch run sweep: %v", err)
return
}
defer cur.Close(ctx)
cache := map[string]int{}
now := time.Now()
for cur.Next(ctx) {
var r struct {
RunID string `bson:"run_id"`
InstanceID string `bson:"instance_id"`
FinishedAt *time.Time `bson:"finished_at"`
}
if cur.Decode(&r) != nil || r.FinishedAt == nil {
continue
}
days, ok := cache[r.InstanceID]
if !ok {
days = defaultRetentionDays
if v, err := GetWorkflowLogRetentionDays(r.InstanceID); err == nil {
days = v
}
cache[r.InstanceID] = days
}
if days <= 0 || !r.FinishedAt.Before(now.AddDate(0, 0, -days)) {
continue
}
if _, err := db.Col(patchRunsCol).DeleteOne(ctx, bson.M{"run_id": r.RunID}); err == nil {
_, _ = db.Col(patchRunOutputsCol).DeleteMany(ctx, bson.M{"instance_id": r.InstanceID, "run_id": r.RunID})
}
}
}
@@ -0,0 +1,20 @@
package services
import "testing"
// A tenant-scoped collection missing from ScopedCollections outlives its
// instance when the instance is purged.
func TestPatchCollectionsAreScoped(t *testing.T) {
for _, name := range []string{"maintenance_windows", "patch_policies", "patch_runs", "patch_run_outputs"} {
found := false
for _, got := range ScopedCollections {
if got == name {
found = true
break
}
}
if !found {
t.Errorf("ScopedCollections is missing %q", name)
}
}
}
@@ -0,0 +1,90 @@
package services
import (
"errors"
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func goodWindow() models.MaintenanceWindow {
return models.MaintenanceWindow{Name: "Sunday", Cron: "0 2 * * 0", TZ: "Europe/London", DurationMinutes: 120}
}
func TestValidateWindow(t *testing.T) {
if err := ValidateWindow(goodWindow()); err != nil {
t.Fatalf("good window: %v", err)
}
bad := map[string]func(*models.MaintenanceWindow){
"empty name": func(w *models.MaintenanceWindow) { w.Name = " " },
"long name": func(w *models.MaintenanceWindow) { w.Name = strings.Repeat("a", 101) },
"too short": func(w *models.MaintenanceWindow) { w.DurationMinutes = 14 },
"too long": func(w *models.MaintenanceWindow) { w.DurationMinutes = 721 },
"bad cron": func(w *models.MaintenanceWindow) { w.Cron = "every sunday" },
"six-field cron": func(w *models.MaintenanceWindow) { w.Cron = "0 0 2 * * 0" },
"bad tz": func(w *models.MaintenanceWindow) { w.TZ = "Mars/Olympus" },
"no tz": func(w *models.MaintenanceWindow) { w.TZ = "" },
}
for name, mut := range bad {
w := goodWindow()
mut(&w)
if err := ValidateWindow(w); !errors.Is(err, ErrWindowInvalid) {
t.Errorf("%s: err = %v, want ErrWindowInvalid", name, err)
}
}
}
func TestPreviewWindowSpansDoNotOverlap(t *testing.T) {
from := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
spans, err := PreviewWindow("0 * * * *", "UTC", 90, from, 3)
if err != nil {
t.Fatal(err)
}
if len(spans) != 3 {
t.Fatalf("got %d spans", len(spans))
}
for i := 1; i < len(spans); i++ {
if spans[i].Start.Before(spans[i-1].End) {
t.Fatalf("span %d starts %s before previous ends %s", i, spans[i].Start, spans[i-1].End)
}
}
if !spans[0].End.Equal(spans[0].Start.Add(90 * time.Minute)) {
t.Fatal("end must be start + duration")
}
}
func goodPolicy() models.PatchPolicy {
return models.PatchPolicy{
Name: "Sunday prod", WindowID: "w1", TargetTags: map[string]string{"env": "prod"},
Scope: models.PatchScopeSecurity, Reboot: models.PatchRebootNever,
}
}
func TestValidatePolicy(t *testing.T) {
if err := ValidatePolicy(goodPolicy()); err != nil {
t.Fatalf("good policy: %v", err)
}
bad := map[string]func(*models.PatchPolicy){
"empty name": func(p *models.PatchPolicy) { p.Name = "" },
"no window": func(p *models.PatchPolicy) { p.WindowID = "" },
"bad scope": func(p *models.PatchPolicy) { p.Scope = "everything" },
"bad reboot": func(p *models.PatchPolicy) { p.Reboot = "always" },
"negative cap": func(p *models.PatchPolicy) { p.MaxConcurrent = -1 },
"huge cap": func(p *models.PatchPolicy) { p.MaxConcurrent = 1001 },
"uppercase tag": func(p *models.PatchPolicy) { p.TargetTags = map[string]string{"Env": "prod"} },
}
for name, mut := range bad {
p := goodPolicy()
mut(&p)
if err := ValidatePolicy(p); err == nil {
t.Errorf("%s: want an error", name)
}
}
p := goodPolicy()
p.TargetTags = nil
if err := ValidatePolicy(p); !errors.Is(err, ErrNoTargets) {
t.Errorf("empty selector: err = %v, want ErrNoTargets", err)
}
}
+166
View File
@@ -0,0 +1,166 @@
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var (
ErrWindowInvalid = errors.New("invalid maintenance window")
ErrWindowNotFound = errors.New("maintenance window not found")
ErrWindowInUse = errors.New("maintenance window is used by a patch policy")
)
const (
minWindowMinutes = 15
maxWindowMinutes = 720
)
func patchCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
func ValidateWindow(w models.MaintenanceWindow) error {
if n := strings.TrimSpace(w.Name); n == "" || len(n) > 100 {
return fmt.Errorf("%w: name must be 1 to 100 characters", ErrWindowInvalid)
}
if w.DurationMinutes < minWindowMinutes || w.DurationMinutes > maxWindowMinutes {
return fmt.Errorf("%w: duration must be between %d and %d minutes", ErrWindowInvalid, minWindowMinutes, maxWindowMinutes)
}
if _, err := workflowsched.ParseSchedule(w.Cron, w.TZ); err != nil {
return fmt.Errorf("%w: %v", ErrWindowInvalid, err)
}
return nil
}
type WindowSpan struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
// PreviewWindow returns the next n windows, computed exactly as the scheduler
// computes them, so the editor cannot disagree with what will fire.
func PreviewWindow(cron, tz string, durationMinutes int, from time.Time, n int) ([]WindowSpan, error) {
if err := ValidateWindow(models.MaintenanceWindow{Name: "preview", Cron: cron, TZ: tz, DurationMinutes: durationMinutes}); err != nil {
return nil, err
}
out := make([]WindowSpan, 0, n)
for len(out) < n {
start, err := patchsched.NextStart(cron, tz, from)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrWindowInvalid, err)
}
end := patchsched.WindowEnd(start, durationMinutes)
out = append(out, WindowSpan{Start: start, End: end})
from = patchsched.Later(start, end)
}
return out, nil
}
func ListWindows(instanceID string) ([]models.MaintenanceWindow, error) {
ctx, cancel := patchCtx()
defer cancel()
cur, err := db.Col("maintenance_windows").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"name": 1}))
if err != nil {
return nil, err
}
out := []models.MaintenanceWindow{}
return out, cur.All(ctx, &out)
}
func GetWindow(instanceID, windowID string) (*models.MaintenanceWindow, error) {
w, err := LookupWindow(instanceID, windowID)
if err == nil && w == nil {
return nil, ErrWindowNotFound
}
return w, err
}
// LookupWindow is GetWindow for the scheduler, which must tell "gone" (nil,
// nil: disable the policy) from a database error (retry next tick).
func LookupWindow(instanceID, windowID string) (*models.MaintenanceWindow, error) {
ctx, cancel := patchCtx()
defer cancel()
var w models.MaintenanceWindow
err := db.Col("maintenance_windows").FindOne(ctx, bson.M{"instance_id": instanceID, "window_id": windowID}).Decode(&w)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &w, nil
}
func CreateWindow(instanceID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error) {
w.Name = strings.TrimSpace(w.Name)
if err := ValidateWindow(w); err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
w.InstanceID, w.WindowID = instanceID, uuid.New().String()
w.CreatedAt = time.Now()
w.UpdatedAt = w.CreatedAt
if _, err := db.Col("maintenance_windows").InsertOne(ctx, w); err != nil {
return nil, err
}
return &w, nil
}
// UpdateWindow saves the window and moves next_run_at on every enabled policy
// that uses it, so an edited Sunday becomes the next Sunday everywhere at once.
func UpdateWindow(instanceID, windowID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error) {
w.Name = strings.TrimSpace(w.Name)
if err := ValidateWindow(w); err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
res, err := db.Col("maintenance_windows").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "window_id": windowID},
bson.M{"$set": bson.M{"name": w.Name, "cron": w.Cron, "tz": w.TZ, "duration_minutes": w.DurationMinutes, "updated_at": time.Now()}})
if err != nil {
return nil, err
}
if res.MatchedCount == 0 {
return nil, ErrWindowNotFound
}
saved, err := GetWindow(instanceID, windowID)
if err != nil {
return nil, err
}
return saved, recomputePolicySchedules(ctx, *saved)
}
func DeleteWindow(instanceID, windowID string) error {
ctx, cancel := patchCtx()
defer cancel()
n, err := db.Col("patch_policies").CountDocuments(ctx, bson.M{"instance_id": instanceID, "window_id": windowID})
if err != nil {
return err
}
if n > 0 {
return ErrWindowInUse
}
res, err := db.Col("maintenance_windows").DeleteOne(ctx, bson.M{"instance_id": instanceID, "window_id": windowID})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrWindowNotFound
}
return nil
}
+3 -2
View File
@@ -11,8 +11,8 @@ import (
// the vocabulary below.
var ErrInvalidScope = errors.New("invalid scope")
// ScopeResources is the whole vocabulary. Ten resources, each with :read and
// :write, and write implies read on the same resource.
// ScopeResources is the whole vocabulary. Eleven resources, each with :read
// and :write, and write implies read on the same resource.
//
// It is deliberately coarse. A scope per endpoint is a table nobody maintains,
// and a route added without an entry either fails closed and breaks, or
@@ -27,6 +27,7 @@ var ScopeResources = []string{
"workloads",
"settings",
"status",
"patching",
// mcp:read is permission to reach the MCP endpoint at all; mcp:write is
// permission for its write tools, which are not merely refused without it
// but omitted from tools/list entirely.
@@ -0,0 +1,27 @@
package services
import (
"encoding/base64"
"net/url"
"strings"
"testing"
)
func TestMaskSecretsEncodedAndMultiline(t *testing.T) {
pem := "-----BEGIN KEY-----\nAbCdEfGh12345678\n-----END KEY-----"
secrets := map[string]string{"TOKEN": "p@ss w0rd/x+y", "KEY": pem, "SHORT": "ab"}
for _, in := range []string{
"raw p@ss w0rd/x+y",
"b64 " + base64.StdEncoding.EncodeToString([]byte("p@ss w0rd/x+y")),
"url " + url.QueryEscape("p@ss w0rd/x+y"),
"AbCdEfGh12345678",
} {
out := maskSecrets(in, secrets)
if !strings.Contains(out, "***") || strings.Contains(out, "w0rd") || strings.Contains(out, "AbCd") {
t.Errorf("not masked: %q -> %q", in, out)
}
}
if out := maskSecrets("about", secrets); out != "about" {
t.Errorf("short secret masked ordinary text: %q", out)
}
}
+4 -1
View File
@@ -119,7 +119,7 @@ func ResolveSecretsReadToken(token string) (string, bool) {
return s.InstanceID, true
}
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error {
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, requireMFA *bool, apiTokenMaxDays *int) error {
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
@@ -149,6 +149,9 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays
if localLoginEnabled != nil {
set["local_login_enabled"] = *localLoginEnabled
}
if requireMFA != nil {
set["require_mfa"] = *requireMFA
}
if apiTokenMaxDays != nil {
set["api_token_max_days"] = *apiTokenMaxDays
}
+2
View File
@@ -332,6 +332,7 @@ func HasServerRunLog(runID, serverID string) bool {
func StartLogSweeper(ctx context.Context) {
go func() {
sweepLogs()
sweepPatchRuns()
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
@@ -340,6 +341,7 @@ func StartLogSweeper(ctx context.Context) {
return
case <-t.C:
sweepLogs()
sweepPatchRuns()
}
}
}()
+15
View File
@@ -105,3 +105,18 @@ func SelectorNarrowerOrEqual(child, parent map[string]string) bool {
}
return true
}
// FilterByVisibleServer drops items tied to a server the credential cannot
// see. Items with no server (every non-metric incident) are always kept.
func FilterByVisibleServer[T any](items []T, serverID func(T) string, visible map[string]bool, restricted bool) []T {
if !restricted {
return items
}
out := items[:0:0]
for _, it := range items {
if id := serverID(it); id == "" || visible[id] {
out = append(out, it)
}
}
return out
}
+17
View File
@@ -0,0 +1,17 @@
package services
import "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
// InstallableUpdateCount is the number of pending updates an upgrade would
// install now. Ubuntu phased updates are listed by apt but deferred until the
// host's phase comes up, so they are not counted: counting them made a patch
// run look as if it installed less than it did.
func InstallableUpdateCount(ups []models.PackageUpdate) int {
n := 0
for _, u := range ups {
if !u.Phased {
n++
}
}
return n
}
@@ -0,0 +1,23 @@
package services
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// Phased updates are listed but not installable yet, so they never count as
// pending: they must not lower "updates installed" on a patch run.
func TestInstallableUpdateCount(t *testing.T) {
ups := []models.PackageUpdate{
{Name: "curl"},
{Name: "netplan.io", Phased: true},
{Name: "openssl"},
}
if got := InstallableUpdateCount(ups); got != 2 {
t.Fatalf("got %d, want 2", got)
}
if got := InstallableUpdateCount(nil); got != 0 {
t.Fatalf("got %d for nil, want 0", got)
}
}
+39 -9
View File
@@ -1,8 +1,11 @@
package services
import (
"encoding/base64"
"fmt"
"net/url"
"os"
"sort"
"strings"
"time"
@@ -17,6 +20,10 @@ import (
const stepDispatchGrace = 15 * time.Second
// minMaskLen skips masking values so short that replacing them would shred
// ordinary output, such as a one-character line of a multi-line secret.
const minMaskLen = 4
// TriggerWorkflow starts a run of workflow workflowID.
//
// tokenScope is the acting credential's tag restriction, nil meaning
@@ -245,7 +252,7 @@ func runServer(instanceID, runID string, srvIdx int, steps []models.ResolvedStep
marker := fmt.Sprintf("===== step %d/%d: %s (%s) =====", step.Order+1, len(steps), step.Name, step.Interpreter)
offset, _ := AppendMarker(runID, serverID, marker)
secretsSlice := secretValues(secretVals)
secretsSlice := secretValues(allSecrets)
commandID := uuid.New().String()
for attempts < maxAttempts {
@@ -378,13 +385,7 @@ func resolveSecrets(instanceID string, refs []string) map[string]string {
}
func maskSecrets(s string, secrets map[string]string) string {
for _, v := range secrets {
if v == "" {
continue
}
s = strings.ReplaceAll(s, v, "***")
}
return s
return string(maskBytes([]byte(s), secretValues(secrets)))
}
func setServerRun(runID string, srvIdx int, set bson.M) {
@@ -423,11 +424,40 @@ func finishStep(runID, serverID string, order int, status string, attempts, exit
})
}
// secretValues returns every form of the secrets worth masking: the raw value,
// its base64 and URL-encoded forms, and each line of a multi-line value, since
// logs are masked a line at a time and a PEM key would otherwise never match.
// Longest first, so a shorter value never breaks up a longer one before it is
// replaced.
func secretValues(m map[string]string) []string {
out := make([]string, 0, len(m))
seen := map[string]bool{}
add := func(v string) {
if len(strings.TrimSpace(v)) >= minMaskLen {
seen[v] = true
}
}
for _, v := range m {
for _, f := range []string{v,
base64.StdEncoding.EncodeToString([]byte(v)),
base64.RawStdEncoding.EncodeToString([]byte(v)),
base64.URLEncoding.EncodeToString([]byte(v)),
base64.RawURLEncoding.EncodeToString([]byte(v)),
url.QueryEscape(v),
url.PathEscape(v),
} {
add(f)
}
if strings.Contains(v, "\n") {
for _, line := range strings.Split(v, "\n") {
add(strings.TrimRight(line, "\r"))
}
}
}
out := make([]string, 0, len(seen))
for v := range seen {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
return out
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { me, type MePasskey } from "@/lib/api";
import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn";
import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, ConfirmDialog, friendlyMessage, Modal, useToast } from "@/components/ui";
import { MfaEnrolWizard, type MfaEnrolEndpoints } from "@/components/mfa/MfaEnrolWizard";
import { RecoveryCodes } from "@/components/mfa/RecoveryCodes";
/**
* Adapts the /api/me/* client methods to the shape MfaEnrolWizard expects.
* The only mismatch is the field name: the server calls it `otpauth_uri`
* here (it is `otpauth_url` on the ticket-scoped enrolment endpoints from
* Task 10, which this page does not use).
*/
const SESSION_ENDPOINTS: MfaEnrolEndpoints = {
totpSetup: async () => {
const res = await me.totpSetup();
return { secret: res.secret, otpauth_url: res.otpauth_uri };
},
totpConfirm: (code) => me.totpConfirm(code),
passkeyBegin: () => me.passkeyRegisterBegin(),
passkeyFinish: (ceremonyId, credential, name) => me.passkeyRegisterFinish(ceremonyId, credential, name),
};
function formatDate(value?: string): string {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
export default function SecurityPage() {
const queryClient = useQueryClient();
const toast = useToast();
const { data: status, isLoading, error } = useQuery({ queryKey: ["me-mfa"], queryFn: me.mfa });
const [wizardOpen, setWizardOpen] = useState(false);
const [removingTotp, setRemovingTotp] = useState(false);
const [removingPasskey, setRemovingPasskey] = useState<MePasskey | null>(null);
const [renamingPasskey, setRenamingPasskey] = useState<MePasskey | null>(null);
const [renameValue, setRenameValue] = useState("");
const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[] | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["me-mfa"] });
const removeTotp = useMutation({
mutationFn: () => me.removeTotp(),
onSuccess: () => {
invalidate();
toast.success("Authenticator app removed.");
setRemovingTotp(false);
},
});
const deletePasskey = useMutation({
mutationFn: (id: string) => me.deletePasskey(id),
onSuccess: (_data, id) => {
invalidate();
toast.success("Passkey removed.");
if (removingPasskey?.id === id) setRemovingPasskey(null);
},
});
const renamePasskey = useMutation({
mutationFn: () => me.renamePasskey(renamingPasskey!.id, renameValue),
onSuccess: () => {
invalidate();
setRenamingPasskey(null);
},
});
const regenerateRecovery = useMutation({
mutationFn: () => me.regenerateRecovery(),
onSuccess: (res) => setNewRecoveryCodes(res.recovery_codes),
});
const addPasskeyDirect = useMutation({
mutationFn: async () => {
const { publicKey, ceremony_id } = await me.passkeyRegisterBegin();
const cred = (await navigator.credentials.create({ publicKey: toCreateOptions(publicKey) })) as PublicKeyCredential;
return me.passkeyRegisterFinish(ceremony_id, credentialToJSON(cred));
},
onSuccess: () => invalidate(),
});
function factorCount(s: NonNullable<typeof status>): number {
return (s.totp_enabled ? 1 : 0) + s.passkeys.length;
}
return (
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6 lg:p-8">
<h1 className="text-2xl font-bold text-text-primary">Security</h1>
<AsyncBoundary isLoading={isLoading} error={error}>
{status && !status.applicable ? (
<Card>
<p className="text-sm text-text-secondary">
Your sign-in is managed by this instance's identity provider. Two-factor authentication and passkeys are configured there, not here.
</p>
</Card>
) : status ? (
<>
{status.require_mfa && (
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
This instance requires a second sign-in factor. You cannot remove your last one.
</div>
)}
<Card>
<CardHeader className="mb-4">
<CardTitle>Authenticator app</CardTitle>
<Badge variant={status.totp_enabled ? "success" : "neutral"}>{status.totp_enabled ? "Enabled" : "Not set up"}</Badge>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Generates a 6-digit code every 30 seconds in an app like Google Authenticator or 1Password.</p>
{status.totp_enabled ? (
<Button
variant="danger"
size="sm"
disabled={status.require_mfa && factorCount(status) <= 1}
title={status.require_mfa && factorCount(status) <= 1 ? "This instance requires at least one factor." : undefined}
onClick={() => setRemovingTotp(true)}
>
Remove
</Button>
) : (
<Button variant="secondary" size="sm" onClick={() => setWizardOpen(true)}>
Set up
</Button>
)}
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Passkeys</CardTitle>
{isPasskeySupported() && (
<Button variant="secondary" size="sm" loading={addPasskeyDirect.isPending} onClick={() => addPasskeyDirect.mutate()}>
Add a passkey
</Button>
)}
</CardHeader>
{addPasskeyDirect.error && <p className="mb-3 text-sm text-danger">{friendlyMessage(addPasskeyDirect.error)}</p>}
{status.passkeys.length === 0 ? (
<p className="text-sm text-text-secondary">No passkeys registered.</p>
) : (
<ul className="divide-y divide-border">
{status.passkeys.map((pk) => {
const lastFactor = status.require_mfa && !status.totp_enabled && status.passkeys.length <= 1;
return (
<li key={pk.id} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-text-primary">{pk.name}</p>
<p className="text-xs text-text-secondary">
Added {formatDate(pk.created_at)} · Last used {formatDate(pk.last_used_at)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setRenamingPasskey(pk);
setRenameValue(pk.name);
}}
>
Rename
</Button>
<Button
variant="danger"
size="sm"
disabled={lastFactor}
title={lastFactor ? "This instance requires at least one factor." : undefined}
onClick={() => setRemovingPasskey(pk)}
>
Remove
</Button>
</div>
</li>
);
})}
</ul>
)}
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Recovery codes</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">
{status.recovery_remaining} unused code{status.recovery_remaining === 1 ? "" : "s"} remaining. Regenerating invalidates every existing code.
</p>
<Button variant="secondary" size="sm" loading={regenerateRecovery.isPending} onClick={() => regenerateRecovery.mutate()}>
Regenerate
</Button>
{regenerateRecovery.error && <p className="mt-3 text-sm text-danger">{friendlyMessage(regenerateRecovery.error)}</p>}
</Card>
</>
) : null}
</AsyncBoundary>
<Modal open={wizardOpen} title="Set up a second factor" onClose={() => setWizardOpen(false)}>
<MfaEnrolWizard
mode="session"
endpoints={SESSION_ENDPOINTS}
onCancel={() => setWizardOpen(false)}
onComplete={() => {
invalidate();
setWizardOpen(false);
}}
/>
</Modal>
<Modal open={newRecoveryCodes !== null} title="New recovery codes" onClose={() => setNewRecoveryCodes(null)}>
{newRecoveryCodes && (
<RecoveryCodes
codes={newRecoveryCodes}
onAcknowledge={() => {
invalidate();
setNewRecoveryCodes(null);
}}
/>
)}
</Modal>
<Modal open={renamingPasskey !== null} title="Rename passkey" onClose={() => setRenamingPasskey(null)}>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
renamePasskey.mutate();
}}
>
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
{renamePasskey.error && <p className="text-sm text-danger">{friendlyMessage(renamePasskey.error)}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setRenamingPasskey(null)}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={renamePasskey.isPending} disabled={!renameValue.trim()}>
Save
</Button>
</div>
</form>
</Modal>
<ConfirmDialog
open={removingTotp}
title="Remove authenticator app"
confirmLabel="Remove"
loading={removeTotp.isPending}
error={removeTotp.error ? friendlyMessage(removeTotp.error) : null}
onClose={() => setRemovingTotp(false)}
onConfirm={() => removeTotp.mutate()}
body={<p>You will no longer be asked for a code from your authenticator app when signing in.</p>}
/>
<ConfirmDialog
open={removingPasskey !== null}
title="Remove passkey"
confirmLabel="Remove"
loading={deletePasskey.isPending}
error={deletePasskey.error ? friendlyMessage(deletePasskey.error) : null}
onClose={() => setRemovingPasskey(null)}
onConfirm={() => removingPasskey && deletePasskey.mutate(removingPasskey.id)}
body={
<p>
<span className="text-text-primary">{removingPasskey?.name}</span> will no longer be accepted for sign-in.
</p>
}
/>
</div>
);
}

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