Compare commits
56
Commits
fbcf436ef6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb6f47c8ed | ||
|
|
ffc6853142 | ||
|
|
42766968e3 | ||
|
|
cffa7e84b2 | ||
|
|
2701b32b5f | ||
|
|
753ba09e6e | ||
|
|
7c2c5d95e3 | ||
|
|
13e7160405 | ||
|
|
2afdc267f8 | ||
|
|
8908b435a0 | ||
|
|
4158696388 | ||
|
|
3cb8463dc7 | ||
|
|
2017c95a7a | ||
|
|
4068349afe | ||
|
|
f01375470e | ||
|
|
12136d1c17 | ||
|
|
1a1f6998a4 | ||
|
|
42358f57cb | ||
|
|
17ed0192c1 | ||
|
|
839d716a47 | ||
|
|
09888825a8 | ||
|
|
590c369d36 | ||
|
|
64dbad1d20 | ||
|
|
aaad7db09d | ||
|
|
fab9d11c7e | ||
|
|
73da464701 | ||
|
|
d59da68701 | ||
|
|
189bce55ba | ||
|
|
217e2dc5a9 | ||
|
|
934501f4ec | ||
|
|
b1193c59e3 | ||
|
|
069e7e7c61 | ||
|
|
19383abaf8 | ||
|
|
dcdfd3ce52 | ||
|
|
142b99e408 | ||
|
|
14e9db606a | ||
|
|
3e341b17ec | ||
|
|
32fd11cde7 | ||
|
|
26825841fa | ||
|
|
d8597ee3ae | ||
|
|
f87626cf17 | ||
|
|
bd0639acfa | ||
|
|
e2ff0dace9 | ||
|
|
d54d8971b2 | ||
|
|
14a1cdb2b0 | ||
|
|
1445af11ab | ||
|
|
3fa469c303 | ||
|
|
2d75832ceb | ||
|
|
dfcfd1d3e2 | ||
|
|
9c60adc836 | ||
|
|
330c326fb5 | ||
|
|
25541345a8 | ||
|
|
b78a9b3832 | ||
|
|
9aee0a61aa | ||
|
|
0aabb664e2 | ||
|
|
e0468aa7b9 |
@@ -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
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HeartbeatTokenHeader carries the ping token for callers who want it out of
|
||||
// URLs, and so out of their own proxies' access logs.
|
||||
const HeartbeatTokenHeader = "X-Vantage-Token"
|
||||
|
||||
const heartbeatPathPrefix = "/public/hb/"
|
||||
|
||||
func heartbeatKind(seg string) string {
|
||||
switch seg {
|
||||
case "":
|
||||
return services.HeartbeatPing
|
||||
case "start":
|
||||
return services.HeartbeatStart
|
||||
case "fail":
|
||||
return services.HeartbeatFail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveHeartbeat maps the three route shapes onto a token and a kind. a and b
|
||||
// are the first and second path segments after /public/hb. A token in the URL
|
||||
// wins over the header, so a URL copied from the UI behaves the same no matter
|
||||
// what headers a client adds.
|
||||
func resolveHeartbeat(header, a, b string) (string, string, bool) {
|
||||
var token, kindSeg string
|
||||
switch {
|
||||
// a is a URL token unless a header is present, no second segment follows
|
||||
// and a is itself a kind word (the header routes /public/hb/start|fail).
|
||||
case a != "" && (header == "" || b != "" || heartbeatKind(a) == ""):
|
||||
token, kindSeg = a, b
|
||||
case header != "":
|
||||
token, kindSeg = header, a
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
kind := heartbeatKind(kindSeg)
|
||||
if token == "" || kind == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return token, kind, true
|
||||
}
|
||||
|
||||
// MaskLogPath hides a heartbeat token in a request path before it is logged.
|
||||
// "start" and "fail" directly under the prefix are the header-token routes and
|
||||
// carry no secret, so they are left readable.
|
||||
func MaskLogPath(path string) string {
|
||||
rest, found := strings.CutPrefix(path, heartbeatPathPrefix)
|
||||
if !found {
|
||||
return path
|
||||
}
|
||||
end := strings.IndexAny(rest, "/?")
|
||||
if end < 0 {
|
||||
end = len(rest)
|
||||
}
|
||||
seg := rest[:end]
|
||||
if seg == "" || seg == "start" || seg == "fail" {
|
||||
return path
|
||||
}
|
||||
return heartbeatPathPrefix + "***" + rest[end:]
|
||||
}
|
||||
|
||||
// heartbeatLimitKey buckets requests by token, kind and wall-clock second. The
|
||||
// kind is part of the key so a fast job's success ping is not rejected for
|
||||
// landing in the same second as its /start, which would leave the run marked
|
||||
// started and page as never finished. The token is hashed so Redis never holds
|
||||
// the secret.
|
||||
func heartbeatLimitKey(token, kind string, now time.Time) string {
|
||||
return "vantage:hbrl:" + services.HashHeartbeatToken(token) + ":" + kind + ":" + strconv.FormatInt(now.Unix(), 10)
|
||||
}
|
||||
|
||||
// readHeartbeatBody keeps up to MaxHeartbeatBody bytes of a /fail body as the
|
||||
// incident message. Other kinds carry no message, so their bodies are ignored.
|
||||
func readHeartbeatBody(kind string, r io.Reader) string {
|
||||
if kind != services.HeartbeatFail || r == nil {
|
||||
return ""
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(r, services.MaxHeartbeatBody))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func writeHeartbeatLimited(c *gin.Context) {
|
||||
c.Header("Retry-After", "1")
|
||||
c.String(http.StatusTooManyRequests, "too many requests")
|
||||
}
|
||||
|
||||
// heartbeatAllowed admits one request per token and kind per second. A cron job pinging
|
||||
// in a loop should not become a write per request, and a leaked URL should not
|
||||
// be a way to hammer Mongo. Like the status page limiter it allows when Redis
|
||||
// is down: a missed ping pages someone. It runs inside the handler rather than
|
||||
// as middleware because the token may come from a header.
|
||||
func heartbeatAllowed(c *gin.Context, token, kind string) bool {
|
||||
rdb := auth.Redis()
|
||||
if rdb == nil {
|
||||
return true
|
||||
}
|
||||
key := heartbeatLimitKey(token, kind, time.Now())
|
||||
count, err := rdb.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if count == 1 {
|
||||
rdb.Expire(c.Request.Context(), key, 2*time.Second)
|
||||
}
|
||||
return count <= 1
|
||||
}
|
||||
|
||||
// handleHeartbeat records a push from a job. It is mounted on the gin root
|
||||
// under /public for the same reasons as the status page (see
|
||||
// getPublicStatusPage): no session, no token, no licence gate, and /public is
|
||||
// already routed to this server by every deployment.
|
||||
//
|
||||
// Unknown token, disabled monitor and an unresolvable path all answer the same
|
||||
// 404.
|
||||
func handleHeartbeat(c *gin.Context) {
|
||||
token, kind, ok := resolveHeartbeat(c.GetHeader(HeartbeatTokenHeader), c.Param("a"), c.Param("b"))
|
||||
if !ok {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if !heartbeatAllowed(c, token, kind) {
|
||||
writeHeartbeatLimited(c)
|
||||
return
|
||||
}
|
||||
err := services.RecordHeartbeat(token, kind, readHeartbeatBody(kind, c.Request.Body), time.Now())
|
||||
if errors.Is(err, services.ErrHeartbeatNotFound) {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("heartbeat: %v", err)
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, "OK")
|
||||
}
|
||||
|
||||
// rotateHeartbeatToken godoc
|
||||
//
|
||||
// @Summary Rotate a heartbeat monitor's ping token
|
||||
// @Description Issues a new token and invalidates the old ping URL immediately. The token is returned only in this response.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {object} object{heartbeat_token=string}
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/rotate-token [post]
|
||||
func rotateHeartbeatToken(c *gin.Context) {
|
||||
tok, err := services.RotateHeartbeatToken(auth.InstanceID(c), c.Param("id"))
|
||||
if errors.Is(err, services.ErrHeartbeatNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "heartbeat monitor not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"heartbeat_token": tok})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResolveHeartbeat(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, header, a, b string
|
||||
token, kind string
|
||||
ok bool
|
||||
}{
|
||||
{"url ping", "", "tok", "", "tok", "ping", true},
|
||||
{"url start", "", "tok", "start", "tok", "start", true},
|
||||
{"url fail", "", "tok", "fail", "tok", "fail", true},
|
||||
{"url bad kind", "", "tok", "explode", "", "", false},
|
||||
{"header ping", "htok", "", "", "htok", "ping", true},
|
||||
{"header start", "htok", "start", "", "htok", "start", true},
|
||||
{"header fail", "htok", "fail", "", "htok", "fail", true},
|
||||
{"url token wins over header", "htok", "tok", "", "tok", "ping", true},
|
||||
{"url token and kind win over header", "htok", "tok", "fail", "tok", "fail", true},
|
||||
{"nothing", "", "", "", "", "", false},
|
||||
// Without a header, /public/hb/start is a token called "start": it
|
||||
// resolves, and the lookup answers 404 like any unknown token.
|
||||
{"bare start without header", "", "start", "", "start", "ping", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
tok, kind, ok := resolveHeartbeat(c.header, c.a, c.b)
|
||||
if tok != c.token || kind != c.kind || ok != c.ok {
|
||||
t.Fatalf("got (%q,%q,%v), want (%q,%q,%v)", tok, kind, ok, c.token, c.kind, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A ping URL is a credential. Anything that logs request paths must see a
|
||||
// masked one.
|
||||
func TestMaskLogPath(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/public/hb/abc123": "/public/hb/***",
|
||||
"/public/hb/abc123/fail": "/public/hb/***/fail",
|
||||
"/public/hb/abc123?x=1": "/public/hb/***?x=1",
|
||||
"/public/hb/start": "/public/hb/start",
|
||||
"/public/hb/fail": "/public/hb/fail",
|
||||
"/public/hb": "/public/hb",
|
||||
"/public/status/page": "/public/status/page",
|
||||
"/api/monitors/abc/uptime": "/api/monitors/abc/uptime",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := MaskLogPath(in); got != want {
|
||||
t.Errorf("MaskLogPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatUnresolvableIs404(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/public/hb/:a/:b", handleHeartbeat)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/public/hb/abc/explode", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatLimitKey(t *testing.T) {
|
||||
now := time.Unix(1700000000, 0)
|
||||
start := heartbeatLimitKey("tok", services.HeartbeatStart, now)
|
||||
ping := heartbeatLimitKey("tok", services.HeartbeatPing, now)
|
||||
if start == ping {
|
||||
t.Fatalf("start and ping share a limiter key: %s", start)
|
||||
}
|
||||
if ping != heartbeatLimitKey("tok", services.HeartbeatPing, now.Add(500*time.Millisecond)) {
|
||||
t.Fatal("same kind in the same second should share a key")
|
||||
}
|
||||
if strings.Contains(ping, "tok") {
|
||||
t.Fatalf("key leaks the plaintext token: %s", ping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeartbeatBody(t *testing.T) {
|
||||
long := strings.Repeat("x", services.MaxHeartbeatBody+500)
|
||||
if got := readHeartbeatBody(services.HeartbeatFail, strings.NewReader(long)); len(got) != services.MaxHeartbeatBody {
|
||||
t.Fatalf("fail body not capped: %d", len(got))
|
||||
}
|
||||
if got := readHeartbeatBody(services.HeartbeatPing, strings.NewReader("hi")); got != "" {
|
||||
t.Fatalf("ping body should be ignored, got %q", got)
|
||||
}
|
||||
if got := readHeartbeatBody(services.HeartbeatFail, nil); got != "" {
|
||||
t.Fatalf("nil body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatRateLimitedWritesRetryAfter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
writeHeartbeatLimited(c)
|
||||
if w.Code != http.StatusTooManyRequests || w.Header().Get("Retry-After") != "1" {
|
||||
t.Fatalf("got %d retry-after %q", w.Code, w.Header().Get("Retry-After"))
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,32 @@ import (
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [get]
|
||||
// instanceUserResponse wraps a member with whether they hold an MFA factor,
|
||||
// for the settings page's member column and reset action. A wrapper rather
|
||||
// than a field on models.User because User is shared with Vantage HQ.
|
||||
type instanceUserResponse struct {
|
||||
models.User `bson:",inline"`
|
||||
MFAEnabled bool `json:"mfa_enabled"`
|
||||
}
|
||||
|
||||
func listInstanceUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
// One aggregate over two small collections beats N round trips for a
|
||||
// member list that renders on every settings page load.
|
||||
enabled, err := services.UsersWithMFA(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]instanceUserResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, instanceUserResponse{User: u, MFAEnabled: enabled[u.UserID]})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func actorMayGrantOwner(c *gin.Context) bool {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// oidcUser refuses MFA management for a user whose IdP owns authentication.
|
||||
func oidcUser(c *gin.Context) bool {
|
||||
u, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
|
||||
return err == nil && u.AuthSource == models.AuthOIDC
|
||||
}
|
||||
|
||||
// getMyMFA reports this user's factors.
|
||||
//
|
||||
// @Summary Get my MFA status
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{totp_enabled=bool,passkeys=[]models.WebAuthnCredential,recovery_remaining=int,require_mfa=bool,applicable=bool}
|
||||
// @Router /me/mfa [get]
|
||||
func getMyMFA(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
m, err := services.GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
passkeys, err := services.ListPasskeys(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"totp_enabled": m != nil && m.TOTPConfirmedAt != nil,
|
||||
"passkeys": passkeys,
|
||||
"recovery_remaining": services.RecoveryCodesRemaining(m),
|
||||
"require_mfa": services.RequireMFAForInstance(instanceID),
|
||||
"applicable": !oidcUser(c),
|
||||
})
|
||||
}
|
||||
|
||||
// setupTOTP issues a new unconfirmed secret.
|
||||
//
|
||||
// @Summary Start TOTP setup
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{secret=string,otpauth_uri=string}
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp/setup [post]
|
||||
func setupTOTP(c *gin.Context) {
|
||||
if oidcUser(c) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "your identity provider manages sign-in", "code": "mfa_not_applicable"})
|
||||
return
|
||||
}
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
issuer := "Vantage"
|
||||
if inst, err := services.GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
|
||||
issuer = inst.Name
|
||||
}
|
||||
secret, uri, err := services.StartTOTPSetup(instanceID, userID, issuer, auth.GetSessionFromContext(c).Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
|
||||
}
|
||||
|
||||
// confirmTOTP activates the pending secret and issues recovery codes if this
|
||||
// is the user's first factor.
|
||||
//
|
||||
// @Summary Confirm TOTP setup
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Six-digit code"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp/confirm [post]
|
||||
func confirmTOTP(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
|
||||
return
|
||||
}
|
||||
if err := services.ConfirmTOTP(instanceID, userID, body.Code); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that code is not valid", "code": "invalid_code"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.enrolled", actorFromCtx(c), "", "", "factor=totp")
|
||||
|
||||
m, _ := services.GetUserMFA(instanceID, userID)
|
||||
if services.RecoveryCodesRemaining(m) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
codes, err := services.IssueRecoveryCodes(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
}
|
||||
|
||||
// removeTOTP drops the TOTP factor. Step-up guarded at the route.
|
||||
//
|
||||
// @Summary Remove TOTP
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 204
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp [delete]
|
||||
func removeTOTP(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorTOTP); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
|
||||
return
|
||||
}
|
||||
if err := services.RemoveTOTP(instanceID, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=totp")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// regenerateRecoveryCodes invalidates the old set. Step-up guarded.
|
||||
//
|
||||
// @Summary Regenerate recovery codes
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{recovery_codes=[]string}
|
||||
// @Router /me/mfa/recovery/regenerate [post]
|
||||
func regenerateRecoveryCodes(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
codes, err := services.IssueRecoveryCodes(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.recovery_regenerated", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
||||
}
|
||||
|
||||
// renamePasskey and deletePasskey work on the hex credential ID.
|
||||
//
|
||||
// @Summary Rename a passkey
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Credential ID"
|
||||
// @Param body body object{name=string} true "New name"
|
||||
// @Success 204
|
||||
// @Router /me/passkeys/{id} [patch]
|
||||
func renamePasskey(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||||
return
|
||||
}
|
||||
err := services.RenamePasskey(auth.InstanceID(c), auth.UserID(c), c.Param("id"), body.Name)
|
||||
if errors.Is(err, services.ErrNoPasskey) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Delete a passkey
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Param id path string true "Credential ID"
|
||||
// @Success 204
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/passkeys/{id} [delete]
|
||||
func deletePasskey(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorWebAuthn); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
|
||||
return
|
||||
}
|
||||
err := services.DeletePasskey(instanceID, userID, c.Param("id"))
|
||||
if errors.Is(err, services.ErrNoPasskey) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=webauthn")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// resetUserMFA lets an owner or admin clear somebody else's factors.
|
||||
//
|
||||
// @Summary Reset another member's MFA
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204
|
||||
// @Failure 403 {object} object{error=string}
|
||||
// @Router /org/users/{id}/mfa [delete]
|
||||
func resetUserMFA(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
target, err := services.GetUserInInstance(instanceID, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such member"})
|
||||
return
|
||||
}
|
||||
// An admin resetting an owner's MFA would be a promotion path: clear the
|
||||
// factor, phish the password, hold the instance.
|
||||
if auth.Role(c) != models.RoleOwner && target.Role == models.RoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can reset an owner's MFA"})
|
||||
return
|
||||
}
|
||||
if err := services.ClearMFA(instanceID, target.UserID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.reset", actorFromCtx(c), "", "", "target="+target.Email)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// stepUp re-authenticates the current session.
|
||||
//
|
||||
// @Summary Re-authenticate before a sensitive action
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{totp=string,recovery=string,password=string} true "One factor"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/step-up [post]
|
||||
func stepUp(c *gin.Context) {
|
||||
sess := auth.GetSessionFromContext(c)
|
||||
var body struct {
|
||||
TOTP string `json:"totp"`
|
||||
Recovery string `json:"recovery"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case body.TOTP != "":
|
||||
err = services.VerifyTOTPCode(sess.InstanceID, sess.UserID, body.TOTP)
|
||||
case body.Recovery != "":
|
||||
err = services.UseRecoveryCode(sess.InstanceID, sess.UserID, body.Recovery)
|
||||
case body.Password != "":
|
||||
// Password is offered only to a user with no MFA at all; accepting it
|
||||
// from an enrolled user would demote step-up to what they already did.
|
||||
has, herr := services.HasMFA(sess.InstanceID, sess.UserID)
|
||||
if herr != nil || has {
|
||||
err = services.ErrBadCode
|
||||
} else {
|
||||
u, uerr := services.GetUserInInstance(sess.InstanceID, sess.UserID)
|
||||
if uerr != nil || !services.VerifyPassword(u, body.Password) {
|
||||
err = services.ErrBadCode
|
||||
}
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
services.LogEvent(sess.InstanceID, "step_up.failed", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that did not verify", "code": "invalid_code"})
|
||||
return
|
||||
}
|
||||
if err := auth.TouchStepUpFromRequest(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "step_up.ok", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -21,6 +22,8 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/monitors/:id/incidents", getMonitorIncidents)
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
g.GET("/monitors/:id/samples", getMonitorSamples)
|
||||
g.GET("/monitors/:id/servers", getMonitorServers)
|
||||
g.POST("/monitors/:id/rotate-token", rotateHeartbeatToken)
|
||||
}
|
||||
|
||||
// listMonitors godoc
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// enrolTicketStillValid refuses an enrol-only ticket once the user has any
|
||||
// factor. Otherwise someone holding only the password who started sign-in
|
||||
// before the real user enrolled could, within the ticket's lifetime, add
|
||||
// their own passkey and replace the user's recovery codes. Answered as an
|
||||
// expired ticket, the single indistinguishable error, and the ticket is
|
||||
// destroyed. A lookup failure is treated the same way: fail closed.
|
||||
//
|
||||
// It runs before the factor is written, not in finishEnrolment: by then the
|
||||
// factor being enrolled already counts, so HasMFA would always be true.
|
||||
func enrolTicketStillValid(c *gin.Context, t *Ticket, ticketID string) bool {
|
||||
has, err := services.HasMFA(t.InstanceID, t.UserID)
|
||||
if err != nil || has {
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
abortTicketExpired(c)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
|
||||
// from, before they hold a session. Only an enrol-only ticket reaches it.
|
||||
//
|
||||
// @Summary Start forced TOTP enrolment during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{secret=string,otpauth_uri=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/totp/setup [post]
|
||||
func HandleEnrolTOTPSetup(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
inst, err := services.GetInstance(t.InstanceID)
|
||||
issuer := "Vantage"
|
||||
if err == nil && inst != nil && inst.Name != "" {
|
||||
issuer = inst.Name
|
||||
}
|
||||
secret, uri, err := services.StartTOTPSetup(t.InstanceID, t.UserID, issuer, t.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
|
||||
}
|
||||
|
||||
// HandleEnrolTOTPConfirm finishes forced enrolment and signs the user in.
|
||||
//
|
||||
// @Summary Confirm forced TOTP enrolment and sign in
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Six-digit code"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/totp/confirm [post]
|
||||
func HandleEnrolTOTPConfirm(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
|
||||
return
|
||||
}
|
||||
if err := services.ConfirmTOTP(t.InstanceID, t.UserID, body.Code); err != nil {
|
||||
left, ferr := FailTicket(c.Request.Context(), ticketID)
|
||||
if ferr != nil || left == 0 {
|
||||
abortTicketExpired(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
|
||||
})
|
||||
return
|
||||
}
|
||||
finishEnrolment(c, t, ticketID, services.FactorTOTP)
|
||||
}
|
||||
|
||||
// finishEnrolment issues recovery codes, mints the session and audits, so the
|
||||
// TOTP and passkey enrolment paths cannot drift apart.
|
||||
func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
|
||||
codes, err := services.IssueRecoveryCodes(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue recovery codes"})
|
||||
return
|
||||
}
|
||||
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyBegin starts forced passkey enrolment during sign-in.
|
||||
// Identical to HandleRegisterPasskeyBegin except the user comes from the
|
||||
// enrol-only ticket rather than a session, since none exists yet.
|
||||
//
|
||||
// @Summary Begin forced passkey enrolment during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/begin [post]
|
||||
func HandleEnrolPasskeyBegin(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: t.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyFinish stores the new credential and finishes forced
|
||||
// enrolment, minting the session that HandleEnrolTOTPConfirm also produces.
|
||||
//
|
||||
// @Summary Complete forced passkey enrolment and sign in
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/finish [post]
|
||||
func HandleEnrolPasskeyFinish(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok || !enrolTicketStillValid(c, t, ticketID) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that enrolment expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "enrolment registration", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, tr := range parsed.Response.Transports {
|
||||
transports = append(transports, string(tr))
|
||||
}
|
||||
if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
finishEnrolment(c, t, ticketID, services.FactorWebAuthn)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// loginDecision is the branch a verified password takes.
|
||||
func loginDecision(hasMFA, requireMFA bool) string {
|
||||
switch {
|
||||
case hasMFA:
|
||||
return "verify"
|
||||
case requireMFA:
|
||||
return "enrol"
|
||||
default:
|
||||
return "session"
|
||||
}
|
||||
}
|
||||
|
||||
// newSession builds the session a sign-in produces. Sign-in counts as a
|
||||
// step-up, so StepUpAt is always the moment of sign-in.
|
||||
func newSession(u *models.User, amr []string, now time.Time) *Session {
|
||||
return &Session{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
AMR: amr, StepUpAt: &now,
|
||||
}
|
||||
}
|
||||
|
||||
// oidcSession is the session an SSO callback mints. AMR "oidc" is what exempts
|
||||
// it from step-up: the IdP owns authentication policy, and these users have
|
||||
// no password or local factor to step up with.
|
||||
func oidcSession(u *models.User, name string, now time.Time) *Session {
|
||||
s := newSession(u, []string{"oidc"}, now)
|
||||
s.Name = name
|
||||
return s
|
||||
}
|
||||
|
||||
// mintSession is the single place a session is created from a user, so every
|
||||
// path records amr and step-up freshness the same way.
|
||||
func mintSession(c *gin.Context, u *models.User, amr []string) error {
|
||||
return saveSignIn(c, newSession(u, amr, time.Now()))
|
||||
}
|
||||
|
||||
// saveSignIn persists a sign-in session and sets its cookie. The pending-MFA
|
||||
// cookie is cleared because a completed sign-in supersedes any ticket.
|
||||
func saveSignIn(c *gin.Context, sess *Session) error {
|
||||
sessionID, err := SaveSession(c.Request.Context(), sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = services.TouchLastLogin(sess.UserID)
|
||||
ClearPendingCookie(c)
|
||||
SetSessionCookie(c, sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleMFATOTP completes a sign-in with a TOTP code.
|
||||
//
|
||||
// @Summary Complete sign-in with a TOTP code
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Six-digit code"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/totp [post]
|
||||
func HandleMFATOTP(c *gin.Context) {
|
||||
handleMFAVerify(c, services.FactorTOTP)
|
||||
}
|
||||
|
||||
// HandleMFARecovery completes a sign-in with a recovery code.
|
||||
//
|
||||
// @Summary Complete sign-in with a recovery code
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{code=string} true "Recovery code"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/recovery [post]
|
||||
func HandleMFARecovery(c *gin.Context) {
|
||||
handleMFAVerify(c, services.FactorRecovery)
|
||||
}
|
||||
|
||||
func handleMFAVerify(c *gin.Context, factor string) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch factor {
|
||||
case services.FactorTOTP:
|
||||
err = services.VerifyTOTPCode(t.InstanceID, t.UserID, body.Code)
|
||||
case services.FactorRecovery:
|
||||
err = services.UseRecoveryCode(t.InstanceID, t.UserID, body.Code)
|
||||
}
|
||||
if err != nil {
|
||||
left, ferr := FailTicket(c.Request.Context(), ticketID)
|
||||
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor="+factor)
|
||||
if ferr != nil || left == 0 {
|
||||
abortTicketExpired(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
if factor == services.FactorRecovery {
|
||||
services.LogEvent(t.InstanceID, "mfa.recovery_used", u.Email, "", "", "")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
// The whole point of the feature is in this table: a user with MFA is never
|
||||
// handed a session by a password alone, and require_mfa turns "no factor" into
|
||||
// forced enrolment rather than a free pass.
|
||||
func TestLoginDecision(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hasMFA bool
|
||||
requireMFA bool
|
||||
want string
|
||||
}{
|
||||
{"no mfa, not required", false, false, "session"},
|
||||
{"no mfa, required", false, true, "enrol"},
|
||||
{"has mfa, not required", true, false, "verify"},
|
||||
{"has mfa, required", true, true, "verify"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := loginDecision(tc.hasMFA, tc.requireMFA); got != tc.want {
|
||||
t.Fatalf("loginDecision(%v,%v) = %q, want %q", tc.hasMFA, tc.requireMFA, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
ticketTTL = 5 * time.Minute
|
||||
maxTicketAttempts = 5
|
||||
ticketPrefix = "km:mfa:"
|
||||
pendingCookieName = "km_mfa_pending"
|
||||
)
|
||||
|
||||
// ErrTicketExpired covers every unusable ticket - missing, timed out, or
|
||||
// destroyed by too many wrong codes. They are one message on purpose: which of
|
||||
// the three it was tells an attacker whether the password was right.
|
||||
var ErrTicketExpired = errors.New("this sign-in attempt has expired; start again")
|
||||
|
||||
// Ticket is a password that verified but has not yet become a session. It is
|
||||
// deliberately NOT a Session: nothing half-authenticated may reach a route
|
||||
// under Middleware, and the way to guarantee that is for it never to be the
|
||||
// type those routes read.
|
||||
type Ticket struct {
|
||||
UserID string `json:"user_id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Email string `json:"email"`
|
||||
Methods []string `json:"methods"`
|
||||
EnrolOnly bool `json:"enrol_only"`
|
||||
}
|
||||
|
||||
type ticketScope int
|
||||
|
||||
const (
|
||||
scopeVerify ticketScope = iota
|
||||
scopeEnrol
|
||||
)
|
||||
|
||||
func (t *Ticket) allows(s ticketScope) bool {
|
||||
if t.EnrolOnly {
|
||||
return s == scopeEnrol
|
||||
}
|
||||
return s == scopeVerify
|
||||
}
|
||||
|
||||
func attemptsLeft(attempts int) int {
|
||||
if attempts >= maxTicketAttempts {
|
||||
return 0
|
||||
}
|
||||
return maxTicketAttempts - attempts
|
||||
}
|
||||
|
||||
// attemptsKey is the counter backing a ticket's brute-force cap. It is a
|
||||
// separate key rather than a field on the ticket JSON so INCR can make the
|
||||
// count atomic: two requests racing on the same ticket must each cost one
|
||||
// attempt, not both read the same count and both write count+1.
|
||||
func attemptsKey(id string) string {
|
||||
return ticketPrefix + id + ":attempts"
|
||||
}
|
||||
|
||||
func CreateTicket(ctx context.Context, t *Ticket) (string, error) {
|
||||
id, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, ticketPrefix+id, data, ticketTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func LoadTicket(ctx context.Context, id string) (*Ticket, error) {
|
||||
data, err := rdb.Get(ctx, ticketPrefix+id).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t Ticket
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// FailTicket records a wrong code and returns how many attempts remain. The
|
||||
// count is kept in its own INCR-backed key rather than the ticket JSON: a
|
||||
// read-modify-write on the JSON lets requests racing on the same ticket all
|
||||
// read the same count and all write count+1, which bypasses the cap instead
|
||||
// of costing one attempt each. At zero the ticket is destroyed rather than
|
||||
// left to time out.
|
||||
func FailTicket(ctx context.Context, id string) (int, error) {
|
||||
// Confirms the ticket exists first, so a missing/expired/destroyed ticket
|
||||
// still answers with the one indistinguishable ErrTicketExpired rather
|
||||
// than incrementing a counter for an id nobody holds.
|
||||
if _, err := LoadTicket(ctx, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
key := attemptsKey(id)
|
||||
n, err := rdb.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n == 1 {
|
||||
// Only the creator of the counter sets its expiry, so a later
|
||||
// increment never extends it past the ticket's own window.
|
||||
if err := rdb.Expire(ctx, key, ticketTTL).Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
left := attemptsLeft(int(n))
|
||||
if left == 0 {
|
||||
_ = DeleteTicket(ctx, id)
|
||||
return 0, nil
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func DeleteTicket(ctx context.Context, id string) error {
|
||||
return rdb.Del(ctx, ticketPrefix+id, attemptsKey(id)).Err()
|
||||
}
|
||||
|
||||
func SetPendingCookie(c *gin.Context, id string) {
|
||||
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: pendingCookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(ticketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearPendingCookie(c *gin.Context) {
|
||||
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: pendingCookieName, Value: "", Path: "/",
|
||||
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// ticketFromRequest resolves the pending ticket and enforces its scope. It
|
||||
// writes the response and returns false when the ticket is unusable.
|
||||
func ticketFromRequest(c *gin.Context, scope ticketScope) (*Ticket, string, bool) {
|
||||
cookie, err := c.Request.Cookie(pendingCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
abortTicketExpired(c)
|
||||
return nil, "", false
|
||||
}
|
||||
t, err := LoadTicket(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
abortTicketExpired(c)
|
||||
return nil, "", false
|
||||
}
|
||||
if !t.allows(scope) {
|
||||
abortTicketExpired(c)
|
||||
return nil, "", false
|
||||
}
|
||||
return t, cookie.Value, true
|
||||
}
|
||||
|
||||
func abortTicketExpired(c *gin.Context) {
|
||||
ClearPendingCookie(c)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": ErrTicketExpired.Error(), "code": "mfa_ticket_expired",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
// The ticket's attempt cap is the only per-ticket brute-force guard: five
|
||||
// wrong codes must destroy it rather than let an attacker keep guessing
|
||||
// against a single stolen password.
|
||||
func TestAttemptsLeftCountsDownAndHitsZero(t *testing.T) {
|
||||
cases := []struct {
|
||||
attempts int
|
||||
want int
|
||||
}{
|
||||
{0, 5}, {1, 4}, {4, 1}, {5, 0}, {9, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := attemptsLeft(tc.attempts); got != tc.want {
|
||||
t.Errorf("attemptsLeft(%d) = %d, want %d", tc.attempts, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attemptsKey must derive deterministically from the ticket id and stay
|
||||
// distinct from the ticket's own key, since FailTicket relies on INCR against
|
||||
// it being the sole writer of the attempt count.
|
||||
func TestAttemptsKeyIsDerivedFromTicketID(t *testing.T) {
|
||||
got := attemptsKey("abc123")
|
||||
want := "km:mfa:abc123:attempts"
|
||||
if got != want {
|
||||
t.Errorf("attemptsKey(%q) = %q, want %q", "abc123", got, want)
|
||||
}
|
||||
if attemptsKey("abc123") == ticketPrefix+"abc123" {
|
||||
t.Error("attempts key must not collide with the ticket's own key")
|
||||
}
|
||||
}
|
||||
|
||||
// An enrol-only ticket exists because the instance requires MFA the user does
|
||||
// not have. It must not satisfy a verification endpoint, and a verification
|
||||
// ticket must not reach the enrolment endpoints - each would skip the other's
|
||||
// purpose.
|
||||
func TestTicketScopeIsEnforcedInBothDirections(t *testing.T) {
|
||||
verify := &Ticket{Methods: []string{"totp"}}
|
||||
enrol := &Ticket{EnrolOnly: true}
|
||||
|
||||
if !verify.allows(scopeVerify) || verify.allows(scopeEnrol) {
|
||||
t.Error("a verification ticket must allow only verification")
|
||||
}
|
||||
if !enrol.allows(scopeEnrol) || enrol.allows(scopeVerify) {
|
||||
t.Error("an enrolment ticket must allow only enrolment")
|
||||
}
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// StepUpWindow is how long one re-authentication covers. Ten minutes is long
|
||||
// enough to open several consoles in a row and short enough that a walked-away
|
||||
// laptop is not a fleet-wide credential.
|
||||
const StepUpWindow = 10 * time.Minute
|
||||
|
||||
func stepUpFresh(sess *Session, now time.Time) bool {
|
||||
if sess == nil {
|
||||
return false
|
||||
}
|
||||
// An API token authenticates per request and has no human to prompt.
|
||||
if sess.TokenID != "" {
|
||||
return true
|
||||
}
|
||||
for _, a := range sess.AMR {
|
||||
if a == "oidc" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return sess.StepUpAt != nil && now.Sub(*sess.StepUpAt) < StepUpWindow
|
||||
}
|
||||
|
||||
// RequireStepUp guards the actions that hand out credentials rather than
|
||||
// describe them: secret reveal, private key download, console connect.
|
||||
//
|
||||
// It answers a machine-readable code rather than a bare 403 so web/ can open
|
||||
// the re-authentication modal and retry the original request.
|
||||
func RequireStepUp() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
if stepUpFresh(sess, time.Now()) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if sess == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
methods, err := services.MFAMethods(sess.InstanceID, sess.UserID)
|
||||
if err != nil || len(methods) == 0 {
|
||||
methods = []string{services.FactorPassword}
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "re-authentication required",
|
||||
"code": "step_up_required",
|
||||
"methods": methods,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TouchStepUpFromRequest records a fresh step-up against the session the
|
||||
// request arrived on. It lives in auth because the cookie name and the Redis
|
||||
// key are this package's business.
|
||||
func TouchStepUpFromRequest(c *gin.Context) error {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := GetSessionFromContext(c)
|
||||
return TouchStepUp(c.Request.Context(), cookie.Value, sess)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestStepUpFresh(t *testing.T) {
|
||||
now := time.Now()
|
||||
ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sess *Session
|
||||
want bool
|
||||
}{
|
||||
{"just signed in", &Session{StepUpAt: ago(time.Minute)}, true},
|
||||
{"nine minutes ago", &Session{StepUpAt: ago(9 * time.Minute)}, true},
|
||||
{"eleven minutes ago", &Session{StepUpAt: ago(11 * time.Minute)}, false},
|
||||
{"never", &Session{}, false},
|
||||
// An API token has no human to prompt; the spec exempts it and records
|
||||
// the bypass as a known limitation.
|
||||
{"api token", &Session{TokenID: "tok_1"}, true},
|
||||
// An OIDC session's IdP owns authentication policy.
|
||||
{"oidc session", &Session{AMR: []string{"oidc"}, StepUpAt: ago(time.Hour)}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := stepUpFresh(tc.sess, now); got != tc.want {
|
||||
t.Fatalf("stepUpFresh = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A missing session must refuse rather than panic dereferencing sess.
|
||||
// auth.Middleware guarantees a non-nil session on every route today, but
|
||||
// RequireStepUp must not rely on that holding forever.
|
||||
func TestRequireStepUpNilSessionRefusesWithoutPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/console/connect", nil)
|
||||
|
||||
RequireStepUp()(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionIsExemptFromStepUp(t *testing.T) {
|
||||
now := time.Now()
|
||||
u := &models.User{UserID: "u1", InstanceID: "i1", Role: "member", Email: "a@example.com"}
|
||||
s := oidcSession(u, "Ann", now.Add(-24*time.Hour))
|
||||
if !stepUpFresh(s, now) {
|
||||
t.Fatal("an OIDC session must be exempt from step-up however old its sign-in")
|
||||
}
|
||||
if s.StepUpAt == nil || s.Name != "Ann" || s.UserID != "u1" || s.InstanceID != "i1" {
|
||||
t.Fatalf("oidc session missing fields: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInSessionIsFreshAtSignIn(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := newSession(&models.User{UserID: "u1"}, []string{"pwd"}, now)
|
||||
if !stepUpFresh(s, now) {
|
||||
t.Fatal("a session is fresh at the moment of sign-in")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
ceremonyPrefix = "km:wa:"
|
||||
ceremonyTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// rpConfig derives the relying party from the request. The RP ID is the host
|
||||
// without its port - WebAuthn forbids a port there - while the origin keeps it.
|
||||
//
|
||||
// This is why the reverse proxy must preserve Host: a proxy rewriting it makes
|
||||
// every passkey on the instance fail to verify, with no error that says so.
|
||||
func rpConfig(c *gin.Context) (string, string) {
|
||||
host := c.Request.Host
|
||||
rpID := host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
rpID = h
|
||||
}
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
// Only development is ever plain HTTP; WebAuthn permits it on localhost.
|
||||
scheme = "http"
|
||||
}
|
||||
return rpID, scheme + "://" + host
|
||||
}
|
||||
|
||||
// logWebAuthnFailure records why a ceremony was refused. The response stays
|
||||
// deliberately vague, so without this the only evidence of a misconfigured
|
||||
// relying party - most often a proxy that terminates TLS without passing
|
||||
// X-Forwarded-Proto: https - is a user reporting that their passkey "could not
|
||||
// be verified". Nothing logged here is secret: RP ID, origins, and the
|
||||
// library's error, whose DevInfo names expected and received values.
|
||||
func logWebAuthnFailure(c *gin.Context, stage string, err error) {
|
||||
rpID, origin := rpConfig(c)
|
||||
detail := err.Error()
|
||||
var perr *protocol.Error
|
||||
if errors.As(err, &perr) && perr.DevInfo != "" {
|
||||
detail += " (" + perr.DevInfo + ")"
|
||||
}
|
||||
log.Printf("webauthn: %s refused: rp_id=%q expected_origin=%q request_origin=%q x_forwarded_proto=%q: %s",
|
||||
stage, rpID, origin, c.GetHeader("Origin"), c.GetHeader("X-Forwarded-Proto"), detail)
|
||||
}
|
||||
|
||||
func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
|
||||
rpID, origin := rpConfig(c)
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: "Vantage",
|
||||
RPID: rpID,
|
||||
RPOrigins: []string{origin},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
// Preferred, not required, and not checked on the result. Requiring
|
||||
// it made password managers such as NordPass ask for their master
|
||||
// password on every use, which other sites avoid by preferring it.
|
||||
// The cost is deliberate: an authenticator that skips verification
|
||||
// makes a passkey possession-only, so passwordless sign-in and
|
||||
// step-up then rest on the device or vault being unlocked.
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// waUser adapts our records to the library's interface. The handle is random
|
||||
// and per-user: a resident credential hands its user handle to any origin that
|
||||
// asks, so the user ID must not be it.
|
||||
type waUser struct {
|
||||
handle []byte
|
||||
name string
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u waUser) WebAuthnID() []byte { return u.handle }
|
||||
func (u waUser) WebAuthnName() string { return u.name }
|
||||
func (u waUser) WebAuthnDisplayName() string { return u.name }
|
||||
func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
|
||||
func toLibCredential(c models.WebAuthnCredential) webauthn.Credential {
|
||||
return webauthn.Credential{
|
||||
ID: c.CredentialID,
|
||||
PublicKey: c.PublicKey,
|
||||
AttestationType: "none",
|
||||
Flags: webauthn.CredentialFlags{
|
||||
BackupEligible: c.BackupEligible != nil && *c.BackupEligible,
|
||||
BackupState: c.BackupState,
|
||||
},
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: c.AAGUID,
|
||||
SignCount: c.SignCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// backupEligibleFor is the eligibility to validate an assertion against.
|
||||
//
|
||||
// A recorded value always stands, so the library still refuses a credential
|
||||
// whose eligibility changed. A row registered before the flag was stored has no
|
||||
// baseline at all, and comparing against a zero value refused every synced
|
||||
// passkey; for those the flag in the signed authenticator data is adopted, and
|
||||
// TouchPasskey records it after the assertion verifies.
|
||||
func backupEligibleFor(stored *bool, observed bool) bool {
|
||||
if stored != nil {
|
||||
return *stored
|
||||
}
|
||||
return observed
|
||||
}
|
||||
|
||||
func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) {
|
||||
id, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blob, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so
|
||||
// it is deleted as it is read.
|
||||
func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) {
|
||||
blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data webauthn.SessionData
|
||||
if err := json.Unmarshal(blob, &data); err != nil {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in.
|
||||
//
|
||||
// @Summary Begin passkey verification during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/webauthn/begin [post]
|
||||
func HandleMFAWebAuthnBegin(c *gin.Context) {
|
||||
t, _, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
creds, err := services.ListPasskeys(t.InstanceID, t.UserID)
|
||||
if err != nil || len(creds) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(creds))
|
||||
for _, cr := range creds {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib},
|
||||
webauthn.WithUserVerification(protocol.VerificationPreferred))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleMFAWebAuthnFinish verifies the assertion and signs the user in.
|
||||
//
|
||||
// @Summary Complete sign-in with a passkey
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/webauthn/finish [post]
|
||||
func HandleMFAWebAuthnFinish(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "second-factor assertion", err)
|
||||
left, ferr := FailTicket(c.Request.Context(), ticketID)
|
||||
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn")
|
||||
if ferr != nil || left == 0 {
|
||||
abortTicketExpired(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
|
||||
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyBegin starts registration for the signed-in user.
|
||||
//
|
||||
// @Summary Begin passkey registration
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Router /me/passkeys/begin [post]
|
||||
func HandleRegisterPasskeyBegin(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: sess.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyFinish stores the new credential.
|
||||
//
|
||||
// @Summary Complete passkey registration
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Router /me/passkeys/finish [post]
|
||||
func HandleRegisterPasskeyFinish(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "registration", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, t := range parsed.Response.Transports {
|
||||
transports = append(transports, string(t))
|
||||
}
|
||||
if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn")
|
||||
|
||||
// A first factor earns recovery codes; later ones do not reissue them.
|
||||
m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID)
|
||||
if services.RecoveryCodesRemaining(m) == 0 {
|
||||
codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleStepUpWebAuthnBegin offers an assertion challenge for step-up
|
||||
// re-authentication: the signed-in user proving it is still them before a
|
||||
// guarded action, rather than a pending ticket proving it during sign-in.
|
||||
//
|
||||
// @Summary Begin passkey step-up
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 400 {object} object{error=string}
|
||||
// @Router /me/step-up/webauthn/begin [post]
|
||||
func HandleStepUpWebAuthnBegin(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
creds, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
|
||||
if err != nil || len(creds) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(creds))
|
||||
for _, cr := range creds {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib},
|
||||
webauthn.WithUserVerification(protocol.VerificationPreferred))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleStepUpWebAuthnFinish verifies the assertion and records a fresh
|
||||
// step-up for the current session. Unlike HandleMFAWebAuthnFinish this does
|
||||
// not mint a session: the caller is already signed in, this only proves they
|
||||
// still hold the passkey. finishAssertion is called with the session's own
|
||||
// user ID, so a credential belonging to somebody else is refused rather than
|
||||
// stepping up this session on their behalf.
|
||||
//
|
||||
// @Summary Complete passkey step-up
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/step-up/webauthn/finish [post]
|
||||
func HandleStepUpWebAuthnFinish(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
cred, err := finishAssertion(c, sess.InstanceID, sess.UserID, sess.Email)
|
||||
if err != nil {
|
||||
logWebAuthnFailure(c, "step-up assertion", err)
|
||||
services.LogEvent(sess.InstanceID, "step_up.failed", sess.Email, "", "", "factor=webauthn")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"})
|
||||
return
|
||||
}
|
||||
_ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState)
|
||||
if err := TouchStepUpFromRequest(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "step_up.ok", sess.Email, "", "", "factor=webauthn")
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
|
||||
// step-up, so the verification rules (backup eligibility, clone detection,
|
||||
// instance scope) exist once.
|
||||
func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) {
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
return nil, errors.New("assertion required")
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID != "" && stored.UserID != userID {
|
||||
return nil, errors.New("credential belongs to another user")
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(instanceID, stored.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libCred := toLibCredential(*stored)
|
||||
libCred.Flags.BackupEligible = backupEligibleFor(stored.BackupEligible,
|
||||
parsed.Response.AuthenticatorData.Flags.HasBackupEligible())
|
||||
user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{libCred}}
|
||||
var cred *webauthn.Credential
|
||||
if userID == "" {
|
||||
// Discoverable (passwordless) ceremony: the session carries no user,
|
||||
// so the library asks us to resolve the authenticator's user handle.
|
||||
// The only acceptable answer is the credential's own stored owner in
|
||||
// this instance; any other handle is refused.
|
||||
cred, err = w.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
|
||||
if !discoverableHandleMatches(handle, userHandle) {
|
||||
return nil, errors.New("user handle does not match the credential owner")
|
||||
}
|
||||
return user, nil
|
||||
}, *sessionData, parsed)
|
||||
} else {
|
||||
cred, err = w.ValidateLogin(user, *sessionData, parsed)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A counter that fails to advance is the library's clone signal. Zero on
|
||||
// both sides means the authenticator does not keep one, which is normal.
|
||||
if cred.Authenticator.CloneWarning {
|
||||
return nil, errors.New("authenticator may be cloned")
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// discoverableHandleMatches reports whether the user handle an authenticator
|
||||
// returned belongs to the credential's stored owner. Empty never matches.
|
||||
func discoverableHandleMatches(ownerHandle, userHandle []byte) bool {
|
||||
return len(ownerHandle) > 0 && subtle.ConstantTimeCompare(ownerHandle, userHandle) == 1
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it
|
||||
// silently makes every existing passkey unusable - so the port-stripping and
|
||||
// scheme rules are pinned here.
|
||||
func TestRPConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, host, proto string
|
||||
wantID, wantOrig string
|
||||
}{
|
||||
{"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"},
|
||||
{"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"},
|
||||
{"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil)
|
||||
c.Request.Host = tc.host
|
||||
if tc.proto != "" {
|
||||
c.Request.Header.Set("X-Forwarded-Proto", tc.proto)
|
||||
}
|
||||
id, origin := rpConfig(c)
|
||||
if id != tc.wantID || origin != tc.wantOrig {
|
||||
t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverableHandleMatches(t *testing.T) {
|
||||
owner := bytes.Repeat([]byte{7}, 64)
|
||||
if !discoverableHandleMatches(owner, bytes.Repeat([]byte{7}, 64)) {
|
||||
t.Fatal("the owner's own handle must match")
|
||||
}
|
||||
other := bytes.Repeat([]byte{7}, 64)
|
||||
other[63] = 8
|
||||
if discoverableHandleMatches(owner, other) {
|
||||
t.Fatal("another user's handle must not match")
|
||||
}
|
||||
if discoverableHandleMatches(nil, nil) || discoverableHandleMatches(owner, nil) {
|
||||
t.Fatal("an empty handle must never match")
|
||||
}
|
||||
}
|
||||
|
||||
// Synced passkeys (iCloud Keychain, Google Password Manager, 1Password) report
|
||||
// backup eligibility, and go-webauthn refuses any assertion whose flag differs
|
||||
// from the stored credential's. Not storing it made every synced passkey fail
|
||||
// with "Backup Eligible flag inconsistency".
|
||||
func TestLibCredentialCarriesStoredBackupFlags(t *testing.T) {
|
||||
yes := true
|
||||
lc := toLibCredential(models.WebAuthnCredential{BackupEligible: &yes, BackupState: true})
|
||||
if !lc.Flags.BackupEligible || !lc.Flags.BackupState {
|
||||
t.Fatalf("flags not carried: %+v", lc.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupEligibleFor(t *testing.T) {
|
||||
yes, no := true, false
|
||||
cases := []struct {
|
||||
name string
|
||||
stored *bool
|
||||
observed bool
|
||||
want bool
|
||||
}{
|
||||
// Registered before the flag was recorded: no baseline, adopt it.
|
||||
{"legacy row, synced passkey", nil, true, true},
|
||||
{"legacy row, hardware key", nil, false, false},
|
||||
// Recorded: the stored value stands, so the library still refuses a
|
||||
// credential whose eligibility genuinely changed.
|
||||
{"recorded eligible", &yes, true, true},
|
||||
{"recorded not eligible, now claims eligible", &no, true, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := backupEligibleFor(tc.stored, tc.observed); got != tc.want {
|
||||
t.Fatalf("backupEligibleFor = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ?? ""}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+439
-119
File diff suppressed because it is too large
Load Diff
+12
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user