Compare commits

...
56 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
98 changed files with 15697 additions and 391 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
+120 -3
View File
@@ -267,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
@@ -746,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
@@ -757,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
@@ -967,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`:
@@ -983,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
@@ -1012,6 +1123,12 @@ 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
@@ -1067,7 +1184,7 @@ plane, each of which this codebase enforces:
## 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` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `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
@@ -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.
+22 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
@@ -23,6 +24,7 @@ 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"
@@ -138,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)
}
@@ -169,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)
}
@@ -221,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
@@ -254,6 +265,7 @@ 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,
@@ -300,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())
+12 -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,6 +46,8 @@ 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
@@ -47,7 +58,7 @@ require (
)
require (
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.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
+24 -2
View File
@@ -1,9 +1,11 @@
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 h1:EtojZ1d3cN9foHpc/CAI3KzBewYGn4sKWdkWs2MV78Q=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
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=
+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
+56 -5
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
@@ -926,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 {
@@ -936,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
@@ -949,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
+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()
}
}
+38 -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
@@ -238,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
@@ -253,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)
}
+36 -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 ----
@@ -424,6 +436,23 @@ var serverScopedRoutes = map[string]scopeDecl{
"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)
+40 -12
View File
@@ -102,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
@@ -227,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")
}
}
+2 -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"
@@ -225,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)
}
})
}
}
+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 {
+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
+2
View File
@@ -48,6 +48,8 @@ type Inventory struct {
// 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"`
}
+8 -1
View File
@@ -17,6 +17,7 @@ const TypePatch = "patch"
type Event struct {
MonitorName string
ServerName string
Type string
OldStatus string
NewStatus string
@@ -42,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 {
+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)
}
}
+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)
}
}
+42 -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 {
@@ -29,6 +33,7 @@ func StoreInventory(serverID string, r *pb.InventoryReport) error {
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
@@ -49,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",
@@ -52,6 +53,8 @@ var ScopedCollections = []string{
"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
}
@@ -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
}
+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
}
+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>
);
}
+2
View File
@@ -1,5 +1,6 @@
import { AuthProvider } from "@/components/AuthProvider";
import { AppShell } from "@/components/AppShell";
import { StepUpModal } from "@/components/mfa/StepUpModal";
export default function AppLayout({
children,
@@ -9,6 +10,7 @@ export default function AppLayout({
return (
<AuthProvider>
<AppShell>{children}</AppShell>
<StepUpModal />
</AuthProvider>
);
}
+118 -19
View File
@@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Incident, Rollup } from "@/lib/api";
import { Button, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { HeartbeatUrlPanel } from "@/components/monitors/HeartbeatUrlPanel";
import { MetricServersTable } from "@/components/monitors/MetricServersTable";
import {
Slot,
StatusChip,
@@ -17,6 +19,7 @@ import {
formatMs,
formatPct,
markIncidents,
metricCopy,
relativeTime,
slotChartColor,
slotLabel,
@@ -268,14 +271,17 @@ function Figure({ label, value, unit, tone = "" }: { label: string; value: strin
);
}
function IncidentRow({ incident }: { incident: Incident }) {
function IncidentRow({ incident, hostname }: { incident: Incident; hostname?: string }) {
const open = !incident.resolved_at;
const started = new Date(incident.started_at);
return (
<div className="grid grid-cols-[14px_minmax(0,1fr)] items-start gap-3 border-t border-border-soft px-5 py-3 first:border-t-0">
<span className={`mt-1.5 block h-2 w-2 rounded-full ${open ? "bg-danger ring-4 ring-danger/20" : "bg-text-tertiary"}`} />
<div className="min-w-0">
<p className="text-[13.5px] text-text-primary">{incident.cause || "Check failed"}</p>
<p className="text-[13.5px] text-text-primary">
{incident.cause || "Check failed"}
{hostname && <span className="ml-2 font-mono text-[11px] text-text-tertiary">· {hostname}</span>}
</p>
<p className="mt-1 flex flex-wrap gap-2 font-mono text-[11px] text-text-tertiary">
<span>
{started.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}
@@ -319,9 +325,26 @@ export default function MonitorDetailPage() {
const queryClient = useQueryClient();
const monitorId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmRotate, setConfirmRotate] = useState(false);
const [range, setRange] = useState<Range>(RANGES[0]);
const toast = useToast();
/* The token is only ever handed back once, on the create response. The
create page parks it in sessionStorage for this one navigation, because a
query string would reach proxy access logs. Read it once and remove it
straight away so a reload or another tab never shows it again. */
const [heartbeatToken, setHeartbeatToken] = useState<string | null>(null);
useEffect(() => {
const key = `vantage:hbtoken:${monitorId}`;
try {
const t = sessionStorage.getItem(key);
sessionStorage.removeItem(key);
if (t) setHeartbeatToken(t);
} catch {
// Storage blocked: nothing was handed over.
}
}, [monitorId]);
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
queryFn: () => api.getMonitor(monitorId),
@@ -363,6 +386,14 @@ export default function MonitorDetailPage() {
enabled: !!monitor && monitor.runner !== "server",
});
const { data: metricServers } = useQuery({
queryKey: ["monitors", monitorId, "servers"],
queryFn: () => api.monitorServers(monitorId),
refetchInterval: 30_000,
enabled: monitor?.type === "metric",
});
const hostnameByServerId = new Map((metricServers ?? []).map((s) => [s.server_id, s.hostname ?? s.server_id]));
const {
mutate: deleteMonitor,
isPending: isDeleting,
@@ -387,6 +418,19 @@ export default function MonitorDetailPage() {
onError: toast.error,
});
const {
mutate: rotateToken,
isPending: isRotating,
error: rotateError,
} = useMutation({
mutationFn: () => api.rotateHeartbeatToken(monitorId),
onSuccess: (res) => {
setHeartbeatToken(res.heartbeat_token);
setConfirmRotate(false);
toast.success("Token rotated. The old URL stops working immediately.");
},
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
@@ -430,17 +474,32 @@ export default function MonitorDetailPage() {
? Math.round((new Date(monitor.state.cert_expiry_at).getTime() - Date.now()) / 86400_000)
: null;
const isHeartbeat = monitor.type === "heartbeat";
const openIncidents = (incidents ?? []).filter((i) => !i.resolved_at).length;
const runnerName =
monitor.runner === "server" ? "Control plane" : servers?.find((s) => s.server_id === monitor.runner)?.hostname || monitor.runner;
const alertChannels = (monitor.channel_ids ?? []).map((id) => channels?.find((c) => c.channel_id === id));
const isMetric = monitor.type === "metric";
let ruleSummary = "";
if (isMetric && monitor.target.metric) {
const kind = monitor.target.metric;
const copy = metricCopy[kind];
const value = copy.unit ? `${monitor.target.threshold ?? ""}${copy.unit}` : "";
const forMin = Math.round((monitor.for_sec ?? 0) / 60);
const selectorEntries = Object.entries(monitor.target.selector ?? {});
const on = selectorEntries.length > 0 ? selectorEntries.map(([k, v]) => `${k}=${v}`).join(", ") : "all servers";
ruleSummary = `${copy.label} ${value} for ${forMin} min on ${on}`.replace(/\s+/g, " ").trim();
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<Link href="/monitors" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
{isHeartbeat && heartbeatToken && <div className="mb-5"><HeartbeatUrlPanel token={heartbeatToken} /></div>}
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
@@ -496,6 +555,18 @@ export default function MonitorDetailPage() {
}
/>
<ConfirmDialog
open={confirmRotate}
title="Rotate ping token"
confirmLabel="Rotate"
destructive={false}
loading={isRotating}
error={rotateError ? friendlyMessage(rotateError) : null}
onClose={() => setConfirmRotate(false)}
onConfirm={() => rotateToken()}
body={<p>The current URL stops working immediately.</p>}
/>
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-border bg-surface">
@@ -525,7 +596,7 @@ export default function MonitorDetailPage() {
/>
<Figure label="Uptime 30d" value={formatPct(pct30d)} unit={pct30d !== null ? "%" : undefined} />
<Figure
label="Response now"
label={isHeartbeat ? "Duration" : "Response now"}
value={status === "down" ? "-" : formatMs(latency)}
tone={status === "down" ? "text-danger" : ""}
/>
@@ -548,26 +619,54 @@ export default function MonitorDetailPage() {
No incidents recorded. Every check has passed since this monitor was created.
</p>
) : (
incidents.map((inc) => <IncidentRow key={inc.incident_id} incident={inc} />)
incidents.map((inc) => (
<IncidentRow
key={inc.incident_id}
incident={inc}
hostname={inc.server_id ? hostnameByServerId.get(inc.server_id) ?? inc.server_id : undefined}
/>
))
)}
</Panel>
{isMetric && (
<Panel title="Servers" aside={ruleSummary} padded={false}>
<MetricServersTable monitorId={monitorId} />
</Panel>
)}
</div>
<div className="flex flex-col gap-5">
<Panel title="Check">
<dl className="text-[13px]">
<Row label="Runs from" value={runnerName} />
<Row label="Every" value={`${monitor.interval_sec} s`} />
<Row label="Fails after" value={`${monitor.retries} ${monitor.retries === 1 ? "try" : "tries"}`} />
{monitor.type === "http" && monitor.target.method && <Row label="Method" value={monitor.target.method} />}
{monitor.type === "http" && monitor.target.expected_status && (
<Row label="Expects" value={monitor.target.expected_status} />
)}
{monitor.target.keyword && <Row label="Body contains" value={monitor.target.keyword} />}
{monitor.type === "tls" && <Row label="Warns at" value={`${monitor.target.tls_warn_days ?? 14} days`} />}
<Row label="Last checked" value={relativeTime(monitor.state.last_check_at)} />
</dl>
</Panel>
{isHeartbeat ? (
<Panel title="Heartbeat">
<dl className="text-[13px]">
<Row label="Last ping" value={relativeTime(monitor.state.last_ping_at)} />
<Row label="Expected every" value={`${Math.round((monitor.target.period_sec ?? 0) / 60)} min`} />
<Row label="Grace" value={`${Math.round((monitor.target.grace_sec ?? 0) / 60)} min`} />
{monitor.state.started_at && (
<Row label="Running since" value={relativeTime(monitor.state.started_at)} />
)}
</dl>
<Button variant="secondary" size="sm" className="mt-4" onClick={() => setConfirmRotate(true)}>
Rotate token
</Button>
</Panel>
) : isMetric ? null : (
<Panel title="Check">
<dl className="text-[13px]">
<Row label="Runs from" value={runnerName} />
<Row label="Every" value={`${monitor.interval_sec} s`} />
<Row label="Fails after" value={`${monitor.retries} ${monitor.retries === 1 ? "try" : "tries"}`} />
{monitor.type === "http" && monitor.target.method && <Row label="Method" value={monitor.target.method} />}
{monitor.type === "http" && monitor.target.expected_status && (
<Row label="Expects" value={monitor.target.expected_status} />
)}
{monitor.target.keyword && <Row label="Body contains" value={monitor.target.keyword} />}
{monitor.type === "tls" && <Row label="Warns at" value={`${monitor.target.tls_warn_days ?? 14} days`} />}
<Row label="Last checked" value={relativeTime(monitor.state.last_check_at)} />
</dl>
</Panel>
)}
<Panel title="Alerts">
{alertChannels.length === 0 ? (
+9
View File
@@ -24,6 +24,15 @@ export default function NewMonitorPage() {
// survive the route change, which is the one thing an inline
// banner on the form cannot do. The error stays on the form.
toast.success(`Created ${m.name}. First check runs within its interval.`);
// The token is shown once. Hand it over in memory, never in the
// URL: the RSC fetch for a query string lands in proxy access logs.
if (m.heartbeat_token) {
try {
sessionStorage.setItem(`vantage:hbtoken:${m.monitor_id}`, m.heartbeat_token);
} catch {
// Storage blocked: the user can rotate to get a token.
}
}
router.push(`/monitors/${m.monitor_id}`);
},
});
+9 -1
View File
@@ -199,7 +199,15 @@ function MonitorRow({ monitor, rollups, incidents }: MonitorRowData) {
{monitor.type}
</span>
</span>
<span className="mt-1 block truncate font-mono text-[11.5px] text-text-tertiary">{targetSummary(monitor)}</span>
<span className="mt-1 block truncate font-mono text-[11.5px] text-text-tertiary">
{monitor.type === "heartbeat"
? monitor.state.last_ping_at
? `Last ping ${relativeTime(monitor.state.last_ping_at)}`
: "Waiting for first ping"
: monitor.type === "metric"
? (monitor.state.message ?? "Awaiting first check")
: targetSummary(monitor)}
</span>
</div>
<Tape slots={slots} live={monitor.enabled} />
+45 -1
View File
@@ -51,6 +51,18 @@ function DocumentIcon() {
);
}
function ShieldIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
}
function KeyIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -127,7 +139,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
export default function SettingsPage() {
const queryClient = useQueryClient();
const { instance, isAdmin } = useAuth();
const { instance, isAdmin, user } = useAuth();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
@@ -145,6 +157,7 @@ export default function SettingsPage() {
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [offlineChannelIds, setOfflineChannelIds] = useState<string[]>([]);
const [apiTokenMaxDays, setApiTokenMaxDays] = useState(0);
const [requireMfa, setRequireMfa] = useState(false);
const toast = useToast();
useEffect(() => {
@@ -153,6 +166,7 @@ export default function SettingsPage() {
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
setOfflineChannelIds(settings.alerts.offline_channel_ids ?? []);
setApiTokenMaxDays(settings.api_token_max_days ?? 0);
setRequireMfa(settings.require_mfa ?? false);
}, [settings]);
// The one place the in-progress form is turned into a payload. Both the
@@ -235,6 +249,36 @@ export default function SettingsPage() {
save({ ...currentPayload(), local_login_enabled: v });
}}
/>
{user?.role === "owner" && (
<SectionCard
title="Require MFA"
description="Require a second factor for every password sign-in on this instance."
icon={<ShieldIcon />}
>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={requireMfa}
onChange={(e) => {
if (!settings) return;
const v = e.target.checked;
setRequireMfa(v);
// Same in-progress form state the main Save button
// submits, not the stale loaded `settings` object -
// otherwise an unsaved edit elsewhere on this page is
// silently reverted the moment this toggle is flipped.
save({ ...currentPayload(), require_mfa: v });
}}
className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
/>
Require MFA for password sign-in
</label>
<p className="mt-1.5 text-xs text-text-tertiary">
Members without a second factor already set up must enrol one at their next sign-in. Members who sign in through
a provider or a passkey are unaffected.
</p>
</SectionCard>
)}
</Group>
<Group label="Monitoring">
+182 -3
View File
@@ -2,11 +2,13 @@
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { auth, type PublicProvider } from "@/lib/api";
import { auth, ApiError, type PublicProvider } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Logo } from "@/components/Logo";
import { NetworkBackground } from "@/components/NetworkBackground";
import { ProviderIcon } from "@/components/settings/ProviderIcon";
import { MfaEnrolWizard } from "@/components/mfa/MfaEnrolWizard";
import { isPasskeySupported, toRequestOptions, credentialToJSON } from "@/lib/webauthn";
const ERROR_MESSAGES: Record<string, string> = {
oidc_unavailable: "Single sign-on is not available on this instance's plan.",
@@ -23,8 +25,27 @@ const ERROR_MESSAGES: Record<string, string> = {
state_failed: "Could not start sign-in. Please try again.",
unknown_host: "This address does not name a known instance.",
instance_locked: "Access to this instance is suspended.",
invalid_code: "That code was not correct.",
mfa_ticket_expired: "That sign-in attempt expired. Please sign in again.",
};
/** Turns a thrown error into the page's inline message, handling the rate limit specially. */
function describeError(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 429) {
return err.retryAfter ? `Too many attempts. Try again in ${err.retryAfter}s.` : "Too many attempts. Please wait a moment and try again.";
}
if (err.code === "invalid_code" && err.attemptsLeft !== undefined) {
return `That code was not correct. ${err.attemptsLeft} attempt${err.attemptsLeft === 1 ? "" : "s"} left.`;
}
if (err.code && ERROR_MESSAGES[err.code]) return ERROR_MESSAGES[err.code];
return err.message || "Sign-in failed. Please try again.";
}
return "Sign-in failed. Please try again.";
}
type Step = "credentials" | "factor" | "enrol";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -38,6 +59,15 @@ export default function LoginPage() {
// says so instead of drawing a form that cannot sign anyone in.
const [locked, setLocked] = useState(false);
const [step, setStep] = useState<Step>("credentials");
const [factorMethods, setFactorMethods] = useState<string[]>([]);
const [useRecovery, setUseRecovery] = useState(false);
const [factorCode, setFactorCode] = useState("");
const [factorError, setFactorError] = useState("");
const [factorBusy, setFactorBusy] = useState(false);
const [credentialsNotice, setCredentialsNotice] = useState("");
const [passkeyBusy, setPasskeyBusy] = useState(false);
useEffect(() => {
const code = new URLSearchParams(window.location.search).get("error");
if (code === "instance_locked") setLocked(true);
@@ -80,16 +110,98 @@ export default function LoginPage() {
error,
} = useMutation({
mutationFn: () => auth.login(email, password),
onSuccess: () => {
onSuccess: (res) => {
if ("mfa_required" in res && res.mfa_required) {
setFactorMethods(res.methods);
setUseRecovery(false);
setFactorCode("");
setFactorError("");
setStep("factor");
return;
}
if ("enrol_required" in res && res.enrol_required) {
setStep("enrol");
return;
}
window.location.href = "/";
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setCredentialsNotice("");
signIn();
}
function returnToCredentials(message: string) {
setStep("credentials");
setCredentialsNotice(message);
}
async function handleFactorSubmit(e: React.FormEvent) {
e.preventDefault();
setFactorError("");
setFactorBusy(true);
try {
if (useRecovery) {
await auth.mfaRecovery(factorCode);
} else {
await auth.mfaTotp(factorCode);
}
window.location.href = "/";
} catch (err) {
if (err instanceof ApiError && err.code === "mfa_ticket_expired") {
returnToCredentials("That sign-in attempt expired. Please sign in again.");
return;
}
setFactorError(describeError(err));
} finally {
setFactorBusy(false);
}
}
async function handleFactorPasskey() {
setFactorError("");
setFactorBusy(true);
try {
const { publicKey, ceremony_id } = await auth.mfaWebAuthnBegin();
const cred = (await navigator.credentials.get({
publicKey: toRequestOptions(publicKey),
})) as PublicKeyCredential;
await auth.mfaWebAuthnFinish(ceremony_id, credentialToJSON(cred));
window.location.href = "/";
} catch (err) {
if (err instanceof ApiError && err.code === "mfa_ticket_expired") {
returnToCredentials("That sign-in attempt expired. Please sign in again.");
return;
}
setFactorError(describeError(err));
} finally {
setFactorBusy(false);
}
}
async function handlePasswordlessPasskey() {
setCredentialsNotice("");
setPasskeyBusy(true);
try {
const { publicKey, ceremony_id } = await auth.passkeyLoginBegin();
const cred = (await navigator.credentials.get({
publicKey: toRequestOptions(publicKey),
})) as PublicKeyCredential;
await auth.passkeyLoginFinish(ceremony_id, credentialToJSON(cred));
window.location.href = "/";
} catch (err) {
setCredentialsNotice(describeError(err));
} finally {
setPasskeyBusy(false);
}
}
function handleEnrolComplete() {
window.location.href = "/";
}
const showLocal = localEnabled || providers.length === 0;
const showDivider = showLocal && providers.length > 0;
@@ -111,9 +223,70 @@ export default function LoginPage() {
Nobody can sign in, and its servers are not being managed, until this is resolved. If you manage this account, check your email from Vantage for details.
</p>
</Card>
) : step === "enrol" ? (
<Card>
<MfaEnrolWizard mode="ticket" onComplete={handleEnrolComplete} />
</Card>
) : step === "factor" ? (
<Card>
<form onSubmit={handleFactorSubmit} className="space-y-4">
<p className="text-sm text-text-secondary">{useRecovery ? "Enter one of your recovery codes." : "Enter the 6-digit code from your authenticator app."}</p>
<div>
<label htmlFor="factor-code" className="mb-1.5 block text-sm font-medium text-text-secondary">
{useRecovery ? "Recovery code" : "Verification code"}
</label>
<input
id="factor-code"
type="text"
inputMode={useRecovery ? "text" : "numeric"}
autoComplete="one-time-code"
required
autoFocus
value={factorCode}
onChange={(e) => setFactorCode(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"
/>
</div>
{factorError && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{factorError}</div>}
<Button type="submit" variant="primary" loading={factorBusy} className="w-full justify-center">
Verify
</Button>
{factorMethods.includes("webauthn") && (
<Button type="button" variant="secondary" loading={factorBusy} className="w-full justify-center" onClick={handleFactorPasskey}>
Use passkey
</Button>
)}
{factorMethods.includes("recovery") && (
<button
type="button"
className="w-full text-center text-sm text-text-secondary underline-offset-2 hover:underline"
onClick={() => {
setUseRecovery((v) => !v);
setFactorCode("");
setFactorError("");
}}
>
{useRecovery ? "Use a verification code instead" : "Use a recovery code instead"}
</button>
)}
<button
type="button"
className="w-full text-center text-sm text-text-tertiary underline-offset-2 hover:underline"
onClick={() => returnToCredentials("")}
>
Back
</button>
</form>
</Card>
) : (
<Card>
{ssoError && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{ssoError}</div>}
{credentialsNotice && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{credentialsNotice}</div>}
{showLocal && (
<form onSubmit={handleSubmit} className="space-y-4">
@@ -147,11 +320,17 @@ export default function LoginPage() {
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{describeError(error)}</div>}
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
Sign In
</Button>
{isPasskeySupported() && localEnabled && (
<Button type="button" variant="secondary" loading={passkeyBusy} className="w-full justify-center" onClick={handlePasswordlessPasskey}>
Sign in with passkey
</Button>
)}
</form>
)}
+1
View File
@@ -204,6 +204,7 @@ const navGroups: NavGroup[] = [
// keys, capped at their own role, so gating the page would hide a
// capability they have.
{ href: "/tokens", label: "API Keys", icon: <TokenIcon /> },
{ href: "/account/security", label: "Security", icon: <ShieldIcon /> },
],
},
{
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { auth } from "@/lib/api";
import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn";
import { Button } from "@/components/ui";
import { RecoveryCodes } from "./RecoveryCodes";
/**
* The calls a wizard step makes to set up a second factor. Defaults to the
* ticket-scoped `/auth/mfa/enrol/*` endpoints (an in-progress, unauthenticated
* sign-in identified by the server's ticket cookie) for `mode="ticket"`.
*
* `mode="session"` is for an already-authenticated user managing their own
* factors (the account security page) and has no safe default: that page's
* endpoints do not exist yet, so its caller must pass `endpoints` explicitly.
*/
export interface MfaEnrolEndpoints {
totpSetup: () => Promise<{ secret: string; otpauth_url: string }>;
totpConfirm: (code: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
passkeyBegin: () => Promise<{ publicKey: any; ceremony_id: string }>;
passkeyFinish: (ceremonyId: string, credential: unknown, name?: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
}
const TICKET_ENDPOINTS: MfaEnrolEndpoints = {
totpSetup: () => auth.enrolTotpSetup(),
totpConfirm: (code) => auth.enrolTotpConfirm(code),
passkeyBegin: () => auth.enrolPasskeyBegin(),
passkeyFinish: (ceremonyId, credential, name) => auth.enrolPasskeyFinish(ceremonyId, credential, name),
};
interface MfaEnrolWizardProps {
mode: "session" | "ticket";
/** Required for mode="session"; defaults to the ticket-scoped endpoints for mode="ticket". */
endpoints?: MfaEnrolEndpoints;
onComplete: (recoveryCodes: string[]) => void;
/** Omit to make the wizard mandatory, as in forced enrolment during sign-in. */
onCancel?: () => void;
}
type Step = "choose" | "totp" | "passkey" | "recovery";
export function MfaEnrolWizard({ mode, endpoints, onComplete, onCancel }: MfaEnrolWizardProps) {
const api = endpoints ?? (mode === "ticket" ? TICKET_ENDPOINTS : undefined);
const [step, setStep] = useState<Step>("choose");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
// TOTP setup state
const [secret, setSecret] = useState("");
const [qrDataUrl, setQrDataUrl] = useState("");
const [totpCode, setTotpCode] = useState("");
// Recovery codes state
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
if (!api) {
return <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">This wizard was not given its endpoints for session mode.</div>;
}
async function startTotp() {
setError("");
setBusy(true);
try {
const { secret, otpauth_url } = await api!.totpSetup();
setSecret(secret);
setQrDataUrl(await QRCode.toDataURL(otpauth_url));
setStep("totp");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function confirmTotp(e: React.FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
const res = await api!.totpConfirm(totpCode);
setRecoveryCodes(res.recovery_codes ?? []);
setStep("recovery");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function startPasskey() {
setError("");
setBusy(true);
try {
const { publicKey, ceremony_id } = await api!.passkeyBegin();
const cred = (await navigator.credentials.create({
publicKey: toCreateOptions(publicKey),
})) as PublicKeyCredential;
const res = await api!.passkeyFinish(ceremony_id, credentialToJSON(cred));
setRecoveryCodes(res.recovery_codes ?? []);
setStep("recovery");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (step === "choose") {
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">This instance requires a second sign-in factor. Set one up to continue.</p>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div className="space-y-2">
<Button type="button" variant="primary" className="w-full justify-center" loading={busy} onClick={startTotp}>
Use an authenticator app
</Button>
{isPasskeySupported() && (
<Button type="button" variant="secondary" className="w-full justify-center" loading={busy} onClick={startPasskey}>
Use a passkey
</Button>
)}
</div>
{onCancel && (
<Button type="button" variant="ghost" className="w-full justify-center" onClick={onCancel}>
Cancel
</Button>
)}
</div>
);
}
if (step === "totp") {
return (
<form onSubmit={confirmTotp} className="space-y-4">
<p className="text-sm text-text-secondary">Scan this code with your authenticator app, or enter the key manually.</p>
{qrDataUrl && <img src={qrDataUrl} alt="Authenticator QR code" className="mx-auto h-40 w-40" />}
<p className="break-all rounded-lg border border-border bg-surface-2 px-3 py-2 text-center font-mono text-xs text-text-secondary">{secret}</p>
<div>
<label htmlFor="totp-code" className="mb-1.5 block text-sm font-medium text-text-secondary">
6-digit code
</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(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"
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<Button type="submit" variant="primary" loading={busy} className="w-full justify-center">
Confirm
</Button>
<Button type="button" variant="ghost" className="w-full justify-center" onClick={() => setStep("choose")}>
Back
</Button>
</form>
);
}
// step === "recovery"
return <RecoveryCodes codes={recoveryCodes} onAcknowledge={() => onComplete(recoveryCodes)} />;
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui";
interface RecoveryCodesProps {
codes: string[];
onAcknowledge: () => void;
}
/**
* Shows a fresh batch of recovery codes exactly once, either at the end of
* enrolment (via MfaEnrolWizard) or after a regenerate on the security page.
* The server never returns a previously issued batch, so this is the only
* place in the app these codes are ever visible.
*/
export function RecoveryCodes({ codes, onAcknowledge }: RecoveryCodesProps) {
const [savedConfirmed, setSavedConfirmed] = useState(false);
const [copied, setCopied] = useState(false);
async function copyAll() {
await navigator.clipboard.writeText(codes.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function downloadAll() {
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "vantage-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Save these recovery codes somewhere safe. Each can be used once if you lose access to your other factor. They are shown here exactly once.
</p>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs text-text-primary">
{codes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={copyAll}>
{copied ? "Copied" : "Copy all"}
</Button>
<Button type="button" variant="secondary" size="sm" onClick={downloadAll}>
Download as .txt
</Button>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} className="h-4 w-4 rounded border-border" />
I have saved these codes
</label>
<Button type="button" variant="primary" className="w-full justify-center" disabled={!savedConfirmed} onClick={onAcknowledge}>
Continue
</Button>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { me } from "@/lib/api";
import { registerStepUpPrompt } from "@/lib/stepup";
import { isPasskeySupported, toRequestOptions, credentialToJSON } from "@/lib/webauthn";
import { Button, Modal, friendlyMessage } from "@/components/ui";
const SUPPORTED = ["totp", "recovery", "webauthn", "password"] as const;
type SupportedMethod = (typeof SUPPORTED)[number];
const LABELS: Record<SupportedMethod, string> = {
totp: "Authenticator app",
recovery: "Recovery code",
webauthn: "Passkey",
password: "Password",
};
const INPUT_LABELS: Record<SupportedMethod, string> = {
totp: "6-digit code",
recovery: "Recovery code",
webauthn: "",
password: "Password",
};
/**
* Mounted once in the (app) layout. Registers itself as the step-up prompt on
* mount, so `request()` in lib/api.ts can reach it without importing React.
*/
export function StepUpModal() {
const [open, setOpen] = useState(false);
const [methods, setMethods] = useState<SupportedMethod[]>([]);
const [method, setMethod] = useState<SupportedMethod | null>(null);
const [value, setValue] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const resolveRef = useRef<(() => void) | null>(null);
const rejectRef = useRef<((e: Error) => void) | null>(null);
useEffect(() => {
registerStepUpPrompt((requested) => {
// "webauthn" only counts as offered when this browser can actually
// complete it - otherwise it would sit there as a dead option.
const supported = requested.filter(
(m): m is SupportedMethod =>
(SUPPORTED as readonly string[]).includes(m) && (m !== "webauthn" || isPasskeySupported()),
);
return new Promise<void>((resolve, reject) => {
setMethods(supported);
setMethod(supported[0] ?? null);
setValue("");
setError("");
resolveRef.current = resolve;
rejectRef.current = reject;
setOpen(true);
});
});
// Unregistering on unmount matches "no prompt registered fails rather
// than hangs": outside the app shell there is nothing to reach.
return () => registerStepUpPrompt(null);
}, []);
function cancel(reason: string) {
setOpen(false);
rejectRef.current?.(new Error(reason));
resolveRef.current = null;
rejectRef.current = null;
}
function succeed() {
setOpen(false);
resolveRef.current?.();
resolveRef.current = null;
rejectRef.current = null;
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!method || method === "webauthn") return;
setBusy(true);
setError("");
try {
const factor = method === "totp" ? { totp: value } : method === "recovery" ? { recovery: value } : { password: value };
await me.stepUp(factor);
succeed();
} catch (err) {
setError(friendlyMessage(err));
} finally {
setBusy(false);
}
}
async function usePasskey() {
setBusy(true);
setError("");
try {
const { publicKey, ceremony_id } = await me.stepUpWebAuthnBegin();
const cred = (await navigator.credentials.get({ publicKey: toRequestOptions(publicKey) })) as PublicKeyCredential;
await me.stepUpWebAuthnFinish(ceremony_id, credentialToJSON(cred));
succeed();
} catch (err) {
setError(friendlyMessage(err));
} finally {
setBusy(false);
}
}
return (
<Modal open={open} title="Confirm it's you" onClose={() => cancel("re-authentication was cancelled")}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">This action reveals a credential, so please confirm it is you.</p>
{methods.length === 0 ? (
<>
<p className="text-sm text-danger">No supported re-authentication method is available for this account.</p>
<div className="flex justify-end">
<Button type="button" variant="secondary" onClick={() => cancel("no re-authentication method available")}>
Close
</Button>
</div>
</>
) : (
<>
{methods.length > 1 && (
<div className="flex flex-wrap gap-2">
{methods.map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMethod(m);
setValue("");
setError("");
}}
className={`rounded px-3 py-1.5 text-sm transition-colors ${
method === m ? "bg-accent text-white" : "bg-surface-2 text-text-secondary hover:text-text-primary"
}`}
>
{LABELS[m]}
</button>
))}
</div>
)}
{method === "webauthn" ? (
<div className="space-y-4">
<p className="text-sm text-text-secondary">Use your device's passkey to confirm it is you.</p>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => cancel("re-authentication was cancelled")}>
Cancel
</Button>
<Button type="button" variant="primary" loading={busy} onClick={usePasskey}>
Use passkey
</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<div>
<label htmlFor="step-up-value" className="mb-1.5 block text-sm font-medium text-text-secondary">
{method ? INPUT_LABELS[method] : ""}
</label>
<input
id="step-up-value"
type={method === "password" ? "password" : "text"}
inputMode={method === "totp" ? "numeric" : undefined}
autoComplete={method === "password" ? "current-password" : "one-time-code"}
autoFocus
required
maxLength={method === "totp" ? 6 : undefined}
value={value}
onChange={(e) => setValue(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"
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => cancel("re-authentication was cancelled")}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={busy}>
Confirm
</Button>
</div>
</form>
)}
</>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,35 @@
"use client";
import { heartbeatUrl } from "@/lib/api";
import { Button, useToast } from "@/components/ui";
/** Shown once, straight after create or rotate: the token is not retrievable later. */
export function HeartbeatUrlPanel({ token }: { token: string }) {
const toast = useToast();
const url = heartbeatUrl(token);
const copy = async () => {
await navigator.clipboard.writeText(url);
toast.success("Ping URL copied");
};
return (
<div className="space-y-3 rounded border border-accent/40 bg-well p-4">
<p className="text-sm text-text-primary">
Copy this URL now. It is not shown again, rotate the token if you lose it.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface px-2 py-1.5 font-mono text-xs">{url}</code>
<Button size="sm" onClick={copy}>Copy</Button>
</div>
<pre className="overflow-x-auto rounded bg-surface p-3 font-mono text-[11px] leading-relaxed text-text-secondary">
{`# success
curl -fsS -m 10 --retry 3 ${url}
# mark start, to measure duration
curl -fsS -m 10 ${url}/start
# report failure with output
your-job 2>&1 | tail -c 1024 | curl -fsS -m 10 --data-binary @- ${url}/fail
# or keep the token out of URLs (and your proxy logs)
curl -fsS -m 10 -X POST -H "X-Vantage-Token: ${token}" ${window.location.origin}/public/hb`}
</pre>
</div>
);
}
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { StatusChip, relativeTime } from "@/components/monitors/MonitorVisuals";
/*
* Per-server state for a metric monitor's rule. Polls the same way the
* detail page's other queries do - a 30s refetchInterval - rather than a
* bespoke subscription for one panel.
*/
const order: Record<string, number> = { down: 0, pending: 1, up: 2 };
export function MetricServersTable({ monitorId }: { monitorId: string }) {
const { data, isLoading } = useQuery({
queryKey: ["monitors", monitorId, "servers"],
queryFn: () => api.monitorServers(monitorId),
refetchInterval: 30_000,
});
const rows = [...(data ?? [])].sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
if (isLoading) {
return (
<div className="flex justify-center py-10">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (rows.length === 0) {
return <p className="px-5 py-12 text-center text-sm text-text-secondary">No servers match this rule&apos;s tags.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-[13px]">
<thead>
<tr className="border-b border-border-soft text-[10px] uppercase tracking-[0.14em] text-text-tertiary">
<th className="px-5 py-2.5 font-medium">Server</th>
<th className="px-5 py-2.5 font-medium">Status</th>
<th className="px-5 py-2.5 font-medium">Value</th>
<th className="px-5 py-2.5 font-medium">Since</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.server_id} className="border-t border-border-soft first:border-t-0">
<td className="px-5 py-2.5">
<Link href={`/servers/${row.server_id}`} className="text-text-primary hover:text-accent hover:underline">
{row.hostname ?? row.server_id}
</Link>
</td>
<td className="px-5 py-2.5">
<StatusChip status={row.status} />
</td>
<td className="px-5 py-2.5 font-mono text-text-secondary">{row.message ?? "-"}</td>
<td className="px-5 py-2.5 font-mono text-text-tertiary">
{row.breach_since ? relativeTime(row.breach_since) : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+144 -41
View File
@@ -3,9 +3,10 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Monitor, MonitorInput, MonitorType } from "@/lib/api";
import { api, MetricKind, Monitor, MonitorInput, MonitorType } from "@/lib/api";
import { Button } from "@/components/ui";
import { formatDuration } from "@/components/monitors/MonitorVisuals";
import { formatDuration, metricCopy } from "@/components/monitors/MonitorVisuals";
import { TagRestriction } from "@/components/apikeys/TagRestriction";
/*
* The form is grouped the way the detail page reads it back: what is checked,
@@ -17,11 +18,13 @@ import { formatDuration } from "@/components/monitors/MonitorVisuals";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
const typeCopy: Record<MonitorType, { title: string; blurb: string; target: string }> = {
const typeCopy: Partial<Record<MonitorType, { title: string; blurb: string; target: string }>> = {
http: { title: "HTTP", blurb: "Requests a URL and checks the response", target: "URL" },
tcp: { title: "TCP", blurb: "Opens a socket on a host and port", target: "Host and port" },
icmp: { title: "Ping", blurb: "Pings a host and measures round trip", target: "Host" },
tls: { title: "TLS", blurb: "Reads a certificate and counts days left", target: "Host and port" },
heartbeat: { title: "Heartbeat", blurb: "Your job calls a URL; alert when it stops.", target: "Ping URL" },
metric: { title: "Server metric", blurb: "Alert on disk, memory, load, units or reboots across tagged servers.", target: "Rule" },
};
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
@@ -106,6 +109,15 @@ export function MonitorForm({
const [runner, setRunner] = useState(initial?.runner ?? "server");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
const [periodMin, setPeriodMin] = useState(Math.round((initial?.target.period_sec ?? 3600) / 60));
const [graceMin, setGraceMin] = useState(Math.round((initial?.target.grace_sec ?? 300) / 60));
const [metric, setMetric] = useState<MetricKind>(initial?.target.metric ?? "disk_pct");
const [threshold, setThreshold] = useState<number>(initial?.target.threshold ?? 90);
const [mount, setMount] = useState(initial?.target.mount ?? "");
// for_sec 0 is omitted from JSON, so an existing monitor without it means
// "alert immediately"; only a new monitor gets the 5 minute default.
const [forMin, setForMin] = useState(Math.round((initial ? (initial.for_sec ?? 0) : 300) / 60));
const [selector, setSelector] = useState<Record<string, string>>(initial?.target.selector ?? {});
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
/* The group is free text, so the existing groups are offered as suggestions
@@ -130,13 +142,27 @@ export function MonitorForm({
target.tls_warn_days = tlsWarnDays;
} else if (type === "icmp") {
target.host = host;
} else if (type === "heartbeat") {
target.period_sec = periodMin * 60;
target.grace_sec = graceMin * 60;
} else if (type === "metric") {
target.metric = metric;
target.selector = selector;
if (metricCopy[metric].unit) target.threshold = threshold;
if (metricCopy[metric].mount && mount) target.mount = mount;
} else {
target.host = host;
target.port = port;
}
onSubmit({ name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
const input: MonitorInput = { name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds };
if (type === "metric") input.for_sec = forMin * 60;
onSubmit(input);
}
/* The server rejects a type change to or from heartbeat or metric, so an
edit of one of those locks the picker rather than letting the operator
hit a save error. */
const typePickerDisabled = !!initial && (initial.type === "heartbeat" || initial.type === "metric");
const runnerName = runner === "server" ? "the control plane" : servers?.find((s) => s.server_id === runner)?.hostname || "an agent";
/* Retries are consecutive failures, so the delay before "down" is one
interval per remaining try after the first. Say it in minutes, because
@@ -146,7 +172,7 @@ export function MonitorForm({
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,400px)]">
<Section title="Check" hint={typeCopy[type].target}>
<Section title="Check" hint={typeCopy[type]?.target}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
@@ -177,7 +203,8 @@ export function MonitorForm({
type="button"
onClick={() => setType(t)}
aria-pressed={type === t}
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
disabled={typePickerDisabled}
className={`rounded-lg border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
type === t
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40"
@@ -186,9 +213,9 @@ export function MonitorForm({
<span
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
>
{typeCopy[t].title}
{typeCopy[t]?.title}
</span>
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t]?.blurb}</span>
</button>
))}
</div>
@@ -268,50 +295,126 @@ export function MonitorForm({
/>
</Field>
)}
</Section>
<div className="flex flex-col gap-5">
<Section title="Schedule">
{type === "heartbeat" && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Run every" help="Seconds between checks. Minimum 10.">
<Field label="Expected every (minutes)">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))}
min={10}
/>
</Field>
<Field label="Fails after" help="Consecutive failures before an incident opens.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={retries}
onChange={(e) => setRetries(Number(e.target.value))}
value={periodMin}
onChange={(e) => setPeriodMin(Number(e.target.value))}
min={1}
/>
</Field>
<Field label="Grace (minutes)">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={graceMin}
onChange={(e) => setGraceMin(Number(e.target.value))}
min={0}
/>
</Field>
</div>
)}
<Field
label="Runs from"
help="Pick an agent for anything only reachable from inside that network. Everything else runs centrally."
>
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
<option value="server">Control plane</option>
{servers?.map((s) => (
<option key={s.server_id} value={s.server_id}>
Agent · {s.hostname}
</option>
))}
</select>
</Field>
{type === "metric" && (
<>
<Field label="Metric">
<select className={inputClass} value={metric} onChange={(e) => setMetric(e.target.value as MetricKind)}>
{(Object.keys(metricCopy) as MetricKind[]).map((k) => (
<option key={k} value={k}>
{metricCopy[k].label}
</option>
))}
</select>
</Field>
<p className="rounded-lg bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">
Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "}
{retries === 1 ? "failure" : "consecutive failures"} - roughly {downAfter}.
</p>
</Section>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{metricCopy[metric].unit && (
<Field label={`Threshold${metricCopy[metric].unit ? ` (${metricCopy[metric].unit})` : ""}`}>
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
/>
</Field>
)}
{metricCopy[metric].mount && (
<Field label="Mount">
<input
className={`${inputClass} font-mono`}
value={mount}
onChange={(e) => setMount(e.target.value)}
placeholder="Any mount"
/>
</Field>
)}
<Field label="For (minutes)" help="How long the breach must hold before it alerts.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={forMin}
onChange={(e) => setForMin(Number(e.target.value))}
min={0}
/>
</Field>
</div>
<Field label="Servers">
<TagRestriction selector={selector} onChange={setSelector} />
<span className="mt-1.5 block text-xs text-text-tertiary">Leave empty to watch every server.</span>
</Field>
</>
)}
</Section>
<div className="flex flex-col gap-5">
{type !== "heartbeat" && type !== "metric" && (
<Section title="Schedule">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Run every" help="Seconds between checks. Minimum 10.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))}
min={10}
/>
</Field>
<Field label="Fails after" help="Consecutive failures before an incident opens.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={retries}
onChange={(e) => setRetries(Number(e.target.value))}
min={1}
/>
</Field>
</div>
<Field
label="Runs from"
help="Pick an agent for anything only reachable from inside that network. Everything else runs centrally."
>
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
<option value="server">Control plane</option>
{servers?.map((s) => (
<option key={s.server_id} value={s.server_id}>
Agent · {s.hostname}
</option>
))}
</select>
</Field>
<p className="rounded-lg bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">
Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "}
{retries === 1 ? "failure" : "consecutive failures"} - roughly {downAfter}.
</p>
</Section>
)}
<Section title="Alerts" hint={channelIds.length > 0 ? `${channelIds.length} selected` : undefined}>
{!channels || channels.length === 0 ? (
+16 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Incident, Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
import { Incident, MetricKind, Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
/*
* Shared vocabulary for the monitors screens.
@@ -82,10 +82,25 @@ export function formatMs(ms: number | null): string {
return `${Math.round(ms)} ms`;
}
/** Label and unit per metric kind, shared by the rule builder and the detail
* page's summary line. `mount` marks a kind that also carries a mountpoint. */
export const metricCopy: Record<MetricKind, { label: string; unit?: string; mount?: boolean }> = {
disk_pct: { label: "Disk used", unit: "%", mount: true },
disk_free_gb: { label: "Disk free below", unit: "GB", mount: true },
mem_pct: { label: "Memory used", unit: "%" },
load_per_core: { label: "Load per core", unit: "×" },
unit_failed: { label: "Systemd unit failed" },
container_unhealthy: { label: "Container unhealthy" },
reboot_pending_days: { label: "Reboot pending for", unit: "days" },
agent_offline_min: { label: "Agent offline for", unit: "minutes" },
};
export function targetSummary(m: Monitor): string {
if (m.type === "http") return m.target.url ?? "";
if (m.type === "tls") return `${m.target.host ?? ""}:${m.target.port || 443}`;
if (m.type === "icmp") return m.target.host ?? "";
if (m.type === "heartbeat") return "Ping URL";
if (m.type === "metric") return m.target.metric ? metricCopy[m.target.metric].label : "";
return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
}
+65 -10
View File
@@ -38,6 +38,7 @@ export function MembersCard() {
const toast = useToast();
const [addOpen, setAddOpen] = useState(false);
const [removing, setRemoving] = useState<Member | null>(null);
const [resettingMfa, setResettingMfa] = useState<Member | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("member");
@@ -84,6 +85,20 @@ export function MembersCard() {
},
});
const {
mutate: resetMfa,
isPending: isResettingMfa,
error: resetMfaError,
reset: resetResetMfa,
} = useMutation({
mutationFn: (member: Member) => api.resetMemberMFA(member.id),
onSuccess: (_data, member) => {
invalidate();
toast.success(`Cleared MFA for ${member.email}.`);
setResettingMfa(null);
},
});
// Removal failures are shown inside the confirm dialog that raised them, so
// only the inline role change lands here - otherwise the same sentence
// appears twice on screen.
@@ -121,6 +136,7 @@ export function MembersCard() {
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>MFA</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
@@ -158,6 +174,13 @@ export function MembersCard() {
<Td label="Sign-in">
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
<Td label="MFA">
{managedByHQ ? (
<span className="text-xs text-text-tertiary">-</span>
) : (
<Badge variant={u.mfa_enabled ? "success" : "neutral"}>{u.mfa_enabled ? "Enabled" : "Not set up"}</Badge>
)}
</Td>
<Td label="Last login" className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td label="Actions" className="text-right">
{managedByHQ ? (
@@ -169,16 +192,29 @@ export function MembersCard() {
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove<span className="sr-only"> {u.email}</span>
</Button>
)
<div className="flex justify-end gap-2">
{/* Only an owner may reset an owner's MFA - the same rule the
server enforces, so admins never see a button that would 403. */}
{u.mfa_enabled && (isOwner || u.role !== "owner") && (
<Button
variant="ghost"
size="sm"
onClick={() => setResettingMfa({ id: u.user_id, email: u.email })}
>
Reset MFA<span className="sr-only"> for {u.email}</span>
</Button>
)}
{!locked && (
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove<span className="sr-only"> {u.email}</span>
</Button>
)}
</div>
)}
</Td>
</Tr>
@@ -212,6 +248,25 @@ export function MembersCard() {
}
/>
<ConfirmDialog
open={resettingMfa !== null}
title="Reset MFA"
confirmLabel="Reset MFA"
loading={isResettingMfa}
error={resetMfaError ? friendlyMessage(resetMfaError) : null}
onClose={() => {
resetResetMfa();
setResettingMfa(null);
}}
onConfirm={() => resettingMfa && resetMfa(resettingMfa)}
body={
<p>
<span className="text-text-primary">{resettingMfa?.email}</span> loses every enrolled factor and recovery code. If this
instance requires MFA for password sign-in, they must set up a new factor the next time they sign in.
</p>
}
/>
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from "@playwright/test";
import { ensureOwner, signIn } from "./helpers";
const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
test("heartbeat monitor: URL ping, /fail, header ping", async ({ page, request }) => {
const owner = await ensureOwner(request);
await signIn(page, owner.email, owner.password);
const create = await page.request.post(`${BASE_URL}/api/monitors`, {
data: {
name: `e2e heartbeat ${Date.now()}`,
type: "heartbeat",
target: { period_sec: 3600, grace_sec: 300 },
enabled: true,
},
});
expect(create.status()).toBe(201);
const monitor = await create.json();
expect(monitor.heartbeat_token).toBeTruthy();
const ping = await request.get(`${BASE_URL}/public/hb/${monitor.heartbeat_token}`);
expect(ping.status()).toBe(200);
expect(await ping.text()).toBe("OK");
let got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();
expect(got.state.status).toBe("up");
const fail = await request.post(`${BASE_URL}/public/hb/${monitor.heartbeat_token}/fail`, { data: "disk full" });
expect(fail.status()).toBe(200);
got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();
expect(got.state.status).toBe("down");
expect(got.state.message).toContain("disk full");
const incidents = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}/incidents`)).json();
expect(incidents.length).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 1100)); // same kind (ping) as the first request; limit is 1/s per token and kind
const headerPing = await request.post(`${BASE_URL}/public/hb`, { headers: { "X-Vantage-Token": monitor.heartbeat_token } });
expect(headerPing.status()).toBe(200);
got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();
expect(got.state.status).toBe("up");
expect((await request.get(`${BASE_URL}/public/hb/not-a-real-token`)).status()).toBe(404);
expect((await request.post(`${BASE_URL}/public/hb`)).status()).toBe(404);
await page.goto(`${BASE_URL}/monitors/${monitor.monitor_id}`);
await expect(page.getByText(/last ping/i)).toBeVisible();
});
+36
View File
@@ -0,0 +1,36 @@
import { type APIRequestContext, type Page } from "@playwright/test";
export const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
let ownerEmail = process.env.E2E_OWNER_EMAIL ?? "";
let ownerPassword = process.env.E2E_OWNER_PASSWORD ?? "";
/** Ensures an owner account exists and returns its credentials, bootstrapping the instance if needed. */
export async function ensureOwner(request: APIRequestContext): Promise<{ email: string; password: string }> {
if (ownerEmail && ownerPassword) return { email: ownerEmail, password: ownerPassword };
const status = await request.get(`${BASE_URL}/auth/bootstrap-status`);
const body = await status.json();
if (!body.needs_setup) {
throw new Error(
"Instance is already bootstrapped and E2E_OWNER_EMAIL/E2E_OWNER_PASSWORD were not set - " +
"cannot create the owner account this suite needs to provision fresh members per test.",
);
}
ownerEmail = `e2e-owner-${Date.now()}@vantage.test`;
ownerPassword = "correct horse battery staple 1";
const res = await request.post(`${BASE_URL}/auth/bootstrap`, {
data: { instance_name: "E2E", email: ownerEmail, password: ownerPassword },
});
if (!res.ok()) throw new Error(`bootstrap failed: ${res.status()} ${await res.text()}`);
return { email: ownerEmail, password: ownerPassword };
}
/** Fills the login form and submits it. */
export async function signIn(page: Page, email: string, password: string) {
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign In" }).click();
}
+203
View File
@@ -0,0 +1,203 @@
import { test, expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test";
import { authenticator } from "otplib";
import { BASE_URL, ensureOwner, signIn } from "./helpers";
/**
* End-to-end coverage for Task 15 of the MFA design
* (docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md).
*
* Prerequisites: a running control plane (server + web) backed by MongoDB
* and Redis, reachable at `E2E_BASE_URL` (default http://localhost:3000)
* with the API at the same origin (the nginx fragment in
* deploy/docker/docker-compose.yml, or an equivalent dev proxy).
*
* The suite needs an existing owner session to create fresh member accounts
* per test, so state never leaks between tests. If the instance has never
* been bootstrapped, the first test bootstraps it and every other test reuses
* those owner credentials; set E2E_OWNER_EMAIL / E2E_OWNER_PASSWORD to point
* at an already-bootstrapped instance's owner instead.
*/
/** Creates a fresh, unique local member with no MFA enrolled, using an owner session. */
async function createMember(request: APIRequestContext): Promise<{ email: string; password: string }> {
const owner = await ensureOwner(request);
const login = await request.post(`${BASE_URL}/auth/login`, {
data: { email: owner.email, password: owner.password },
});
if (!login.ok()) throw new Error(`owner login failed: ${login.status()} ${await login.text()}`);
const email = `e2e-member-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@vantage.test`;
const password = "correct horse battery staple 1";
const res = await request.post(`${BASE_URL}/api/org/users`, {
data: { email, password, role: "member" },
});
if (!res.ok()) throw new Error(`create member failed: ${res.status()} ${await res.text()}`);
await request.post(`${BASE_URL}/auth/logout`);
return { email, password };
}
/** Registers Chrome's virtual authenticator (CTAP2, resident keys, internal UV) on this context. */
async function addVirtualAuthenticator(context: BrowserContext, page: Page): Promise<string> {
const client = await context.newCDPSession(page);
await client.send("WebAuthn.enable");
const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
return authenticatorId;
}
test.describe("MFA end-to-end", () => {
test("TOTP sign-in", async ({ page, request }) => {
const { email, password } = await createMember(request);
// Sign in, land on the account security page, enrol TOTP.
await signIn(page, email, password);
await expect(page).toHaveURL("/");
await page.goto("/account/security");
await page.getByRole("button", { name: "Set up" }).click();
await page.getByRole("button", { name: "Use an authenticator app" }).click();
const secret = await page.locator("p.font-mono").innerText();
const code = authenticator.generate(secret.trim());
await page.getByLabel("6-digit code").fill(code);
await page.getByRole("button", { name: "Confirm" }).click();
// Recovery codes step - acknowledge and finish.
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Enabled")).toBeVisible();
// Sign out and sign back in - TOTP is now required.
await page.request.post(`${BASE_URL}/auth/logout`);
await signIn(page, email, password);
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
const nextCode = authenticator.generate(secret.trim());
await page.getByLabel("Verification code").fill(nextCode);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL("/");
});
test("passkey second factor", async ({ page, context, request }) => {
const { email, password } = await createMember(request);
await addVirtualAuthenticator(context, page);
await signIn(page, email, password);
await expect(page).toHaveURL("/");
await page.goto("/account/security");
await page.getByRole("button", { name: "Set up" }).click();
await page.getByRole("button", { name: "Use a passkey" }).click();
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("No passkeys registered.")).toHaveCount(0);
await expect(page.getByText(/Added .* · Last used/)).toBeVisible();
await page.request.post(`${BASE_URL}/auth/logout`);
await signIn(page, email, password);
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
await page.getByRole("button", { name: "Use passkey" }).click();
await expect(page).toHaveURL("/");
});
test("passwordless passkey sign-in", async ({ page, context, request }) => {
const { email, password } = await createMember(request);
await addVirtualAuthenticator(context, page);
// Enrol a passkey first (typing the password once, during setup only).
await signIn(page, email, password);
await expect(page).toHaveURL("/");
await page.goto("/account/security");
await page.getByRole("button", { name: "Add a passkey" }).click();
await page.request.post(`${BASE_URL}/auth/logout`);
// Sign back in with no password typed at all.
await page.goto("/login");
await page.getByRole("button", { name: "Sign in with passkey" }).click();
await expect(page).toHaveURL("/");
});
test("forced enrolment when require_mfa is on", async ({ page, request }) => {
const owner = await ensureOwner(request);
const { email, password } = await createMember(request);
// Owner enables the policy.
const ownerLogin = await request.post(`${BASE_URL}/auth/login`, {
data: { email: owner.email, password: owner.password },
});
expect(ownerLogin.ok()).toBeTruthy();
const settingsRes = await request.put(`${BASE_URL}/api/settings`, {
data: { require_mfa: true },
});
expect(settingsRes.ok()).toBeTruthy();
await request.post(`${BASE_URL}/auth/logout`);
// The member, who has no MFA yet, must enrol before reaching the app.
await signIn(page, email, password);
await expect(page.getByText("This instance requires a second sign-in factor. Set one up to continue.")).toBeVisible();
await page.getByRole("button", { name: "Use an authenticator app" }).click();
const secret = await page.locator("p.font-mono").innerText();
const code = authenticator.generate(secret.trim());
await page.getByLabel("6-digit code").fill(code);
await page.getByRole("button", { name: "Confirm" }).click();
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveURL("/");
// Clean up: turn the policy back off so it doesn't affect other tests.
await request.post(`${BASE_URL}/auth/login`, { data: { email: owner.email, password: owner.password } });
await request.put(`${BASE_URL}/api/settings`, { data: { require_mfa: false } });
await request.post(`${BASE_URL}/auth/logout`);
});
test("fresh sign-in reveals a secret without a step-up prompt", async ({ page, request }) => {
const { email, password } = await createMember(request);
// Enrol TOTP so the account has a factor - not exercised in this test,
// since sign-in itself sets StepUpAt and the window has not gone stale.
await signIn(page, email, password);
await page.goto("/account/security");
await page.getByRole("button", { name: "Set up" }).click();
await page.getByRole("button", { name: "Use an authenticator app" }).click();
const secret = await page.locator("p.font-mono").innerText();
await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim()));
await page.getByRole("button", { name: "Confirm" }).click();
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
// Sign-in counts as a step-up (mintSession sets StepUpAt), and that is
// valid for ten minutes - a browser test cannot wait that long or age a
// session, so this only asserts the fresh-session path: no prompt on the
// first reveal, and none on a second reveal right after. The stale path
// (StepUpAt older than ten minutes) is covered by TestStepUpFresh in
// server/internal/auth/stepup_test.go.
await request.post(`${BASE_URL}/api/secrets`, { data: { group: "e2e-stepup", values: { KEY: "value" } } });
await page.goto("/secrets/e2e-stepup");
await page.getByRole("button", { name: "Reveal" }).click();
await expect(page.getByText("Confirm it's you")).toHaveCount(0);
await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible();
await page.getByRole("button", { name: "Hide" }).click().catch(() => {});
await page.getByRole("button", { name: "Reveal" }).click();
await expect(page.getByText("Confirm it's you")).toHaveCount(0);
await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible();
});
});
+228 -8
View File
@@ -1,3 +1,5 @@
import { requestStepUp } from "./stepup";
export type ServerStatus = "pending" | "active" | "offline";
export type KeySource = "uploaded" | "generated";
@@ -43,9 +45,13 @@ export interface Server {
tags?: Record<string, string>;
}
export type MonitorType = "http" | "tcp" | "icmp" | "tls";
export type MonitorType = "http" | "tcp" | "icmp" | "tls" | "heartbeat" | "metric";
export type MonitorStatus = "up" | "down" | "pending";
export type MetricKind =
| "disk_pct" | "disk_free_gb" | "mem_pct" | "load_per_core"
| "unit_failed" | "container_unhealthy" | "reboot_pending_days" | "agent_offline_min";
export interface MonitorTarget {
url?: string;
host?: string;
@@ -55,6 +61,12 @@ export interface MonitorTarget {
keyword?: string;
tls_warn_days?: number;
insecure?: boolean;
period_sec?: number;
grace_sec?: number;
selector?: Record<string, string>;
metric?: MetricKind;
threshold?: number;
mount?: string;
}
export interface MonitorState {
@@ -64,6 +76,8 @@ export interface MonitorState {
message?: string;
cert_expiry_at?: string;
fails: number;
last_ping_at?: string;
started_at?: string;
}
export interface Monitor {
@@ -80,6 +94,9 @@ export interface Monitor {
channel_ids?: string[];
state: MonitorState;
created_at: string;
for_sec?: number;
/** Plaintext heartbeat ping token. Only ever present on the create response. */
heartbeat_token?: string;
}
export interface MonitorInput {
@@ -92,6 +109,7 @@ export interface MonitorInput {
retries: number;
enabled: boolean;
channel_ids?: string[];
for_sec?: number;
}
export interface Incident {
@@ -100,6 +118,19 @@ export interface Incident {
started_at: string;
resolved_at?: string;
cause?: string;
server_id?: string;
}
/** Per-server state for a metric monitor's rule, one row per matching server. */
export interface MonitorServerState {
monitor_id: string;
server_id: string;
hostname?: string;
status: MonitorStatus;
breach_since?: string;
value: number;
message?: string;
updated_at: string;
}
/** One check result. Kept for 48 hours, which is what the sub-hour views read. */
@@ -297,6 +328,8 @@ export interface Settings {
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
/** Owner-only: members without a factor must enrol at their next sign-in. */
require_mfa?: boolean;
}
export type ApiToken = {
@@ -564,6 +597,7 @@ export interface InstanceUser {
hq_user_id?: string;
created_at: string;
last_login?: string;
mfa_enabled: boolean;
}
export interface OrgUserInput {
@@ -628,17 +662,23 @@ export interface AuthProviderUpdate {
order?: number;
}
class ApiError extends Error {
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public code?: string,
public retryAfter?: number,
public attemptsLeft?: number,
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
/** RequestInit plus the internal flag that stops a step-up retry from looping. */
type ApiRequestInit = RequestInit & { __retried?: boolean };
async function request<T>(path: string, options?: ApiRequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: "include",
headers: {
@@ -652,11 +692,28 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
const text = await res.text().catch(() => "");
let message = text || res.statusText || `HTTP ${res.status}`;
let code: string | undefined;
let methods: string[] | undefined;
try {
const body = JSON.parse(text);
const body = JSON.parse(text || "{}");
if (body?.error) message = body.error;
if (body?.code) code = body.code;
if (Array.isArray(body?.methods)) methods = body.methods;
} catch {}
throw new ApiError(res.status, message);
// A guarded route answers 403 with the factors that would satisfy it.
// Re-authenticate once through the shared modal, then retry the
// original request exactly once - a loop here would prompt forever
// against a server that keeps refusing. This is outside the parse
// try/catch above: a cancelled step-up rejects, and that rejection
// must propagate, not be swallowed as a JSON parse failure.
if (res.status === 403 && code === "step_up_required" && !options?.__retried) {
await requestStepUp(methods ?? ["password"]);
return request<T>(path, { ...options, __retried: true });
}
const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined;
throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined);
}
if (res.status === 204) {
@@ -675,11 +732,16 @@ async function authRequest<T>(path: string, options?: RequestInit): Promise<T> {
if (!res.ok) {
let message = `HTTP ${res.status}`;
let code: string | undefined;
let attemptsLeft: number | undefined;
try {
const body = await res.json();
if (body?.error) message = body.error;
if (body?.code) code = body.code;
if (typeof body?.attempts_left === "number") attemptsLeft = body.attempts_left;
} catch {}
throw new ApiError(res.status, message);
const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined;
throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined, attemptsLeft);
}
if (res.status === 204) {
@@ -701,8 +763,8 @@ export const auth = {
});
},
login(email: string, password: string): Promise<{ ok: boolean }> {
return authRequest<{ ok: boolean }>("/auth/login", {
login(email: string, password: string): Promise<{ ok?: true } | { mfa_required: true; methods: string[] } | { enrol_required: true }> {
return authRequest("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
@@ -712,6 +774,61 @@ export const auth = {
return authRequest<void>("/auth/logout", { method: "POST" });
},
// --- second factor (an in-progress login, identified by the server-side ticket cookie) ---
mfaTotp(code: string): Promise<{ ok: true }> {
return authRequest("/auth/mfa/totp", { method: "POST", body: JSON.stringify({ code }) });
},
mfaRecovery(code: string): Promise<{ ok: true }> {
return authRequest("/auth/mfa/recovery", { method: "POST", body: JSON.stringify({ code }) });
},
mfaWebAuthnBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/mfa/webauthn/begin", { method: "POST" });
},
mfaWebAuthnFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return authRequest("/auth/mfa/webauthn/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
// --- passwordless passkey sign-in ---
passkeyLoginBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/passkey/begin", { method: "POST" });
},
passkeyLoginFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return authRequest("/auth/passkey/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
// --- forced enrolment (a fresh account that has not set up a second factor yet) ---
enrolTotpSetup(): Promise<{ secret: string; otpauth_url: string }> {
return authRequest("/auth/mfa/enrol/totp/setup", { method: "POST" });
},
enrolTotpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return authRequest("/auth/mfa/enrol/totp/confirm", { method: "POST", body: JSON.stringify({ code }) });
},
enrolPasskeyBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/mfa/enrol/passkey/begin", { method: "POST" });
},
enrolPasskeyFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return authRequest("/auth/mfa/enrol/passkey/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }),
});
},
me(): Promise<MeResponse> {
return authRequest<MeResponse>("/auth/me");
},
@@ -727,6 +844,90 @@ export const auth = {
},
};
export interface MePasskey {
id: string;
name: string;
created_at: string;
last_used_at?: string;
transports?: string[];
}
export interface MeMfaStatus {
totp_enabled: boolean;
passkeys: MePasskey[];
recovery_remaining: number;
/** The instance's require_mfa policy: a removal leaving no factor is refused. */
require_mfa: boolean;
/** False when an identity provider owns this user's authentication. */
applicable: boolean;
}
/**
* The signed-in user's own factors, at /api/me/*. Several of these are
* guarded by step-up and answer 403 `step_up_required`; that interception is
* handled globally elsewhere, not here.
*/
export const me = {
mfa(): Promise<MeMfaStatus> {
return request<MeMfaStatus>("/me/mfa");
},
totpSetup(): Promise<{ secret: string; otpauth_uri: string }> {
return request("/me/mfa/totp/setup", { method: "POST" });
},
totpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return request("/me/mfa/totp/confirm", { method: "POST", body: JSON.stringify({ code }) });
},
removeTotp(): Promise<void> {
return request<void>("/me/mfa/totp", { method: "DELETE" });
},
regenerateRecovery(): Promise<{ recovery_codes: string[] }> {
return request("/me/mfa/recovery/regenerate", { method: "POST" });
},
passkeyRegisterBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return request("/me/passkeys/begin", { method: "POST" });
},
passkeyRegisterFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return request("/me/passkeys/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }),
});
},
renamePasskey(id: string, name: string): Promise<void> {
return request<void>(`/me/passkeys/${id}`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deletePasskey(id: string): Promise<void> {
return request<void>(`/me/passkeys/${id}`, { method: "DELETE" });
},
/**
* Re-authenticates the current session against exactly one factor. Called
* by the StepUpModal, and by `request()` internally never - the modal is
* the only caller, so the retry-once guarantee lives entirely in `request`.
*/
stepUp(factor: { totp: string } | { recovery: string } | { password: string }): Promise<{ ok: true }> {
return request("/me/step-up", { method: "POST", body: JSON.stringify(factor) });
},
stepUpWebAuthnBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return request("/me/step-up/webauthn/begin", { method: "POST" });
},
stepUpWebAuthnFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return request("/me/step-up/webauthn/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
};
export const api = {
listInstanceUsers(): Promise<InstanceUser[]> {
return request<InstanceUser[]>("/instance/users");
@@ -747,6 +948,11 @@ export const api = {
return request<void>(`/instance/users/${userId}`, { method: "DELETE" });
},
/** Owner or admin; an admin cannot reset an owner's MFA (403). Step-up guarded. */
resetMemberMFA(userId: string): Promise<void> {
return request<void>(`/org/users/${userId}/mfa`, { method: "DELETE" });
},
listAuthPresets(): Promise<AuthPreset[]> {
return request<AuthPreset[]>("/auth/presets");
},
@@ -841,6 +1047,10 @@ export const api = {
return request<Incident[]>(`/monitors/${monitorId}/incidents`);
},
monitorServers(monitorId: string): Promise<MonitorServerState[]> {
return request<MonitorServerState[]>(`/monitors/${monitorId}/servers`);
},
getMonitorUptime(monitorId: string): Promise<Rollup[]> {
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
},
@@ -849,6 +1059,10 @@ export const api = {
return request<MonitorSample[]>(`/monitors/${monitorId}/samples?minutes=${minutes}`);
},
rotateHeartbeatToken(monitorId: string) {
return request<{ heartbeat_token: string }>(`/monitors/${monitorId}/rotate-token`, { method: "POST" });
},
listStatusPages(): Promise<StatusPage[]> {
return request<StatusPage[]>("/status-pages");
},
@@ -1025,6 +1239,7 @@ export const api = {
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
require_mfa?: boolean;
}): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
@@ -1263,6 +1478,11 @@ export const api = {
},
};
/** The ping URL a job calls. Same origin as the app: /public is routed to the server everywhere. */
export function heartbeatUrl(token: string): string {
return `${window.location.origin}/public/hb/${token}`;
}
export type LicenseState = "valid" | "expired" | "invalid";
export interface LicenseInfo {
+18
View File
@@ -0,0 +1,18 @@
// The step-up broker. `request()` in api.ts lives outside React, so it cannot
// render a dialog itself; instead it calls back into whatever prompt the
// StepUpModal registered when it mounted inside the (app) layout.
type Prompt = (methods: string[]) => Promise<void>;
let prompt: Prompt | null = null;
// The provider registers the real prompt at mount. Before that, or outside the
// app shell, step-up simply fails rather than hanging forever.
export function registerStepUpPrompt(fn: Prompt | null) {
prompt = fn;
}
export async function requestStepUp(methods: string[]): Promise<void> {
if (!prompt) throw new Error("re-authentication is required");
return prompt(methods);
}
+68
View File
@@ -0,0 +1,68 @@
// Browser-side WebAuthn helpers. The server sends and expects base64url;
// the browser's credential APIs need ArrayBuffers.
function b64urlToBuffer(value: string): ArrayBuffer {
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), "="));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToB64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function isPasskeySupported(): boolean {
return typeof window !== "undefined" && !!window.PublicKeyCredential;
}
export function toCreateOptions(options: any): PublicKeyCredentialCreationOptions {
return {
...options,
challenge: b64urlToBuffer(options.challenge),
user: { ...options.user, id: b64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials ?? []).map((c: any) => ({
...c,
id: b64urlToBuffer(c.id),
})),
};
}
export function toRequestOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: b64urlToBuffer(options.challenge),
allowCredentials: (options.allowCredentials ?? []).map((c: any) => ({
...c,
id: b64urlToBuffer(c.id),
})),
};
}
// credentialToJSON produces the shape go-webauthn's parsers read.
export function credentialToJSON(cred: PublicKeyCredential): unknown {
const response = cred.response as AuthenticatorAttestationResponse & AuthenticatorAssertionResponse;
const json: any = {
id: cred.id,
rawId: bufferToB64url(cred.rawId),
type: cred.type,
clientExtensionResults: cred.getClientExtensionResults(),
response: { clientDataJSON: bufferToB64url(response.clientDataJSON) },
};
if (response.attestationObject) {
json.response.attestationObject = bufferToB64url(response.attestationObject);
if (typeof response.getTransports === "function") {
json.response.transports = response.getTransports();
}
}
if (response.authenticatorData) {
json.response.authenticatorData = bufferToB64url(response.authenticatorData);
json.response.signature = bufferToB64url(response.signature);
json.response.userHandle = response.userHandle ? bufferToB64url(response.userHandle) : null;
}
return json;
}
+439 -119
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -6,25 +6,30 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test:e2e": "playwright test"
},
"dependencies": {
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1",
"next": "16.2.9",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"otplib": "^12.0.1",
"@types/node": "^20.14.11",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9"
"typescript": "^5.5.3"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Minimal Playwright setup for the MFA end-to-end coverage (Task 15).
*
* There was no Playwright config in this repository before this task, so
* this is the smallest one that works: one project (Chromium, the only
* browser that implements the WebAuthn virtual authenticator CDP domain the
* passkey tests need), pointed at an already-running stack.
*
* Prerequisites (not started by this config - the stack needs MongoDB and
* Redis, which `webServer` cannot provision):
* docker compose -f ../deploy/docker/docker-compose.yml up -d
* npm run dev # or the built `server`/`web`, whichever is already running
*
* Run with: npx playwright test e2e/mfa.spec.ts
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: "list",
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+3 -1
View File
@@ -36,6 +36,8 @@
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"e2e",
"playwright.config.ts"
]
}
File diff suppressed because one or more lines are too long