diff --git a/api.md b/api.md index 31d7bd4..5db3b64 100644 --- a/api.md +++ b/api.md @@ -34,21 +34,24 @@ are called out explicitly below. | Method | Path | Purpose | |---|---|---| -| `POST` | `/api/keys` | Create a key. Bearer auth. Body: `{ memo, response_kind?, response_payload?, destinations?, expires_at?, dedupe_window_seconds?, monitor_mode?, monitor_window_seconds? }` | +| `POST` | `/api/keys` | Create a key. Bearer auth. Body: `{ memo, external_id?, response_kind?, response_payload?, destinations?, expires_at?, dedupe_window_seconds?, monitor_mode?, monitor_window_seconds? }`. Supply `external_id` to make creation idempotent — see [below](#idempotent-creation). The one route enrollment-scoped keys may call. | | `GET` | `/api/keys?limit=&cursor=` | List accessible keys. Bearer auth. Admin keys see all; non-admin keys see rows they created. | | `GET` | `/api/keys/:id` | Get one accessible key. Bearer auth. | | `PATCH` | `/api/keys/:id` | Update memo, response, expiry, dedupe, monitor mode/window, `disabled`, or replace destinations. Bearer auth. | | `DELETE` | `/api/keys/:id` | Hard-delete a key and cascading hits/notifications. Bearer auth. | | `GET` | `/api/keys/:id/hits?limit=&cursor=` | Paginated hit log for one key. Bearer auth. | | `GET` | `/api/hits/recent?since=&cursor=&key_id=&limit=` | Recent hit feed across accessible keys, used by CLI watch mode. Bearer auth. | -| `GET` | `/api/keys/:id/download?format=` | Download a generated artifact. Bearer or session auth. Formats: `docx`, `xlsx`, `pptx`, `pdf`, `folder`, `nfc-label`, `apple-wallet`, `svg`, `html`, `md`, `eml`, `ics`, `vcf`. | +| `GET` | `/api/keys/:id/download?format=` | Download a generated artifact. Bearer or session auth. Formats: `docx`, `xlsx`, `pptx`, `pdf`, `folder`, `nfc-label`, `apple-wallet`, `svg`, `html`, `md`, `eml`, `ics`, `vcf`, `rtf`, `cookies`, `bookmarks`, `env`, `aws-credentials`, `netrc`, `kubeconfig`, `ovpn`, `rdp`. See [file keys](/file-keys). | +| `POST` | `/api/keys/bulk-download` | Zip one generated artifact per key. Bearer or session auth. Body: `{ ids, format }`, max 50 ids; returns a `.zip` with one artifact (or per-key folder) per key. Ids not visible to the caller are silently skipped, not rejected. | +| `POST` | `/api/keys/device-bundle` | Package an already-minted device suite into an installable zip. Bearer or session auth. Body: `{ device, os, vectors }`, max 20 vectors; returns the install zip, or a JSON file map with `?format=json`. Backs the dashboard's device page and `mantis device new --bundle`. | | `GET` | `/api/keys/:id/install?type=[&hostname=example.com][&format=json]` | Generated installer snippet for host, web, NFC, and IoT events. Bearer or session auth. | | `POST` | `/api/keys/:id/reset` | Reset a key's latched monitor state. Bearer or session auth. | | `POST` | `/api/keys/:id/destinations/:destinationId/signing-secret` | Reveal a webhook destination's plaintext HMAC signing secret. Bearer or session auth. Audited. | | `POST` | `/api/keys/:id/destinations/:destinationId/rotate-secret` | Rotate a webhook destination's HMAC signing secret and return the new secret once. Bearer or session auth. Audited. | -| `GET` | `/api/api-keys` | List API keys. Bearer auth. Hashes are never returned; non-admin keys see only themselves. | -| `POST` | `/api/api-keys` | Mint a new API key. Bearer auth. Body: `{ name, is_admin? }`; plaintext key returned once. Only admins can mint admin keys. | +| `GET` | `/api/api-keys` | List API keys. Bearer auth. Hashes are never returned; non-admin keys see only themselves. Each row includes its `scope`. | +| `POST` | `/api/api-keys` | Mint a new API key. Bearer auth. Body: `{ name, is_admin?, scope? }`; plaintext key returned once. Only admins can mint admin keys. `scope` is `full` (default) or `enroll` — see [key scope](#api-key-scope-full-vs-enroll). | | `DELETE` | `/api/api-keys/:id` | Revoke an API key. Bearer auth. Self-revoke is allowed; revoking others requires admin. | +| `GET` | `/api/device-profiles` | The device-profile / vector catalog used by `mantis device`. Bearer or session auth. | | `GET` | `/api/audit?limit=&cursor=&since=&event_type=&actor=` | Admin-only audit log. Bearer or session auth. | | `GET` `HEAD` | `/api/health` | **Public unless gated by your proxy.** Liveness + `SELECT 1` readiness. 200 = app and DB ok, 503 = DB failure. | | `GET` `POST` | `/api/cron/notifications?max=` | Notification retry and retention worker endpoint for serverless deployments. Requires `Authorization: Bearer $CRON_SECRET`; returns 401 if `CRON_SECRET` is unset. | @@ -77,6 +80,38 @@ Webhook destinations get an HMAC secret. Outbound raw-webhook deliveries include `.`. The plaintext secret is only shown on create, replace, explicit reveal, or rotate responses; normal listing returns a fingerprint. +## API key scope: full vs enroll + +Every API key carries a `scope`, orthogonal to `is_admin`: + +- **`full`** (default) — behaves as described throughout this page. Subject to the admin / non-admin visibility rules. +- **`enroll`** — create-only. An enroll key may call **only** `POST /api/keys`. Every other management route (list/read/update/delete keys — including the ones it created — plus `/api/hits/recent`, `/api/api-keys`, the audit log, and any session-reachable route) returns `403 forbidden`, and an enroll key cannot log in to the dashboard. `is_admin: true` together with `scope: "enroll"` is rejected at validation. + +Enroll keys are the intended credential for MDM / fleet provisioning: you embed one on every managed machine and accept that a curious user will extract it. An extracted enroll key cannot read hit history, alert routing or signing secrets, and cannot enumerate or list keys — but it is not inert, so size the blast radius before you embed one: + +- **It can confirm and retrieve any key whose `external_id` it guesses.** A `POST /api/keys` that collides with an existing `external_id` returns that key's trigger URL, memo, `public_id` and expiry (`"reused": true`, HTTP `200`) — see [Idempotent creation](#idempotent-creation) — regardless of which API key created it. `mantis device` derives `external_id`s deterministically as `mantis:device:::`, so an attacker who knows your naming convention can guess a machine's ids and read back that machine's canary URLs, which is exactly what lets an intruder route around the tripwires. Each such claim is recorded in the audit log as `key.claimed`. +- **It can supply `destinations` on creation**, and Mantis fires the activation ping synchronously — so the key can make your instance POST to an attacker-chosen HTTP(S) endpoint (private, loopback and metadata addresses are rejected unless `ALLOW_PRIVATE_WEBHOOKS=1`) or, if `SMTP_URL` is set, send it mail. + +See the Kandji recipe in the product repo's `deploy/kandji/`. + +## Idempotent creation + +`POST /api/keys` also accepts an optional `external_id` (1–128 chars, matching +`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) stored on a unique column. When supplied, a +repeat POST with the same `external_id` returns the **existing** key — +`"reused": true` with HTTP `200` instead of `201` — rather than minting a +duplicate. The other body fields (`memo`, `destinations`, …) apply only when the +row is actually created; a later claim never mutates what the key was first +configured with. Keys created without an `external_id` are unaffected (unique +constraint treats NULLs as distinct). + +This is the mechanism the fleet-enrollment flow relies on — one key per machine +serial, so re-running enrollment on a reimaged machine reuses its key instead of +littering the list. Enroll-scoped callers (and callers claiming another creator's +`external_id`) get a reduced response shape — trigger URL and identity only, no +alert routing or signing secrets. A claim that races a concurrent delete returns +`409 conflict`; retry. + ## Response kinds for the trigger endpoint | `response_kind` | Payload | Result | diff --git a/architecture.md b/architecture.md index 54ddd53..00baf13 100644 --- a/architecture.md +++ b/architecture.md @@ -17,6 +17,7 @@ src/ # Next.js server: dashboard, API, public triggers new/ # create form [id]/ # detail, hits, downloads, installers, destinations settings/wallet/ # admin Apple Wallet / PassKit config + settings/notifications/ # admin instance-wide (global) notify destinations api/ keys/... # authenticated key CRUD, hits, downloads, installers api-keys/... # authenticated API-key listing, minting, revocation @@ -48,8 +49,7 @@ src/ # Next.js server: dashboard, API, public triggers safe-body.ts # bounded request body readers for JSON/text routes public-only-hosts.ts # split public/dashboard host routing retention.ts # optional row-level cleanup - proxy.ts # Host/path guard for public-only deployments - middleware.ts # wires proxy.ts host-split gate into the request path + proxy.ts # Next request-path entrypoint (formerly middleware.ts, renamed in Next 16); host/path guard for public-only deployments instrumentation.ts # boot hook: migrations, bootstrap key, notify worker cli/ # @mantis/cli terminal client diff --git a/cli.md b/cli.md index 2778100..384acb1 100644 --- a/cli.md +++ b/cli.md @@ -1,12 +1,12 @@ --- title: "CLI reference" -description: "Every mantis CLI command and flag, verified against the v0.1.6 source." +description: "Every mantis CLI command and flag, verified against the v0.2.0 source." sidebarTitle: "CLI" --- The `mantis` CLI is the primary way to work with a Mantis server (and with the stateless [edge worker](/edge-deployment)). This page documents **every** command -and flag in the CLI, transcribed from the v0.1.6 source. For the conceptual +and flag in the CLI, transcribed from the v0.2.0 source. For the conceptual map — diagrams, mental model, common workflows — see `cli/COMMAND_MAP.md` in the app repo. @@ -14,7 +14,7 @@ New to the CLI? Run `mantis init` for guided, interactive setup, or `mantis` with no arguments for a context-aware welcome screen. -This reference covers CLI **v0.1.6**. Check yours with `mantis --version`, and +This reference covers CLI **v0.2.0**. Check yours with `mantis --version`, and run `mantis doctor` after upgrading the server to confirm compatibility. @@ -167,7 +167,7 @@ shot. `[memo]` is a human-readable label. | Flag | What it does | |---|---| -| `-N, --notify ` | Destination as `:`. Channels: `webhook`, `email`, `slack`, `discord`, `teams`. Repeatable. | +| `-N, --notify ` | Destination as `:`. Channels: `webhook`, `email`, `slack`, `discord`, `teams`, `home_assistant`. Repeatable. | | `-w, --notify-webhook ` | Shortcut for `--notify webhook:`. Repeatable. | | `-e, --notify-email ` | Shortcut for `--notify email:`. Repeatable. | | `-r, --response-kind ` | Trigger response shape: `gif`, `empty`, `json`, `redirect`, `html` | @@ -229,6 +229,27 @@ The input CSV is capped at **64 MiB**. Raise it with [`MANTIS_BULK_CREATE_MAX_BYTES`](#environment-only-settings) if you trust the file. +**Per-row CSV columns.** The flags above set one value for *every* row. To vary a +row from the rest, add these columns to the input CSV. `response_kind`, +`response_payload`, and `expires_at` override the matching flag for that row only; +the `notify` / `notify_` columns add their destinations *on top of* the +`--notify` flags rather than replacing them: + +| Column | Effect | +|---|---| +| `memo` | The memo. If absent, falls back to an `area` then a `name` column (or use `--memo-column` / `--memo-template`). | +| `notify` | One or more `:` destinations for that row (`;`-separated), added on top of the `--notify` defaults. | +| `notify_` | A per-channel column — `notify_webhook`, `notify_email`, `notify_slack`, `notify_discord`, `notify_teams`, `notify_home_assistant`; the cell is the target. | +| `response_kind` | Per-row response shape, as the flag. | +| `response_payload` | Per-row payload JSON, as the flag. | +| `expires_at` | Per-row expiry, as the flag. | + +**Output columns.** The output CSV is your input with six columns appended +(reused in place if a same-named column already exists, matched +case-insensitively): `mantis_memo`, `mantis_id`, `mantis_public_id`, +`mantis_url`, `mantis_created_at`, and `mantis_error` (populated per row on +failure, so a partial run tells you exactly which rows didn't create). + ### `mantis list` (alias `ls`) List keys. @@ -309,6 +330,20 @@ Download generated bait files for an existing key. Each flag writes one file. | `--eml ` | `.eml` email message | | `--ics ` | Calendar event | | `--vcf ` | Contact card | +| `--rtf ` | `.rtf` document — beacons on open like `.docx`, but plain text | +| `--cookies ` | Netscape `cookies.txt` session jar | +| `--bookmarks ` | Browser `bookmarks.html` export | +| `--env ` | `.env` credentials file | +| `--aws-credentials ` | `~/.aws/credentials` file | +| `--netrc ` | `.netrc` (auto-read by curl/wget/git) | +| `--kubeconfig ` | kubeconfig with a bait API server | +| `--ovpn ` | OpenVPN profile | +| `--rdp ` | Remote Desktop profile | + +The last eight are the credential/config-store bait formats: unlike the document +formats above (`--rtf` included, which beacons on open like `.docx`), they fire +when the URL inside is **used**, not when the file is opened. Save each under the name the real thing has (`cookies.txt`, `.netrc`, +`~/.aws/credentials`, …) — see [file keys](/file-keys#credential-and-config-stores). ### `mantis install ` @@ -342,6 +377,7 @@ plugin-provided type). See [Host events](/host-events). | `js-clone-detector` | Web | Page runs on an unexpected hostname | | `nfc-ndef` | Tag | NFC tag URL is opened | | `homeassistant` | Smart home | HA automation calls the generated `rest_command` | +| `homeassistant-receiver` | Smart home | Ready-to-paste HA automation that listens on a Mantis webhook destination and reacts (drops the activation ping, example actions) | | `scrypted` | Smart home | Scrypted Script sees the selected device event | Plugins can register additional types; `mantis install` validates against the @@ -349,6 +385,40 @@ union of built-ins plus installed plugins. --- +## Device suites + +`mantis device ` — mint and install the full set of host alarms for +one machine in a single step. Where `mantis new --install` templates one alarm, +a device suite mints **one key per vector** (shell login, sudo, wake, boot, +network) for a named machine, so a hit tells you *which* alarm fired rather than +just "something happened on web01". Each key is keyed idempotently by +device-name + vector, so re-running for a rebuilt machine reuses its keys instead +of minting a second set. + +### `mantis device profiles` + +List the vectors each OS profile would mint (and which need extra setup). No flags. + +### `mantis device new` + +Mint one key per host alarm for a machine. + +| Flag | What it does | +|---|---| +| `-o, --os ` | Target OS: `macos`, `linux`, `windows`, or `auto`. `auto` uses this machine's OS — only safe when you're on the target machine. | +| `-n, --name ` | Machine these alarms are for; appears in every memo. Defaults to this host's name only with `--install`. | +| `--vectors ` | Comma-separated alarm slugs (see `mantis device profiles`); defaults to the profile's recommended set. | +| `--all` | Every alarm in the profile, including ones needing extra setup (e.g. macOS wake needs `sleepwatcher`). | +| `--bundle ` | Write the install bundle (`.zip` of installers + a bootstrap script) to this path. | +| `--install` | Apply the alarms to **this** machine now — touches LaunchAgents / systemd units / scheduled tasks, so it confirms first. | +| `-y, --yes` | Skip the `--install` confirmation. | +| `--dry-run` | Show what would be minted, and mint nothing. | + +`--bundle` is the reversible option — you read the script before running it; +`--install` is the apply-now shortcut for the machine you're on. + +--- + ## Hits & monitoring ### `mantis hits ` @@ -406,12 +476,12 @@ Reset a key's tripped monitor state (latch mode). No flags. `mantis destinations ` (alias `dest`) — incrementally manage notification destinations on a key. Channels: `webhook`, `email`, `slack`, -`discord`, `teams`. +`discord`, `teams`, `home_assistant`. | Command | Flags | |---|---| | `destinations list ` (alias `ls`) | none | -| `destinations add [channel] [target]` | `--channel ` (`webhook`/`email`/`slack`/`discord`/`teams`), `--target ` (URL or email) | +| `destinations add [channel] [target]` | `--channel ` (`webhook`/`email`/`slack`/`discord`/`teams`/`home_assistant`), `--target ` (URL or email) | | `destinations rm ` (alias `remove`) | none | | `destinations test ` | `-y, --yes` (skip the confirmation prompt) | | `destinations rotate-secret ` | `-y, --yes` (skip the confirmation prompt) | @@ -420,6 +490,12 @@ notification destinations on a key. Channels: `webhook`, `email`, `slack`, - `test` fires a synthetic hit on the key URL and reports which destinations succeeded. - `rotate-secret` rotates the HMAC signing secret on a webhook destination; the new secret is shown **once**. +These subcommands manage destinations **on one key**. To route *every* key's +hits to a shared destination without re-entering it each time, set an +instance-wide destination in the dashboard at `/settings/notifications` (admin +only) — see [global destinations](/configuration#global-notification-destinations). +There is no CLI subcommand for the global set. + --- ## Audit log @@ -444,6 +520,17 @@ Worker) key flow. See [Edge deployment](/edge-deployment). Generate a 32-byte AES key for an edge worker (prints to stdout). No flags. +### `mantis edge deploy` + +Deploy the `mantis-edge` Worker (wraps `wrangler deploy`) and capture its URL. +Runs the worker's own `wrangler` via `npx`, so no global install is needed. + +| Flag | What it does | +|---|---| +| `--dir ` | Worker directory to deploy from (defaults to `./` or `./mantis-edge`) | +| `--set-key` | After a successful deploy, store the AES key locally for the deployed URL (prompts) | +| `[wranglerArgs...]` | Extra args forwarded verbatim to `wrangler deploy` — put them after `--` | + ### `mantis edge set-key [worker] [key]` Store an edge AES key in the OS keychain for a given worker URL. `[worker]` is @@ -490,6 +577,28 @@ on a TTY to launch the interactive wizard. `--edge-key` is named separately from the global `--key` to avoid a collision. +### `mantis edge device` + +The stateless counterpart to [`mantis device new`](#mantis-device-new): mint one +edge URL per host alarm for a machine and write an install bundle **directory**, +with no server and no database. Same vector model as `mantis device`, plus the +edge minting flags. + +| Flag | What it does | +|---|---| +| `-o, --os ` | Target OS: `macos`, `linux`, `windows`, or `auto` | +| `-n, --name ` | Machine these alarms are for; appears in every memo | +| `--vectors ` | Comma-separated alarm slugs (see `mantis device profiles`); defaults to the recommended set | +| `--all` | Every alarm in the profile, including ones needing extra setup | +| `--bundle ` | Write the install bundle to this **directory** (must not already have contents) | +| `--install` | Apply the alarms to this machine now (asks first) | +| `-y, --yes` | Skip the `--install` confirmation | +| `--dry-run` | Show what would be minted, and mint nothing | +| `--worker ` | Worker base URL (`https://…`); falls back to the current profile's edge worker | +| `--webhook ` | Webhook URL the worker POSTs on hit — every minted URL embeds it | +| `--channel ` | Destination channel formatter: `webhook`, `slack`, `discord`, `teams` | +| `--edge-key ` | Override the stored AES key for this mint | + ### `mantis edge install ` Generate an installer snippet for a stateless edge URL — the same snippets diff --git a/configuration.md b/configuration.md index 63a3496..46fa930 100644 --- a/configuration.md +++ b/configuration.md @@ -8,7 +8,8 @@ These are the variables operators usually need to understand. ## Required -- `DATABASE_URL` — Postgres connection string. +- `DATABASE_URL` — Postgres connection string. Used directly when you run Mantis **from source** (`pnpm dev`, `pnpm db:migrate`) — point it at your own Postgres. The Docker Compose deploy **ignores** this value and derives its own from `POSTGRES_PASSWORD` (connecting to host `postgres`, not `localhost`), so a `localhost` value kept here for run-from-source does no harm. +- `POSTGRES_PASSWORD` — password for the Docker Compose Postgres service, and the single source of truth from which Compose builds the app's `DATABASE_URL`. It ships **empty** in `.env.example` and has no insecure default: both Compose services abort on boot if it is unset or empty. Generate it (together with the pepper below) by running `./scripts/setup.sh` — a bare `cp .env.example .env` is not enough to start the stack. Not needed when running from source against your own Postgres. - `MANTIS_API_KEY_PEPPER` — server-side secret used as the HMAC key when hashing API keys at rest. Generate with `openssl rand -base64 32`. A leaked database alone is useless to an attacker without this value too. **Do not rotate after the first key is minted** — rotating invalidates every existing API key used by the CLI or API clients. If you must rotate, plan to re-mint and re-distribute every API key on the same maintenance window. Upgrading from a pre-pepper deployment? Set the pepper once and existing API keys keep working — the verifier accepts the old pre-pepper SHA-256 hashes too, and opportunistically re-hashes each row to the peppered form on next use. After a few weeks of normal traffic the legacy rows drain to zero. @@ -34,11 +35,23 @@ These are the variables operators usually need to understand. - `SMTP_FROM` — email sender, default `Mantis `. - `ALLOW_PRIVATE_WEBHOOKS=1` — allow webhook/Slack/Discord/Teams targets that resolve to private, loopback, link-local, or cloud-metadata IPs. Default is blocked as an SSRF guard. +## Global notification destinations + +Beyond the per-key destinations managed with `mantis destinations`, an admin can +set **instance-wide** destinations from the dashboard at `/settings/notifications`. +Every key's hits fan out to its own destinations *plus* every global one, so you +can set a Slack or webhook once and have every key you mint — including +bulk-created ones — alert you without further setup. A destination listed both +globally and on a key fires once (the key's own row wins, keeping its signing +secret and activation history). There is no environment variable or CLI +subcommand for the global set; it lives only in the admin dashboard. + ## Proxy and public-only hosts - `TRUST_PROXY_HEADERS=1` — trust `cf-connecting-ip`, `x-vercel-forwarded-for`, `x-real-ip`, and `x-forwarded-for` for forensic IP logging. Set only behind a proxy that strips and re-injects those headers. Auto-enabled on Vercel and in non-production; set `TRUST_PROXY_HEADERS=0` to force it off even there. In production with no trusted proxy (the default), client IPs are recorded as `null` rather than spoofable values, and Mantis logs a one-time startup warning. - `TRUSTED_IP_HEADER` — pin client-IP extraction to a single header (matched case-insensitively), e.g. `x-real-ip`. By default Mantis tries `cf-connecting-ip`, `x-vercel-forwarded-for`, `x-real-ip`, then `x-forwarded-for` and takes the first present; behind a non-Cloudflare proxy (nginx, Caddy, Traefik) that sets only `x-real-ip` and doesn't strip an inbound `cf-connecting-ip`, an attacker could forge `cf-connecting-ip` and have it trusted. Pin this to the one header your proxy authoritatively writes so all others are ignored. Only takes effect when `TRUST_PROXY_HEADERS` is on. - `TRUST_PROXY_HOPS` — number of trusted reverse-proxy hops in front of Mantis, default `1` (clamped to 1–16). Only affects `x-forwarded-for` parsing: the client IP is taken this many entries from the right of the chain (your nearest proxy appends the real peer on the right), so a client can't forge it past your proxy. Raise it only if you stack multiple trusted proxies; it has no effect on single-value headers like `cf-connecting-ip` or `x-real-ip`. +- `FORCE_SECURE_COOKIES` — override the `Secure` flag on the dashboard session cookie. `1` forces it on, `0` forces it off; unset derives it from the request scheme (`X-Forwarded-Proto`, or the RFC 7239 `Forwarded` header's leftmost hop). Set `1` behind a proxy or tunnel that terminates real TLS but sets **neither** scheme header — otherwise the session cookie travels un-`Secure` over genuine HTTPS. Leave it unset for the proxies Mantis documents (Cloudflare, cloudflared, Tailscale, nginx), which all set a scheme header. Don't force it on over plain HTTP: the browser would then never send the cookie back and login would break. - `PUBLIC_ONLY_HOSTS` — comma/space-separated hostnames that should expose only public routes: trigger URLs, status URLs, and Wallet callbacks. - `DASHBOARD_HOSTS` — hostnames allowed to serve the dashboard and management API. When `PUBLIC_ONLY_HOSTS` is set, unknown hosts fail closed to public-only unless explicitly listed here. - `PUBLIC_ONLY_ALLOW_HEALTH=1` — allow `/api/health` on public-only hosts. diff --git a/deployment/cloudflare.md b/deployment/cloudflare.md index a6864e3..b56665e 100644 --- a/deployment/cloudflare.md +++ b/deployment/cloudflare.md @@ -48,15 +48,18 @@ Cloudflare Zero Trust is a separate dashboard at `https://one.dash.cloudflare.co ```bash cd mantis -cp .env.example .env -# Edit .env: +./scripts/setup.sh # creates .env with a random DB password + API-key pepper +# Then edit .env: # CLOUDFLARE_TUNNEL_TOKEN=eyJh... # PUBLIC_BASE_URL=https://mantis. -# MANTIS_API_KEY_PEPPER= docker compose --profile cloudflared up -d ``` +`./scripts/setup.sh` generates `POSTGRES_PASSWORD` and `MANTIS_API_KEY_PEPPER`, +which Compose refuses to start without — a plain `cp .env.example .env` leaves +`POSTGRES_PASSWORD` empty and the stack won't boot. + ## Step 4 — Verify ```bash @@ -173,6 +176,7 @@ The CLI sends `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers on eve - `/api/keys*` - `/api/hits*` - `/api/api-keys*` + - `/api/device-profiles*` - `/api/audit*` - `/api/cron*` 5. **Identity providers** (for Option 1 / browser SSO): tick the methods you configured. diff --git a/deployment/docker-local.md b/deployment/docker-local.md index fd7197b..319adf5 100644 --- a/deployment/docker-local.md +++ b/deployment/docker-local.md @@ -6,17 +6,19 @@ sidebarTitle: "Local Docker" ```bash git clone mantis && cd mantis -cp .env.example .env -pepper="$(openssl rand -base64 32)" -sed -i.bak "s|^MANTIS_API_KEY_PEPPER=.*|MANTIS_API_KEY_PEPPER=$pepper|" .env -rm .env.bak +./scripts/setup.sh # creates .env with a random DB password + API-key pepper docker compose up -d docker compose logs mantis | grep "bootstrap API key" -A1 ``` Mantis is bound to `127.0.0.1:3000` by default. Reachable from the host only. To expose to LAN: set `MANTIS_BIND_HOST=0.0.0.0` in `.env`. -`MANTIS_API_KEY_PEPPER` is required. Generate it once and keep it stable for -that database; rotating it invalidates existing API keys. +`./scripts/setup.sh` (also `pnpm setup`) generates the two required secrets into +`.env`: `POSTGRES_PASSWORD` — the single source of truth for the database +password, from which Docker Compose derives the app's `DATABASE_URL` — and +`MANTIS_API_KEY_PEPPER`. It's idempotent, so re-running leaves existing secrets +untouched. Compose refuses to start if either is empty, so `cp .env.example .env` +alone is not enough. Keep the pepper stable for that database; rotating it +invalidates existing API keys. The Docker localhost setup is for **evaluation only** — don't rely on it for canaries that need to fire when you're not at your machine. diff --git a/deployment/tailscale.md b/deployment/tailscale.md index 230a3cd..2486cc3 100644 --- a/deployment/tailscale.md +++ b/deployment/tailscale.md @@ -59,16 +59,20 @@ This is the existing one-host setup. It exposes the whole app publicly through Funnel; dashboard and API routes still require mantis auth. ```bash -cp .env.example .env -# Edit .env: +./scripts/setup.sh # creates .env with a random DB password + API-key pepper +# Then edit .env: # TS_AUTHKEY=tskey-auth-xxxxxxxxxxxx # TS_HOSTNAME=mantis # PUBLIC_BASE_URL=https://mantis..ts.net -# MANTIS_API_KEY_PEPPER= docker compose --profile tailscale up -d ``` +`./scripts/setup.sh` generates the two secrets Compose won't start without — +`POSTGRES_PASSWORD` and `MANTIS_API_KEY_PEPPER` — so you only fill in the +Tailscale and URL values above. A plain `cp .env.example .env` leaves +`POSTGRES_PASSWORD` empty and the stack refuses to boot. + Verify: ```bash @@ -88,14 +92,15 @@ It starts two Tailscale sidecars: - `mantis-public..ts.net` uses Tailscale Funnel. Mantis allows only public routes on this host: `/c/*`, `/status/*`, and `/api/wallet/*`. -Configure `.env`: +Run `./scripts/setup.sh` first to create `.env` with the two required secrets +(`POSTGRES_PASSWORD` and `MANTIS_API_KEY_PEPPER`), then add the split-host +configuration to it: ```bash TS_AUTHKEY=tskey-auth-xxxxxxxxxxxx TS_PRIVATE_HOSTNAME=mantis-private TS_PUBLIC_HOSTNAME=mantis-public TS_EXTRA_ARGS=--advertise-tags=tag:mantis -MANTIS_API_KEY_PEPPER= # Generated trigger/status/wallet URLs should be public. PUBLIC_BASE_URL=https://mantis-public..ts.net diff --git a/edge-deployment.md b/edge-deployment.md index a6c2eb8..cfb479f 100644 --- a/edge-deployment.md +++ b/edge-deployment.md @@ -70,6 +70,10 @@ When the allowlist is set, encrypted URLs with non-matching webhook hosts return pnpm exec wrangler deploy ``` +Or let the CLI wrap it — [`mantis edge deploy`](/cli#mantis-edge-deploy) runs the +worker's own `wrangler deploy` via `npx`, captures the deployed URL, and with +`--set-key` stores the AES key for it in one step. + Wrangler prints the deployed Worker URL, usually: ```text diff --git a/file-keys.md b/file-keys.md index a332ee0..d7491e3 100644 --- a/file-keys.md +++ b/file-keys.md @@ -3,9 +3,12 @@ title: "File keys" description: "Generate bait files that fire a Mantis trigger when opened or rendered." --- -Mantis can generate 13 server-backed artifacts. Most embed the key URL so the -trigger fires when a viewer renders the file; NFC and Wallet artifacts fire -through the platform action they are designed for. +Mantis can generate 22 server-backed artifacts, in two classes. Most are +**document formats** that embed the key URL so the trigger fires when a viewer +renders the file; NFC and Wallet artifacts fire through the platform action they +are designed for. The **[credential and config stores](#credential-and-config-stores)** +are different — they sit where an intruder who already has a shell goes looking, +and fire when the URL inside them is *used*, not when the file is opened. | Format | Mechanism | Best in | Notes | |---|---|---|---| @@ -13,6 +16,7 @@ through the platform action they are designed for. | `.xlsx` | Same external-image trick, attached to a worksheet drawing | Excel, LibreOffice Calc | Identical reliability to DOCX | | `.pptx` | Same external-image trick on slide 1 | PowerPoint, Keynote (some), LibreOffice Impress | Same as above | | `.pdf` | **Combo**: `/OpenAction → /URI` + clickable `/Link` annotation | Adobe Reader, Foxit, most enterprise PDF readers | Less reliable — Chrome's PDFium viewer doesn't fire OpenAction; macOS Preview doesn't either. The clickable link covers the "user reads + clicks" case in those viewers. | +| `.rtf` | `INCLUDEPICTURE` field referencing the trigger URL | Word, WordPad, TextEdit | Beacons on open like `.docx`, but as plain text it survives being opened in a text editor, and WordPad/TextEdit render it without the Protected-View banner `.docx` inherits from the Mark-of-the-Web. The `\d` switch re-fetches on every open, so a re-opened file fires again. | | `.zip` (`folder`) | Honey-directory bundle of 9 bait files | Shared drives, unpacked project folders | Each file in the extracted folder triggers the same key | | `.pdf` (`nfc-label`) | Printable QR/NFC sticker label | Physical tags, asset labels | The PDF does not fire by itself; the scan/tap opens the key URL | | `.pkpass` (`apple-wallet`) | Signed Apple Wallet pass with web-service callbacks | iPhone Wallet | Requires Wallet config; install, uninstall, and fetch callbacks record hits | @@ -23,6 +27,32 @@ through the platform action they are designed for. | `.ics` | iCalendar event with `ATTACH;FMTTYPE=image/png` + `URL` | Apple Calendar, some CalDAV clients | Behavior varies by client | | `.vcf` | vCard 4.0 with `PHOTO;VALUE=URI` | macOS / iOS Contacts, CardDAV clients | Fires when the contact's avatar renders | +## Credential and config stores + +The remaining eight formats are a different class of bait. Instead of beaconing +when a viewer renders them, they carry the trigger URL in the slot a real file of +that kind would — and fire when someone (or a tool they point at it) *uses* that +URL. These are the files an intruder who already has a shell goes looking for, +and the ones infostealer and forensic tooling greps by name, so a bait URL in one +is found by exactly the person you want to catch. Every embedded secret is a +documented example value (AWS's own `AKIAIOSFODNN7EXAMPLE`, Stripe's published +test key) or generated nonsense, so nothing here is a live credential anywhere. + +Because the filename *is* the disguise, most of these keep the name the real thing +has rather than taking the memo — a cookie jar named `payroll-laptop.txt` is not a +cookie jar. + +| Format | Saved as | Fires when | +|---|---|---| +| `cookies` | `cookies.txt` | A stolen jar is replayed. The bait cookie is path-scoped to the canary, so curl / wget / yt-dlp or any "export cookies" tool that reloads it hits the URL exactly. | +| `bookmarks` | `bookmarks.html` | The file is opened in a browser (the bait's `ICON_URI` is fetched on render) **or** the bait bookmark — an internal VPN portal / admin console — is clicked. | +| `env` | `.env` | A tool reads the file and resolves `API_BASE_URL` against the canary. (`DEPLOY_WEBHOOK_URL` points at `/hooks/deploy` on the same host, which has no route — it is plausible filler, not a second trigger.) | +| `aws-credentials` | `credentials` | The AWS CLI or an SDK is pointed at the profile — it honours the profile's `endpoint_url`, so calls resolve against the canary rather than AWS. | +| `netrc` | `.netrc` | curl, wget, git or ftp authenticate to the canary host. `.netrc` is auto-consumed, so this fires without the file ever being opened. | +| `kubeconfig` | `config` | Someone reads the file and curls the `server:` URL to see what cluster it is. (kubectl appends its own API paths and 404s, so a real `kubectl get pods` won't register — this catches the read.) | +| `ovpn` | `.ovpn` | Someone follows the profile-update URL in the file. OpenVPN speaks its own protocol, so pointing the client at an HTTP canary won't fire — this is discovery bait, a weaker trigger than a document beacon, and the dashboard preset says so. | +| `rdp` | `.rdp` | Someone follows the `workspacefeedurl` in the file. Like `.ovpn`, RDP won't beacon on its own; the hit comes when a human opens the URL. | + See **[self-hosted apps](/self-hosted-apps)** for per-app recipes (Immich, Paperless, Joplin, Vaultwarden, dashboards, code hosts, etc.). ```bash @@ -48,8 +78,13 @@ mantis download --pdf ./out.pdf mantis download --nfc-label ./nfc-label.pdf mantis download --apple-wallet ./mantis.pkpass +# Credential/config-store bait — save under the real thing's name +mantis download --env ./.env +mantis download --netrc ./.netrc +mantis download --aws-credentials ./credentials + # Dashboard: key detail page → "file keys" card has download links for all formats -# API: GET /api/keys//download?format=docx|xlsx|pptx|pdf|folder|nfc-label|apple-wallet|svg|html|md|eml|ics|vcf (Bearer or session) +# API: GET /api/keys//download?format=docx|xlsx|pptx|pdf|folder|nfc-label|apple-wallet|svg|html|md|eml|ics|vcf|rtf|cookies|bookmarks|env|aws-credentials|netrc|kubeconfig|ovpn|rdp (Bearer or session) ``` **Office reader caveats**: @@ -64,7 +99,20 @@ mantis download --apple-wallet ./mantis.pkpass - ⚠ Chrome, Edge, Firefox built-in viewers — OpenAction not honored; mantis fires only if user clicks the visible "View the latest version online" link. - ❌ macOS Preview — OpenAction not honored; click-the-link fallback works. -All generated files include placeholder body text (`CONFIDENTIAL DRAFT — do not distribute.`). Edit the file after download to make it look authentic. +When you supply no body text, the document formats (`.docx`, `.xlsx`, `.pptx`, +`.pdf`, `.html`, `.md`, `.eml`, `.rtf`) fall back to a built-in multi-paragraph +internal-memo body that opens `CONFIDENTIAL — INTERNAL DISTRIBUTION ONLY`. It +deliberately reads as a genuine document — an earlier default that described +itself as placeholder text gave the canary away to the first person who opened +it — so editing the file to look authentic afterwards is optional polish, not a +required step. The credential/config stores carry their own fixed content and use +neither the body nor the memo — except `bookmarks`, where the memo becomes the +visible name of the bait bookmark, so give that key a memo that reads like a real +bookmark rather than one that names it as a canary. The `.svg` / `.ics` / `.vcf` / NFC / Wallet formats +have no multi-paragraph document body to fall back on either — instead they +surface the key memo/title in their title, summary, name, or pass field, and +`.ics` / `.vcf` route any body you do supply into the event `DESCRIPTION` / +contact `NOTE` (falling back to that same title when you give none). The key memo and title flow into these artifacts (document properties, calendar/contact fields), so Mantis sanitizes them on generation: control bytes illegal in XML 1.0 are stripped from the Office / SVG / HTML formats, and a lone carriage return is normalized in the line-oriented `.ics` / `.vcf` formats — both so a memo carrying stray control characters can't produce a file that silently fails to open or that injects a forged property line. diff --git a/getting-started.md b/getting-started.md index 5ca6263..b4496e5 100644 --- a/getting-started.md +++ b/getting-started.md @@ -108,6 +108,13 @@ mantis bulk-create \ --memo-template "{{area}} - {{device}}" ``` +The output CSV is your input with `mantis_id`, `mantis_url`, and four more +columns appended. The input can also carry per-row `notify`, `response_kind`, +`response_payload`, and `expires_at` columns so a row can vary from the rest — +`response_kind`, `response_payload`, and `expires_at` override the matching flag, +while a row's `notify` destinations are added on top of the `--notify` flags — see +[`mantis bulk-create`](/cli#mantis-bulk-create-alias-import-csv) for the full column list. + See [`cli/README.md`](https://github.com/privacykey/mantis/tree/main/cli) for the full command reference and [`COMMAND_MAP.md`](https://github.com/privacykey/mantis/blob/main/cli/COMMAND_MAP.md) for the option matrix. ## Browse hits in the dashboard (optional) diff --git a/reliability.md b/reliability.md index 660dd4a..1257d87 100644 --- a/reliability.md +++ b/reliability.md @@ -13,6 +13,8 @@ Public trigger requests are rate-limited per IP (when a trusted client IP exists ## Notification retry queue +On each hit, notifications are enqueued for that key's own destinations **plus** every instance-wide [global destination](/configuration#global-notification-destinations) (`key_id IS NULL`, configured at `/settings/notifications`). A destination that appears in both sets is sent once, de-duplicated on `(channel, target)` with the key's own row winning so its signing secret and activation history are used — a key with no destinations of its own still alerts if a global one exists. + Notifications are inserted into a `notifications` table on hit. A background worker claims pending rows (`FOR UPDATE SKIP LOCKED` — safe for multiple instances), attempts delivery with a 5s timeout, and on failure schedules the next attempt with exponential backoff. Status (`pending` / `in_flight` / `succeeded` / `failed` / `aborted`) is surfaced in the dashboard, CLI (`mantis hits `), and API (`GET /api/keys//hits`). **Worker mode** (default for non-Vercel deployments): diff --git a/single-user.md b/single-user.md index 42ea57d..24f3c03 100644 --- a/single-user.md +++ b/single-user.md @@ -17,3 +17,19 @@ keep your bootstrap key safe. Admin privileges are also required for the instance-wide audit log and Apple Wallet settings. The `audit log` (`mantis audit log`, admin-only) records create / update / delete / login / destination-secret / wallet-config events across the instance. + +## Key scope: a second axis + +`is_admin` is not the only authorization axis. Every API key also has a +`scope` — `full` or `enroll` — set when it is minted (`POST /api/api-keys` body +`{ scope }`, default `full`). A `full` key behaves as described above. An +`enroll` key is **create-only**: it can call only `POST /api/keys`, and gets +`403` on every other route — it cannot list keys, read hit history, read alert +routing or signing secrets, or log in to the dashboard. The one thing it can +read back is a key it can name: re-posting an `external_id` that already exists +returns that key — trigger URL, memo, `public_id`, expiry — no matter which API +key created it. So an enroll key is safe to embed on managed machines only to +the extent your `external_id`s are hard to guess; `mantis device` derives them +deterministically from the machine name. `is_admin` and `enroll` +are mutually exclusive. See [key scope in the HTTP +API](/api#api-key-scope-full-vs-enroll). diff --git a/trying-locally.md b/trying-locally.md index 3440274..1fefc0b 100644 --- a/trying-locally.md +++ b/trying-locally.md @@ -11,14 +11,16 @@ Run the stack against your laptop with Docker: ```bash git clone mantis && cd mantis -cp .env.example .env -pepper="$(openssl rand -base64 32)" -sed -i.bak "s|^MANTIS_API_KEY_PEPPER=.*|MANTIS_API_KEY_PEPPER=$pepper|" .env -rm .env.bak +./scripts/setup.sh # creates .env with a random DB password + API-key pepper docker compose up -d docker compose logs mantis | grep "bootstrap API key" -A1 ``` +`./scripts/setup.sh` (also `pnpm setup`) writes `.env` and generates the two +secrets Compose refuses to start without — `POSTGRES_PASSWORD` (the DB password +the app's `DATABASE_URL` is derived from) and `MANTIS_API_KEY_PEPPER`. It's +idempotent, so re-running leaves existing secrets untouched. + The first boot prints a bootstrap API key. Copy it; it won't be shown again. Then drive the CLI as usual, just pointing at localhost: @@ -37,7 +39,7 @@ When you're ready to move from "trying it" to "running it", pick a real deployme ## Local dev (for contributors) -The source workspace uses `pnpm@11.1.1` and requires Node `>=25.8.0`. +The source workspace uses `pnpm@11.1.3` and requires Node `>=25.8.0`. ```bash corepack enable