From fda7be3214d0efe266214917e5b07ad881f430bb Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:35:49 +0200 Subject: [PATCH] docs: handleiding + functionele + technische specificatie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drie nieuwe markdown-bestanden onder /docs: - handleiding.md — voor de dagelijkse gebruiker: eerste login, modules, veelvoorkomende taken (Caddy editen, sprint mergen via flow), wat expliciet niet vanuit de UI kan, log-locaties bij incidenten, veiligheidsadvies. - specs/functional.md — wat de app doet: scope per module met acceptatiecriteria, flow state-machine (pending/running/success/ failed/cancelled/timeout), hard limits (1 actieve flow, 64KB log knippen, 24u session), expliciete buiten-scope-lijst. - specs/technical.md — hoe het werkt: 3-process architectuur (dashboard container + agent op host + Postgres), stack-tabel met versies en redenen, data-model (User/Session/FlowRun/FlowStep), auth-flow met CSRF, agent-protocol over SSE, security-eigenschappen per laag. Lengtes pragmatisch gekozen — geen completeness-fetisj, wel genoeg om iemand die nieuw is in de codebase binnen 30 min te oriënteren. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/handleiding.md | 128 +++++++++++++++++++ docs/specs/functional.md | 147 +++++++++++++++++++++ docs/specs/technical.md | 270 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 docs/handleiding.md create mode 100644 docs/specs/functional.md create mode 100644 docs/specs/technical.md diff --git a/docs/handleiding.md b/docs/handleiding.md new file mode 100644 index 0000000..d5ceb1a --- /dev/null +++ b/docs/handleiding.md @@ -0,0 +1,128 @@ +# Handleiding — Ops Dashboard + +Voor de dagelijkse beheerder van een single-host server-stack (Docker, systemd, Git, Caddy, Postgres). Deze handleiding beschrijft *hoe* je de app gebruikt, niet *hoe* hij werkt — voor dat laatste zie [`specs/technical.md`](./specs/technical.md). + +## Eerste inlog + +1. Open `https:///` (bv. `https://ops.jp-visser.nl`). +2. Je wordt doorgestuurd naar `/login`. Vul de admin-credentials in die je tijdens deploy in `SEED_USER_EMAIL` / `SEED_USER_PASSWORD` hebt gezet. +3. Na succesvolle login zit je 24 uur ingelogd via een HttpOnly-cookie. Daarna opnieuw inloggen. + +Wachtwoord vergeten? Geen reset-flow in de app — los op met een SQL-update: + +```bash +# Genereer een nieuwe bcrypt-hash voor je nieuwe wachtwoord +docker run --rm -e PW='' node:22-alpine sh -c ' + cd /tmp && npm init -y >/dev/null 2>&1 && npm install --silent bcryptjs >/dev/null 2>&1 + node -e "console.log(require(\"bcryptjs\").hashSync(process.env.PW, 12))" +' +# Plak in psql: +docker exec -it scrum4me-postgres psql -U scrum4me -d ops_dashboard \ + -c "UPDATE \"User\" SET pwd_hash = '' WHERE email = '';" +``` + +## Dashboard (home) + +Vijf live status-widgets, auto-refresh ~5 sec: + +| Widget | Toont | +|---|---| +| **Docker** | Aantal draaiende containers + lijst | +| **Git** | Branch en uncommitted-status per geconfigureerd repo-pad | +| **systemd** | Service-status (active/inactive/failed) per geconfigureerde unit | +| **Caddy** | TLS-certs met dichtstbijzijnde expiratiedatum (geel = <30 dagen) | +| **Audit** | Laatste flow-run met timestamp en exit-status | + +Klik een widget aan om naar de detail-pagina te gaan. + +## Modules + +### `/docker` — Containers + +Tabel van `docker ps` met auto-refresh. Klik op een container-naam voor detail (logs, image, ports, status). + +Read-only — geen start/stop/restart vanuit de UI. Voor wijzigingen: een **Flow** (zie hieronder). + +### `/git` — Repositories + +Per geconfigureerd pad in `REPO_PATHS` (env var): branch, uncommitted-files (M/A/D/??), laatste 3 commits. Klik door voor diff-viewer. + +Read-only — pulls/commits gaan via een Flow. + +### `/systemd` — Services + +Lijst van services uit `SYSTEMD_UNITS` (env var). Toont status, laatste log-regels. Klik door voor full journal-tail van die unit. + +Restart-knop: alleen voor units die in `commands.yml` zijn whitelisted én in `sudoers.d/ops-agent` met `NOPASSWD` staan. + +### `/caddy` — Reverse-proxy & TLS + +Toont de actieve Caddyfile (syntax-highlighted) plus alle TLS-certs (subject + expiry). "Edit"-knop opent een editor — opslaan **valideert** de Caddyfile via `caddy validate` voordat het wordt geschreven. + +### `/flows` — Multi-step deployments + +Twee voor-gedefinieerde flows: + +- **Update Scrum4Me website** — pull main, build container, restart, smoke-test +- **Update Caddy config** — schrijf nieuwe Caddyfile, valideer, restart Caddy, verifieer dat alle hostnames nog reageren + +Een flow draait stap-voor-stap met **dry-run** als standaard. Na dry-run zie je per stap wat het gaat doen. Klik "Run" om echt uit te voeren. Tijdens executie zie je live stdout/stderr per stap. + +### `/audit` — Flow-runs + +Chronologische lijst van alle gestarte flows: starttijd, duur, exit-status, wie 'm startte. Klik door voor de volledige output (stdout/stderr per stap). + +### `/settings/backups` — Backups + +Postgres backup-management: + +- Lijst van bestaande dump-bestanden in `/srv/scrum4me/backups` +- "Backup now"-knop maakt een dump met timestamp-naam +- Restore-runbook (handmatige stappen — geen automatische restore vanuit UI om de blast-radius klein te houden) + +## Veelvoorkomende taken + +### Container hangt — wat nu? + +1. `/docker` → klik container-naam → bekijk logs +2. Diagnose? Open een SSH-sessie en gebruik `docker logs`, `docker exec` etc. (Niet vanuit de UI — dat is buiten scope.) +3. Restart nodig? Voeg de container toe aan `commands.yml` whitelist (op de host) + run via `/flows` + +### Caddy-config wijzigen + +1. `/caddy` → "Edit" +2. Pas Caddyfile aan in de editor +3. Save → app draait `caddy validate` → bij succes wordt het geschreven en Caddy herstart +4. Verifieer in `/caddy` dat het cert-overzicht klopt + +> Voor breaking changes (verkeerde syntax of niet-bestaande site): de validate-stap blokkeert. Bij twijfel: maak eerst een backup van `/srv/scrum4me/caddy/Caddyfile`. + +### Sprint mergen via flow + +`/flows/update-scrum4me-web` — kies branch (default `main`), klik dry-run, lees wat het doet, klik "Run". Stap-output stream live. Na success: smoke-test verifieert dat de homepage 200 geeft. + +## Wat kan **niet** vanuit de UI + +- SSH-toegang of arbitrary shell-commando's (alleen whitelisted commands.yml-keys) +- User-management (één admin via seed; multi-user is buiten scope) +- Container starten met andere image of args (alleen restart van bestaande) +- Wachtwoord reset (SQL-update vereist) +- Cert handmatig forceren (Caddy doet auto-ACME) + +## Logs voor incident-response + +| Component | Log-locatie | +|---|---| +| Dashboard app | `docker logs scrum4me-ops-dashboard` | +| ops-agent | `journalctl -u ops-agent -f` | +| Caddy | `docker logs scrum4me-caddy` | +| Postgres | `docker logs scrum4me-postgres` | + +Audit-trail van wat-doet-wie-wanneer: tabel `FlowRun` + `FlowStep` in de `ops_dashboard` database, of via `/audit` in de UI. + +## Veiligheidsadvies + +- Houd port 3099 (ops-agent) **niet** open naar de buitenwereld. UFW-regel scoped op `172.18.0.0/16`. Zie [`runbooks/post-install.md`](./runbooks/post-install.md). +- Roteer `OPS_AGENT_SECRET` jaarlijks: nieuw secret in `.env` én `/etc/ops-agent/secret`, dan beide herstarten. +- Voeg geen wildcards toe in `sudoers.d/ops-agent` — elke `systemctl`-actie moet een expliciete service-naam zijn. +- `commands.yml` is single source of truth voor wat de agent mag — alles wat niet in de whitelist staat, kan een aanvaller niet uitvoeren ook al heeft hij het secret. diff --git a/docs/specs/functional.md b/docs/specs/functional.md new file mode 100644 index 0000000..ca2512c --- /dev/null +++ b/docs/specs/functional.md @@ -0,0 +1,147 @@ +# Functionele specificatie — Ops Dashboard + +## Doel + +Eén web-UI waarmee de eigenaar van een single-host server-stack (Docker + systemd + Git-checkouts + Caddy + Postgres) dezelfde operaties kan uitvoeren die anders in een SSH-terminal gebeuren — met audit-log, herhaalbare flows en minder typefouten. + +**Schaal:** één host, één admin-gebruiker. Multi-host/team is buiten scope. + +## Gebruikers en rollen + +| Rol | Beschrijving | Hoeveelheid | +|---|---|---| +| **admin** | Volle toegang tot alle modules en flows. Single account, geseed via env. | 1 | + +Geen RBAC, geen tenant-isolatie, geen "view-only"-modus. Wie inlogt kan alles. + +## Functionele scope per module + +### Dashboard (`/`) + +5 live status-widgets met auto-refresh ~5s: + +| Widget | Data-bron | Indicator | +|---|---|---| +| Docker | `docker ps --format json` | Count van running containers, lijst (naam + status) | +| Git | `git status --short --branch` per pad in `REPO_PATHS` | Branch + dirty-vlag | +| systemd | `systemctl is-active ` per item in `SYSTEMD_UNITS` | Active / Inactive / Failed | +| Caddy | `caddy admin-cmd certificates` (of equiv. shell-output parse) | Aantal certs + dichtstbijzijnde expiry | +| Audit | DB-query op `FlowRun` desc | Laatste run + status | + +**Acceptatie:** +- Widget laadt < 1 s na page-load +- Auto-refresh werkt in achtergrond zonder volledig herrenderen +- Bij fout (agent down, command faalt): widget toont rood errorblok, niet de hele page + +### Auth (`/login`) + +- Email + wachtwoord (single user) +- 5 failed attempts in 1 minuut → 429 rate-limit per IP +- Succesvolle login → session-cookie 24u, HttpOnly, SameSite=strict, Secure (production) +- `/api/auth/logout` invalideert sessie en wist cookie + +### Docker (`/docker`) + +- Lijst running containers (CONTAINER ID, IMAGE, COMMAND, CREATED, STATUS, PORTS, NAMES) +- Auto-refresh elke 5s +- `/docker/[name]` → detail-page met logs (laatste 200 regels), image-info, environment +- **Geen** start/stop/restart vanuit UI — alleen via flows + +### Git (`/git`) + +- Per pad in `REPO_PATHS`: huidige branch, ahead/behind count, modified-files-count, laatste 3 commits +- `/git/[repo]` → diff-viewer voor uncommitted changes + commit-historie laatste 20 + +### systemd (`/systemd`) + +- Per unit in `SYSTEMD_UNITS`: active/inactive/failed, last-changed-timestamp +- `/systemd/[unit]` → laatste 100 journal-regels van die unit, met level-filter +- **Restart-actie**: alleen voor units die expliciet in `sudoers.d/ops-agent` met NOPASSWD staan + +### Caddy (`/caddy`) + +- Toon huidige `/srv/scrum4me/caddy/Caddyfile` met syntax-highlighting +- Toon alle uitgegeven certs (subject, issuer, expiry, dichtstbijzijnde eerst) +- Geel-warning bij expiry < 30 dagen, rood bij < 7 dagen +- `/caddy/edit` → editor met save-knop; save valideert via `caddy validate` voor commit en restart van caddy-container + +### Flows (`/flows`) + +Twee voor-gedefinieerde flows in YAML in `ops-agent/flows.example/`: + +| Flow | Stappen | +|---|---| +| `update_scrum4me_web` | git pull → npm run build → docker compose up -d --build → smoke-test op homepage | +| `update_caddy_config` | write nieuw Caddyfile → caddy validate → docker compose restart caddy → check cert renewal | + +Per flow: +- Dry-run default (toont alleen wat het zou doen) +- "Run"-knop voert echt uit; toont live SSE-stream van stdout/stderr per stap +- Bij stap-fail: stop, markeer FlowRun als `failed`, latere stappen niet uitgevoerd +- Bij success: FlowRun = `success`, totaalduur opgeslagen + +### Audit (`/audit`) + +- Lijst van alle `FlowRun` records, default 50 laatste, sort desc op `started_at` +- Filter op status, datumrange, flow-name +- `/audit/[flow_run_id]` → volledige output per stap, scrollable + +### Settings/Backups (`/settings/backups`) + +- Lijst van `.sql.gz` bestanden in `/srv/scrum4me/backups`, met size + mtime +- "Backup now"-knop → maakt nieuwe dump met `pg_dumpall` voor alle databases +- Restore: **handmatig vanuit terminal** — UI toont alleen de stappen als runbook + +## State-machine flows + +``` + ┌────────┐ + │ pending │ + └────┬────┘ + │ (start request) + ┌────▼────┐ + │ running │ + └────┬────┘ + ┌────┼────┬────────┐ + ▼ ▼ ▼ ▼ + success failed cancelled timeout (>30min) +``` + +`pending` → `running`: bij ontvangst start-request +`running` → `success`: alle stappen exit-code 0 +`running` → `failed`: een stap exit-code ≠ 0 +`running` → `cancelled`: user klikt cancel +`running` → `timeout`: na 30 min nog steeds running (cleanup-job) + +## Hard limits + +- Max 1 actieve flow tegelijk (lock-file in `/var/run/agent/`); 2e start-request → 409 Conflict +- Stdout/stderr per stap geknipt op 64 KB om audit-log niet te laten exploderen +- Session-TTL hard 24 uur, geen "remember me" +- Auto-refresh max 1 keer per 5 seconden om agent niet te overbelasten + +## Niet-functionele eisen + +| Eis | Doel | +|---|---| +| First load < 1s | Single-user, lokale Postgres, geen onnodige round-trips | +| Module-page TTI < 2s | Server-side render met direct agent-call, geen client-fetch waterval | +| Audit-trail volledig | Elke flow-start logt user + tijdstempel + args; elke command-execution logt exit + duration | +| Geen geheime data in URLs | Tokens in headers, secrets nooit in query-params | +| CSP strict | `script-src 'self' 'unsafe-inline'`; geen externe CDNs | +| HTTPS-only in productie | Caddy auto-ACME; cookies Secure-flag in prod-mode | + +## Buiten scope + +- Meerdere admins / RBAC +- Meerdere hosts / cluster-management +- Custom container starten (alleen restart bestaande) +- Real-time alerts (geen pager, geen email) +- Externe monitoring-integratie (Grafana/Prometheus/Sentry) +- Wachtwoord-reset-flow / SSO + +## Verwante documenten + +- [Technische specificatie](./technical.md) — hoe het werkt +- [Handleiding](../handleiding.md) — hoe je het gebruikt +- [Post-install runbook](../runbooks/post-install.md) — eerste deploy diff --git a/docs/specs/technical.md b/docs/specs/technical.md new file mode 100644 index 0000000..33e67cb --- /dev/null +++ b/docs/specs/technical.md @@ -0,0 +1,270 @@ +# Technische specificatie — Ops Dashboard + +## Architectuur in één plaatje + +``` +┌────────────────┐ HTTPS ┌──────┐ HTTP ┌─────────────────┐ +│ Browser (jou) ├─────────►│Caddy ├────────►│ ops-dashboard │ +│ │ │ :443 │ │ Next.js 16 :3000│ +└────────────────┘ └──────┘ └────┬────────┬───┘ + │ │ + HMAC HTTP │ │ TCP/SQL + :3099 │ │ + ┌───────────────▼┐ │ + │ ops-agent │ │ + │ Fastify on host│ │ + │ spawn/exec │ │ + └───┬────────────┘ │ + │ │ + ┌───────┴───────┐ ┌───────▼────────┐ + │ Whitelisted │ │ Postgres 17 │ + │ host commands │ │ db=ops_dashb.. │ + │ docker/git/etc │ └────────────────┘ + └───────────────┘ +``` + +Drie processen, één host: + +1. **ops-dashboard** — Next.js app in Docker, op compose-bridge, exposed via Caddy +2. **ops-agent** — Node/Fastify service direct op host (geen container), heeft sudoers + docker.sock access +3. **postgres** — Docker container, dezelfde als die Scrum4Me al gebruikt; ops-dashboard heeft eigen DB `ops_dashboard` + +## Stack + +| Laag | Technologie | Versie | Reden | +|---|---|---|---| +| App framework | Next.js | 16.2 (App Router) | RSC server-side fetching matched onze "render with agent data" patroon | +| UI library | React | 19 | Bundled bij Next 16 | +| Styling | Tailwind CSS | 4 | Utility-first; geen custom design system | +| UI primitives | `@base-ui/react` | 1.4 | Headless components, geen Radix-lock-in | +| Code highlighting | shiki | 1.29 | Server-side highlighting in Caddyfile view | +| Database ORM | Prisma | 7.8 (via `@prisma/adapter-pg`) | Same as Scrum4Me; één skill om beide te onderhouden | +| Auth (password) | bcryptjs | 3 | Geen native bindings nodig | +| Session | Custom in `lib/session.ts` | — | Eenvoudig: token in DB, hash in cookie | +| Agent | Fastify | 5 | Lichtgewicht, native SSE-streaming | +| Agent whitelist | js-yaml | 4 | Read-only configfile | + +## Deploy-topologie + +| Component | Locatie | Beheer | +|---|---|---| +| ops-dashboard | Docker container `scrum4me-ops-dashboard`, image `ops-dashboard:latest` | `docker compose` in `/srv/scrum4me/compose/docker-compose.yml` | +| ops-agent | systemd unit `ops-agent.service`, host-binary `/opt/ops-agent/dist/index.js` | systemd, geïnstalleerd via `deploy/ops-agent/setup.sh` | +| Caddyfile-route | Block in `/srv/scrum4me/caddy/Caddyfile` | Handmatig, na add restart Caddy-container | +| Database | Postgres-container `scrum4me-postgres`, db `ops_dashboard` | Hergebruik bestaande container | +| Backups | `/srv/scrum4me/backups/*.sql.gz` | Cron of handmatig via UI | + +Caddy routeert `ops.jp-visser.nl` → service-naam `ops-dashboard:3000` op compose-bridge. + +## Data-model + +``` +User +├── id cuid (string PK) +├── email unique +├── pwd_hash bcrypt $2b$12$... +└── created_at + +Session +├── id cuid (PK) +├── user_id → User +├── token_hash sha256 hex (cookie waarde wordt gehashed opgeslagen) +└── expires_at 24h na create + +FlowRun +├── id cuid (PK) +├── user_id → User +├── flow_name string (bv. "update_scrum4me_web") +├── status enum: pending|running|success|failed|cancelled +├── started_at +├── finished_at nullable +└── (1:N) FlowStep + +FlowStep +├── id cuid (PK) +├── flow_run_id → FlowRun (cascade delete) +├── step_index int +├── name string (zoals in YAML flow-definitie) +├── exit_code int nullable +├── stdout text (max 64KB, geknipt) +├── stderr text (max 64KB, geknipt) +├── started_at +└── finished_at nullable +``` + +Migrations in `prisma/migrations/`. Seed in `prisma/seed.ts` (creëert eerste admin uit `SEED_USER_*`). + +## Auth-flow + +``` +1. Browser GET /login + ← Set-Cookie: csrf_token=; SameSite=strict; httpOnly=false + ← HTML form + +2. Browser POST /api/auth/login + Headers: + Cookie: csrf_token=; ops_session=... + x-csrf-token: ← double-submit CSRF check + Body: { email, password } + +3. Server: + a. proxy.ts CSRF check (cookie==header) + b. /api/auth/login route: + - rate-limit per IP (5/min) + - prisma.user.findUnique({ email }) + - bcrypt.compare(password, user.pwd_hash) + c. Bij succes: + - generateSessionToken (32 bytes hex) + - prisma.session.create({ token_hash: sha256(token), expires_at: now+24h }) + - Set-Cookie ops_session=; HttpOnly; SameSite=strict; Secure (in prod) + +4. Browser GET / + Server: proxy.ts → als geen ops_session cookie → redirect /login + Anders: getCurrentUser() leest cookie, hashed, prisma.session.findUnique({ token_hash }) +``` + +CSRF: double-submit cookie pattern. CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy via proxy.ts response-headers. + +## Agent-protocol + +Dashboard → agent communicatie via `lib/agent-client.ts`: + +``` +POST http://172.18.0.1:3099/agent/v1/exec +Headers: + Authorization: Bearer + Content-Type: application/json +Body: + { command_key: "docker_ps", args?: string[], stdin?: string } +``` + +Response: SSE stream + +``` +event: stdout +data: {"data": ""} + +event: stderr +data: {"data": ""} + +event: exit +data: {"code": 0} +``` + +Agent server-side flow per call: +1. `req.body.command_key` → lookup in `/etc/ops-agent/commands.yml` +2. Bij hit: spawn `def.cmd[0]` met `def.cmd.slice(1) ++ args` (geen shell, geen interpolatie) +3. Stream stdout/stderr chunks naar SSE +4. Bij `child.close`: write `event: exit`, end response +5. Bij `child.error`: write `event: error`, end response +6. Bij `reply.raw.close` (client-disconnect): `child.kill()` +7. Audit-log naar journalctl: `{audit:true, command_key, args, exit_code, duration_ms}` + +`commands.yml` voorbeeld: + +```yaml +docker_ps: + cmd: ["docker", "ps", "--format", "json"] + description: "List running containers" + +git_status: + cmd: ["git", "status", "--short", "--branch"] + cwd_pattern: true # args[0] = cwd, rest = command args + description: "Git status in a repo" + +systemctl_restart_caddy: + cmd: ["sudo", "/usr/bin/systemctl", "restart", "caddy"] + description: "Restart caddy service" +``` + +Geen `command_key` in whitelist → 403 Forbidden. + +## Flows engine + +YAML-definitie in `ops-agent/flows.example/*.yml`: + +```yaml +name: update_scrum4me_web +description: Pull main, build, restart container, verify +steps: + - name: Pull latest main + command_key: git_pull + args: ["/srv/scrum4me/repos/Scrum4Me", "main"] + precondition: git_status_clean + - name: Build container + command_key: docker_compose_build + args: ["scrum4me-web"] + - name: Restart + command_key: docker_compose_up + args: ["-d", "scrum4me-web"] + - name: Smoke test + command_key: curl_status + args: ["https://scrum4me.jp-visser.nl"] + expect_exit_code: 0 +``` + +Runner (`ops-agent/src/lib/flow-runner.ts`): +- Sequential, fail-fast +- Per stap: check preconditions, spawn, capture stdout/stderr, store in FlowStep +- Bij dry-run: vervang `spawn` door log van `def.cmd ++ args` +- Bij echte run: stream via SSE naar dashboard `/api/flows/run` route + +## Realtime in de UI + +Niet via WebSocket of Server-Sent Events op de dashboard-side. Auto-refresh wordt server-rendered (`export const dynamic = 'force-dynamic'`) met client-side `useEffect(setInterval, 5000)` om `router.refresh()` te triggeren. + +Flow-execution: client opent `EventSource` op `/api/flows/run/[id]` die de SSE van de agent doorstuurt. + +## Configuratie + +Verplicht in `.env`: + +```bash +DATABASE_URL=postgresql://USER:PASS@postgres:5432/ops_dashboard +OPS_AGENT_URL=http://172.18.0.1:3099 +OPS_AGENT_SECRET= +SEED_USER_EMAIL=admin@example.com +SEED_USER_PASSWORD= +``` + +Optioneel: + +```bash +SYSTEMD_UNITS=scrum4me-web,ops-agent # comma-separated +REPO_PATHS=/srv/scrum4me/repos/Scrum4Me,… # comma-separated absolute paths +``` + +Bij start: app valideert dat verplichte env vars gezet zijn; faalt fast met duidelijke error. + +## Security-eigenschappen + +| Eigenschap | Implementatie | +|---|---| +| Wachtwoord-hashing | bcrypt 12 rounds | +| Session-cookie | HttpOnly, SameSite=strict, Secure in prod, 24u TTL | +| CSRF | Double-submit cookie pattern, validated in `proxy.ts` voor POSTs | +| CSP | Strict in response headers — geen inline scripts behalve Next.js internals met nonce | +| Agent-auth | HMAC via Bearer-token (`OPS_AGENT_SECRET`) — symmetrisch | +| Command-injection | `spawn(bin, args, {shell: false})` — geen shell-interpolatie ooit | +| Whitelist | `commands.yml` is single source of truth voor wat draaibaar is | +| Sudo | `sudoers.d/ops-agent` met absolute paden + service-namen, geen wildcards | +| Audit | Elke `/agent/v1/exec` call logt naar journalctl met `{audit:true, …}` markeer | +| Rate-limit | Login 5/min/IP; agent per-secret zonder rate-limit (single-user trust) | +| Bind | Agent bindt op `0.0.0.0:3099`; UFW staat alleen `172.18.0.0/16` toe | + +## Niet-functionele eigenschappen + +| Eigenschap | Specificatie | +|---|---| +| Geen multi-tenancy | Eén user-row in DB, app verifieert alleen "is er een geldig session-record"; geen `WHERE user_id = ?` filter (single-tenant) | +| Geen retry/queue | Failed flows blijven failed; user moet handmatig opnieuw klikken | +| Geen migrations-automation | `prisma migrate deploy` is **niet** in de boot-flow; doe je expliciet bij elke deploy | +| Geen graceful shutdown | Container SIGTERM → in-flight requests verloren; geen drain | +| Logging | Stdout/stderr van containers via `docker logs`; agent via `journalctl -u ops-agent`; geen aggregator | + +## Open punten + +- **Echte caddyfile-grammar** (IDEA-061) — nu nginx-fallback +- **Multi-user / RBAC** — buiten scope, mogelijk later +- **Rate-limit op agent** — voor multi-user toekomst nodig +- **Real-time alerts** — momenteel pull-based, push naar Slack/Tailscale-only nog niet