chore: remove internal/AI docs from the repo (keep local)

The GitHub-private staging repo is a full push-mirror of Gitea, so export-ignore
(which only protects the public repo via git archive) does not strip these from
the mirror. Untrack the internal/AI-collaboration docs — CLAUDE.md, rules.md,
handoff.md, kickoff-prompt.md and docs/ (specs, plans, runbooks, handoffs) — and
gitignore them so they stay on the dev box but never reach Gitea, the mirror, or
any shipped image. The public repo was already clean via export-ignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-23 01:09:04 +02:00
parent 665c9f93bd
commit 06e9fcbad7
49 changed files with 7 additions and 18523 deletions

7
.gitignore vendored
View File

@ -39,3 +39,10 @@ yarn-error.log
# the dir itself is kept (bind-mount target) but its runtime contents are never committed
/run/*
!/run/.gitkeep
# internal / AI-collaboration docs — kept on the dev box, never committed, mirrored or shipped
/CLAUDE.md
/rules.md
/handoff.md
/kickoff-prompt.md
/docs/

266
CLAUDE.md
View File

@ -1,266 +0,0 @@
# CLAUDE.md — Clusev
Project context for any agent/developer. **Read `rules.md` first — those rules are
non-negotiable.** This file is the fast ramp-up: what we're building, the stack, where things
live, how to run them, and the conventions. Source of truth for decisions: `handoff.md`.
---
## 1. Product
**Clusev** — a self-hosted control panel to administer a **fleet of Linux servers from one
dashboard**, connecting **agentless over SSH**. The operator sees and steers the whole fleet
(metrics, services, files, audit) in one security-first UI (2FA, full audit log).
- **Backend reality:** Laravel is the **control-plane** (UI + API + provisioning). It does **not**
reimplement SSH/daemons — it orchestrates real servers over SSH via **phpseclib** (exec + SFTP).
- **Audience:** developers, self-hosters, sysadmins.
- **Differentiator:** multi-server fleet management with a modern, polished UI. **Multi-server is
free and never paywalled.** Paid = Team/Enterprise (SSO/LDAP, RBAC, audit export, alerting,
backups) + optional managed control-plane. **License:** AGPL core + commercial Pro modules —
architect open-core from day one (clean core, Pro as separate modules/flags).
### v1 scope (build this first — do NOT boil the ocean)
- **Foundation (once):** auth + **2FA**, **audit log**, encrypted **SSH-credential vault**,
**SSH layer** (exec + SFTP via phpseclib), **Reverb** realtime channel, **queue** workers,
**multi-server switcher**.
- **Features:** (1) **Dashboard / live metrics**, (2) **systemd services** (list + start/stop/
restart + logs), (3) **SFTP file manager**, plus the **Server-Details** page (identity,
resource gauges, specs, volumes, interfaces, security hardening, SSH keys).
### Deferred (keep OUT of v1)
- **Web terminal** (xterm.js + ws↔SSH sidecar — PHP can't hold an interactive PTY).
- **Package management** (apt/dnf), firewall / users / cron, app store, push-metrics agent.
---
## 2. Tech stack (hard requirements)
| Layer | Choice |
|---|---|
| Framework | **Laravel 13** |
| UI / interactivity | **Livewire v3** — class-based, **NOT Volt** |
| CSS | **Tailwind v4** (CSS-first, `@theme` in `app.css`, **no `tailwind.config.js`**) |
| Build | **Vite** |
| Modals | **wire-elements/modal** |
| Realtime | **Laravel Reverb** + Echo (live metrics, broadcasts) |
| Queue / cache | **Redis** |
| DB | **MariaDB** *(confirmed)* |
| SSH | **phpseclib** (exec + SFTP) |
| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade |
| Icons | **Lucide**, inline SVG via a Blade `x-icon` component |
| Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — **self-hosted only** (`public/fonts/*.woff2` + `@font-face` in `app.css`), **never a CDN/Google Fonts link or `@import`** (R14) |
> **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**.
**Composer packages:** `livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb`.
**NPM (Tailwind v4):** `tailwindcss @tailwindcss/vite` (+ Echo + a charts lib).
---
## 3. Architecture notes
- **Pages = full-page class-based Livewire components** mapped directly in `routes/web.php`
(R1/R2). No page controllers.
- **SSH layer** (`app/Support/Ssh/`): `SshClient` (exec), `Sftp`, `CredentialVault`
(encrypted credentials). Domain services in `app/Services/` (`FleetService`, `MetricsPoller`,
`Provisioner`) orchestrate them; queued long ops go in `app/Jobs/`.
- **Realtime:** `MetricsPoller` → broadcast events (`app/Events/`, e.g. `MetricsTicked`) over
**Reverb**; the Dashboard Livewire component receives them (Echo / `wire:stream`) and updates
the chart island. **Mock data first, real SSH after.** **Convention: every broadcast channel is
PRIVATE** (authenticated via `/broadcasting/auth`) — the app key is bundled into the JS and the
Reverb socket can answer on a stale Caddy host, so a public channel would leak fleet data. Declare
events with `PrivateChannel`/`PresenceChannel` and authorize in `routes/channels.php` (see the
header comment there); per-server channels also scope to records the user may see.
- **Open-core:** keep the core clean; Pro features behind separate modules/flags from day one.
---
## 4. Folder map (§5 — follow exactly, R6)
```
app/
Livewire/ # class-based components (full-page = routes, + nested)
Dashboard.php
Servers/{Index.php, Show.php} # Show = Server-Details page
Services/Index.php
Files/Index.php
Audit/Index.php
Auth/{Login.php, TwoFactor.php}
Modals/ # wire-elements/modal components (ConfirmDelete, ServerForm, …)
Concerns/ # shared traits (e.g. WithFleetContext)
Models/ # Server, AuditEvent, User, …
Support/Ssh/ # phpseclib wrappers: SshClient, Sftp, CredentialVault
Services/ # FleetService, MetricsPoller, Provisioner
Events/ # broadcast events (MetricsTicked, …)
Jobs/ # queued ops
resources/
views/
livewire/ # mirrors app/Livewire (kebab-case)
dashboard.blade.php
servers/{index,show}.blade.php
services/index.blade.php
files/index.blade.php
audit/index.blade.php
modals/…
components/ # Blade UI: panel, kpi, status-pill, status-dot, badge,
… # sidebar, topbar, server-item, nav-item, icon, ring
layouts/app.blade.php
css/app.css # @import "tailwindcss"; + @theme { tokens } + base layer
js/app.js # Echo/Reverb bootstrap, Alpine islands (charts; xterm later)
routes/web.php # full-page Livewire routes
config/livewire.php # class_namespace=App\Livewire, view_path=views/livewire
docker/ # Dockerfile bits, php-fpm/nginx config, entrypoint
docker-compose.yml # DEV (build, bind-mount, Vite)
docker-compose.prod.yml # PROD (image from GHCR, no mount)
.dockerignore
handoff.md rules.md CLAUDE.md
```
---
## 5. Design system → Tailwind v4 `@theme`
Direction: **"Tactical Terminal"** — dark graphite, **signal-orange** brand (softened on
selection/active tints), **cyan** counter-accent, ops status triad, **mono for every
number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
- **Surfaces:** `void, base, surface, raised, inset``bg-surface`, `bg-raised`, …
- **Brand:** `accent, accent-bright, accent-deep, accent-text``text-accent`, `bg-accent/10`
(use **opacity utilities** for tints, e.g. `bg-accent/10`, `border-accent/25` — no `--accent-dim`).
- **Counter-accent:** `cyan, cyan-bright`.
- **Status:** `online (#35D07F)`, `warning (#E8B931)`, `offline (#FF5247)``text-online`, …
- **Text ramp:** `ink, ink-2, ink-3, ink-4``text-ink`, `text-ink-2`, …
- **Borders (hairline):** `line, line-soft, line-strong``border-line`, …
- **Fonts:** `--font-display` (Chakra Petch), `--font-sans` (Space Grotesk), `--font-mono`
(JetBrains Mono) → `font-display`, `font-mono`.
- **Radii:** `--radius-xs/sm/md/lg``rounded-sm`, `rounded-lg`.
> Full token values are in `handoff.md` §6 — port them verbatim into `app.css`. Selection
> (`::selection`) and active nav/server tints use `bg-accent/25` and `bg-accent/10` + `border-accent/25`
> (keep subtle). **`../bastion` mockup is NOT present on this machine** → rebuild from §6 tokens +
> the §11 component list; pull Lucide SVG paths directly from Lucide.
**Blade components to build:** `x-panel`, `x-kpi`, `x-status-pill`, `x-status-dot`, `x-badge`,
`x-icon` (Lucide), `x-ring`, `x-server-item`, `x-nav-item`, plus `sidebar` + `topbar` partials.
---
## 6. Conventions
- **UI copy is localized (DE default + EN)** — never hard-code visible strings; use
`__('group.key')` with translations in `lang/{de,en}/<group>.php` (one group per feature +
shared `common`), keys identical in both languages. Register: terse/operational, **no emoji**
(status = color/dots/pills). Native technical tokens stay as-is (`nginx.service`, `SSH`, `2FA`). (R9, R16)
- **Colors:** only `@theme` token utilities in markup — never raw hex/rgb. (R3)
- **Inline styles:** forbidden except a progress bar's `width`. (R4)
- **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show`
(kebab, mirrored path). Components in `app/Livewire/…`, views in `resources/views/livewire/…`.
- **Pages:** full-page Livewire components as routes — no page controllers. (R1/R2)
- **Blade:** use block `@php … @endphp` (not inline `@php(...)`); never put the literal tokens
`@php`/`@endphp` in a comment/text — the raw-block matcher eats them. (R17)
- **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5)
- **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer
PK (`getRouteKeyName(): 'uuid'`). (R11)
- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not
`/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13)
- **Fonts self-hosted only**`.woff2` in `resources/fonts/` (Vite-bundled, relative
`url('../fonts/…')`) + `@font-face` in `app.css`; never a Google Fonts / CDN `<link>` or
`@import`. (R14)
- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid
4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7)
- **Docs language:** these meta-docs are in English; **UI strings are German**.
---
## 7. Commands (everything in containers — R8)
> Dev stack services: `app` (php-fpm + nginx + **vite** via supervisor — ONE container),
> `reverb`, `queue`, `mariadb`, `redis`. App on **:80**, Vite HMR on **:5173**
> (`hmr.host=10.10.90.136`). Vite is **not** a separate service. Containers run as `HOST_UID`
> (1002 on this VM); run write-tooling as
> `docker compose run --rm --no-deps -u "${HOST_UID}:${HOST_GID}" app …`.
```bash
# ── Stack lifecycle (DEV) ────────────────────────────────────────────────
docker compose up -d # start the dev stack
docker compose logs -f app # tail app logs
docker compose down # stop
# ── Run tooling INSIDE the container ─────────────────────────────────────
docker compose exec app php artisan migrate
docker compose exec app php artisan make:livewire Servers/Show # class + view (never make:volt)
docker compose exec app composer require some/package
docker compose exec app php artisan tinker
# ── One-time bootstrap (host has no PHP/Composer/Node) ───────────────────
# run in a throwaway container or the app image:
# composer create-project laravel/laravel .
# composer require livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb
# npm install -D tailwindcss @tailwindcss/vite
# php artisan livewire:publish --config # class-based, view_path set
# ── Realtime / queue (run as their own services; manual run if needed) ───
docker compose exec app php artisan reverb:start
docker compose exec app php artisan queue:work
# ── Prod deploy (plain Docker Compose — no Portainer) ────────────────────
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
```
---
## 8. Infrastructure & deploy (§8)
- **Target VM:** Debian 13, **`10.10.90.136`**, **only Docker** + a sudo user. Everything in
containers. **No Portainer, no external orchestrator.** Dev happens on this same VM (prod-parity).
- **`docker-compose.yml` (DEV):** `build:`, **bind-mounts** source. The `app` container runs
php-fpm + nginx + Vite (HMR) via supervisor (one unit). Ports/UID are **env-driven**
(`APP_PORT`, `VITE_PORT`, `REVERB_HOST_PORT`, `DB_PORT`, `HOST_UID`/`HOST_GID` in `.env`) —
nothing hardcoded. App on **:80**, Vite remote HMR (`server.host=0.0.0.0`,
`hmr.host=10.10.90.136`), MariaDB only on `127.0.0.1:3306`.
- **`docker-compose.prod.yml` (PROD):** `image: ghcr.io/OWNER/clusev:<tag>` *(GHCR namespace is a
**placeholder `OWNER`** — set via `.env` before the first prod push)*, **no bind-mount**, assets
baked into the image, no Vite. Reverse proxy + TLS (Caddy/Traefik) is just another service.
- **Deploy:** wrap pull+up+migrate in `deploy.sh` (or a CI job that SSHes in). Image flow:
push → CI builds + pushes to **GHCR**`deploy.sh` pulls onto the VM. Bootstrap before CI:
build on the VM. Use `docker buildx` (multi-arch) only if any dev happens on arm64; prod is amd64.
---
## 9. Repo & workflow
- **Project root = `/home/nexxo/clusev`** (its own git repo). The parent `$HOME` holds only
`.env.gitea` + home dotfiles. Host user **nexxo = uid 1002** (`wp-test`=1000, `testwp`=1001
pre-existed) → `HOST_UID/HOST_GID=1002` in `.env`.
- **Remote:** Gitea `git.bave.dev/boban/clusev.git`. Credentials live in **`.env.gitea`**
(untracked) — the `GIT_ACCESS_TOKEN` must later move into the app `.env`.
- **Work on a feature branch, not `main`.** Commit in sensible steps.
- **Secret hygiene:** `.env*` (incl. `.env.gitea`) + keys are gitignored; never commit/echo the
Gitea token (read it only at push time). See `rules.md` → Appendix.
- **Verify before "done" (R12):** load **every touched page in a real browser** (headless ok) →
**HTTP 200** (no 500) + **zero console/network errors**; for lazy `wire:init` pages check the
**loaded** state, not the skeleton. A green `Livewire::test` is **not** enough. Test the 3 breakpoints.
---
## 10. Before you code — checklist
1. **Read `rules.md`.** R1R11 are non-negotiable; on any conflict, **stop and ask**.
2. New page? → full-page **class-based** Livewire component + route in `web.php` (R1/R2). Never Volt.
3. Need a color/spacing? → an **`@theme` token utility** (R3). No raw hex, no inline style except
progress `width` (R4).
4. Destructive/confirm? → **wire-elements/modal** (R5).
5. Place files per the **§4 folder map** (R6). Class ↔ mirrored kebab view.
6. Build **responsive**: 375 / 768 / 1280; ≥44px touch targets (R7).
7. Run all tooling **inside the container** (R8).
8. UI copy **German, no emoji**; status via color/dots/pills (R9).
9. **Reuse** the token set + Blade component kit (R10).
10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console
errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough;
**inspect the rendered DOM** (not just status/console) for leaked `@`/`{{ }}`/`$var`/`group.key`
text — those return 200 too (R17); build + 3 breakpoints; then `git status` for stray secrets, commit.
11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and
no security issues**. Fix + re-run until clean. The task is not done until Codex passes.

View File

@ -1,215 +0,0 @@
# Clusev — End-to-End-Design: Install, Domain/TLS & In-Dashboard-Updates
Entscheidung des Lead-Architekten. Verbindlich, opinionated. Priorität: Korrektheit > Sicherheit > Operator-Einfachheit > wenige bewegliche Teile.
---
## 0. Kurzfassung (TL;DR)
- **Install** = dünnes `install.sh` (Secrets + Infra + Schema + erster Admin) **plus** ein **authentifizierter** In-App-Onboarding-Wizard (Passwortwechsel, 2FA-Pflicht, erster Server). Ein unauthentifizierter `/install`-Webwizard wird abgelehnt.
- **Ports/Domain** sind vollständig `.env`-getrieben. Eine einzige Stellschraube — `APP_DOMAIN` — entscheidet HTTP-auf-IP vs. HTTPS-auf-Domain. Alles andere (`APP_URL`, `REVERB_*`, `SITE_ADDRESS`) wird daraus **abgeleitet**, nichts hardcoded.
- **Proxy/TLS** = **Caddy** als einziger host-exponierter Dienst im Stack (Auto-Let's-Encrypt, Auto-Renew, Reverb-wss über dasselbe :443). Traefik und certbot+nginx werden verworfen.
- **Admin** wird im Shell-Flow per `artisan clusev:install` mit **Zufallspasswort** angelegt, das **genau einmal** im Operator-Terminal erscheint. Kein Default-Credential, kein „first-visitor-wins"-Claim-Fenster.
- **In-Dashboard-Update** = die App schreibt nur eine **HMAC-signierte Intent-Datei**; ein **host-seitiger Updater** (systemd path+oneshot) führt aus. Die App fasst den Docker-Socket **nie** an. Digest-Pinning + cosign-Verify + 2FA + Audit + Backup-vor-Migration + Rollback-by-Digest.
---
## 1. EMPFEHLUNG je Nutzerfrage
### (1) Install: Anleitung vs. `install.sh` vs. In-App-Wizard
**Klarer Split — beides, mit Trennlinie an der Trust-Boundary „wer SSH/Docker auf der Box hat":**
- **`install.sh` (PRIMÄR)** macht genau das, was *vor* Existenz der App nötig ist: Preflight, Repo materialisieren, Secrets idempotent generieren, `.env` schreiben, Stack hochfahren, auf DB-Health warten, `migrate --force`, **ersten Admin** per `artisan clusev:install` anlegen. Dünn, keine Business-Logik.
- **In-App-Wizard (SEKUNDÄR, AUTHENTIFIZIERT)** = Onboarding *nach* dem ersten Login: Passwort-Pflichtwechsel, 2FA-Pflicht-Enrolment, ersten Server hinzufügen, SMTP/Branding. **Kein** unauthentifizierter `/install`-Endpoint.
- **Schriftliche Anleitung (FALLBACK)** = nur als README-Appendix für Operatoren, die `curl|bash` misstrauen und jeden Befehl selbst lesen wollen.
**Begründung:** Ein offener, privilegierter `/install`-Route auf einem Security-First-Fleet-Panel ist genau die Art Endpoint, die vergessen und ausgenutzt wird (historische „Setup-Wizard"-RCEs). Die Shell hat die gewünschte Trust-Boundary bereits: nur wer SSH/Docker auf der VM hat, kann sie ausführen, und das Einmalpasswort erscheint nur in *dessen* Terminal.
### (2) Ports/Domain NICHT hardcoded — env-getrieben
Eine Master-Variable, alles andere abgeleitet:
| `.env`-Knopf | leer / IP-Modus | gesetzt / Domain-Modus |
|---|---|---|
| `APP_DOMAIN` | *(leer)* | `clusev.example.com` |
| `APP_SCHEME` | `http` | `https` |
| `APP_PORT` | z. B. `80` (host bind) | 80+443 von Caddy belegt |
| `SITE_ADDRESS` (abgeleitet) | `:${APP_PORT}` | `https://${APP_DOMAIN}` |
| `APP_URL` (abgeleitet) | `http://<IP>:${APP_PORT}` | `https://${APP_DOMAIN}` |
| `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` (abgeleitet) | `<IP>` / `${APP_PORT}` / `http` | `${APP_DOMAIN}` / `443` / `https` |
| `ACME_EMAIL` | — | Admin-E-Mail |
`VITE_REVERB_*` interpolieren bereits aus `REVERB_*` (siehe `.env.example`), folgen also automatisch. Der Installer berechnet `SITE_ADDRESS`/`APP_URL`/`REVERB_*` **einmal** aus `APP_DOMAIN` und schreibt sie idempotent. Eine einzige Caddyfile-Vorlage deckt beide Modi ab.
### (3) Optionale Domain + Auto-TLS — welcher Proxy
**Caddy 2** (`caddy:2-alpine`, per `@sha256`-Digest gepinnt), als **einziger host-exponierter Dienst**.
- **Caddy gewinnt** gegen Traefik (Label-Sprawl + Docker-Socket-Mount für den Provider — unnötige Angriffsfläche bei nur 2 Backends) und gegen certbot+nginx (zweites nginx, Renewal-Timer, Reload-Hook — die meisten beweglichen Teile). ~12-Zeilen-Caddyfile, Auto-HTTPS + Auto-Renew + HTTP→HTTPS-Redirect ohne ein einziges Script, WebSocket-Upgrade implizit.
- **Reverb-wss** wird per Pfad (`/app/*`, `/apps/*`) auf `reverb:8080` über **dasselbe :443** geroutet — kein extra exponierter Port, kein Mixed-Content.
- **App & Reverb verlieren ihre `ports:`** und sind nur noch im internen `clusev`-Bridge-Netz erreichbar (`app:80`, `reverb:8080`). Kleinere öffentliche Angriffsfläche.
- **Zertifikate persistieren** im Named Volume `caddy-data` → Stack-Recreate beim Update triggert **kein** erneutes ACME (keine Rate-Limit-Probleme).
**Scharfe Kante (muss in die Prompts):** Let's Encrypt HTTP-01/TLS-ALPN-01 braucht öffentlich erreichbare 80/443. Die Ziel-IP `10.10.90.136` ist RFC1918-privat → öffentliches Cert schlägt dort fehl. Der Installer muss das erkennen und entweder bare-IP-HTTP anbieten oder einen DNS-01-Caddy-Build (anderes Image + DNS-Credentials) dokumentieren. **Niemals** bei gesetzter Domain still auf HTTP zurückfallen — laut scheitern.
### (4) Admin-User + Random-Passwort am Ende
- `artisan clusev:install --email=…` ist **idempotent**: existiert bereits ein Admin → No-op („bereits installiert"). Sonst: Admin mit `Str::password(20)`, Flags `must_change_password=true` + `two_factor`-noch-nicht-enrolled, **nur den bcrypt-Hash** speichern.
- Das **Klartext-Passwort** wird ausschließlich auf stdout (eine Marker-Zeile) ausgegeben; die Shell zeigt es im Schlussbanner — **nie** in `.env`, Datei, DB oder History.
- **Kein Default-Credential** (Umami-Antipattern), **kein Claim-Fenster** (Coolify/Plausible/Kuma). Erzwungener Passwortwechsel + 2FA-Pflicht beim ersten Login sind die kompensierenden Kontrollen gegen Scrollback/tmux-Leaks.
- `installed`-Marker (DB-Flag/Sentinel) deaktiviert den First-Run-Pfad hart nach erstem Lauf (Gitea `INSTALL_LOCK`-Idiom).
### (5) In-Dashboard-Updates — Mechanismus + Sicherheit + Rollback
**Gewählter Mechanismus: App schreibt Intent, host-seitiger Updater führt aus (out-of-band).** Verworfen: Socket-Sidecar in/neben der App (ein RCE = Host-Root, und die App hält den Fleet-SSH-Vault) und Watchtower (unbeaufsichtigt, kein 2FA, keine Migration, kein Rollback).
Löst „ein Container kann seinen eigenen Parent-Stack nicht neu erzeugen", weil der Executor **außerhalb** des `clusev`-Compose-Lifecycles lebt (eigene systemd-Unit) — App/reverb/queue neu zu erzeugen tötet nicht den Prozess, der das Update fährt. **Die App bekommt keinerlei Docker-Privileg** — ihre einzige neue Fähigkeit ist „eine signierte Datei schreiben".
---
## 2. Konkrete Install-UX
**One-Liner:** `curl -fsSL https://raw.githubusercontent.com/<owner>/clusev/main/install.sh | bash` — oder aus einem Clone `./install.sh`. Interaktiv nur am TTY; ohne TTY (gepiped) liest er `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` aus env oder fällt auf bare-IP/HTTP zurück.
**Prompts (Deutsch, kein Emoji):**
```
Clusev Installer
================
Domain (leer = Zugriff per IP über HTTP): > clusev.example.com
HTTP-Port (nur ohne Domain, Standard 80): > 80
Admin E-Mail (Login + Let's Encrypt): > admin@example.com
```
**Generiert (idempotent — `set_kv` schreibt nur bei leer/Platzhalter, regeneriert NIE):**
- `APP_KEY` via `docker compose run --rm --no-deps app php artisan key:generate --show` (kein Host-PHP)
- `DB_PASSWORD`, `DB_ROOT_PASSWORD`, `REVERB_APP_SECRET` via `openssl rand -hex 32`
- `REVERB_APP_ID`, `REVERB_APP_KEY` via `openssl rand -hex 16`
- `APP_ENV=production`, `APP_DEBUG=false`, `REDIS_PASSWORD` gesetzt; `HOST_UID/GID` aus `id`
- abgeleitet: `SITE_ADDRESS`, `APP_URL`, `REVERB_HOST/PORT/SCHEME`
**Phasen-Fortschritt:**
```
[1/7] Voraussetzungen prüfen … Docker + compose vorhanden, Ports frei — ok
[2/7] Secrets generieren … .env vorhanden, vorhandene Werte bleiben erhalten
[3/7] Images holen … ghcr.io/<owner>/clusev@sha256:… gezogen
[4/7] Stack starten … 6 Container laufen (inkl. caddy)
[5/7] Datenbank bereit … MariaDB healthy nach 9s
[6/7] Migrationen … 3 ausgeführt; config:cache/route:cache gebaut
[7/7] Admin anlegen … erstellt
```
**Finale Ausgabe (Passwort erscheint NUR hier):**
```
============================================================
Clusev ist bereit.
URL: https://clusev.example.com
Admin: admin@example.com
Passwort: 7Qf2-Kp9x-Lm4v-Rt8z <-- nur jetzt sichtbar, nicht gespeichert
Beim ersten Login: Passwort ändern + 2FA einrichten (Pflicht).
============================================================
```
---
## 3. Reverse-Proxy/TLS-Design
**Topologie (prod):** Heute publizieren `app:80` und `reverb:8080` direkt auf den Host. Neu: **nur Caddy** bindet Host-Ports; `app`/`reverb` verlieren `ports:`.
**Caddy-Service** (in `docker-compose.prod.yml`): `image: caddy:2-alpine` (per Digest pinnen), `ports: ${APP_PORT:-80}:80`, `443:443`, `443:443/udp` (HTTP/3); Volumes `./docker/caddy/Caddyfile:ro`, `caddy-data` (ACME-Account + Certs — **muss** persistieren), `caddy-config`; `depends_on: [app, reverb]`.
**Caddyfile (eine Vorlage, beide Modi):**
```
{
email {$ACME_EMAIL}
}
{$SITE_ADDRESS} {
@reverb path /app/* /apps/*
reverse_proxy @reverb reverb:8080
reverse_proxy app:80
}
```
**Die Magie:** `SITE_ADDRESS=:${APP_PORT}` → Caddy serviert Klartext-HTTP, kein Cert. `SITE_ADDRESS=https://${APP_DOMAIN}` → Auto-Let's-Encrypt, :443, Auto-Renew ~30 Tage vorher, Auto-Redirect 80→443. WebSocket-Upgrade ist implizit, daher funktioniert `/app/*` als wss auf 443 ohne Spezialdirektive.
**Vor dem Shippen verifizieren (OFFENER PUNKT):** `config/reverb.php` / `config/broadcasting.php` sind im Repo noch nicht publiziert. Die exakten Echo-WS-Pfade (`/app/*` WS, `/apps/*` Event-API) müssen gegen die Config geprüft werden, sonst bricht Realtime still.
**Bare-IP-Warnung:** IP-Modus serviert Panel inkl. 2FA + Audit über Klartext-HTTP. Für ein Security-First-Produkt: lauter Warnhinweis in Install-Ausgabe **und** UI; optional self-signed/Internal-CA-Option für IP-only.
---
## 4. Self-Update-Mechanismus
**Kontroll-/Datenfluss:**
1. **UI-Aktion** (`app/Livewire/System/Update.php`, Vollseite, Deutsch): GHCR-API liefert Tags/Digests + Changelog (read-only Token). Zeigt aktuellen Digest vs. Ziel-Digest + Release-Notes.
2. **Gate:** frische **TOTP-2FA-Challenge** (Step-up, nicht aus der Session geerbt) + `can.update`-Authorization. AuditEvent **vor** jeder Aktion.
3. **Intent schreiben (kein Socket):** App schreibt `update-request.json` ins geteilte Volume `clusev-control`: `{target_digest, target_tag, changelog_hash, requested_by, requested_at, nonce, hmac}`. HMAC-Key **separat von `APP_KEY`**, nur App+Updater bekannt → ein Volume-Write-Angreifer kann kein Update fälschen.
4. **Host-Updater führt aus:** **systemd `clusev-updater.path`** (überwacht die Datei) + `clusev-updater.service` (`Type=oneshot`, läuft als docker-group-User, **nicht** als App). Einmalige Human-sudo-Installation zweier Unit-Files — die einzige Host-Konzession (vom Brief erlaubt).
5. **Update-Script (`docker/update.sh`), idempotent:**
- HMAC + Nonce verifizieren (Anti-Replay); abbrechen wenn `target_digest == current`.
- Compose-Kontext aus Labels entdecken (`com.docker.compose.project=clusev`, `config_files`, `working_dir`) — **null Hardcoding**.
- **cosign keyless verify** (GitHub-OIDC, Identity = Release-Workflow) des Ziel-Digests **vor** Pull/Swap. **Fail-closed.**
- `docker pull …@<digest>`, Digest nach Pull erneut prüfen (Tag-Race-Schutz).
- **Snapshot:** aktuellen Digest aller Services + `mariadb-dump` nach `./backups/pre-update-<ts>.sql.gz` (0600).
- Digest idempotent in `.env` pinnen (`CLUSEV_IMAGE=…@sha256:…`).
- **Migration (expand/contract):** Queue stoppen → `docker compose run --rm app php artisan migrate --force` während alter App-Code noch läuft (nur sicher für additive Changes) → `reverb`+`queue`, dann `app` **zuletzt** neu erzeugen → Queue **zuletzt** starten. Destruktive (contract) Changes erst in einem **späteren** Release. CI-Lint blockt expand+contract in einem Release.
- **Healthcheck + Auto-Rollback:** `/up` pollen; bei Fehler vorigen Digest re-pinnen, `up -d`, DB-Dump restoren falls Migration lief, `update_status='rolled_back'`.
6. **App beobachtet:** Updater schreibt `update-result.json` zurück; App zeigt Fortschritt/Ergebnis, finaler AuditEvent, Request-Datei geräumt.
**Sicherheits-Controls (verdichtet):**
- App fasst Docker-Socket **nie** an → Laravel-RCE kann keine Container neu erzeugen.
- Digest-Pinning end-to-end (`:latest`-Default + `OWNER`-Platzhalter **müssen weg**), cosign-Verify fail-closed, HMAC+Nonce auf Intent, frisches 2FA, lückenloses Audit.
- **Caddy bleibt aus der Update-Transaktion** (eigener gepinnter Tag, separat aktualisiert) → ein App-Update kann TLS-Terminierung nie mid-flight wegreißen.
**Rollback-Story:** Jeder Deploy pinnt einen immutablen Digest und snapshottet den vorigen + DB-Dump. Rollback = vorigen Digest re-pinnen + `compose up` (+ optional DB-Restore). Expand-only-Updates sind immer rollback-sicher. Cross-contract-Rollback ist verlustbehaftet (Writes seit Dump gehen verloren) → in der UI explizit warnen + Extra-Bestätigung.
---
## 5. Datei-/Artefakt-Liste fürs Repo
**Neu:**
- `install.sh` (Repo-Root, `chmod +x`, raw-curlbar) — Preflight, Clone, idempotente `.env` via `set_kv()`, Proxy-Wiring, Pull/Build, Up, Wait-Health, Migrate, `clusev:install`.
- `docker/caddy/Caddyfile` — env-getemplatet, `@reverb`-Matcher + 2 `reverse_proxy`.
- `app/Console/Commands/Install.php` — artisan `clusev:install`, idempotenter Admin-Bootstrap + Einmalpasswort-Marker.
- `database/migrations/…_add_admin_onboarding_columns.php``must_change_password` + `two_factor_*`.
- `app/Services/UpdateService.php` — GHCR-Query, Tag→Digest, HMAC-Intent schreiben, Status lesen, AuditEvents.
- `app/Livewire/System/Update.php` (+ kebab-View) — Update/Rollback-UI, 2FA-Step-up, Live-Fortschritt.
- `docker/update.sh` — host-seitiges Update/Rollback-Script.
- `clusev-updater.path` + `clusev-updater.service` — host systemd Units (einmal sudo).
- `docker-compose.prod.yml` (override/Fragment) — Caddy-Service + `caddy-data`/`caddy-config`/`clusev-control`-Volumes.
**Geändert:**
- `docker-compose.prod.yml``ports:` von `app`/`reverb` entfernen; Caddy als einziger Host-Publisher; Image-Ref auf `CLUSEV_IMAGE`-Digest-Var umstellen; `OWNER`+`:latest`-Default raus.
- `.env.example``APP_DOMAIN`, `APP_SCHEME`, `ACME_EMAIL`, `SITE_ADDRESS`, `CLUSEV_IMAGE`, `UPDATE_HMAC_KEY` ergänzen; `APP_ENV=production`/`APP_DEBUG=false` für den Prod-Pfad; Platzhalter-Secrets dokumentieren als „werden vom Installer generiert".
- `README.md` — One-Liner + Fallback-Manual-Appendix.
- **CI** (`.github/workflows/…`) — cosign-Signierung der GHCR-Images + Migrations-Lint (expand/contract).
**Unverändert (referenziert):** `Dockerfile` (`rm -f .env` im Prod-Stage), `docker/entrypoint.sh`, `docker/nginx/nginx.conf`, `docker/supervisor/supervisord.conf`.
---
## 6. Phasenplan
**JETZT (v1-Scaffold):**
1. `install.sh` + `set_kv()` + `clusev:install` + Onboarding-Migration → near-one-command-Install mit gesetztem Admin.
2. Caddy-Service + Caddyfile + abgeleitete `.env`-Vars → env-getriebenes TLS (IP-HTTP / Domain-HTTPS / Reverb-wss).
3. Prod-Compose härten: `ports:` raus außer Caddy, `OWNER`/`:latest`-Default ersetzen, `APP_ENV=production`/`APP_DEBUG=false`, `REDIS_PASSWORD` setzen.
4. Authentifizierter Onboarding-Wizard (Passwortwechsel + 2FA-Pflicht).
5. **CI cosign-Signierung zuerst verdrahten** (Voraussetzung für (3) unten, fail-closed).
**SPÄTER (v1.x):**
6. Voller In-Dashboard-Updater (Intent-Protokoll, `update.sh`, systemd Units, UI, Rollback, Backup-Gate). **Nicht** shippen, bevor CI-Signierung steht.
7. DNS-01-Caddy-Variante für private-IP/Internal-Domain-Deployments.
8. Migrations-Lint (CI) erzwingt expand/contract.
9. Blue/Green-App-Replicas für echte Zero-Downtime (über v1-Single-VM-Scope hinaus).
---
## 7. Entscheidungen, die der Owner VOR dem Bauen bestätigen muss
1. **Updater-Executor:** systemd `path`+`oneshot` (empfohlen, eine einmalige Human-sudo-Installation) — **oder** separater `clusev-ops`-Container mit gehärtetem Socket-Proxy (kein sudo, aber Socket wieder im Spiel)? Empfehlung: systemd.
2. **Private-IP-Realität:** Ist `10.10.90.136` final/intern? Falls ja und trotzdem eine Domain gewünscht → DNS-01-Build nötig (anderes Caddy-Image + DNS-Provider-Credentials). Bestätigen, ob das in v1 gehört.
3. **Bare-IP-HTTP zulassen?** 2FA/Audit über Klartext ist ein Security-Kompromiss. Erlauben (mit lauter Warnung) oder bei fehlender Domain self-signed/Internal-CA erzwingen?
4. **cosign-Signierung zuerst:** Owner bestätigt, dass CI Images keyless signiert, bevor Self-Update ausgeliefert wird (sonst Verify = Theater, Update muss fail-closed blocken).
5. **Image-Referenz-Variable:** Umstieg von `IMAGE_TAG=latest` auf eine Digest-gepinnte `CLUSEV_IMAGE` in Prod-Compose — bricht keinen Dev-Flow, muss aber bewusst durchgewunken werden.
6. **`curl|bash`-Vertrauen:** One-Liner als Primärpfad akzeptiert, oder gepinnte Checksum + dokumentierter „clone, lesen, dann ausführen"-Pfad als Default für die Security-Bewussten?

View File

@ -1,46 +0,0 @@
# Release pipeline (maintainers — INTERNAL)
> This file is `export-ignore` (see `.gitattributes`) — it never reaches the public repo. It may
> name the private repositories.
Clusev is promoted across three repositories, dev → staging → public:
| Repo | URL | Role |
|---|---|---|
| Gitea (dev) | `https://git.bave.dev/boban/clusev` | Sole development source. Commit + tag here. |
| GitHub private (staging) | `https://github.com/boksbc/clusev-staging` | Push-mirror of Gitea. Runs CI; betas tested on staging. |
| GitHub public (release) | `https://github.com/clusev/clusev` | Receives only released versions — one clean commit per release, beta + stable tags. Public users install/update from here. |
Channels follow semver tags: **beta** = `vX.Y.Z-betaN`, **stable** = `vX.Y.Z`.
No private repo URL is committed: `config/clusev.php` defaults `repository` to the public URL,
`install.sh` derives `CLUSEV_REPOSITORY` from the clone origin into the (gitignored) `.env`, so dev
points at Gitea, staging at GitHub-private, and public users at GitHub-public — automatically.
## One-time wiring
1. **Gitea push-mirror** (Gitea → repo → Settings → Mirror → Push Mirror):
- Remote: `https://github.com/boksbc/clusev-staging.git`
- Auth: GitHub username + a GitHub PAT (scope `repo`) as the password.
- Enable "sync when new commits are pushed".
2. **`boksbc/clusev-staging` → Settings:**
- Variable `PUBLIC_REPO_SLUG = clusev/clusev` (the only variable the promotion workflows read).
- Secret `PUBLIC_REPO_TOKEN` = a PAT/deploy token with push to `clusev/clusev`.
- Environment `production` with a required reviewer (gates the promotion workflows).
- (Optional staging deploy) Variable `STAGING_ENABLED=true`, `STAGING_PROJECT_DIR`, and a
self-hosted runner labelled `clusev-staging` on the internal network — GitHub-hosted runners
cannot reach the internal staging host (`.165`). Until then, update staging from its Versions page.
## Promoting
- **To staging:** push a beta tag `vX.Y.Z-betaN` to Gitea → mirror → CI tests it; staging runs the beta.
- **To public (beta):** on `boksbc/clusev-staging` → Actions → run **Promote to public (beta channel)**
with the beta tag → publishes it to `clusev/clusev` (beta channel).
- **To stable:** run **Promote beta to stable** with the beta tag + the stable tag → publishes
`vX.Y.Z` to `clusev/clusev` (stable channel).
There are no custom webhooks: Gitea's push-mirror + GitHub Actions (`on: push tags` / `workflow_dispatch`)
replace them. `promote.sh` copies the release tree with `git archive`, which honours `export-ignore`, so
internal docs (`CLAUDE.md`, `rules.md`, `handoff.md`, `docs/`) never reach the public repo. Phase B will
drive the `workflow_dispatch` promotions from the dev dashboard via the GitHub API with a deploy token in
the encrypted vault — see `docs/superpowers/specs/2026-06-22-3-repo-release-promotion-design.md`.

View File

@ -1,170 +0,0 @@
# Clusev — Session Handoff (2026-06-14)
Self-contained ramp-up for the next session. Read `rules.md` (R1R17) + `CLAUDE.md` first;
this file = current state, open items, and the exact work recipes. `handoff.md` is the
original foundational decisions doc (do not overwrite it).
---
## 1. Current state
- **Branch:** `feat/v1-foundation` (never commit to `main`). Remote: Gitea
`git.bave.dev/boban/clusev.git`.
- **Shipped tags** (all pushed):
- `v0.3.0` — full bilingual UI (DE default + EN), user-selectable; rule **R16**.
- `v0.4.0` — change the panel domain from the dashboard (DB snapshot → applied on restart;
Caddy on-demand TLS; request-derived Reverb endpoint; anti-lockout middleware).
- `v0.4.1` — fixed a garbled `<header>` on every signed-in page (Blade raw-block bug → rule
**R17**); made broadcast channels **private** (metrics no longer public).
- `v0.4.2` — Server-Details UX + hardening polish (see §2b): uniform `danger-soft` buttons,
installed-only firewall/fail2ban grid, auto-updates as a neutral preference, SSH-verified
server creation + `pending` status, SSH key-only hint, and the fail2ban/UFW detection fix.
- `v0.4.3` — responsive audit (R7, all views @ 375/768): hardened mobile overflow from long
panel domains (`break-all`/`min-w-0`) and gebannte IPv6 in der fail2ban-Liste. Live pages were
already overflow-free; only data-dependent edge cases fixed.
- `v0.4.4` — stop iOS zoom-on-focus: one unlayered `@media (pointer: coarse)` rule in `app.css`
forces 16px on `input`/`textarea`/`select` on touch devices (phone + tablet any size), desktop
sizing unchanged.
- `v0.6.0`**WebAuthn / YubiKey** as an optional login 2nd factor (`web-auth/webauthn-lib`),
gated on domain+HTTPS (rpId = active domain; hidden on bare-IP). TOTP + backup codes stay the
required 2FA. Register/list/remove keys in Settings → Security; use a key at the login
challenge. **DEFERRED MANUAL CHECK:** real YubiKey register + login is browser-verifiable only
on a domain+HTTPS host (the IP rpId is invalid here) — the ceremony code is unit-tested at the
option/storage/gating level + Codex-clean, but the end-to-end crypto is unverified until tested
on a domain.
- `v0.5.0`**account recovery** (spec/plan in `docs/superpowers/`): 2FA backup codes
(encrypted, shown once + downloadable + regenerable, usable at the login challenge),
forgot-password via 2FA/backup code (email-link path gated on a configured mailer), and a
`clusev:reset-admin` CLI lockout fallback. Resets rotate `remember_token`; codes consumed
atomically. **Phase 2 still open: WebAuthn/YubiKey** as a login factor (needs `laragear/webauthn`,
JS ceremonies, HTTPS/domain) — own spec.
- `v0.4.6` — bilingual input validation: added `lang/{de,en}/validation.php` (full Laravel 13
key set + localized `attributes`); previously all field-validation errors were English-only.
- `v0.4.5` — branding: full favicon/PWA set (`public/favicon.*`, `icon-192/512`,
`site.webmanifest`) from the orange server brand-mark, wired via `partials/head-icons`; custom
bilingual error pages (`resources/views/errors/*` + `lang/errors.php`) replacing Laravel's
defaults, prod `APP_DEBUG=false` pinned, no raw error text. NOTE: unmatched-404 + 503 render in
the DE default (no session pre-StartSession) — deliberate per R16, custom message still shows.
- **Dev stack** runs on this VM via `docker compose` (app container IP **172.18.0.6**, host
`10.10.90.136:80`). Managed fleet host = **10.10.90.162** (Debian / apt / ufw; the only
live-verified target — dnf/zypper/firewalld command strings are built but not live-tested).
---
## 2. RESOLVED — fail2ban & UFW falsely shown "nicht installiert" (fixed `221e49f`)
**Was:** the Hardening checklist showed `fail2ban` and `Firewall (UFW)` as **OFFEN ·
nicht installiert**, while the fail2ban-Status panel below showed `aktiv · 14 gesperrt` with
real SSH-scanner bans. Contradiction.
**Root cause — two different detection methods (same host):**
- `HardeningService::state` (checklist): installed = `dpkg -l <pkg> | grep -q '^ii'` /
`rpm -q`; active = `systemctl is-active`. → asked "is the **apt/rpm package** recorded?"
- `Fail2banService::status` + `FirewallTool` (panels): installed = `command -v <client>`;
active = `systemctl is-active`. → asked "is the **binary present + service running**?"
- The host is Debian/apt and fail2ban/ufw is genuinely running, but dpkg does not list it as
`^ii` (source install, or dpkg `rc` config-only state) → checklist false-negative.
**Fix (committed on `feat/v1-foundation`, NOT yet tagged/pushed):** the checklist's installed
probe in `app/Services/HardeningService.php::state` now ORs three conditions —
package-manager test (`dpkg ^ii` / `rpm -q`) **OR** client binary on PATH (`command -v`)
**OR** service active (`systemctl is-active`) — via the local `$anyOf()` helper, for **both**
fail2ban and the firewall (ufw/firewalld). Consistent with `Fail2banService::status`. Since a
component can never be active without being installed, this only removes FALSE negatives,
never adds a false-positive. `PackageManager`/`FirewallTool` were left unchanged (the OR is
composed in `HardeningService`, not in their primitives).
Verified: new unit test `tests/Feature/HardeningServiceTest.php` (Debian/apt source-install
fallback) **passes**; Pint clean; Codex review clean (no actionable regression).
**Still pending (deferred this session):** (a) live acceptance check against fleet host
10.10.90.162 — load Server-Details and confirm fail2ban/ufw now read "installiert"; (b) no
version bump / CHANGELOG / tag / push — the fix lives on the branch only. The prior session's
"Fix fail2ban/UFW false negative" task chip is now **stale** (work is done).
---
## 2b. DONE — Server-Details UX + hardening polish (2026-06-14)
Seven reported issues fixed on `feat/v1-foundation`, **released as `v0.4.2`** (tagged + pushed). Spec:
`docs/superpowers/specs/2026-06-14-server-details-hardening-polish-design.md`; plan:
`docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md`.
1. **Buttons unified**`x-btn`: retired borderless `ghost`/`ghost-danger`, added `danger-soft`
(red-tinted border+bg, always). Löschen/trash now consistent, no hover-only red. All 10 usages
migrated (servers/settings/files).
2. **Firewall + fail2ban panels** — render only when the tool is installed **or** the read failed
(`readError`); 2-col `lg` grid when both, full width when one, hidden when absent. Removed the
redundant "nicht installiert" boxes.
3. **Auto-updates** — reframed as an operator preference: `secure` always true, muted
aktiv/inaktiv chip (never SICHER/OFFEN). `HardeningService` `neutral` row flag.
4. **Create-server** — verifies the SSH login inside the create transaction via
`FleetService::testConnection` (echoes a marker, requires it in output — proves exec, tolerates
missing exit-status). Failure rolls back + shows the SSH reason. New server starts `pending`.
5. **`pending` status** = "Initialisierung" (cyan, never red) across status-pill/dot, server-item,
fleet index, and the details hero. Promotes on first contact.
6. **SSH key-only hint** on the SSH-Passwort-Login checklist row while password auth is on.
Verified: 16 tests pass (`ButtonComponentTest`, `StatusComponentTest`, `HardeningServiceTest`,
`FleetTestConnectionTest`, `CreateServerTest`, `ServerShowPanelsTest`, `ServerShowSshHintTest`);
Pint clean; R12 browser DE+EN at 375/768/1280 (200, 0 console, 0 leaked tokens); Codex clean after
3 review rounds. **Pre-existing** stock `tests/Feature/ExampleTest.php` still fails (asserts `/` =
200 but the app redirects to login) — unrelated; a task chip was spawned to delete/fix it.
## 3. Other open items / follow-ups
- `resources/js/app.js` `dualChart` is **dead code** — the dashboard chart is a server-rendered
SVG, nothing subscribes via Echo. Consider removing it (its `Echo.private('metrics')` is the
only metrics subscriber and it never runs).
- **Caddy on-demand TLS / real ACME issuance is config-validated only** (no Caddy in dev, no
public DNS). App-side logic (`/_caddy/ask`, snapshot, redirects, Reverb tunnel) is
browser-verified; the actual cert issuance needs a real prod + DNS test.
- "Install-with-domain → clear-to-bare-IP" is the rough edge: re-run `install.sh` for a clean
bare-IP realtime (REVERB_* in `.env` otherwise stays domain-shaped).
- firewalld is read-only (rule add/delete is ufw-only) — needs a RHEL/Fedora/openSUSE host to
finish.
---
## 4. Plugins installed this session (need a SESSION RESTART to load)
Installed via the server's `claude` CLI (`~/.claude/remote/ccd-cli/<ver>`), scope **user**,
into the **local** plugin system (separate from the cloud-synced set):
- `caveman@caveman` — ultra-compressed output (token saver); SessionStart hook auto-activates.
- `superpowers@superpowers-dev` (v5.1.0) — workflow skills (TDD, systematic-debugging, …).
- `ui-ux-pro-max@ui-ux-pro-max-skill` (v2.5.0).
Marketplaces persisted in `~/.claude/plugins/known_marketplaces.json`. They are **not** in the
current session's skill registry until the session is **restarted** (that's why `/caveman:caveman`
still fails this session). After restart: `/caveman:*`, `/superpowers:*`, `ui-ux-pro-max` work.
---
## 5. Work recipes (how to actually verify & ship)
- **Everything in containers (R8).** Pint:
`docker compose exec -u "${HOST_UID:-1002}:${HOST_GID:-1002}" -T app ./vendor/bin/pint --dirty`.
Build: same prefix + `npm run build`.
- **R12 — verify in a real browser, BOTH locales:** every touched route → HTTP 200 + 0 console
errors AND **inspect the rendered DOM** for leaked `@` / `{{ }}` / `$var` / `group.key` text
(R17 — this is how the v0.3.0/v0.4.0 `<header>` bug slipped: 200 + clean console but garbage
markup). Recipe: headless Puppeteer image `ghcr.io/puppeteer/puppeteer:latest` on docker
network `clusev_clusev`, target `http://172.18.0.6`, `NODE_PATH=/home/pptruser/node_modules`.
Auth via a **TEMP** route `Route::get('/__peek', fn (Request $r) => [AuthFacade::loginUsingId(1),
redirect($r->query('to','/'))][1])` — **remove before commit** (route:clear after). Locale is
per-user (`user->locale` wins over session), so to test EN the peek must also persist
`user->locale`. Dashboard route is `/` (not `/dashboard`).
- **R15 — Codex:** `export PATH="$HOME/.npm-global/bin:$PATH"; codex review --uncommitted` until
clean (filter node_modules noise).
- **Localization (R16):** every visible string in `lang/{de,en}/<group>.php`, keys identical in
both. Verify: scan all `__('group.key')` usages resolve via `Lang::has($k,'de'/'en')` and that
each `lang/de/*.php` group has the same keys as `lang/en/*.php`.
- **Push:** token in `/home/nexxo/.env.gitea` (`GIT_ACCESS_TOKEN`) — read ONLY at push time,
never echo/commit; sanitize push output
(`sed -E 's#https://[^@]*@#https://<redacted>@#g; s/[0-9a-f]{40}/<redacted>/g'`).
- **Fleet host safety:** SSH password is in the encrypted vault (never in code/logs). Never
ban/lock-out the control plane (10.10.90.x).
**Done = ** Pint clean → npm build → R12 (200 + 0 console + DOM scan, DE+EN) → Codex clean →
version bump + CHANGELOG → commit on `feat/v1-foundation` → tag → push (sanitized) → temp
`/__peek` removed.

View File

@ -1,131 +0,0 @@
# Session Handoff — Clusev v0.9.25 (clusev host CLI + short commands + help tab-URL)
**Date:** 2026-06-20
**Branch:** `feat/v1-foundation`
**Status:** Code released (tagged + pushed). **Production deploy is PENDING — user-side.**
---
## TL;DR for the next session
A feature shipping a host CLI (`clusev`), short operator commands, a help "Commands / CLI"
topic, and help-tab URL syncing is **fully implemented, tested, Codex-reviewed, locally
browser-verified, tagged `v0.9.25`, and pushed** to Gitea. The **only remaining step** is the
production deploy (which I cannot do from this host — no SSH to the prod VM) and the post-deploy
domain R12. Pick up there.
---
## What shipped (v0.9.25)
Five user requests, all delivered:
1. **Help tabs in the URL**`#[Url]` on `App\Livewire\Help\Index::$topic`. Clicking a topic
syncs `?topic=<key>`; reload/bookmark/share keep the active tab. Default `overview` is omitted.
(Settings tabs already had `#[Url]`.)
2. **Short host commands** — new `clusev` wrapper installed by `install.sh` to `/usr/local/bin`.
Subcommands: `update`, `reset-admin`, `restart`, `logs`, `ps`/`status`, `migrate`, `artisan`,
`version`, `help`. Wraps the long `docker compose -f <prod compose> …` invocations. Host command
uses a space (`clusev reset-admin`); the underlying artisan name stays `clusev:reset-admin`.
3. **All operator-facing surfaces switched to short commands + help explains the real command**
new bilingual help topic **"Befehle / CLI"** (`commands`) documents every command with purpose,
when-to-use, and the real underlying command (shown WITHOUT the long `-f <compose>` filename).
Switched: versions update panel, help recovery/updates/domain-tls, settings/system reset hints,
sentinel-write-failed lang strings, MOTD.
4. **Long compose filename hidden, not renamed** — user chose "wrapper hides it" (renaming collides
with the existing dev `docker-compose.yml` and risks orphaning a live stack). A regression test
(`tests/Feature/CommandShortcutsTest.php`) forbids `docker-compose.prod.yml` on any display
surface; the new CHANGELOG entry was even worded to avoid the literal (the `/versions` page
renders CHANGELOG.md — see gotcha below).
5. **Detailed help descriptions** — done in the commands topic + reworded recovery/updates/versions.
## Spec / plan / commits
- Spec: `docs/superpowers/specs/2026-06-19-clusev-cli-and-command-shortcuts-design.md`
- Plan: `docs/superpowers/plans/2026-06-19-clusev-cli-and-command-shortcuts.md`
- Commits on `feat/v1-foundation` (newest first):
- `b619b32` chore: release 0.9.25 (config version bump + CHANGELOG) ← **tag `v0.9.25`**
- `5967b7b` fix: shell-escape the install dir when generating the clusev CLI (R15 Codex)
- `8d2b708` feat: short clusev commands in versions panel, lang strings, MOTD (+ test-string fixups)
- `437a225` feat: help tab-URL + Commands/CLI topic + short command copy
- `580c72a` feat: clusev host CLI wrapper for short stack commands
- Branch + tag **pushed** to `git.bave.dev/boban/clusev.git`. Working tree clean.
## Key files
- New: `docker/clusev/clusev` (wrapper template, `__CLUSEV_DIR__` placeholder, **unquoted**
assignment — install.sh injects a `printf %q` shell-escaped path; do NOT re-quote it).
- New: `tests/scripts/test-clusev-cli.sh` (host bash test, 8 checks incl. injection-resistance).
- New: `resources/views/livewire/help/content/{de,en}/commands.blade.php`.
- New: `tests/Feature/CommandShortcutsTest.php` (leak guard + short-command assertions).
- Changed: `install.sh` (phase 9 renders+installs the CLI), `app/Livewire/Help/Index.php`,
`lang/{de,en}/{help,versions,settings,system}.php`, help partials (recovery, updates, domain-tls),
`resources/views/livewire/versions/index.blade.php`, `docker/motd/00-clusev`,
`tests/Feature/{HelpPageTest,ForgotPasswordSmtpAwareTest}.php`, `config/clusev.php`, `CHANGELOG.md`.
## Verification done
- `php artisan test` (whole suite): **236 passed**.
- `bash tests/scripts/test-clusev-cli.sh`: green (incl. a real injection-resistance check —
confirmed the old quoted-rendering was exploitable, the new one is not).
- `npm run build`: OK.
- **Codex R15: PASS.** It found ONE security issue — unescaped `$(pwd)` rendered into the generated
CLI, a root-code-injection vector via a hostile checkout path. Fixed (`printf %q` + unquoted
assignment) and re-reviewed PASS, including the embedded-newline edge case.
- **Local R12** (headless Chrome / puppeteer Docker against the dev stack on `http://localhost`):
`/help` 200, URL syncs to `?topic=commands` on click, deep-link reload keeps the topic, commands
content renders, **0 console/page/network errors**; `/versions` 200, shows `sudo clusev update`,
no `docker-compose.prod.yml`. (Used gkonrad id=4, 2FA-off; temp password set + **restored** to the
original hash afterwards.)
---
## NEXT STEP (start here)
### 1. Deploy v0.9.25 to production (user runs this on the prod VM)
From this host I have **no SSH access** to the prod VM (`10.10.90.165` — publickey/password denied)
and there is **no local prod stack**, so I cannot deploy. The operator runs, on the prod VM:
```bash
cd clusev && sudo ./update.sh
```
(This is the release that first ships the `clusev` wrapper, so use `./update.sh` this once; the
update runs `install.sh` which installs `/usr/local/bin/clusev`, so from the NEXT release
`sudo clusev update` works. Alternatively click "Jetzt aktualisieren" in the panel.)
After it finishes, `which clusev` on the VM should resolve.
### 2. Domain R12 (I CAN do this over HTTP after the deploy)
The public domain `panel.test.bave.dev` → external proxy `10.10.20.20` (Zoraxy) → prod VM. Only
HTTP is needed (no SSH), via the puppeteer Docker image. **Only meaningful AFTER the deploy** (the
domain serves the old image until then). Verify on the domain:
- `/help` 200, click "Befehle / CLI" → URL `?topic=commands`, reload keeps it, DE/EN switch, no
console errors.
- `/versions` shows `sudo clusev update`.
- On the VM shell (read-only): `clusev help`, `clusev ps`, `clusev version`. Do NOT run
`reset-admin`/`update` against the real admin.
---
## Constraints & gotchas (carry forward)
- **Respond in German** in chat (code/commits/docs stay English). Caveman-terse mode was active.
- **Gitea token**: read ONLY at push time from `/home/nexxo/.env.gitea` (`GIT_ACCESS_TOKEN`), never
echo/commit; sanitize push output `sed -E 's#https://[^@]*@#https://<redacted>@#g; s/[0-9a-f]{40,}/<redacted>/g'`.
- **De-Claude history**: commits intentionally carry NO `Co-Authored-By: Claude` trailer (the
project wants Claude-free history; history rewrite + force-push only at beta with explicit OK).
- **Dev DB**: never `Server::create` to inspect; use RefreshDatabase tests / `Blade::render`.
- **View-cache flake**: run `php artisan view:clear` before browser-verifying a `.blade.php` change;
for `artisan test` flakiness isolate with `VIEW_COMPILED_PATH=/tmp/vc-xxx`.
- **`/versions` renders CHANGELOG.md** — whole-page leak scans flag historical changelog prose
(e.g. the v0.4.1 entry literally contains `@php`); scope leak checks to UI chrome, not changelog.
- Memory index updated: see `clusev-prod-deploy-user-side`, `clusev-versions-page-renders-changelog`.
## Not done / possible follow-ups
- Production deploy + domain R12 (above) — the only open item for THIS feature.
- (Backlog) de-Claude history rewrite, deferred to beta.
- (Optional) bash completion for `clusev`; was intentionally left out (YAGNI).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,605 +0,0 @@
# WebAuthn / YubiKey 2nd-Factor Implementation Plan (Phase 2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add WebAuthn security keys (YubiKey / platform authenticators) as an optional login 2nd factor, gated on a domain + HTTPS, without weakening the existing TOTP + backup-code recovery.
**Architecture:** A single `WebauthnService` wraps `web-auth/webauthn-lib`; everything else (gating, storage, Livewire wiring, JS) is project code. The key is an *alternative* at the existing TOTP challenge — TOTP + backup codes stay required and remain the only recovery. The feature is hidden + routes abort unless `DeploymentService::domain()` is set and the request is HTTPS (rpId = the domain; an IP is not a valid rpId).
**Tech Stack:** Laravel 13, Livewire 3, `web-auth/webauthn-lib ^5.3` (verified to resolve on L13/PHP 8.3), Vite, Pest/PHPUnit. All tooling in the `app` container (R8). Copy bilingual (R16).
**Lib-API note:** `web-auth/webauthn-lib` v5 is NOT in context7 and its v5 serializer/validator API must not be guessed. In the `WebauthnService` task, **read the installed package** (`vendor/web-auth/webauthn-lib/src`) for the exact classes (`WebauthnSerializerFactory`, `PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions`, `AuthenticatorAttestationResponseValidator`, `AuthenticatorAssertionResponseValidator`, `PublicKeyCredentialSource`, the `*Repository`/`CeremonyStep` shape) before writing the body. The service's **public interface + behavior + tests** below are fixed; only the internal lib calls are confirmed against the source.
**Conventions:** tests `docker compose exec -T app php artisan test --filter=<Name>`; migrate `… php artisan migrate`; Pint `… -u "1002:1002" app ./vendor/bin/pint --dirty`; build `… npm run build`. Conventional Commits + Co-Authored-By trailer.
**Testability:** the cryptographic ceremony (real key register/login) CANNOT run on this bare-IP host (no domain+HTTPS, IP rpId invalid). It is browser-verified later on a domain. Here: unit/feature tests (gating, option shape, storage, login-on-verified with a **mocked** `WebauthnService`) + R12 that the option is correctly hidden on bare-IP.
---
### Task 1: Install lib + credential storage
**Files:**
- Modify: `composer.json`/`composer.lock` (require)
- Create: `database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php`, `app/Models/WebauthnCredential.php`
- Modify: `app/Models/User.php`
- Test: `tests/Feature/WebauthnCredentialTest.php`
- [ ] **Step 1: Require the library**
```bash
docker compose exec -T -u "1002:1002" app composer require web-auth/webauthn-lib:^5.3 --no-interaction
```
Expected: installs `web-auth/webauthn-lib` 5.3.x + deps (symfony/serializer, web-auth/cose-lib, spomky-labs/cbor-php).
- [ ] **Step 2: Migration**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webauthn_credentials', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('credential_id'); // base64url of the raw credential id
$table->unique(['user_id', 'credential_id'], 'webauthn_user_credential_unique');
$table->text('public_key'); // serialized PublicKeyCredentialSource (JSON)
$table->string('aaguid')->nullable();
$table->json('transports')->nullable();
$table->unsignedBigInteger('sign_count')->default(0);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webauthn_credentials');
}
};
```
- [ ] **Step 3: Write the failing test**
```php
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WebauthnCredentialTest extends TestCase
{
use RefreshDatabase;
public function test_user_has_webauthn_credentials(): void
{
$user = User::factory()->create();
$this->assertFalse($user->hasWebauthnCredentials());
WebauthnCredential::create([
'user_id' => $user->id, 'name' => 'YubiKey 5C', 'credential_id' => 'abc',
'public_key' => '{}', 'sign_count' => 0,
]);
$this->assertTrue($user->fresh()->hasWebauthnCredentials());
$this->assertCount(1, $user->fresh()->webauthnCredentials);
}
}
```
- [ ] **Step 4: Run test (fails — model/relation missing)**
Run: `docker compose exec -T app php artisan test --filter=WebauthnCredentialTest` → FAIL.
- [ ] **Step 5: Model + relation**
Create `app/Models/WebauthnCredential.php`:
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebauthnCredential extends Model
{
protected $guarded = [];
protected $casts = [
'transports' => 'array',
'sign_count' => 'integer',
'last_used_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
```
In `app/Models/User.php` add `use Illuminate\Database\Eloquent\Relations\HasMany;` and:
```php
public function webauthnCredentials(): HasMany
{
return $this->hasMany(WebauthnCredential::class);
}
public function hasWebauthnCredentials(): bool
{
return $this->webauthnCredentials()->exists();
}
```
- [ ] **Step 6: Run test (pass), migrate, commit**
Run: `docker compose exec -T app php artisan test --filter=WebauthnCredentialTest` → PASS.
```bash
docker compose exec -T app php artisan migrate
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add composer.json composer.lock database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php app/Models/WebauthnCredential.php app/Models/User.php tests/Feature/WebauthnCredentialTest.php
git commit -m "feat(webauthn): install web-auth/webauthn-lib + credential storage"
```
---
### Task 2: `WebauthnService::available()` gate
**Files:**
- Create: `app/Services/WebauthnService.php` (initial — just the gate + ctor)
- Test: `tests/Feature/WebauthnAvailableTest.php`
- [ ] **Step 1: Write the failing test**
```php
<?php
namespace Tests\Feature;
use App\Services\DeploymentService;
use App\Services\WebauthnService;
use Tests\TestCase;
class WebauthnAvailableTest extends TestCase
{
private function service(?string $domain): WebauthnService
{
$deployment = $this->mock(DeploymentService::class);
$deployment->shouldReceive('domain')->andReturn($domain);
return app(WebauthnService::class);
}
public function test_available_only_with_domain_and_https(): void
{
// domain set + secure request
$this->app['request']->server->set('HTTPS', 'on');
$this->assertTrue($this->service('panel.example.com')->available());
}
public function test_unavailable_without_domain(): void
{
$this->app['request']->server->set('HTTPS', 'on');
$this->assertFalse($this->service(null)->available());
}
public function test_unavailable_without_https(): void
{
$this->app['request']->server->remove('HTTPS');
$this->assertFalse($this->service('panel.example.com')->available());
}
}
```
- [ ] **Step 2: Run test (fails — service missing)**
Run: `docker compose exec -T app php artisan test --filter=WebauthnAvailableTest` → FAIL.
- [ ] **Step 3: Initial service**
```php
<?php
namespace App\Services;
use Illuminate\Http\Request;
class WebauthnService
{
public function __construct(private DeploymentService $deployment) {}
/** WebAuthn needs a secure context AND a domain rpId — never a bare IP. */
public function available(): bool
{
return $this->deployment->domain() !== null && request()->isSecure();
}
/** The Relying-Party ID = the active domain (asserted present by available()). */
public function rpId(): string
{
return (string) $this->deployment->domain();
}
}
```
- [ ] **Step 4: Run test (pass) + commit**
Run: `docker compose exec -T app php artisan test --filter=WebauthnAvailableTest` → PASS (3).
```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Services/WebauthnService.php tests/Feature/WebauthnAvailableTest.php
git commit -m "feat(webauthn): WebauthnService::available gate (domain + https)"
```
---
### Task 3: Ceremony options + verification (against the installed lib)
**Read `vendor/web-auth/webauthn-lib/src` first** to confirm the v5 API, then implement.
**Files:**
- Modify: `app/Services/WebauthnService.php`
- Test: `tests/Feature/WebauthnOptionsTest.php`
**Public interface to implement (fixed; internals use the lib):**
```php
public function registrationOptions(User $user): array; // JSON-ready PublicKeyCredentialCreationOptions; stores session('webauthn.register')
public function verifyRegistration(User $user, array $response, string $name): WebauthnCredential; // validates + stores; throws RuntimeException on mismatch
public function assertionOptions(User $user): array; // JSON-ready PublicKeyCredentialRequestOptions; stores session('webauthn.login')
public function verifyAssertion(User $user, array $response): bool; // validates against stored credential + session challenge; bumps sign_count/last_used_at
```
Behavior:
- `registrationOptions`: rpId = `rpId()`, rpName "Clusev", user entity id = a stable per-user handle (e.g. `hash('sha256', $user->id)` raw-> the user's id bytes), `excludeCredentials` = the user's stored credential ids, `userVerification = preferred`, fresh random challenge persisted in the session. Serialize with the lib's `WebauthnSerializerFactory` serializer to an array.
- `verifyRegistration`: deserialize the client response, run `AuthenticatorAttestationResponseValidator` against the session challenge + an rp host/id check, map the resulting `PublicKeyCredentialSource` into a `WebauthnCredential` row (credential_id base64url, serialized source in `public_key`, aaguid, transports, sign_count). Reject duplicates (unique index).
- `assertionOptions`: rpId, `allowCredentials` = the user's credential ids, fresh challenge in session.
- `verifyAssertion`: find the matching stored credential by raw id, run `AuthenticatorAssertionResponseValidator`; on success persist the new sign count + `last_used_at` and return true; any failure returns false (never throws into the login path).
- [ ] **Step 1: Write the failing test (option shape — no real ceremony needed)**
```php
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\DeploymentService;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WebauthnOptionsTest extends TestCase
{
use RefreshDatabase;
private function service(string $domain = 'panel.example.com'): WebauthnService
{
$d = $this->mock(DeploymentService::class);
$d->shouldReceive('domain')->andReturn($domain);
return app(WebauthnService::class);
}
public function test_registration_options_use_domain_rpid_and_store_challenge(): void
{
$user = User::factory()->create();
$opts = $this->service()->registrationOptions($user);
$this->assertSame('panel.example.com', data_get($opts, 'rp.id'));
$this->assertNotEmpty(data_get($opts, 'challenge'));
$this->assertNotNull(session('webauthn.register'));
}
public function test_assertion_options_list_user_credentials(): void
{
$user = User::factory()->create();
WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => base64_encode('rawid'), 'public_key' => '{}', 'sign_count' => 0]);
$opts = $this->service()->assertionOptions($user);
$this->assertSame('panel.example.com', data_get($opts, 'rpId'));
$this->assertNotEmpty(data_get($opts, 'allowCredentials'));
$this->assertNotNull(session('webauthn.login'));
}
}
```
(If the lib's serialized key names differ from `rp.id`/`rpId`/`allowCredentials`, adjust these asserts to the real JSON shape after reading the lib — keep the intent: rpId = domain, challenge stored, allow-list present.)
- [ ] **Step 2: Run test (fails)**`--filter=WebauthnOptionsTest`.
- [ ] **Step 3: Implement the four methods** against the installed lib (read vendor first). Map `PublicKeyCredentialSource``WebauthnCredential` in a private helper. `verifyAssertion`/`verifyRegistration` wrap the lib validators in try/catch (verify returns false / throws localized).
- [ ] **Step 4: Run test (pass) + commit**
```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Services/WebauthnService.php tests/Feature/WebauthnOptionsTest.php
git commit -m "feat(webauthn): registration/assertion options + verification"
```
---
### Task 4: Login challenge integration
**Files:**
- Modify: `app/Livewire/Auth/TwoFactorChallenge.php`
- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php`
- Test: `tests/Feature/TwoFactorWebauthnTest.php`
- [ ] **Step 1: Write the failing test (mock the service's verifyAssertion)**
```php
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TwoFactorWebauthnTest extends TestCase
{
use RefreshDatabase;
public function test_verified_assertion_logs_in(): void
{
$user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]);
session()->put('2fa.user', $user->id);
$svc = $this->mock(WebauthnService::class);
$svc->shouldReceive('available')->andReturn(true);
$svc->shouldReceive('verifyAssertion')->andReturn(true);
Livewire::test(TwoFactorChallenge::class)
->call('verifyWebauthn', ['fake' => 'assertion']);
$this->assertAuthenticatedAs($user);
}
public function test_failed_assertion_does_not_log_in(): void
{
$user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]);
session()->put('2fa.user', $user->id);
$svc = $this->mock(WebauthnService::class);
$svc->shouldReceive('available')->andReturn(true);
$svc->shouldReceive('verifyAssertion')->andReturn(false);
Livewire::test(TwoFactorChallenge::class)
->call('verifyWebauthn', ['fake' => 'assertion'])
->assertHasErrors('code');
$this->assertGuest();
}
}
```
- [ ] **Step 2: Run test (fails — verifyWebauthn missing)**`--filter=TwoFactorWebauthnTest`.
- [ ] **Step 3: Add `verifyWebauthn` + `webauthnOptions` to `TwoFactorChallenge`**
Inject `WebauthnService`. Add a computed `webauthnAvailable()` (= service `available()` AND the pending user has credentials), an `assertionOptions()` action returning JSON for the JS, and:
```php
public function verifyWebauthn(array $assertion, WebauthnService $webauthn): mixed
{
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages(['code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])]);
}
$user = User::find(session('2fa.user'));
if (! $user || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
}
RateLimiter::clear($key);
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
Auth::login($user, $remember);
session()->regenerate();
return $this->redirectIntended(route('dashboard'), navigate: true);
}
```
(Reuses the same session gate + rate-limit as `verify()`.)
- [ ] **Step 4: View — the Security-Key button (only when available)**
In the challenge view, below the code form, add (gated):
```blade
@if ($this->webauthnAvailable())
<div class="flex items-center gap-3"><span class="h-px flex-1 bg-line"></span><span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.or') }}</span><span class="h-px flex-1 bg-line"></span></div>
<x-btn variant="secondary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login()">
<x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
</x-btn>
@endif
```
The JS (`window.clusevWebauthn.login`, Task 5) calls `$wire.assertionOptions()`, runs `navigator.credentials.get`, then `$wire.verifyWebauthn(<assertion>)`.
- [ ] **Step 5: Run test (pass) + lang + commit**
Add `auth.webauthn_failed`, `auth.webauthn_login`, `common.or` (DE+EN). Run `--filter=TwoFactorWebauthnTest` → PASS.
```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php lang/de/auth.php lang/en/auth.php lang/de/common.php lang/en/common.php tests/Feature/TwoFactorWebauthnTest.php
git commit -m "feat(webauthn): use a security key at the login challenge"
```
---
### Task 5: Settings registration UI + JS module
**Files:**
- Create: `app/Livewire/Settings/WebauthnKeys.php`, `resources/views/livewire/settings/webauthn-keys.blade.php`, `resources/js/webauthn.js`
- Modify: `resources/js/app.js` (import the module), `resources/views/livewire/settings/index.blade.php` (embed the keys panel in the security tab), `lang/{de,en}/auth.php`
- Test: `tests/Feature/WebauthnKeysTest.php`
- [ ] **Step 1: Write the failing test (register via mocked verify, list, remove)**
```php
<?php
namespace Tests\Feature;
use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class WebauthnKeysTest extends TestCase
{
use RefreshDatabase;
public function test_register_stores_credential(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$svc = $this->mock(WebauthnService::class);
$svc->shouldReceive('available')->andReturn(true);
$svc->shouldReceive('verifyRegistration')->andReturnUsing(fn ($u, $r, $name) => WebauthnCredential::create([
'user_id' => $u->id, 'name' => $name, 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0,
]));
Livewire::test(WebauthnKeys::class)
->set('newName', 'YubiKey 5C')
->call('register', ['fake' => 'attestation'])
->assertHasNoErrors();
$this->assertDatabaseHas('webauthn_credentials', ['user_id' => $user->id, 'name' => 'YubiKey 5C']);
}
public function test_remove_deletes_credential(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
$this->mock(WebauthnService::class)->shouldReceive('available')->andReturn(true);
Livewire::test(WebauthnKeys::class)->call('remove', $cred->id);
$this->assertDatabaseMissing('webauthn_credentials', ['id' => $cred->id]);
}
}
```
- [ ] **Step 2: Run test (fails)**`--filter=WebauthnKeysTest`.
- [ ] **Step 3: `app/Livewire/Settings/WebauthnKeys.php`**
```php
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate;
use Livewire\Component;
class WebauthnKeys extends Component
{
#[Validate('required|string|max:60')]
public string $newName = '';
public function options(WebauthnService $webauthn): array
{
abort_unless($webauthn->available(), 404);
return $webauthn->registrationOptions(Auth::user());
}
public function register(array $attestation, WebauthnService $webauthn): void
{
abort_unless($webauthn->available(), 404);
$this->validate();
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
$this->reset('newName');
AuditEvent::create(['user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'webauthn.register', 'target' => $cred->name, 'ip' => request()->ip()]);
$this->dispatch('notify', message: __('auth.webauthn_added'));
}
public function remove(int $id, WebauthnService $webauthn): void
{
abort_unless($webauthn->available(), 404);
$cred = Auth::user()->webauthnCredentials()->whereKey($id)->first();
if ($cred) {
$cred->delete();
AuditEvent::create(['user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'webauthn.remove', 'target' => $cred->name, 'ip' => request()->ip()]);
$this->dispatch('notify', message: __('auth.webauthn_removed'));
}
}
public function render()
{
return view('livewire.settings.webauthn-keys', [
'available' => app(WebauthnService::class)->available(),
'keys' => Auth::user()->webauthnCredentials()->latest()->get(),
]);
}
}
```
- [ ] **Step 4: View `resources/views/livewire/settings/webauthn-keys.blade.php`**
Panel titled "Security-Keys". When `! $available`: a muted hint (`auth.webauthn_unavailable`). When available: a name input + "Hinzufügen" (`x-on:click="window.clusevWebauthn.register($wire)"`) and a list of `$keys` (name, `created_at`, `last_used_at`) each with a `danger-soft` remove button (`wire:click="remove(id)"` via the confirm modal, R5). Reuse existing field/btn classes.
- [ ] **Step 5: JS `resources/js/webauthn.js`** — base64url helpers + `register($wire)` (calls `$wire.options()`, `navigator.credentials.create`, `$wire.register(encoded)`) and `login()` (locates the challenge component, `$wire.assertionOptions()`, `navigator.credentials.get`, `$wire.verifyWebauthn(encoded)`); expose `window.clusevWebauthn`. Import it in `resources/js/app.js`.
- [ ] **Step 6: Embed in Settings security tab**
In `resources/views/livewire/settings/index.blade.php` security tab, after the 2FA panel, add `<livewire:settings.webauthn-keys />`.
- [ ] **Step 7: Run test (pass), lang, build, commit**
Add `auth.webauthn_added/removed/unavailable/manage_title` + the keys-panel copy (DE+EN). Run `--filter=WebauthnKeysTest` → PASS.
```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
docker compose exec -T -u "1002:1002" app npm run build
git add app/Livewire/Settings/WebauthnKeys.php resources/views/livewire/settings/webauthn-keys.blade.php resources/js/webauthn.js resources/js/app.js resources/views/livewire/settings/index.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/WebauthnKeysTest.php
git commit -m "feat(webauthn): register/list/remove security keys in settings"
```
---
### Task 6: Verification (R12 gating + Codex + release)
- [ ] **Step 1: Full suite + Pint**`php artisan test` (only the pre-existing ExampleTest may fail); `./vendor/bin/pint --test`.
- [ ] **Step 2: Build**`npm run build`.
- [ ] **Step 3: R12 (bare-IP, DE+EN, 375/768/1280):** Settings security tab shows the "Security-Keys" panel with the **unavailable hint** (no register button) since `domain()` is null; the login challenge shows **no** Security-Key button. HTTP 200, 0 console, DOM scan (R17). (Real key register/login is NOT testable here — see deferred note.)
- [ ] **Step 4: Codex review (R15)**`codex review --base <pre-feature-sha>`; fix + re-run until clean.
- [ ] **Step 5: Handoff + release** — record in `docs/session-handoff.md` incl. the **deferred manual check** (real YubiKey on a domain+HTTPS host); version bump + CHANGELOG + tag + sanitized push per the session cadence.
---
## Self-Review
**Spec coverage:** library (Task 1), gating `available()` (Task 2), data model (Task 1), `WebauthnService` four methods (Tasks 2-3), challenge integration (Task 4), Settings register/list/remove + JS + gating hint (Task 5), security (rpId=domain via gate, single-use challenge, sign-count via lib, audited, recovery preserved — Tasks 2-5), testing strategy + R12 + deferred E2E (Task 6). ✓
**Placeholder scan:** the only intentional non-literal is Task 3's lib-internal body, explicitly pinned to reading `vendor/web-auth/webauthn-lib/src` (the v5 API is not guessable / not in context7); its public interface + tests are concrete. All other steps have full code.
**Type consistency:** `WebauthnService` methods (`available`, `rpId`, `registrationOptions`, `verifyRegistration`, `assertionOptions`, `verifyAssertion`) defined in Tasks 2-3, used in Tasks 4-5. `WebauthnCredential` fields consistent across tasks. `verifyWebauthn`/`options`/`register`/`remove` Livewire actions consistent with their tests. `window.clusevWebauthn.{login,register}` consistent between Tasks 4 and 5.
**Note (lib API):** Task 3 test asserts JSON keys `rp.id`/`rpId`/`allowCredentials`; confirm exact serialized names against the installed lib and adjust the asserts (intent fixed: rpId=domain, challenge stored, allow-list present).

View File

@ -1,471 +0,0 @@
# Airtight Show-Once Recovery Codes — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the snapshot-replay vector from the recovery-codes reveal so the plaintext codes and the "revealed" state never enter any persisted Livewire property or snapshot.
**Architecture:** The `RecoveryCodes` modal keeps zero secret state. On a fresh reveal (one-time `2fa.codes_fresh` flag in `mount()`, or `regenerate()`) it dispatches a transient `reveal-codes` browser event carrying the codes; an Alpine `x-data` holds them in JS memory only and renders them with `x-for`. Because `mount()` never runs on hydrate and dispatched events live in the one-time response `effects.dispatches` (never the snapshot), a replayed/stale snapshot has nothing to re-render and fires no event. The download route gains a Redis-backed session lock (`->block(5, 5)`) to close the concurrent-download grant race.
**Tech Stack:** Laravel 13, Livewire 3 (class-based, not Volt), Alpine.js, wire-elements/modal, Tailwind v4, Redis (cache/session locks). All tooling runs in-container (R8).
**Spec:** `docs/superpowers/specs/2026-06-15-recovery-codes-airtight-reveal-design.md`
---
## File Structure
- `tests/Feature/RecoveryCodesModalTest.php` — rewritten test suite (the executable spec).
- `app/Livewire/Modals/RecoveryCodes.php` — remove `$revealed`, add `dispatchCodes()`, adjust `mount()`/`regenerate()`/`render()`.
- `resources/views/livewire/modals/recovery-codes.blade.php` — Alpine `x-data` + `x-for` + `x-show` panels.
- `routes/web.php``->block(5, 5)` on `two-factor.recovery.download` + comment.
**Not touched (verified during design):**
- `resources/css/app.css``[x-cloak] { display: none }` already present (line ~107). No change.
- `app/Livewire/Auth/TwoFactorSetup.php`, `app/Livewire/Settings/WebauthnKeys.php`, `app/Livewire/Settings/Index.php` — still set `2fa.codes_fresh` / `2fa.download_grant` as today. No change.
- No i18n changes — no new visible strings.
**Commit strategy:** The component, view, and route changes are coupled (the suite cannot go green until all three land), so there is a single feature commit after the suite passes (Task 5). This is intentional given the coupling.
---
### Task 1: Rewrite the test suite (the failing spec)
**Files:**
- Modify (replace whole file): `tests/Feature/RecoveryCodesModalTest.php`
- [ ] **Step 1: Replace the test file with the new spec**
Replace the entire contents of `tests/Feature/RecoveryCodesModalTest.php` with:
```php
<?php
namespace Tests\Feature;
use App\Livewire\Modals\RecoveryCodes;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
use Tests\TestCase;
class RecoveryCodesModalTest extends TestCase
{
use RefreshDatabase;
public function test_manage_view_without_fresh_flag_dispatches_nothing_and_hides_codes(): void
{
$user = User::factory()->create();
$codes = $user->replaceRecoveryCodes();
// Opened from "Backup-Codes verwalten" with no fresh-generation flag: never reveal.
// No reveal event fires, and the codes never appear in the server-rendered HTML.
Livewire::actingAs($user)->test(RecoveryCodes::class)
->assertNotDispatched('reveal-codes')
->assertDontSee($codes[0])
->assertDontSee($codes[7])
->assertSee(__('auth.recovery_hidden_notice'));
}
public function test_fresh_flag_dispatches_codes_once_and_keeps_them_out_of_the_snapshot(): void
{
$user = User::factory()->create();
$codes = $user->replaceRecoveryCodes();
session(['2fa.codes_fresh' => true]);
// The fresh flag delivers the codes via a one-time transient event — never as a
// property and never in the server-rendered HTML, so they are absent from the snapshot.
Livewire::actingAs($user)->test(RecoveryCodes::class)
->assertDispatched('reveal-codes', codes: $codes)
->assertDontSee($codes[0])
->assertDontSee($codes[7]);
// pull() forgets the flag — it is single-use.
$this->assertFalse(session()->has('2fa.codes_fresh'));
}
public function test_replayed_or_stale_snapshot_renders_no_codes(): void
{
$user = User::factory()->create();
$codes = $user->replaceRecoveryCodes();
session(['2fa.codes_fresh' => true]);
// Legitimate one-time reveal: consumes the flag, fires the event once, renders no codes.
// A re-render of the SAME component (hydrate does not re-run mount()) renders no codes —
// this models replaying a captured snapshot to /livewire/update.
Livewire::actingAs($user)->test(RecoveryCodes::class)
->assertDispatched('reveal-codes', codes: $codes)
->assertDontSee($codes[0])
->call('$refresh')
->assertDontSee($codes[0]);
// A fresh mount from the now-consumed flag (the state a replayed snapshot starts from)
// dispatches nothing and renders nothing — codes cannot be re-revealed.
Livewire::actingAs($user)->test(RecoveryCodes::class)
->assertNotDispatched('reveal-codes')
->assertDontSee($codes[0]);
}
public function test_regenerate_dispatches_the_new_codes_without_rendering_them(): void
{
$user = User::factory()->create();
$old = $user->replaceRecoveryCodes();
// Manage view (no flag) → nothing dispatched → regenerate → the NEW set is dispatched once.
$component = Livewire::actingAs($user)->test(RecoveryCodes::class)
->assertNotDispatched('reveal-codes')
->call('regenerate');
$new = $user->fresh()->recoveryCodes();
$this->assertNotEquals($old, $new);
// New set delivered via the transient event; neither the old nor the new set is in the HTML.
$component->assertDispatched('reveal-codes', codes: $new)
->assertDontSee($old[0])
->assertDontSee($new[0]);
// Regenerate also grants exactly one download of the fresh set.
$this->assertTrue(session()->has('2fa.download_grant'));
}
public function test_download_requires_a_fresh_grant(): void
{
$user = User::factory()->create();
$user->replaceRecoveryCodes();
// Without a fresh-generation grant, a direct hit of the download route is forbidden,
// so stored codes can never be re-downloaded by an authed user at will.
$this->actingAs($user)
->get(route('two-factor.recovery.download'))
->assertForbidden();
// With the one-time grant (set when codes are freshly generated/regenerated) the
// download succeeds exactly once.
session(['2fa.download_grant' => true]);
$this->actingAs($user)
->get(route('two-factor.recovery.download'))
->assertOk()
->assertDownload('clusev-recovery-codes.txt');
// The grant is single-use: pull() consumed it, so an immediate re-download 403s.
$this->actingAs($user)
->get(route('two-factor.recovery.download'))
->assertForbidden();
}
public function test_old_recovery_route_is_gone(): void
{
$this->assertFalse(Route::has('two-factor.recovery'));
$this->assertTrue(Route::has('two-factor.recovery.download'));
}
}
```
Notes for the implementer:
- The `CannotUpdateLockedPropertyException` import and the old `test_revealed_property_is_locked_against_client_tampering` test are intentionally gone — the `$revealed` prop no longer exists, so there is nothing to lock.
- `assertDispatched('reveal-codes', codes: $codes)` matches the named event param against the dispatched array. `replaceRecoveryCodes()` returns the same array `recoveryCodes()` later reads, so they compare equal.
- The authoritative replay proof is the second block of `test_replayed_or_stale_snapshot_renders_no_codes` (a brand-new mount with the flag already consumed). If your Livewire version accumulates dispatch effects across roundtrips on a single testable, only keep `assertDontSee` on the `$refresh` chain (already the case here) and rely on the fresh-mount block for `assertNotDispatched`.
- [ ] **Step 2: Run the suite to verify it fails (red)**
Run: `docker compose exec app php artisan test --filter=RecoveryCodesModalTest`
Expected: FAIL. The current component dispatches no `reveal-codes` event and renders the codes into the HTML when `revealed`, so `test_fresh_flag_...` (expects a dispatch + `assertDontSee`), `test_replayed_...`, `test_regenerate_...`, and `test_manage_view_...` (expects `assertNotDispatched`) fail. The download tests still pass.
Do not commit yet — the suite is red until the implementation tasks land.
---
### Task 2: Strip secret state from the component, dispatch codes transiently
**Files:**
- Modify (replace whole file): `app/Livewire/Modals/RecoveryCodes.php`
- [ ] **Step 1: Replace the component with the stateless version**
Replace the entire contents of `app/Livewire/Modals/RecoveryCodes.php` with:
```php
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
* from Settings → Security ("Backup-Codes verwalten"). The download endpoint stays a route.
*
* Show-once is structural, not UX-level: the component holds NO codes and NO "revealed"
* state. Codes reach the browser only via a transient `reveal-codes` event fired from
* mount() (one-time `2fa.codes_fresh` flag) or regenerate(). Because mount() never runs on
* hydrate and dispatched events live in the one-time response effects (never the snapshot),
* a replayed/stale snapshot has nothing to re-render and fires no event — captured codes
* cannot be re-revealed by replaying a signed snapshot.
*/
class RecoveryCodes extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// One-time, server-set flag (put on first-factor enrollment). pull() reads + forgets,
// so a refresh of the manage view will no longer reveal the codes. mount() does not
// run on hydrate, so a replayed snapshot never re-triggers this.
if (session()->pull('2fa.codes_fresh', false)) {
$this->dispatchCodes();
}
}
/** Regenerate the current user's backup codes (invalidates the old set). */
public function regenerate(): void
{
Auth::user()->replaceRecoveryCodes();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'two_factor.recovery_regenerate',
'target' => Auth::user()->email,
'ip' => request()->ip(),
]);
// One-time download grant for the freshly regenerated set (consumed by the
// download route). Without it a later direct hit of the route 403s.
session()->put('2fa.download_grant', true);
// Reveal the freshly generated set once, via the transient event (never a property).
$this->dispatchCodes();
$this->dispatch('notify', message: __('auth.recovery_regenerated'));
}
public function render()
{
return view('livewire.modals.recovery-codes');
}
/**
* Deliver the current user's recovery codes to the browser via a one-time event. The
* codes never enter a Livewire property or the snapshot — Alpine holds them in JS memory
* only, so a replayed snapshot cannot re-render them.
*/
protected function dispatchCodes(): void
{
$this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes());
}
}
```
What changed vs. the old file: removed the `Livewire\Attributes\Locked` import and the `#[Locked] public bool $revealed` property; `mount()` now dispatches instead of setting `$revealed`; `regenerate()` drops `$this->revealed = true` and dispatches; `render()` no longer passes a `codes` array; added `dispatchCodes()`.
---
### Task 3: Render codes client-side via Alpine (never server-rendered)
**Files:**
- Modify (replace whole file): `resources/views/livewire/modals/recovery-codes.blade.php`
- [ ] **Step 1: Replace the view with the Alpine version**
Replace the entire contents of `resources/views/livewire/modals/recovery-codes.blade.php` with:
```blade
<div class="p-5 sm:p-6" x-data="{ codes: [] }" @reveal-codes.window="codes = $event.detail.codes" x-cloak>
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ __('auth.recovery_heading') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
</div>
</div>
{{-- Revealed panel: shown only once codes arrive via the transient `reveal-codes` event.
The codes live in Alpine JS memory (x-for) and are never in the server HTML/snapshot. --}}
<div x-show="codes.length">
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
</div>
<div class="mt-4 grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
<template x-for="c in codes" :key="c">
<span class="font-mono text-sm tracking-wide text-ink" x-text="c"></span>
</template>
</div>
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
</div>
{{-- Hidden notice: the default state (no codes in memory) — manage view, or any replayed
snapshot, which fires no event and therefore never populates `codes`. --}}
<div x-show="!codes.length">
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-line bg-inset px-4 py-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-ink-3" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_hidden_notice') }}</p>
</div>
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
</div>
</div>
```
Notes:
- `[x-cloak] { display: none }` already exists in `resources/css/app.css` — no CSS change needed. It hides the modal body until Alpine initializes, preventing a raw-template flash.
- Both panels exist in the server HTML; `x-show` toggles visibility client-side. The codes themselves come only from `x-for` over the JS `codes` array, so the server HTML/snapshot contains no codes. The `assertDontSee($codes[0])` assertions therefore hold in every state.
---
### Task 4: Add the session lock to the download route
**Files:**
- Modify: `routes/web.php` (the `two-factor.recovery.download` route, around line 57-70)
- [ ] **Step 1: Append `->block(5, 5)` to the route**
Find this line (end of the download route definition):
```php
})->name('two-factor.recovery.download');
```
Replace it with:
```php
})->name('two-factor.recovery.download')
// Session blocking (Redis-backed atomic lock) serializes concurrent same-session
// requests so the one-time `2fa.download_grant` cannot be double-read by a racing
// request. True concurrency cannot be unit-tested; lock-capable stores (redis in
// prod, array/file in tests) keep the happy path green.
->block(5, 5);
```
Leave the route body (the `abort_unless(session()->pull('2fa.download_grant', false), 403)` and the streamed download) unchanged.
---
### Task 5: Green suite, browser verification, Codex review, commit
**Files:** none (verification + commit)
- [ ] **Step 1: Run the full recovery-codes suite (expect green)**
Run: `docker compose exec app php artisan test --filter=RecoveryCodesModalTest`
Expected: PASS — all of `test_manage_view_...`, `test_fresh_flag_...`, `test_replayed_...`, `test_regenerate_...`, `test_download_requires_a_fresh_grant`, `test_old_recovery_route_is_gone`.
If `test_download_requires_a_fresh_grant` errors on the lock (rather than failing an assertion), confirm the test cache store supports atomic locks (`array`/`file`/`redis` do). If your test env uses an unsupported store, set `CACHE_STORE=array` for the test run; do not remove `->block()`.
- [ ] **Step 2: Run the broader suite to catch regressions**
Run: `docker compose exec app php artisan test --filter=Recovery`
Expected: PASS (includes `UserRecoveryCodesTest`, `FirstFactorCodesTest`-style coverage that touches `codes_fresh`). Then run the full `docker compose exec app php artisan test` if time allows.
- [ ] **Step 3: Browser verification (R12)**
With the dev stack up (`docker compose up -d`), load both states and check HTTP 200 + zero console/network errors at 375 / 768 / 1280:
- Fresh reveal: trigger first-factor enrollment (or `regenerate` from the modal) → confirm the 8 codes render and the download button appears.
- Manage view: open the modal from Settings → Security ("Backup-Codes verwalten") with no fresh flag → confirm the hidden notice + regenerate button, no codes.
- In DevTools Network, open the `openModal` (Livewire `/livewire/update`) response for the fresh reveal and confirm the codes appear ONLY under `effects.dispatches` (the `reveal-codes` event) and are ABSENT from `serverMemo`/`snapshot` and the rendered `html`.
- Inspect the rendered DOM for leaked `@`/`{{ }}`/`$var`/`group.key` text (R17).
- [ ] **Step 4: Codex review (R15)**
Run `/codex:review` over the change. Fix and re-run until it reports no errors and no security issues. The task is not done until Codex passes.
- [ ] **Step 5: Commit**
```bash
cd /home/nexxo/clusev
git checkout -b feature/recovery-codes-airtight-reveal
git add app/Livewire/Modals/RecoveryCodes.php \
resources/views/livewire/modals/recovery-codes.blade.php \
routes/web.php \
tests/Feature/RecoveryCodesModalTest.php \
docs/superpowers/specs/2026-06-15-recovery-codes-airtight-reveal-design.md \
docs/superpowers/plans/2026-06-15-recovery-codes-airtight-reveal.md
git commit -m "feat(2fa): make recovery-codes reveal airtight against snapshot replay
Codes and reveal-state no longer enter any Livewire property/snapshot — they
are delivered via a transient reveal-codes event and rendered client-side by
Alpine, so a replayed/stale snapshot re-renders nothing. Add ->block(5,5)
session lock to the download route to close the grant race."
```
(Run `git status` first to confirm no stray secrets are staged. Do not push unless asked.)
---
## Self-Review
**Spec coverage:**
- Threat model / structural show-once → Task 2 (no `$revealed`, no codes prop; mount/regenerate dispatch only). ✓
- Transient dispatch + Alpine render → Task 2 (`dispatchCodes`) + Task 3 (`x-data`/`x-for`/`x-show`). ✓
- Download-race lock → Task 4 (`->block(5, 5)`). ✓
- Replay-proof tests → Task 1 (`test_replayed_or_stale_snapshot_renders_no_codes`). ✓
- Removed locked-prop test → Task 1 (dropped + import removed). ✓
- No i18n / no CSS change → confirmed in File Structure (x-cloak already present). ✓
- Verification R12/R15 → Task 5. ✓
**Placeholder scan:** No TBD/TODO; every code step shows full file or exact edit. ✓
**Type/name consistency:** `dispatchCodes()` defined in Task 2 and referenced only there; event name `reveal-codes` and detail key `codes` match across component dispatch (Task 2), Alpine listener `$event.detail.codes` (Task 3), and tests `assertDispatched('reveal-codes', codes: ...)` (Task 1). Route name `two-factor.recovery.download` unchanged. ✓
---
## Post-review additions (Codex review, 2026-06-15)
Codex raised three findings; resolutions:
- **F1 (Alpine reveal-state persists across morphs):** Not an exploitable vector — the
snapshot-replay threat needs the codes *in the snapshot*, and they are not. The persisting
state is client-side JS memory in the same tab that already saw the codes legitimately. No
code change; proven by the snapshot assertions below.
- **F2 (download grant had no expiry):** Implemented a 10-minute TTL. The grant is now stored
as `now()->timestamp` by all three setters (`Modals/RecoveryCodes::regenerate()`,
`Auth/TwoFactorSetup.php`, `Settings/WebauthnKeys.php`), and `routes/web.php` rejects any
non-integer or >600s-old grant:
`abort_unless(is_int($grantedAt) && now()->timestamp - $grantedAt <= 600, 403);`
New test `test_a_stale_download_grant_is_rejected` covers the stale, legacy-bool, and fresh
cases. The existing `test_download_requires_a_fresh_grant` now seeds `now()->timestamp`.
- **F3 (snapshot leak not directly tested):** Added explicit snapshot assertions via a
`assertSnapshotHidesCodes(array $codes, $component)` helper that loops over EVERY code and
asserts `assertStringNotContainsString($code, json_encode($component->snapshot))` in the
fresh-flag, replay, and regenerate tests, plus `assertNotDispatched('reveal-codes')` after
`$refresh` (hydration).
- **F4 (state-changing GET is CSRF-exposable — a cross-site request could burn the victim's
one-time grant):** Changed the download route `GET`→`POST` (CSRF-protected) and made the
view's download control a `<form method="POST">` + `@csrf`; download tests use `->post(...)`.
No code disclosure was possible (cross-origin response is unreadable); the fix prevents the
grant-consumption nuisance.
- **F5 (`2fa.codes_fresh` reveal flag had no expiry — symmetric to F2):** The flag is now a
timestamp set by `Auth/TwoFactorSetup.php` and `Settings/WebauthnKeys.php`, and
`RecoveryCodes::mount()` reveals only within a 10-minute window. New test
`test_a_stale_codes_fresh_flag_reveals_nothing`. `tests/Feature/FirstFactorCodesTest.php`
updated to assert the flag is an int (`assertIsInt`) rather than `true`.
- **F6 (timestamp comparison accepted a future value):** Both checks (download route and
`mount()`) now validate `$age >= 0 && $age <= 600`, rejecting future-dated timestamps. New
case added to `test_a_stale_download_grant_is_rejected`.
Touched beyond the original plan: `app/Livewire/Auth/TwoFactorSetup.php`,
`app/Livewire/Settings/WebauthnKeys.php` (both flag types → timestamps), and
`tests/Feature/FirstFactorCodesTest.php` (flag type assertion).
Final suite: `RecoveryCodesModalTest` 8 tests / 65 assertions, all green; recovery/2FA
regression set green.

View File

@ -1,740 +0,0 @@
# `clusev` Host-CLI + kurze Befehle + Tab-URL — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Operatoren rufen kurze Host-Befehle auf (`sudo clusev update`, `clusev reset-admin`, …) statt langer `docker compose -f docker-compose.prod.yml …`; alle user-sichtbaren Stellen zeigen die Kurzform, die Hilfe erklärt sie ausführlich, und der Help-Tab landet (wie Settings) in der URL.
**Architecture:** Ein vom Installer generiertes Host-Skript `/usr/local/bin/clusev` kapselt den Prod-Stack (Install-Verzeichnis eingebacken). UI/Hilfe/Lang-Strings/MOTD zeigen nur noch `clusev <sub>``docker-compose.prod.yml` verschwindet aus jeder Anzeige. Der Help-Komponente bekommt `#[Url]` auf `$topic` und ein neues Thema „Befehle / CLI".
**Tech Stack:** Bash (Wrapper + Skript-Test), Laravel 13 / Livewire 3 (`#[Url]`), Blade-Hilfe-Partials (DE/EN), PHPUnit (Feature-Tests), `install.sh` (Template-Rendering wie beim MOTD).
**Conventions (Projekt):** UI-Strings DE+EN (`__('group.key')`, R9/R16); keine Volt; Tests laufen im Container — bevorzugt isoliert gegen den Live-Dev-Stack:
`docker compose exec -T app php artisan test --filter=<X>` (oder mit eigenem `VIEW_COMPILED_PATH`, siehe `[[clusev-test-view-cache-race]]`). Vor Browser-Verify `view:clear` (siehe `[[clusev-dev-view-cache-gotcha]]`).
---
## File Structure
- **Neu** `docker/clusev/clusev` — Bash-Wrapper-Template (Platzhalter `__CLUSEV_DIR__`). Eine Verantwortung: kurze Unterbefehle → Prod-Stack-Kommandos.
- **Neu** `tests/scripts/test-clusev-cli.sh` — Host-Bash-Test für den Wrapper (Syntax, help, unknown, version, install-Verdrahtung). Kein Docker nötig.
- **Neu** `resources/views/livewire/help/content/de/commands.blade.php` + `…/en/commands.blade.php` — Hilfe-Thema „Befehle / CLI".
- **Neu** `tests/Feature/CommandShortcutsTest.php` — Regressions-Guard: keine Anzeige-Fläche leakt `docker-compose.prod.yml`; Kurzformen vorhanden.
- **Ändern** `install.sh` — Wrapper rendern + nach `/usr/local/bin/clusev` installieren (Phase 9 → „MOTD + CLI").
- **Ändern** `app/Livewire/Help/Index.php``#[Url]` auf `$topic`; `commands` in `TOPICS` + Label.
- **Ändern** `lang/de/help.php` + `lang/en/help.php``topic_commands`.
- **Ändern** `resources/views/livewire/help/content/{de,en}/recovery.blade.php``<pre>``clusev reset-admin`.
- **Ändern** `resources/views/livewire/help/content/{de,en}/updates.blade.php``sudo ./update.sh``sudo clusev update`.
- **Ändern** `resources/views/livewire/versions/index.blade.php` — 3-Zeilen-`<pre>` → `sudo clusev update`.
- **Ändern** `lang/de/versions.php` + `lang/en/versions.php``update_hint`.
- **Ändern** `lang/de/settings.php` + `lang/en/settings.php``recovery_note` (`clusev:reset-admin` → `clusev reset-admin`).
- **Ändern** `lang/de/system.php` + `lang/en/system.php``ssh_reset_hint` (dito).
- **Ändern** `docker/motd/00-clusev` — Verwalten/Update/Reset-Zeilen → Kurzform.
- **Ändern** `tests/Feature/HelpPageTest.php` — Marker `updates`/`recovery`, `commands`-Thema, `#[Url]`-Test, „kein docker-compose.prod.yml".
- **Ändern** `config/clusev.php`, `CHANGELOG.md` — Release.
---
## Task 1: `clusev` Host-CLI-Wrapper
**Files:**
- Create: `docker/clusev/clusev`
- Create: `tests/scripts/test-clusev-cli.sh`
- Modify: `install.sh` (Phase 9-Block, ~Zeilen 315342)
- [ ] **Step 1: Write the failing test (host bash test)**
Create `tests/scripts/test-clusev-cli.sh`:
```bash
#!/usr/bin/env bash
# Verifies the clusev host CLI template renders and behaves. No docker needed — only the
# help / version / unknown-command paths are exercised. Run from the repo root:
# bash tests/scripts/test-clusev-cli.sh
set -euo pipefail
cd "$(dirname "$0")/../.."
REPO="$(pwd)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
# Render the template the way install.sh does.
sed "s|__CLUSEV_DIR__|${REPO}|g" docker/clusev/clusev > "${TMP}/clusev"
chmod +x "${TMP}/clusev"
CLI="${TMP}/clusev"
# 1. Valid bash syntax.
bash -n "$CLI" || fail "syntax error in rendered clusev"
# 2. help lists the key subcommands.
out="$("$CLI" help)"
for needle in "clusev update" "clusev reset-admin" "clusev logs" "clusev ps" "clusev migrate"; do
printf '%s' "$out" | grep -qF "$needle" || fail "help missing: $needle"
done
# 3. bare invocation and --help also show usage.
"$CLI" | grep -qF "clusev reset-admin" || fail "bare invocation did not show usage"
"$CLI" --help | grep -qF "clusev reset-admin" || fail "--help did not show usage"
# 4. unknown command exits 64 and warns on stderr.
set +e
"$CLI" definitely-not-a-command >/dev/null 2>"${TMP}/err"; rc=$?
set -e
[ "$rc" -eq 64 ] || fail "unknown command exit = $rc (want 64)"
grep -qF "Unbekannter Befehl" "${TMP}/err" || fail "unknown command did not warn"
# 5. version reads config/clusev.php (no docker).
want="$(grep -oE "'version' => '[^']+'" config/clusev.php | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
got="$("$CLI" version)"
[ "$got" = "$want" ] || fail "version = '$got' (want '$want')"
# 6. install.sh wires the CLI into PATH.
grep -qF "/usr/local/bin/clusev" install.sh || fail "install.sh does not install /usr/local/bin/clusev"
# 7. usage text must NOT leak the long compose filename.
printf '%s' "$out" | grep -qF "docker-compose.prod.yml" && fail "usage leaks docker-compose.prod.yml"
printf 'ok — clusev CLI checks passed\n'
```
- [ ] **Step 2: Run it to confirm it fails**
Run: `bash tests/scripts/test-clusev-cli.sh`
Expected: FAIL — `sed: can't read docker/clusev/clusev: No such file or directory` (template missing).
- [ ] **Step 3: Create the wrapper template**
Create `docker/clusev/clusev`:
```bash
#!/usr/bin/env bash
# Clusev host CLI — short wrappers around the production stack.
# Generated by install.sh (it substitutes __CLUSEV_DIR__ with the install directory) and
# installed to /usr/local/bin/clusev. Operators run `clusev <command>` from anywhere instead
# of long `docker compose -f docker-compose.prod.yml ...` invocations. The compose filename is
# referenced internally only — it is never printed to the operator.
set -euo pipefail
CLUSEV_DIR="__CLUSEV_DIR__"
COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
compose() { docker compose -f "$COMPOSE_FILE" "$@"; }
usage() {
cat <<'EOF'
clusev — Fleet-Control Verwaltung (Host)
sudo clusev update Update holen, Image neu bauen, Migrationen anwenden (root)
clusev reset-admin Admin-Zugang zuruecksetzen (2FA entfernen, Passwort neu setzen)
clusev restart Stack neu starten
clusev logs [dienst] Logs folgen (Strg-C beendet)
clusev ps Dienst-Status (Alias: status)
clusev migrate Datenbank-Migrationen anwenden
clusev artisan <...> beliebiges artisan-Kommando im app-Container
clusev version installierte Version anzeigen
clusev help diese Uebersicht
Was im Hintergrund laeuft:
update -> update.sh (git pull + Image-Rebuild + migrate)
reset-admin -> docker compose exec app php artisan clusev:reset-admin
migrate -> docker compose exec app php artisan migrate --force
EOF
}
cmd="${1:-help}"; shift 2>/dev/null || true
case "$cmd" in
update) exec "${CLUSEV_DIR}/update.sh" "$@" ;;
reset-admin) compose exec app php artisan clusev:reset-admin "$@" ;;
restart) compose up -d "$@" ;;
logs) compose logs -f "$@" ;;
ps|status) compose ps "$@" ;;
migrate) compose exec app php artisan migrate --force "$@" ;;
artisan) compose exec app php artisan "$@" ;;
version) grep -oE "'version' => '[^']+'" "${CLUSEV_DIR}/config/clusev.php" \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true ;;
help|-h|--help) usage ;;
*) printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;;
esac
```
- [ ] **Step 4: Run the test to confirm it passes**
Run: `bash tests/scripts/test-clusev-cli.sh`
Expected: PASS — `ok — clusev CLI checks passed`.
- [ ] **Step 5: Wire the wrapper into install.sh**
In `install.sh`, change the phase 9 header (line ~316) and add a render+install block **before** the `motd_render`/MOTD block. Replace:
```bash
# ── [9/9] MOTD (themed, shown at host login) ─────────────────────────
phase 9/9 "MOTD installieren"
```
with:
```bash
# ── [9/9] Host-CLI + MOTD ────────────────────────────────────────────
phase 9/9 "Host-CLI + MOTD installieren"
# clusev host command: render the template with the install dir baked in, drop it in PATH.
# Best-effort — a read-only /usr/local/bin must never fail the installer.
if sed "s|__CLUSEV_DIR__|$(pwd)|g" docker/clusev/clusev > /usr/local/bin/clusev 2>/dev/null \
&& chmod 0755 /usr/local/bin/clusev 2>/dev/null; then
info "Host-Befehl installiert: clusev (z. B. 'clusev ps', 'sudo clusev update')"
else
info "Host-Befehl uebersprungen (/usr/local/bin nicht beschreibbar)"
fi
```
(Leave the rest of the MOTD block unchanged.)
- [ ] **Step 6: Verify install.sh still parses + test still green**
Run: `bash -n install.sh && bash tests/scripts/test-clusev-cli.sh`
Expected: no syntax error; `ok — clusev CLI checks passed`.
- [ ] **Step 7: Commit**
```bash
git add docker/clusev/clusev tests/scripts/test-clusev-cli.sh install.sh
git commit -m "feat: clusev host CLI wrapper for short stack commands"
```
---
## Task 2: Hilfe-Seite — Tab-URL + Thema „Befehle"
**Files:**
- Modify: `app/Livewire/Help/Index.php`
- Modify: `lang/de/help.php`, `lang/en/help.php`
- Create: `resources/views/livewire/help/content/de/commands.blade.php`, `…/en/commands.blade.php`
- Modify: `resources/views/livewire/help/content/de/recovery.blade.php`, `…/en/recovery.blade.php`
- Modify: `resources/views/livewire/help/content/de/updates.blade.php`, `…/en/updates.blade.php`
- Test: `tests/Feature/HelpPageTest.php`
- [ ] **Step 1: Update HelpPageTest — url binding, new topic, short commands**
In `tests/Feature/HelpPageTest.php`, add the `Url` import and three checks, and update the data provider. Add at the top with the other `use` lines:
```php
use Livewire\Attributes\Url;
```
Add these test methods (after `test_security_topic_explains_the_2fa_access_paths`):
```php
public function test_topic_property_is_url_bound(): void
{
$attrs = (new \ReflectionProperty(Index::class, 'topic'))->getAttributes(Url::class);
$this->assertNotEmpty($attrs, 'Help topic must be #[Url]-bound for deep links / reload');
}
public function test_recovery_shows_the_short_host_command_not_the_long_compose_one(): void
{
$this->actAsAdmin();
Livewire::test(Index::class)
->set('topic', 'recovery')
->assertSee('clusev reset-admin')
->assertDontSee('docker-compose.prod.yml');
}
public function test_commands_topic_lists_the_cli(): void
{
$this->actAsAdmin();
Livewire::test(Index::class)
->set('topic', 'commands')
->assertSee('clusev reset-admin')
->assertSee('sudo clusev update')
->assertDontSee('docker-compose.prod.yml');
}
```
Replace the `topicProvider` body:
```php
public static function topicProvider(): array
{
return [
['updates', 'clusev update'],
['servers', 'SSH'],
['sessions', 'Sitzung'],
['email', 'SMTP'],
['audit', 'Audit'],
['recovery', 'clusev reset-admin'],
['commands', 'clusev ps'],
];
}
```
- [ ] **Step 2: Run the tests to confirm they fail**
Run: `docker compose exec -T app php artisan test --filter=HelpPageTest`
Expected: FAIL — `test_topic_property_is_url_bound` (no `#[Url]`), `commands` topic renders overview (no marker), recovery still shows `docker-compose.prod.yml`.
- [ ] **Step 3: Add `#[Url]` + `commands` topic to the component**
In `app/Livewire/Help/Index.php`:
Change the imports (lines 56):
```php
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
```
Change the `TOPICS` constant (insert `commands` after `updates`):
```php
private const TOPICS = [
'overview', 'domain-tls', 'security', 'updates', 'commands',
'servers', 'sessions', 'email', 'audit', 'recovery',
];
```
Add `#[Url]` to the property (line ~22):
```php
#[Url]
public string $topic = 'overview';
```
Add the `commands` label to the `$labels` array in `render()` (after the `updates` entry):
```php
'updates' => __('help.topic_updates'),
'commands' => __('help.topic_commands'),
```
- [ ] **Step 4: Add the `topic_commands` lang key (DE + EN)**
In `lang/de/help.php`, after the `topic_updates` line:
```php
'topic_commands' => 'Befehle / CLI',
```
In `lang/en/help.php`, after the `topic_updates` line:
```php
'topic_commands' => 'Commands / CLI',
```
- [ ] **Step 5: Create the DE „Befehle" partial**
Create `resources/views/livewire/help/content/de/commands.blade.php`:
```blade
@php
$h = 'font-display text-base font-semibold text-ink';
$p = 'text-sm leading-relaxed text-ink-2';
$code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text';
$cmd = 'block w-full overflow-x-auto rounded-md border border-line bg-void px-3 py-2 font-mono text-[12px] text-accent-text';
$real = 'mt-1 font-mono text-[11px] leading-relaxed text-ink-3';
@endphp
<div class="space-y-3">
<h3 class="{{ $h }}">Befehle / CLI</h3>
<p class="{{ $p }}">Auf dem Host richtet der Installer den Befehl <code class="{{ $code }}">clusev</code> ein. Er kapselt die langen Docker-Kommandos — du musst dir weder Compose-Datei noch Container-Namen merken. Per SSH einloggen und von überall <code class="{{ $code }}">clusev &lt;befehl&gt;</code> tippen. <code class="{{ $code }}">clusev help</code> zeigt die Übersicht.</p>
</div>
<div class="space-y-4">
<div class="space-y-1">
<code class="{{ $cmd }}">sudo clusev update</code>
<p class="{{ $p }}">Holt die neueste Version, baut das Image neu (inkl. CSS/JS) und wendet Datenbank-Migrationen an. Braucht root (<code class="{{ $code }}">sudo</code>). Secrets und die konfigurierte Domain bleiben erhalten; das Panel ist ein bis zwei Minuten kurz nicht erreichbar.</p>
<p class="{{ $real }}">→ führt <span class="text-ink-2">update.sh</span> aus (git pull + Image-Rebuild + migrate)</p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev reset-admin</code>
<p class="{{ $p }}">Setzt den Admin-Zugang zurück: entfernt den zweiten Faktor (2FA), sodass beim nächsten Login ein neues Passwort gesetzt werden kann. Letzter Ausweg, wenn Passwort und 2FA verloren sind (siehe <span class="text-ink">Konto-Wiederherstellung</span>).</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose exec app php artisan clusev:reset-admin</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev restart</code>
<p class="{{ $p }}">Startet den Stack neu (alle Container) und wendet Konfigurationsänderungen an, ohne neu zu bauen.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose up -d</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev logs</code>
<p class="{{ $p }}">Folgt den Live-Logs des Stacks (z. B. zur Fehlersuche). Mit einem Dienstnamen dahinter — etwa <code class="{{ $code }}">clusev logs app</code> — nur dessen Logs. Strg-C beendet.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose logs -f</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev ps</code>
<p class="{{ $p }}">Zeigt, welche Dienste laufen (Alias: <code class="{{ $code }}">clusev status</code>). Schneller Gesundheits-Check des Stacks.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose ps</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev migrate</code>
<p class="{{ $p }}">Wendet ausstehende Datenbank-Migrationen an. Normalerweise erledigt das <code class="{{ $code }}">clusev update</code> automatisch — dieser Befehl ist für den seltenen manuellen Fall.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose exec app php artisan migrate --force</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev artisan &lt;...&gt;</code>
<p class="{{ $p }}">Für Fortgeschrittene: reicht ein beliebiges artisan-Kommando in den app-Container durch, etwa <code class="{{ $code }}">clusev artisan about</code>.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose exec app php artisan &lt;...&gt;</span></p>
</div>
</div>
```
- [ ] **Step 6: Create the EN „Commands" partial**
Create `resources/views/livewire/help/content/en/commands.blade.php`:
```blade
@php
$h = 'font-display text-base font-semibold text-ink';
$p = 'text-sm leading-relaxed text-ink-2';
$code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text';
$cmd = 'block w-full overflow-x-auto rounded-md border border-line bg-void px-3 py-2 font-mono text-[12px] text-accent-text';
$real = 'mt-1 font-mono text-[11px] leading-relaxed text-ink-3';
@endphp
<div class="space-y-3">
<h3 class="{{ $h }}">Commands / CLI</h3>
<p class="{{ $p }}">The installer sets up the <code class="{{ $code }}">clusev</code> command on the host. It wraps the long Docker invocations — you never need to remember the compose file or container names. SSH in and run <code class="{{ $code }}">clusev &lt;command&gt;</code> from anywhere. <code class="{{ $code }}">clusev help</code> prints the overview.</p>
</div>
<div class="space-y-4">
<div class="space-y-1">
<code class="{{ $cmd }}">sudo clusev update</code>
<p class="{{ $p }}">Pulls the latest release, rebuilds the image (including CSS/JS) and applies database migrations. Requires root (<code class="{{ $code }}">sudo</code>). Secrets and the configured domain are preserved; the panel is briefly unreachable for a minute or two.</p>
<p class="{{ $real }}">→ runs <span class="text-ink-2">update.sh</span> (git pull + image rebuild + migrate)</p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev reset-admin</code>
<p class="{{ $p }}">Resets dashboard access: clears the second factor (2FA) so you can set a new password on the next login. Last resort when password and 2FA are both lost (see <span class="text-ink">Account recovery</span>).</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose exec app php artisan clusev:reset-admin</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev restart</code>
<p class="{{ $p }}">Restarts the stack (all containers) and applies configuration changes without rebuilding.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose up -d</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev logs</code>
<p class="{{ $p }}">Follows the stack's live logs (handy for troubleshooting). Add a service name — e.g. <code class="{{ $code }}">clusev logs app</code> — for just that one. Ctrl-C stops.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose logs -f</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev ps</code>
<p class="{{ $p }}">Shows which services are running (alias: <code class="{{ $code }}">clusev status</code>). A quick stack health check.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose ps</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev migrate</code>
<p class="{{ $p }}">Applies pending database migrations. Normally <code class="{{ $code }}">clusev update</code> does this for you — this is for the rare manual case.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose exec app php artisan migrate --force</span></p>
</div>
<div class="space-y-1">
<code class="{{ $cmd }}">clusev artisan &lt;...&gt;</code>
<p class="{{ $p }}">For advanced use: passes any artisan command through to the app container, e.g. <code class="{{ $code }}">clusev artisan about</code>.</p>
<p class="{{ $real }}"><span class="text-ink-2">docker compose exec app php artisan &lt;...&gt;</span></p>
</div>
</div>
```
- [ ] **Step 7: Shorten the recovery `<pre>` (DE + EN)**
In `resources/views/livewire/help/content/de/recovery.blade.php`, replace the `<pre>` block (lines 2122) and the following paragraph (line 23):
```blade
<pre class="{{ $pre }}">clusev reset-admin</pre>
<p class="{{ $p }}"><code class="{{ $code }}">clusev reset-admin</code> entfernt den zweiten Faktor, sodass du beim nächsten Login ein neues Passwort setzen kannst. (Dahinter läuft <code class="{{ $code }}">docker compose exec app php artisan clusev:reset-admin</code> — siehe „Befehle / CLI".)</p>
```
In `resources/views/livewire/help/content/en/recovery.blade.php`, replace the `<pre>` block (lines 2122) and the following paragraph (line 23):
```blade
<pre class="{{ $pre }}">clusev reset-admin</pre>
<p class="{{ $p }}"><code class="{{ $code }}">clusev reset-admin</code> clears the second factor so you can set a new password on the next login. (Under the hood it runs <code class="{{ $code }}">docker compose exec app php artisan clusev:reset-admin</code> — see "Commands / CLI".)</p>
```
- [ ] **Step 8: Switch the updates partial to `clusev update` (DE + EN)**
In `resources/views/livewire/help/content/de/updates.blade.php`, replace line 13:
```blade
<li class="{{ $li }}"><span class="text-ink">Per SSH:</span> auf dem Host <code class="{{ $code }}">sudo clusev update</code> ausführen (von überall). Mehr unter „Befehle / CLI".</li>
```
In `resources/views/livewire/help/content/en/updates.blade.php`, replace line 13:
```blade
<li class="{{ $li }}"><span class="text-ink">Over SSH:</span> on the host run <code class="{{ $code }}">sudo clusev update</code> (from anywhere). More under "Commands / CLI".</li>
```
- [ ] **Step 9: Run the Help tests to confirm they pass**
Run: `docker compose exec -T app php artisan test --filter=HelpPageTest`
Expected: PASS — all topics render, url-binding present, recovery/commands show short forms, no `docker-compose.prod.yml`.
- [ ] **Step 10: Commit**
```bash
git add app/Livewire/Help/Index.php lang/de/help.php lang/en/help.php \
resources/views/livewire/help/content/de/commands.blade.php \
resources/views/livewire/help/content/en/commands.blade.php \
resources/views/livewire/help/content/de/recovery.blade.php \
resources/views/livewire/help/content/en/recovery.blade.php \
resources/views/livewire/help/content/de/updates.blade.php \
resources/views/livewire/help/content/en/updates.blade.php \
tests/Feature/HelpPageTest.php
git commit -m "feat: help tab-URL + Commands/CLI topic + short command copy"
```
---
## Task 3: Übrige Anzeige-Flächen + Regressions-Guard
**Files:**
- Modify: `resources/views/livewire/versions/index.blade.php` (lines 162164)
- Modify: `lang/de/versions.php`, `lang/en/versions.php` (`update_hint`)
- Modify: `lang/de/settings.php`, `lang/en/settings.php` (`recovery_note`)
- Modify: `lang/de/system.php`, `lang/en/system.php` (`ssh_reset_hint`)
- Modify: `docker/motd/00-clusev` (lines 4345)
- Test: `tests/Feature/CommandShortcutsTest.php`
- [ ] **Step 1: Write the failing regression test**
Create `tests/Feature/CommandShortcutsTest.php`:
```php
<?php
namespace Tests\Feature;
use Tests\TestCase;
class CommandShortcutsTest extends TestCase
{
/** Files shown to the operator — none may print the long compose filename. */
private const DISPLAY_SURFACES = [
'resources/views/livewire/versions/index.blade.php',
'resources/views/livewire/help/content/de/recovery.blade.php',
'resources/views/livewire/help/content/en/recovery.blade.php',
'resources/views/livewire/help/content/de/updates.blade.php',
'resources/views/livewire/help/content/en/updates.blade.php',
'resources/views/livewire/help/content/de/commands.blade.php',
'resources/views/livewire/help/content/en/commands.blade.php',
'docker/motd/00-clusev',
];
public function test_no_display_surface_leaks_the_long_compose_filename(): void
{
foreach (self::DISPLAY_SURFACES as $rel) {
$contents = file_get_contents(base_path($rel));
$this->assertStringNotContainsString('docker-compose.prod.yml', $contents, "{$rel} still shows docker-compose.prod.yml");
}
}
public function test_versions_panel_shows_the_short_update_command(): void
{
$blade = file_get_contents(base_path('resources/views/livewire/versions/index.blade.php'));
$this->assertStringContainsString('sudo clusev update', $blade);
$this->assertStringNotContainsString('docker compose -f', $blade);
}
public function test_reset_hints_use_the_short_host_command(): void
{
foreach (['de', 'en'] as $locale) {
$settings = require base_path("lang/{$locale}/settings.php");
$system = require base_path("lang/{$locale}/system.php");
$this->assertStringContainsString('clusev reset-admin', $settings['recovery_note']);
$this->assertStringContainsString('clusev reset-admin', $system['ssh_reset_hint']);
}
}
public function test_motd_uses_short_commands(): void
{
$motd = file_get_contents(base_path('docker/motd/00-clusev'));
$this->assertStringContainsString('clusev ps | logs | restart', $motd);
$this->assertStringContainsString('sudo clusev update', $motd);
$this->assertStringContainsString('clusev reset-admin', $motd);
}
}
```
- [ ] **Step 2: Run it to confirm it fails**
Run: `docker compose exec -T app php artisan test --filter=CommandShortcutsTest`
Expected: FAIL — versions blade + MOTD still carry `docker-compose.prod.yml`; lang strings still say `clusev:reset-admin`.
- [ ] **Step 3: Shorten the versions update panel**
In `resources/views/livewire/versions/index.blade.php`, replace the `<pre>` block (lines 162164):
```blade
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">sudo clusev update</pre>
```
- [ ] **Step 4: Reword the `update_hint` lang strings**
In `lang/de/versions.php` (line 47):
```php
'update_hint' => 'Updates kommen über getaggte Releases. Auf dem Host genügt ein Befehl — er baut das Image neu und wendet Migrationen an:',
```
In `lang/en/versions.php` (line 47):
```php
'update_hint' => 'Updates arrive via tagged releases. On the host a single command rebuilds the image and applies migrations:',
```
- [ ] **Step 5: Short host command in the reset hints**
In `lang/de/settings.php` (line 44):
```php
'recovery_note' => 'Konto-Wiederherstellung (letzter Ausweg): per SSH auf den Host einloggen und `clusev reset-admin` ausführen.',
```
In `lang/en/settings.php` (line 44):
```php
'recovery_note' => 'Account recovery (last resort): sign in to the host over SSH and run `clusev reset-admin`.',
```
In `lang/de/system.php` (line 34):
```php
'ssh_reset_hint' => 'Komplett ausgesperrt? Per SSH auf den Host einloggen und `clusev reset-admin` ausführen, um den Zugang zurückzusetzen.',
```
In `lang/en/system.php` (line 34):
```php
'ssh_reset_hint' => 'Fully locked out? SSH into the host and run `clusev reset-admin` to reset dashboard access.',
```
- [ ] **Step 6: Short commands in the MOTD**
In `docker/motd/00-clusev`, replace lines 4345:
```bash
printf ' %sLogin%s %sStandard-Passwort: clusev — beim ersten Login aendern · Reset: clusev reset-admin%s\n' "$D" "$R" "$D" "$R"
printf ' %sVerwalten%s %sclusev ps | logs | restart%s\n' "$D" "$R" "$D" "$R"
printf ' %sUpdate%s %ssudo clusev update%s\n' "$D" "$R" "$D" "$R"
```
(`stack_status()` keeps using `$COMPOSE` internally — that is script logic, not displayed text, and uses the `__CLUSEV_COMPOSE__` placeholder, not the literal filename.)
- [ ] **Step 7: Run the regression test to confirm it passes**
Run: `docker compose exec -T app php artisan test --filter=CommandShortcutsTest`
Expected: PASS — all four assertions green.
- [ ] **Step 8: Commit**
```bash
git add resources/views/livewire/versions/index.blade.php \
lang/de/versions.php lang/en/versions.php \
lang/de/settings.php lang/en/settings.php \
lang/de/system.php lang/en/system.php \
docker/motd/00-clusev tests/Feature/CommandShortcutsTest.php
git commit -m "feat: short clusev commands in versions panel, lang strings, MOTD"
```
---
## Task 4: Integration, Verifizierung & Release
**Files:**
- Modify: `config/clusev.php` (version bump)
- Modify: `CHANGELOG.md`
- [ ] **Step 1: Full test suite green**
Run: `docker compose exec -T app php artisan test`
Expected: PASS — no regressions (note `[[clusev-test-view-cache-race]]`: if a Blade-touch flake appears, re-run the failing file with an isolated `VIEW_COMPILED_PATH`).
- [ ] **Step 2: Wrapper host test green**
Run: `bash tests/scripts/test-clusev-cli.sh`
Expected: `ok — clusev CLI checks passed`.
- [ ] **Step 3: Build assets (prod parity)**
Run: `docker compose exec -T app npm run build`
Expected: Vite build succeeds, no errors.
- [ ] **Step 4: Bump version**
In `config/clusev.php`, set the `'version'` to the next patch (current is `0.9.24``0.9.25`):
```php
'version' => '0.9.25',
```
- [ ] **Step 5: Update CHANGELOG**
Add a top entry to `CHANGELOG.md` (match the existing format/heading style already in the file):
```markdown
## [0.9.25] — 2026-06-19
### Added
- Host CLI `clusev` (installed to `/usr/local/bin`): short wrappers for `update`, `reset-admin`, `restart`, `logs`, `ps`, `migrate`, `artisan`, `version`. Operators no longer type the long `docker compose -f docker-compose.prod.yml …` invocations.
- Help topic „Befehle / CLI" (DE/EN) documenting every `clusev` command and what it runs underneath.
### Changed
- Help tab now reflects in the URL (`#[Url]` on the topic) — reload / bookmark / share keep the active tab, matching Settings.
- All operator-facing surfaces (version update panel, help recovery/updates, settings/system reset hints, MOTD) now show the short `clusev …` commands; the `docker-compose.prod.yml` filename no longer appears in the UI.
```
- [ ] **Step 6: Commit the release**
```bash
git add config/clusev.php CHANGELOG.md
git commit -m "chore: release 0.9.25 — clusev host CLI + short commands + help tab-URL"
```
- [ ] **Step 7: R12 browser verify on the live domain**
Deploy to the VM, then load the panel in headless Chrome (per `[[clusev-prod-browser-verify]]`):
- `/help` → HTTP 200; click „Befehle / CLI" → the new topic renders; reload `…/help?topic=commands` → still on the topic; switch DE/EN; zero console/network errors; no leaked `@`/`{{ }}`/`group.key`.
- `/versions` (Version & Releases) → the update panel shows `sudo clusev update`, not the long command.
- On the host shell run `clusev help`, `clusev ps`, `clusev version` (read-only — NOT `reset-admin`/`update`); confirm sane output.
Expected: every check passes; throwaway admin (if created) removed afterwards.
- [ ] **Step 8: Codex review (R15)**
Run `/codex:review` over the branch diff. Fix anything it flags as an error or security issue; re-run until clean.
- [ ] **Step 9: Tag, push, deploy**
Tag `v0.9.25`, push branch + tag to the Gitea remote (token read only at push time from `/home/nexxo/.env.gitea`, never echoed/committed; sanitize push output `sed -E 's#https://[^@]*@#https://<redacted>@#g; s/[0-9a-f]{40,}/<redacted>/g'`), then deploy to the VM and re-confirm `clusev` is on PATH (`which clusev`).
---
## Self-Review
**1. Spec coverage:**
- Teil A (clusev wrapper) → Task 1 (template, test, install.sh). ✓
- Teil B (Help `#[Url]`) → Task 2 Step 3 + url-binding test. ✓
- Teil C (alle Anzeige-Flächen) → versions+lang+motd Task 3; help recovery/updates Task 2. ✓
- Teil D (Thema „Befehle" + ausführliche Erklärung + echtes Kommando) → Task 2 Steps 46. ✓
- Teil E (Dateistruktur) → File Structure section + per-task file lists. ✓
- Teil F (Test/Verify) → bash test (Task 1), HelpPageTest (Task 2), CommandShortcutsTest (Task 3), suite+build+R12+Codex (Task 4). ✓
- Teil G (kein Rename, keine internen Skripte) → respected; only display surfaces changed; `docker-compose.prod.yml` stays the real filename, referenced internally in the wrapper + MOTD `stack_status`. ✓
**2. Placeholder scan:** No TBD/TODO; every code step shows full content. ✓
**3. Type/name consistency:** Topic key `commands` used identically in `TOPICS`, `$labels`, lang key `topic_commands`, partial filenames `commands.blade.php`, and test markers. Short command spelled `clusev reset-admin` / `sudo clusev update` consistently across wrapper usage, help, lang, MOTD, tests. The wrapper's internal `COMPOSE_FILE` is the only place the long filename appears (not a display surface). ✓
**Note on the regression guard:** `CommandShortcutsTest` deliberately excludes `docker/clusev/clusev` (the wrapper *must* reference the compose file internally) — only operator-visible files are scanned.

View File

@ -1,459 +0,0 @@
# Hilfe-Seite Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eine bilinguale In-Panel-Hilfe-Seite unter `/help` mit linker Themen-Navigation, die alle Einstellungen erklärt — inkl. generischer Reverse-Proxy-Anleitung und dem 2FA-Zugangspfad-Hinweis.
**Architecture:** Full-Page-Livewire-Komponente `App\Livewire\Help\Index` (App-Layout, hinter Auth + Onboarding). Kurze Strings in `lang/{de,en}/help.php`; lange Texte als Blade-Partials je Sprache unter `resources/views/livewire/help/content/{de,en}/<topic>.blade.php`, nach `app()->getLocale()` eingebunden (Fallback `de`). Kein State außer `$topic`.
**Tech Stack:** Laravel 13, Livewire 3 (class-based), Tailwind v4 `@theme`-Tokens, bestehende Blade-Komponenten (`x-nav-item`, `x-panel`, `x-icon`).
**Konventionen (hart):** Route-Pfad englisch (R13); sichtbare Texte DE+EN, kein Emoji (R9/R16); nur `@theme`-Token-Utilities, keine Inline-Styles (R4/R3); Container-Tooling (R8); Browser-Verify (R12) + Codex (R15) vor „fertig".
Spec: `docs/superpowers/specs/2026-06-19-help-page-design.md` (Abschnitt 4 = verbindliche Inhalte).
---
## Task 1: Gerüst + Thema „overview" + Tests (TDD)
**Files:**
- Create: `app/Livewire/Help/Index.php`
- Create: `resources/views/livewire/help/index.blade.php`
- Create: `resources/views/livewire/help/content/de/overview.blade.php`
- Create: `resources/views/livewire/help/content/en/overview.blade.php`
- Create: `lang/de/help.php`, `lang/en/help.php`
- Modify: `routes/web.php` (Route + Import)
- Modify: `resources/views/components/sidebar.blade.php` (Nav-Eintrag nach Zeile 36)
- Modify: `lang/de/shell.php`, `lang/en/shell.php` (`nav_help`)
- Modify: `resources/views/components/command-palette.blade.php` (chords + `$nav`)
- Modify: `resources/js/app.js` (`CMDK_GO` + `h`)
- Test: `tests/Feature/HelpPageTest.php`
- [ ] **Step 1: Failing test**
`tests/Feature/HelpPageTest.php`:
```php
<?php
namespace Tests\Feature;
use App\Livewire\Help\Index;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class HelpPageTest extends TestCase
{
use RefreshDatabase;
private function actAsAdmin(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
public function test_help_route_loads_for_an_authenticated_admin(): void
{
$this->actAsAdmin();
$this->get('/help')->assertOk();
}
public function test_default_topic_is_overview(): void
{
$this->actAsAdmin();
Livewire::test(Index::class)
->assertSet('topic', 'overview')
->assertSee(__('help.topic_overview'));
}
public function test_unknown_topic_falls_back_to_overview(): void
{
$this->actAsAdmin();
Livewire::test(Index::class, ['topic' => 'bogus'])->assertSet('topic', 'overview');
}
}
```
- [ ] **Step 2: Run — expect FAIL**
Run: `docker compose exec -T -u app app php artisan test --filter=HelpPageTest`
Expected: FAIL (class `App\Livewire\Help\Index` not found).
- [ ] **Step 3: Component**
`app/Livewire/Help/Index.php`:
```php
<?php
namespace App\Livewire\Help;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* In-panel help/documentation. Pure display: a left topic nav (like Settings) and a per-locale
* content partial. No persistence, no external calls. Topic keys map to content partials at
* resources/views/livewire/help/content/{de,en}/<topic>.blade.php.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Help topics in display order. Keys are also the content-partial filenames. */
private const TOPICS = [
'overview', 'domain-tls', 'security', 'updates',
'servers', 'sessions', 'email', 'audit', 'recovery',
];
public string $topic = 'overview';
public function mount(?string $topic = null): void
{
if ($topic !== null && in_array($topic, self::TOPICS, true)) {
$this->topic = $topic;
}
}
public function render()
{
$labels = [
'overview' => __('help.topic_overview'),
'domain-tls' => __('help.topic_domain_tls'),
'security' => __('help.topic_security'),
'updates' => __('help.topic_updates'),
'servers' => __('help.topic_servers'),
'sessions' => __('help.topic_sessions'),
'email' => __('help.topic_email'),
'audit' => __('help.topic_audit'),
'recovery' => __('help.topic_recovery'),
];
$topics = array_map(fn (string $key): array => ['key' => $key, 'label' => $labels[$key]], self::TOPICS);
return view('livewire.help.index', ['topics' => $topics])->title(__('help.title'));
}
}
```
- [ ] **Step 4: Chrome lang files**
`lang/de/help.php`:
```php
<?php
return [
'title' => 'Hilfe — Clusev',
'eyebrow' => 'Hilfe',
'heading' => 'Hilfe & Dokumentation',
'subtitle' => 'Einstellungen und Abläufe erklärt.',
'topic_overview' => 'Überblick',
'topic_domain_tls' => 'Domain, TLS & Reverse-Proxy',
'topic_security' => 'Sicherheit & 2FA',
'topic_updates' => 'Updates & Versionen',
'topic_servers' => 'Server & SSH',
'topic_sessions' => 'Sitzungen & Benutzer',
'topic_email' => 'E-Mail (SMTP)',
'topic_audit' => 'Audit-Log',
'topic_recovery' => 'Konto-Wiederherstellung',
];
```
`lang/en/help.php` (same keys, English values):
```php
<?php
return [
'title' => 'Help — Clusev',
'eyebrow' => 'Help',
'heading' => 'Help & documentation',
'subtitle' => 'Settings and procedures explained.',
'topic_overview' => 'Overview',
'topic_domain_tls' => 'Domain, TLS & reverse proxy',
'topic_security' => 'Security & 2FA',
'topic_updates' => 'Updates & versions',
'topic_servers' => 'Servers & SSH',
'topic_sessions' => 'Sessions & users',
'topic_email' => 'Email (SMTP)',
'topic_audit' => 'Audit log',
'topic_recovery' => 'Account recovery',
];
```
- [ ] **Step 5: Index view**
`resources/views/livewire/help/index.blade.php`:
```blade
@php
$locale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
$partial = 'livewire.help.content.'.$locale.'.'.$topic;
if (! view()->exists($partial)) {
$partial = 'livewire.help.content.de.'.$topic;
}
@endphp
<div class="space-y-5">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('help.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('help.heading') }}</h2>
<p class="mt-1 font-mono text-xs text-ink-3">{{ __('help.subtitle') }}</p>
</div>
<div class="grid gap-5 lg:grid-cols-[15rem_1fr]">
{{-- Topic nav --}}
<nav class="flex flex-row flex-wrap gap-1 lg:flex-col">
@foreach ($topics as $t)
<button type="button" wire:click="$set('topic', '{{ $t['key'] }}')" @class([
'rounded-md px-3 py-2 text-left font-mono text-[12px] transition-colors',
'bg-accent/10 text-accent-text shadow-[inset_2px_0_0_var(--color-accent)]' => $topic === $t['key'],
'text-ink-2 hover:bg-raised hover:text-ink' => $topic !== $t['key'],
])>{{ $t['label'] }}</button>
@endforeach
</nav>
{{-- Content --}}
<article class="min-w-0 space-y-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
@include($partial)
</article>
</div>
</div>
```
- [ ] **Step 6: overview partials (de + en)**
`resources/views/livewire/help/content/de/overview.blade.php` — short, real prose. Cover: was Clusev ist (Fleet-Control über SSH); Erst-Login `admin@clusev.local` / `clusev` (sofort ändern, Zwangswechsel); Bare-IP vs. Domain; wo man was findet (Server, Dienste, Dateien, Audit, Einstellungen, System, Version, Hilfe). Use `<h3 class="font-display text-base font-semibold text-ink">`, `<p class="text-sm leading-relaxed text-ink-2">`, `<code class="font-mono text-accent-text">`. NO emoji, NO inline styles.
`resources/views/livewire/help/content/en/overview.blade.php` — same content, English.
- [ ] **Step 7: Route + import**
`routes/web.php`: add import near the other `use App\Livewire\...;` lines:
```php
use App\Livewire\Help;
```
Inside the `EnsureSecurityOnboarded` group, after the `/versions` route:
```php
Route::get('/help', Help\Index::class)->name('help');
```
- [ ] **Step 8: Sidebar nav**
`resources/views/components/sidebar.blade.php` — after the `/versions` `x-nav-item` (line 36), add:
```blade
<x-nav-item icon="help-circle" href="/help" :active="request()->is('help*')">{{ __('shell.nav_help') }}</x-nav-item>
```
If `x-icon` has no `help-circle` case, add it to `resources/views/components/icon.blade.php` with the Lucide path:
```blade
@case('help-circle')
<circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" />
@break
```
(Match the existing `@case`/`@break` structure + the component's stroke attributes.)
- [ ] **Step 9: shell lang + cmdk**
`lang/de/shell.php`: add `'nav_help' => 'Hilfe',`. `lang/en/shell.php`: add `'nav_help' => 'Help',`.
`resources/views/components/command-palette.blade.php`:
- In `$chords` (after `['g v', __('shell.nav_versions')],`): add `['g h', __('shell.nav_help')],`
- In `$nav` (after the versions entry): add `['label' => __('shell.nav_help'), 'href' => '/help', 'hint' => 'g h'],`
`resources/js/app.js` line ~256 `CMDK_GO`: add `h: '/help'` to the map:
```js
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', h: '/help' };
```
- [ ] **Step 10: Run — expect PASS**
Run: `docker compose exec -T -u app app php artisan test --filter=HelpPageTest`
Expected: PASS (3 tests).
- [ ] **Step 11: Pint + commit**
```bash
docker compose exec -T -u app app ./vendor/bin/pint app/Livewire/Help/Index.php tests/Feature/HelpPageTest.php
git add app/Livewire/Help routes/web.php resources/views/livewire/help resources/views/components/sidebar.blade.php resources/views/components/command-palette.blade.php resources/views/components/icon.blade.php resources/js/app.js lang/de/help.php lang/en/help.php lang/de/shell.php lang/en/shell.php tests/Feature/HelpPageTest.php
git commit -m "feat(help): in-panel help page scaffold + overview topic"
```
---
## Task 2: Thema „domain-tls" (Reverse-Proxy-Anleitung)
**Files:**
- Create: `resources/views/livewire/help/content/de/domain-tls.blade.php`
- Create: `resources/views/livewire/help/content/en/domain-tls.blade.php`
- Test: `tests/Feature/HelpPageTest.php` (add a test)
- [ ] **Step 1: Failing test** — add to `HelpPageTest`:
```php
public function test_domain_tls_topic_has_the_reverse_proxy_guide(): void
{
$this->actAsAdmin();
Livewire::test(Index::class)
->set('topic', 'domain-tls')
->assertSee('X-Forwarded-Proto')
->assertSee('TRUSTED_PROXY_CIDR')
->assertDontSee('Zoraxy'); // generic — no product names
}
```
- [ ] **Step 2: Run — expect FAIL** (`view [livewire.help.content.de.domain-tls] not found`).
Run: `docker compose exec -T -u app app php artisan test --filter=HelpPageTest`
- [ ] **Step 3: Write the partials (de + en)**
Content per spec §4a — three modes (Bare-IP / eingebautes TLS / externer Proxy) then the **generic step-by-step** proxy guide. MUST contain the literal tokens `X-Forwarded-Proto` and `TRUSTED_PROXY_CIDR`; MUST NOT contain `Zoraxy` (use „Proxy Manager"). Steps verbatim:
1. Im Panel: System → Domain → TLS „Externer Reverse-Proxy" → speichern → Stack neu starten.
2. Im Proxy Manager: Domain = `panel.<domain>`; Ziel = `http://<Clusev-IP>:80` (HTTP); Host-Header + `X-Forwarded-Proto: https` weiterreichen; WebSocket aktivieren (`/app/*`, `/apps/*`).
3. `.env`: `TRUSTED_PROXY_CIDR` = Proxy-Adresse → Secure-Cookie + echte IP im Audit; danach `sudo ./update.sh`.
4. Firewall: HTTP-Port nur vom Proxy erreichbar.
5. In diesem Modus stellt Clusev kein Zertifikat aus.
Use `<h3>`, `<p>`, `<ol class="...">`/`<ul>`, and a `<pre class="overflow-x-auto rounded-md border border-line bg-inset p-3 font-mono text-[11px] text-ink-2">` for the proxy fields/CLI. @theme tokens only.
- [ ] **Step 4: Run — expect PASS.** `docker compose exec -T -u app app php artisan test --filter=HelpPageTest`
- [ ] **Step 5: Commit**
```bash
git add resources/views/livewire/help/content tests/Feature/HelpPageTest.php
git commit -m "feat(help): domain-tls topic with the reverse-proxy setup guide"
```
---
## Task 3: Thema „security" (2FA-Zugangspfade + Extensions)
**Files:**
- Create: `resources/views/livewire/help/content/de/security.blade.php`
- Create: `resources/views/livewire/help/content/en/security.blade.php`
- Test: `tests/Feature/HelpPageTest.php`
- [ ] **Step 1: Failing test** — add:
```php
public function test_security_topic_explains_the_2fa_access_paths(): void
{
$this->actAsAdmin();
Livewire::test(Index::class)
->set('topic', 'security')
->assertSee('Backup-Code')
->assertSee('TOTP');
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Write the partials (de + en)** — content per spec §4b + §4c:
- TOTP / Security-Keys (YubiKey) / Backup-Codes erklären.
- **Security-Key nur über die HTTPS-Domain** (WebAuthn braucht sicheren Kontext + Domain als rpId); über **Bare-IP/HTTP** kein Security-Key → **Backup-Code** nötig.
- **TOTP funktioniert überall** (auch Bare-IP/HTTP), braucht keinen Backup-Code für den IP-Pfad.
- Empfehlung: reiner Security-Key-Nutzer → Backup-Codes aufbewahren.
- Passwort-Manager-Extensions (Bitwarden/Kaspersky) können die Registrierung abfangen → in der Extension Passkey-Übernahme deaktivieren oder ohne Extension registrieren (privates Fenster).
Must contain literal `Backup-Code` and `TOTP`.
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit**
```bash
git add resources/views/livewire/help/content tests/Feature/HelpPageTest.php
git commit -m "feat(help): security topic — 2FA access paths + extension note"
```
---
## Task 4: Restliche Themen (updates, servers, sessions, email, audit, recovery)
**Files:**
- Create: `resources/views/livewire/help/content/{de,en}/{updates,servers,sessions,email,audit,recovery}.blade.php` (12 Partials)
- Test: `tests/Feature/HelpPageTest.php`
- [ ] **Step 1: Failing test** — assert every topic renders without falling back (a marker string per topic). Add:
```php
/** @dataProvider topicProvider */
public function test_each_topic_renders_its_own_partial(string $topic, string $marker): void
{
$this->actAsAdmin();
Livewire::test(Index::class)->set('topic', $topic)->assertSee($marker);
}
public static function topicProvider(): array
{
return [
['updates', 'update.sh'],
['servers', 'SSH'],
['sessions', 'Sitzung'],
['email', 'SMTP'],
['audit', 'Audit'],
['recovery', 'clusev:reset-admin'],
];
}
```
- [ ] **Step 2: Run — expect FAIL** (missing partials).
- [ ] **Step 3: Write the 12 partials (de + en)** — each short + accurate:
- `updates`: Auto-Check, „Jetzt aktualisieren"-Knopf, CLI `sudo ./update.sh`, Build+Migrate beim Update. (marker `update.sh`)
- `servers`: Server hinzufügen, SSH-Credential-Vault, Hardening, SSH-Key-Provisioning. (marker `SSH`)
- `sessions`: aktive Sitzungen sehen/widerrufen, weitere Admins, Audit-Attribution. (marker `Sitzung`/`Session` — DE uses `Sitzung`)
- `email`: SMTP konfigurieren (Settings → E-Mail) + Testmail. (marker `SMTP`)
- `audit`: was protokolliert wird + Retention. (marker `Audit`)
- `recovery`: Forgot-Password (E-Mail-Link / 2FA-Proof), Bare-IP-Recovery, `clusev:reset-admin`. (marker `clusev:reset-admin`)
Same markup conventions (h3/p/ul/pre), @theme tokens, no emoji. EN partials mirror DE (the marker strings are language-neutral tokens, so the EN test passes too).
- [ ] **Step 4: Run — expect PASS** (full HelpPageTest green).
- [ ] **Step 5: Run full suite + Pint**
```bash
docker compose exec -T -u app app ./vendor/bin/pint
docker compose exec -T -u app app php artisan test
```
Expected: all green.
- [ ] **Step 6: Commit**
```bash
git add resources/views/livewire/help/content tests/Feature/HelpPageTest.php
git commit -m "feat(help): remaining topics (updates, servers, sessions, email, audit, recovery)"
```
---
## Task 5: Browser-Verify (R12), Codex (R15), Release
**Files:** Modify `config/clusev.php` (version), `CHANGELOG.md`.
- [ ] **Step 1: R12 browser-verify on the domain** — temp admin (create + delete after, do not touch the real account), headless Chrome via the puppeteer docker image against `https://panel.test.bave.dev/help`:
- HTTP 200, no console errors, no leaked `{{ }}`/`help.`/`shell.` tokens.
- Click two topic-nav buttons (`domain-tls`, `security`) → content swaps.
- Toggle DE/EN → content language changes (the partial locale switches).
- Check at 375 / 768 / 1280 widths.
- [ ] **Step 2: Codex review** — run `/codex:review` over the diff; fix until no errors / no security issues.
- [ ] **Step 3: Version + CHANGELOG** — bump `config/clusev.php` version; add a `### Hinzugefügt` entry: „In-Panel-Hilfe-Seite (`/help`) mit Themen-Navigation; erklärt alle Einstellungen, die Reverse-Proxy-Einrichtung und die 2FA-Zugangspfade (Security-Key nur über HTTPS-Domain, Backup-Code über Bare-IP, TOTP überall). Zoraxy-Produktname aus dem TLS-Hinweis entfernt."
- [ ] **Step 4: Commit, tag, push** (token from `/home/nexxo/.env.gitea`, output sanitized):
```bash
git add config/clusev.php CHANGELOG.md
git commit -m "chore(release): vX.Y.Z — in-panel help page"
git tag -a vX.Y.Z -m "Clusev vX.Y.Z — Hilfe-Seite"
TOKEN="$(grep -E '^GIT_ACCESS_TOKEN=' /home/nexxo/.env.gitea | cut -d= -f2- | tr -d '\r\n')"
git push "https://boban:${TOKEN}@git.bave.dev/boban/clusev.git" feat/v1-foundation --follow-tags 2>&1 | sed -E 's#https://[^@]*@#https://<redacted>@#g; s/[0-9a-f]{40,}/<redacted>/g'
```
- [ ] **Step 5: Deploy to VM + final verify**`sudo ./update.sh` on the VM; confirm `/help` renders over the domain (R12 spot-check).
---
## Self-Review (Plan vs. Spec)
- **Spec §1 Platzierung** → Task 1 (route, sidebar, cmdk, layout). ✓
- **Spec §2 Aufbau** → Task 1 (component `$topic`, index view, topic nav). ✓
- **Spec §3 zweisprachig** → Task 1 (lang chrome + per-locale partials + fallback). ✓
- **Spec §4a Proxy-Anleitung** → Task 2 (must-have tokens, no product name). ✓
- **Spec §4b 2FA-Pfade** → Task 3 (Backup-Code/TOTP markers). ✓
- **Spec §4c Extensions** → Task 3. ✓
- **Spec §5 Logik klein** → Task 1 component (pure display). ✓
- **Spec §6 Dateistruktur** → all 18 partials across Tasks 14. ✓
- **Spec §7 Test/Verify** → Tasks 14 (Livewire tests) + Task 5 (R12/Codex/Release). ✓
- **Spec §8 YAGNI** → no search/markdown engine; static partials only. ✓
Type/name consistency: topic keys identical in `TOPICS`, labels map, partial filenames, and tests (`overview`, `domain-tls`, `security`, `updates`, `servers`, `sessions`, `email`, `audit`, `recovery`). `$set('topic', …)` matches the public `$topic` prop.

View File

@ -1,921 +0,0 @@
# 2FA Challenge — Backup Code as its own button + view — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move the backup (recovery) code off the combined 2FA challenge screen into its own button + dedicated route/view, sharing all rate-limit and login logic via a trait.
**Architecture:** Extract the pending-user resolver, the brute-force buckets and the login success-tail from `TwoFactorChallenge` into a `CompletesTwoFactorChallenge` trait. A new `TwoFactorBackup` full-page Livewire component (guest route `/two-factor-challenge/backup`) reuses the trait and accepts only recovery codes. The main challenge view shows only the primary factor (TOTP field *or* security-key button) plus a subordinate "Backup-Code verwenden" link; a key-only user with no secure context (http + bare IP) is redirected straight to the backup view from `mount()`. IP-vs-domain factor routing (`WebauthnService::available()`) is unchanged.
**Tech Stack:** Laravel 13, Livewire v3 (class-based, full-page components as routes), Tailwind v4, PHPUnit + `livewire/livewire` testing, Pint. Spec: `docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md`.
---
## Pre-flight
- Dev stack up: `docker compose up -d` (app on :80). **Run ALL tooling inside the container (R8).**
- Branch: `feat/v1-foundation` (already checked out).
- Known flake (memory: test view-cache race): if `php artisan test` shows Blade-compile races against the live dev stack, re-run the affected filter with an isolated compiled-view dir, e.g. `docker compose exec -e VIEW_COMPILED_PATH=/tmp/views-test app php artisan test --filter=...`.
## File structure
| File | Responsibility |
|---|---|
| `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` | **New.** Shared trait: `pendingUser()`, `getPendingHasTotpProperty()`, `webauthnAvailable()`, rate-limit buckets/helpers, `completeLogin()`. |
| `app/Livewire/Auth/TwoFactorChallenge.php` | **Modify.** Use the trait, drop the extracted methods, add the mount→backup redirect. WebAuthn methods stay here. |
| `app/Livewire/Auth/TwoFactorBackup.php` | **New.** Backup-only component: session guard + recovery-code `verify()`. |
| `resources/views/livewire/auth/two-factor-challenge.blade.php` | **Modify.** Gate the form to TOTP users, remove the backup field/hints, add the backup button. |
| `resources/views/livewire/auth/two-factor-backup.blade.php` | **New.** Backup-code field + return links. |
| `routes/web.php` | **Modify.** New `two-factor.challenge.backup` route in the `guest` group. |
| `lang/de/auth.php`, `lang/en/auth.php` | **Modify.** Add 4 keys; later remove 2 orphaned hint keys. |
| `tests/Feature/TwoFactorBackupTest.php` | **New.** Backup component coverage. |
| `tests/Feature/ChallengeFactorAdaptTest.php` | **Modify.** Flip the key-only assertion; add main-view content tests. |
---
### Task 1: i18n keys
**Files:**
- Modify: `lang/de/auth.php`, `lang/en/auth.php`
- [ ] **Step 1: Add the 4 new German keys**
In `lang/de/auth.php`, inside the `// ── 2FA challenge ──` block (right after the `'code' => 'Code',` line, before `'back_to_login'`), add:
```php
'challenge_use_backup' => 'Backup-Code verwenden',
'backup_heading' => 'Backup-Codes',
'backup_subtitle' => 'Gib einen Backup-Code ein, um fortzufahren.',
'back_to_options' => 'Zurück zu Anmelde-Optionen',
```
- [ ] **Step 2: Add the same 4 keys in English**
In `lang/en/auth.php`, in the matching `// ── 2FA challenge ──` block after `'code' => 'Code',`:
```php
'challenge_use_backup' => 'Use backup code',
'backup_heading' => 'Backup codes',
'backup_subtitle' => 'Enter a backup code to continue.',
'back_to_options' => 'Back to sign-in options',
```
- [ ] **Step 3: Verify both files parse and the keys are present + identical**
Run:
```bash
docker compose exec app php -r "\$de=require 'lang/de/auth.php'; \$en=require 'lang/en/auth.php'; \$k=['challenge_use_backup','backup_heading','backup_subtitle','back_to_options']; foreach(\$k as \$x){ echo \$x.': '.(isset(\$de[\$x])&&isset(\$en[\$x])?'OK':'MISSING').PHP_EOL; }"
```
Expected: four `OK` lines.
- [ ] **Step 4: Commit**
```bash
git add lang/de/auth.php lang/en/auth.php
git commit -m "i18n(auth): add 2FA backup-view strings"
```
---
### Task 2: Extract the shared trait
Pure refactor — no behaviour change. The existing 2FA test suite is the safety net.
**Files:**
- Create: `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`
- Modify: `app/Livewire/Auth/TwoFactorChallenge.php`
- [ ] **Step 1: Create the trait**
Create `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`:
```php
<?php
namespace App\Livewire\Concerns;
use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
/**
* Shared 2FA-challenge plumbing for the TOTP/key view and the backup-only view: the
* pending-user resolver, the brute-force buckets (ONE shared counter set across both
* views), and the login success tail. Centralising it guarantees both views hit the
* same rate limit and complete login identically.
*/
trait CompletesTwoFactorChallenge
{
protected ?User $pendingUser = null;
protected bool $pendingResolved = false;
/** The pending 2FA user, resolved once per request from the session. */
protected function pendingUser(): ?User
{
if (! $this->pendingResolved) {
$this->pendingUser = User::find(session('2fa.user'));
$this->pendingResolved = true;
}
return $this->pendingUser;
}
/** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
public function getPendingHasTotpProperty(): bool
{
return (bool) $this->pendingUser()?->hasTotp();
}
/** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
public function webauthnAvailable(): bool
{
$user = $this->pendingUser();
return $user !== null
&& app(WebauthnService::class)->available()
&& $user->hasWebauthnCredentials();
}
/**
* Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
* (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user
* backstop, so a distributed (multi-IP) brute-force of the code is still capped. Both
* decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
*
* @return array<string, array{0:int,1:int}> key => [maxAttempts, decaySeconds]
*/
protected function rateLimitBuckets(): array
{
$uid = (string) session('2fa.user');
return [
'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
'two-factor-acct:'.md5($uid) => [20, 900],
];
}
protected function assertNotRateLimited(): void
{
foreach ($this->rateLimitBuckets() as $key => [$max]) {
if (RateLimiter::tooManyAttempts($key, $max)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
}
}
protected function hitRateLimit(): void
{
foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
RateLimiter::hit($key, $decay);
}
}
protected function clearRateLimit(): void
{
foreach (array_keys($this->rateLimitBuckets()) as $key) {
RateLimiter::clear($key);
}
}
/** Log the verified pending user in and finish the challenge (shared success tail). */
protected function completeLogin(User $user)
{
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
Auth::login($user, $remember);
session()->regenerate();
return $this->redirectIntended(route('dashboard'), navigate: true);
}
}
```
- [ ] **Step 2: Rewrite `TwoFactorChallenge` to use the trait**
Replace the entire contents of `app/Livewire/Auth/TwoFactorChallenge.php` with:
```php
<?php
namespace App\Livewire\Auth;
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
class TwoFactorChallenge extends Component
{
use CompletesTwoFactorChallenge;
#[Validate('required|string')]
public string $code = '';
public function mount()
{
if (! session()->has('2fa.user')) {
return $this->redirect(route('login'), navigate: true);
}
}
public function verify()
{
$this->validate();
$this->assertNotRateLimited();
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
// Accept the TOTP code only when the pending user actually has TOTP (otherwise a null
// secret would break verifyKey); always allow a one-time backup (recovery) code.
$valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
if (! $valid) {
$this->hitRateLimit();
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
$this->clearRateLimit();
return $this->completeLogin($user);
}
/** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
public function assertionOptions(WebauthnService $webauthn): array
{
$user = User::find(session('2fa.user'));
abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404);
return $webauthn->assertionOptions($user);
}
/** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
{
$this->assertNotRateLimited();
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
$this->hitRateLimit();
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
}
$this->clearRateLimit();
return $this->completeLogin($user);
}
public function render()
{
return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge'));
}
}
```
- [ ] **Step 3: Run the existing 2FA suite — must stay green**
Run:
```bash
docker compose exec app php artisan test --filter='TwoFactorChallengeRecoveryTest|TwoFactorWebauthnTest|ChallengeFactorAdaptTest|BruteForceHardeningTest'
```
Expected: PASS (behaviour unchanged — the trait just relocates the same code).
- [ ] **Step 4: Pint + commit**
```bash
docker compose exec app ./vendor/bin/pint app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php
git add app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php
git commit -m "refactor(auth): extract CompletesTwoFactorChallenge trait"
```
---
### Task 3: Backup route + component + view (TDD)
**Files:**
- Create: `tests/Feature/TwoFactorBackupTest.php`
- Modify: `routes/web.php`
- Create: `app/Livewire/Auth/TwoFactorBackup.php`
- Create: `resources/views/livewire/auth/two-factor-backup.blade.php`
- [ ] **Step 1: Write the failing test file**
Create `tests/Feature/TwoFactorBackupTest.php`:
```php
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorBackup;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class TwoFactorBackupTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush(); // start every test with empty rate-limit buckets
}
private function enrolledUser(): User
{
return User::factory()->create([
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
}
private function keyOnlyUser(): User
{
$u = User::factory()->create();
WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
return $u->fresh();
}
public function test_backup_code_logs_in_and_is_consumed(): void
{
$user = $this->enrolledUser();
$code = $user->replaceRecoveryCodes()[0];
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorBackup::class)
->set('code', $code)
->call('verify')
->assertRedirect(route('dashboard'));
$this->assertAuthenticatedAs($user);
$this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed
}
public function test_invalid_backup_code_fails(): void
{
$user = $this->enrolledUser();
$user->replaceRecoveryCodes();
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorBackup::class)
->set('code', 'nope-nope')
->call('verify')
->assertHasErrors('code');
$this->assertGuest();
}
public function test_a_totp_code_is_not_accepted_on_the_backup_view(): void
{
$user = $this->enrolledUser();
$user->replaceRecoveryCodes();
$totp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorBackup::class)
->set('code', $totp)
->call('verify')
->assertHasErrors('code');
$this->assertGuest();
}
public function test_requires_a_pending_2fa_session(): void
{
Livewire::test(TwoFactorBackup::class)
->assertRedirect(route('login'));
}
public function test_field_and_login_link_render(): void
{
$user = $this->enrolledUser();
$user->replaceRecoveryCodes();
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorBackup::class)
->assertSee(__('auth.backup_heading'))
->assertSee(__('auth.challenge_backup_placeholder')) // the backup field is present
->assertSee(__('auth.back_to_login'));
}
public function test_back_to_options_shown_for_a_totp_user(): void
{
$user = $this->enrolledUser();
$user->replaceRecoveryCodes();
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorBackup::class)
->assertSee(__('auth.back_to_options'));
}
public function test_back_to_options_hidden_for_key_only_without_secure_context(): void
{
// No domain configured → webauthnAvailable() false, pendingHasTotp false: backup is the
// only path, so the "back to options" link would loop straight back here and is hidden.
$user = $this->keyOnlyUser();
$user->replaceRecoveryCodes();
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorBackup::class)
->assertDontSee(__('auth.back_to_options'))
->assertSee(__('auth.back_to_login'));
}
public function test_failed_backup_attempts_share_the_rate_limit_with_the_main_challenge(): void
{
$user = $this->enrolledUser();
$codes = $user->replaceRecoveryCodes();
session()->put('2fa.user', $user->id);
// 5 wrong backup attempts trip the per-(user+IP) bucket (5/60s).
for ($i = 0; $i < 5; $i++) {
Livewire::test(TwoFactorBackup::class)->set('code', 'wrong-'.$i)->call('verify');
}
// A genuinely valid, unused backup code is now refused on the MAIN challenge by the
// SAME shared bucket — proving one counter spans both components.
Livewire::test(TwoFactorChallenge::class)
->set('code', $codes[0])
->call('verify')
->assertHasErrors('code');
$this->assertGuest();
}
}
```
- [ ] **Step 2: Run it to confirm it fails**
Run:
```bash
docker compose exec app php artisan test --filter=TwoFactorBackupTest
```
Expected: FAIL — `Class "App\Livewire\Auth\TwoFactorBackup" not found` (and the route does not exist yet).
- [ ] **Step 3: Add the route**
In `routes/web.php`, in the `guest` middleware group, immediately after the existing `two-factor.challenge` line, add the backup sibling:
```php
Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');
Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup');
```
- [ ] **Step 4: Create the `TwoFactorBackup` component**
Create `app/Livewire/Auth/TwoFactorBackup.php`:
```php
<?php
namespace App\Livewire\Auth;
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use App\Models\User;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
class TwoFactorBackup extends Component
{
use CompletesTwoFactorChallenge;
#[Validate('required|string')]
public string $code = '';
public function mount()
{
if (! session()->has('2fa.user')) {
return $this->redirect(route('login'), navigate: true);
}
}
public function verify()
{
$this->validate();
$this->assertNotRateLimited();
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
// Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP).
if (! $user->useRecoveryCode($this->code)) {
$this->hitRateLimit();
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
$this->clearRateLimit();
return $this->completeLogin($user);
}
public function render()
{
return view('livewire.auth.two-factor-backup')->title(__('auth.title_challenge'));
}
}
```
- [ ] **Step 5: Create the backup view**
Create `resources/views/livewire/auth/two-factor-backup.blade.php`:
```blade
@php
$fieldText = 'h-12 w-full rounded-md border border-line bg-inset px-3 text-center font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.backup_heading') }}</h1>
<p class="font-mono text-xs text-ink-3">{{ __('auth.backup_subtitle') }}</p>
</div>
<form wire:submit="verify" class="space-y-4">
<div>
<label for="code" class="{{ $label }}">{{ __('auth.challenge_backup_label') }}</label>
<input wire:model="code" id="code" inputmode="text" autocomplete="one-time-code" autofocus
class="{{ $fieldText }}" placeholder="{{ __('auth.challenge_backup_placeholder') }}" />
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
<svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span wire:loading.remove wire:target="verify">{{ __('common.confirm') }}</span>
<span wire:loading wire:target="verify">{{ __('auth.checking') }}</span>
</x-btn>
</form>
<div class="flex flex-col items-center gap-2.5">
@if ($this->pendingHasTotp || $this->webauthnAvailable())
<a href="{{ route('two-factor.challenge') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_options') }}
</a>
@endif
<a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }}
</a>
</div>
</div>
```
- [ ] **Step 6: Run the test — must pass**
Run:
```bash
docker compose exec app php artisan test --filter=TwoFactorBackupTest
```
Expected: PASS (all 8 tests).
- [ ] **Step 7: Pint + commit**
```bash
docker compose exec app ./vendor/bin/pint routes/web.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/TwoFactorBackupTest.php
git add routes/web.php app/Livewire/Auth/TwoFactorBackup.php resources/views/livewire/auth/two-factor-backup.blade.php tests/Feature/TwoFactorBackupTest.php
git commit -m "feat(auth): dedicated backup-code 2FA view + route"
```
---
### Task 4: Main view redesign + mount redirect (TDD)
The backup route now exists, so `TwoFactorChallenge::mount()` can safely redirect to it.
**Files:**
- Modify: `tests/Feature/ChallengeFactorAdaptTest.php`
- Modify: `app/Livewire/Auth/TwoFactorChallenge.php`
- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php`
- [ ] **Step 1: Update `ChallengeFactorAdaptTest` to the new behaviour (failing)**
Replace the entire contents of `tests/Feature/ChallengeFactorAdaptTest.php` with:
```php
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ChallengeFactorAdaptTest extends TestCase
{
use RefreshDatabase;
private function keyOnlyUser(): User
{
$u = User::factory()->create();
WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
return $u->fresh();
}
public function test_totp_user_sees_the_field(): void
{
$user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
session(['2fa.user' => $user->id, '2fa.remember' => false]);
Livewire::test(TwoFactorChallenge::class)->assertSet('pendingHasTotp', true);
}
public function test_totp_user_sees_the_backup_link_but_not_the_backup_field(): void
{
$user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
session(['2fa.user' => $user->id, '2fa.remember' => false]);
Livewire::test(TwoFactorChallenge::class)
->assertSee('000000') // TOTP field present
->assertSee(__('auth.challenge_use_backup')) // backup link present
->assertDontSee(__('auth.challenge_backup_placeholder')); // no backup field here
}
public function test_key_only_user_without_secure_context_is_redirected_to_the_backup_view(): void
{
// No domain configured → webauthnAvailable() false, pendingHasTotp false: the backup code
// is the only usable path, so mount() sends the user straight to the dedicated view.
$user = $this->keyOnlyUser();
session(['2fa.user' => $user->id, '2fa.remember' => false]);
Livewire::test(TwoFactorChallenge::class)
->assertRedirect(route('two-factor.challenge.backup'));
}
public function test_key_only_user_with_secure_context_stays_and_shows_key_plus_backup(): void
{
$svc = $this->mock(WebauthnService::class);
$svc->shouldReceive('available')->andReturn(true);
$user = $this->keyOnlyUser();
session(['2fa.user' => $user->id, '2fa.remember' => false]);
Livewire::test(TwoFactorChallenge::class)
->assertNoRedirect()
->assertSee(__('auth.webauthn_login')) // security-key button
->assertSee(__('auth.challenge_use_backup')) // backup link
->assertDontSee('000000') // no TOTP field
->assertDontSee(__('auth.challenge_backup_placeholder')); // no inline backup field
}
}
```
- [ ] **Step 2: Run it — confirm the new assertions fail**
Run:
```bash
docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest
```
Expected: FAIL — the redirect test fails (no redirect yet) and the "no backup field" assertions fail (the field is still rendered).
- [ ] **Step 3: Add the mount redirect to `TwoFactorChallenge`**
In `app/Livewire/Auth/TwoFactorChallenge.php`, replace the `mount()` method with:
```php
public function mount()
{
if (! session()->has('2fa.user')) {
return $this->redirect(route('login'), navigate: true);
}
// No TOTP and no usable security key (a key-only user over http + bare IP, where WebAuthn
// has no secure context) → the backup code is the only path: go straight to its own view.
if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) {
return $this->redirect(route('two-factor.challenge.backup'), navigate: true);
}
}
```
- [ ] **Step 4: Redesign the main challenge view**
Replace the entire contents of `resources/views/livewire/auth/two-factor-challenge.blade.php` with:
```blade
@php
$field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.challenge_heading') }}</h1>
<p class="font-mono text-xs text-ink-3">{{ $this->pendingHasTotp ? __('auth.challenge_subtitle') : __('auth.challenge_key_subtitle') }}</p>
</div>
@if (! $this->pendingHasTotp && $this->webauthnAvailable())
{{-- Key-only: the security key is the primary path. --}}
<x-btn variant="primary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login($wire)">
<x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
</x-btn>
@endif
@if ($this->pendingHasTotp)
<form wire:submit="verify" class="space-y-4">
<div>
<label for="code" class="{{ $label }}">{{ __('auth.code') }}</label>
<input wire:model="code" id="code" inputmode="text" autocomplete="one-time-code" autofocus
class="{{ $field }}" placeholder="000000" />
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
<svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span wire:loading.remove wire:target="verify">{{ __('common.confirm') }}</span>
<span wire:loading wire:target="verify">{{ __('auth.checking') }}</span>
</x-btn>
</form>
@if ($this->webauthnAvailable())
{{-- TOTP + key: the security key is the alternate path. --}}
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-line"></span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.or') }}</span>
<span class="h-px flex-1 bg-line"></span>
</div>
<x-btn variant="secondary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login($wire)">
<x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
</x-btn>
@endif
@endif
{{-- Backup code lives on its own view — a deliberate, subordinate step. --}}
<x-btn variant="secondary" size="lg" class="w-full" :href="route('two-factor.challenge.backup')" wire:navigate>
{{ __('auth.challenge_use_backup') }}
</x-btn>
<div class="flex justify-center">
<a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }}
</a>
</div>
</div>
```
(Compared to the old view: `$fieldText` removed from the `@php` block; the always-on backup field, the `challenge_recovery_hint` and the `challenge_backup_only_hint` paragraphs are gone; the form is wrapped in `@if ($this->pendingHasTotp)`; the "Backup-Code verwenden" button is added before the back-to-login link.)
- [ ] **Step 5: Run the adapt test — must pass**
Run:
```bash
docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest
```
Expected: PASS (4 tests).
- [ ] **Step 6: Run the whole 2FA + auth suite — guard against regressions**
Run:
```bash
docker compose exec app php artisan test --filter='TwoFactor|Challenge|Webauthn|BruteForce|Login|Factor'
```
Expected: PASS. (If `BruteForceHardeningTest` or another suite asserted the old combined-view backup field, fix that assertion to key on `challenge_backup_placeholder` per the substring caveat below, then re-run.)
> **Substring caveat:** never assert `assertSee/DontSee(__('auth.challenge_backup_label'))` to detect the backup field — `challenge_backup_label` ("Backup-Code") is a substring of `challenge_use_backup` ("Backup-Code verwenden"). Use `__('auth.challenge_backup_placeholder')` (`xxxxxxxxxx-xxxxxxxxxx`), which is unique to the field.
- [ ] **Step 7: Pint + commit**
```bash
docker compose exec app ./vendor/bin/pint app/Livewire/Auth/TwoFactorChallenge.php tests/Feature/ChallengeFactorAdaptTest.php
git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php tests/Feature/ChallengeFactorAdaptTest.php
git commit -m "feat(auth): split backup code out of the main 2FA challenge view"
```
---
### Task 5: Remove the orphaned hint keys
`challenge_recovery_hint` and `challenge_backup_only_hint` are no longer rendered by any view.
**Files:**
- Modify: `lang/de/auth.php`, `lang/en/auth.php`
- [ ] **Step 1: Confirm they are unreferenced**
Run:
```bash
docker compose exec app grep -rn "challenge_recovery_hint\|challenge_backup_only_hint" resources/ app/ || echo "NO REFERENCES"
```
Expected: `NO REFERENCES`. (If any line prints, stop — that view still needs the key; do not delete it.)
- [ ] **Step 2: Delete the two keys from both language files**
Remove these lines from `lang/de/auth.php`:
```php
'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
'challenge_backup_only_hint' => 'Kein Authenticator nötig — Security-Key oder ein Backup-Code.',
```
And from `lang/en/auth.php`:
```php
'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
'challenge_backup_only_hint' => 'No authenticator needed — use a security key or a backup code.',
```
- [ ] **Step 3: Verify both files still parse**
Run:
```bash
docker compose exec app php -r "require 'lang/de/auth.php'; require 'lang/en/auth.php'; echo 'OK'.PHP_EOL;"
```
Expected: `OK`.
- [ ] **Step 4: Commit**
```bash
git add lang/de/auth.php lang/en/auth.php
git commit -m "i18n(auth): drop orphaned 2FA recovery hint strings"
```
---
### Task 6: Full verification (R12 browser + R15 Codex)
**Files:** none (verification only).
- [ ] **Step 1: Full test suite green**
Run:
```bash
docker compose exec app php artisan test
```
Expected: PASS, 0 failures.
- [ ] **Step 2: Build assets**
Run:
```bash
docker compose exec app npm run build
```
Expected: build completes, no errors.
- [ ] **Step 3: R12 browser verify — `/two-factor-challenge` (DE + EN)**
With a pending-2FA session for a TOTP user, load `/two-factor-challenge` headless. Confirm:
- HTTP 200, zero console/network errors.
- Rendered DOM shows the TOTP field + the "Backup-Code verwenden" button + "Zurück zur Anmeldung"; **no** inline backup field (`xxxxxxxxxx-xxxxxxxxxx` placeholder absent).
- No leaked `@`/`{{ }}`/`$var`/`group.key` text (R17).
- 3 breakpoints (375 / 768 / 1280), touch targets ≥44px.
(Bare-IP setup: a key-only pending session should 302/redirect to `/two-factor-challenge/backup` — verify the redirect lands.)
- [ ] **Step 4: R12 browser verify — `/two-factor-challenge/backup` (DE + EN)**
Load `/two-factor-challenge/backup` with a pending-2FA session. Confirm HTTP 200, zero console/network errors, the backup field + "Bestätigen" + the correct return links (for a TOTP user both "Zurück zu Anmelde-Optionen" and "Zurück zur Anmeldung"), no leaked template tokens, 3 breakpoints.
- [ ] **Step 5: R15 — Codex review clean**
Run `/codex:review` over the branch diff. Fix anything it flags (errors or security) and re-run until it reports no errors and no security issues.
- [ ] **Step 6: Final Pint**
Run:
```bash
docker compose exec app ./vendor/bin/pint --dirty
```
Expected: no changes / all clean. Commit if Pint reformatted anything.
---
## Self-review notes (already reconciled with the spec)
- **Shared rate-limit bucket** — Task 2 keeps the exact bucket key format; Task 3's cross-component test proves the counter spans both views.
- **No property→method churn** — the computed property `getPendingHasTotpProperty()` moves into the trait, so every `$this->pendingHasTotp` blade/test usage is unchanged.
- **Redirect-loop guard**`back_to_options` is hidden exactly when `! pendingHasTotp && ! webauthnAvailable()` (covered by `TwoFactorBackupTest::test_back_to_options_hidden_for_key_only_without_secure_context`).
- **Substring trap** — backup-field assertions use `challenge_backup_placeholder`, never `challenge_backup_label`.
- **Out of scope (Spec 2):** auth-failure → fail2ban hard IP ban. Not in this plan.

View File

@ -1,752 +0,0 @@
# WireGuard dashboard — Phase 1 (live status) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A read-only `/wireguard` dashboard page showing live WireGuard status — gate state, server info, and connected peers (handshake, endpoint, rx/tx) — fed by a periodic host collector through the `./run` bridge.
**Architecture:** A new `clusev-wg.sh collect` subcommand (run by a systemd `.timer` every 5 s) writes `run/wg-status.json`. A PHP `WgStatus` reader parses + validates that file; a full-page Livewire component renders it and `wire:poll`s every 5 s. No writes, no DB, no chart (those are P2P4). Container never touches the host — same constraint as SP1.
**Tech Stack:** bash + `wg`/`iptables`/`systemctl` (host), systemd `.timer`/`.service`, Laravel 13 + Livewire 3, Tailwind v4 tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` (§0 Bridge 1, §1, §2).
**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=<X>'`; Pint `… vendor/bin/pint <files>`; shellcheck `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <script>`. Commit on `feat/v1-foundation`; do not push until the release task.
---
## File Structure
- **`docker/wg/clusev-wg.sh`** (modify) — gains a `collect` subcommand + a `RUN_DIR` derivation (project root from the script location, override via `$CLUSEV_DIR`). Writes `run/wg-status.json` atomically; no secrets in it.
- **`docker/wg/clusev-wg-collect.service`** + **`docker/wg/clusev-wg-collect.timer`** (new) — periodic collector (oneshot every 5 s).
- **`install.sh`** (modify) — render + enable the collect timer inside `install_host_watchers()`.
- **`app/Services/WgStatus.php`** (new) — reads + validates `run/wg-status.json` → a typed array; the single source for both the page and the route. One responsibility: parse the status file.
- **`routes/web.php`** (modify) — `GET /wg-status.json` (auth-gated; has peer names/endpoints) using `WgStatus`.
- **`app/Livewire/Wireguard/Index.php`** + **`resources/views/livewire/wireguard/index.blade.php`** (new) — the page.
- **`resources/views/components/sidebar.blade.php`** (modify) — nav entry; **`lang/{de,en}/shell.php`** — `nav_wireguard`.
- **`lang/{de,en}/wireguard.php`** (new) — page strings (identical keys, R9/R16).
- **Tests:** `tests/Feature/WgStatusTest.php`, `tests/Feature/WireguardPageTest.php`.
---
## Task 1: `clusev-wg.sh collect` subcommand
**Files:** Modify `docker/wg/clusev-wg.sh`
- [ ] **Step 1: Add the `RUN_DIR` derivation** near the other constants (after the `PANEL_PORTS="80,443"` line). It resolves the project root from the script's own location (`docker/wg/clusev-wg.sh` → `../..`), overridable by the systemd unit's `$CLUSEV_DIR`:
```bash
# Project root (for the run/ bridge dir). The collect timer sets CLUSEV_DIR; otherwise derive it
# from this script's location (docker/wg/clusev-wg.sh -> ../..).
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
CLUSEV_DIR="${CLUSEV_DIR:-$(cd "${SELF_DIR}/../.." && pwd)}"
RUN_DIR="${CLUSEV_DIR}/run"
```
- [ ] **Step 2: Add the `cmd_collect` function** (place it just before the `usage()` function). It emits `run/wg-status.json` atomically and never errors:
```bash
# Emit run/wg-status.json (atomic temp+mv). Public, NO secrets. If WG isn't configured, emits
# {"configured":false}. Run every few seconds by clusev-wg-collect.timer; the dashboard reads it.
cmd_collect() {
mkdir -p "$RUN_DIR" 2>/dev/null || true
local now tmp dest
now="$(date +%s 2>/dev/null || echo 0)"
dest="${RUN_DIR}/wg-status.json"
tmp="$(mktemp "${RUN_DIR}/.wg-status.XXXXXX" 2>/dev/null)" || tmp="${RUN_DIR}/.wg-status.tmp"
if [ ! -f "$WG_CONF" ]; then
printf '{"at":%s,"configured":false}\n' "$now" > "$tmp"
mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
chmod 644 "$dest" 2>/dev/null || true
return 0
fi
# shellcheck disable=SC1090
[ -f "$WG_ENV" ] && . "$WG_ENV"
local gate_marker=false gate_chain=false wgq
[ -f "$GATE_MARKER" ] && gate_marker=true
iptables -nL "$GATE_CHAIN" >/dev/null 2>&1 && gate_chain=true
wgq="$(systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || echo inactive)"
# Peers from `wg show wg0 dump`, named via the wg0.conf `# clusev-peer:` comments. Peer names are
# valid_name-restricted and pubkeys/endpoints are from safe charsets, so the values are JSON-safe.
local peers_json
peers_json="$({ wg show "$WG_IF" dump 2>/dev/null || true; } | awk -v conf="$WG_CONF" '
BEGIN {
name=""
while ((getline line < conf) > 0) {
if (line ~ /^# clusev-peer:/) { sub(/^# clusev-peer:[ \t]*/,"",line); name=line }
else if (name != "" && line ~ /^PublicKey[ \t]*=/) { p=line; sub(/^PublicKey[ \t]*=[ \t]*/,"",p); names[p]=name; name="" }
}
close(conf); out=""; first=1
}
NR==1 { next } # interface line
{
pk=$1; endp=$3; hs=$5+0; rx=$6+0; tx=$7+0
if (endp=="(none)") endp=""
nm=(pk in names)?names[pk]:""
rec="{\"name\":\"" nm "\",\"pubkey\":\"" pk "\",\"endpoint\":\"" endp "\",\"latest_handshake\":" hs ",\"rx\":" rx ",\"tx\":" tx "}"
if (first) { out=rec; first=0 } else { out=out "," rec }
}
END { printf "[%s]", out }
')"
[ -n "$peers_json" ] || peers_json="[]"
printf '{"at":%s,"configured":true,"gate":{"marker":%s,"chain":%s},"wg_quick":"%s","server":{"subnet":"%s","server_ip":"%s","port":%s,"endpoint":"%s","pubkey":"%s"},"peers":%s}\n' \
"$now" "$gate_marker" "$gate_chain" "$wgq" \
"${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_SERVER_PUBKEY:-}" \
"$peers_json" > "$tmp"
mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
chmod 644 "$dest" 2>/dev/null || true
}
```
- [ ] **Step 3: Add the dispatch arm.** In the `case "$cmd"` switch, add after the `gate-apply)` arm:
```bash
collect) cmd_collect ;; # called by clusev-wg-collect.timer (root not required: read-only)
```
- [ ] **Step 4: bash -n + shellcheck**
Run: `bash -n docker/wg/clusev-wg.sh` → exit 0.
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/wg/clusev-wg.sh` → clean apart from the existing `# shellcheck disable=SC1090` lines.
- [ ] **Step 5: Smoke `collect` (safe — read-only, no WG on this host).** It must produce a `configured:false` file without error:
```bash
CLUSEV_DIR="$PWD" bash docker/wg/clusev-wg.sh collect; echo "exit=$?"; cat run/wg-status.json; rm -f run/wg-status.json
```
Expected: `exit=0` and `{"at":<number>,"configured":false}` (no `/etc/wireguard/wg0.conf` on the dev host). Then remove the test artifact.
- [ ] **Step 6: Commit**
```bash
git add docker/wg/clusev-wg.sh
git commit -m "feat(wg): clusev-wg.sh collect — emit run/wg-status.json for the dashboard"
```
---
## Task 2: collect timer + service units
**Files:** Create `docker/wg/clusev-wg-collect.service`, `docker/wg/clusev-wg-collect.timer`
- [ ] **Step 1: Write the service** (`docker/wg/clusev-wg-collect.service`):
```ini
# Clusev WireGuard status collector — oneshot (HOST-side). Run every few seconds by the paired
# .timer; writes run/wg-status.json (no secrets) which the dashboard reads. Cheap: `wg show` is
# instant and an unconfigured host exits immediately. install.sh rewrites the /home/nexxo/clusev paths.
[Unit]
Description=Clusev WireGuard status collector
After=network.target
[Service]
Type=oneshot
User=root
Environment=CLUSEV_DIR=/home/nexxo/clusev
WorkingDirectory=/home/nexxo/clusev
ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh collect
```
- [ ] **Step 2: Write the timer** (`docker/wg/clusev-wg-collect.timer`):
```ini
# Fires clusev-wg-collect.service every 5 s (after a 10 s boot delay). The dashboard treats a
# wg-status.json older than ~20 s as "collector offline".
[Unit]
Description=Clusev WireGuard status collector — periodic trigger
[Timer]
OnBootSec=10s
OnUnitActiveSec=5s
AccuracySec=1s
Unit=clusev-wg-collect.service
[Install]
WantedBy=timers.target
```
- [ ] **Step 3: Verify** `systemd-analyze verify docker/wg/clusev-wg-collect.timer docker/wg/clusev-wg-collect.service 2>&1` — no key/section errors (ExecStart-not-found is fine; path rendered at install). If `systemd-analyze` is absent, eyeball that the `[Unit]`/`[Timer]`/`[Install]` and `[Unit]`/`[Service]` sections + directives match the sibling units in `docker/restart-sentinel/`.
- [ ] **Step 4: Commit**
```bash
git add docker/wg/clusev-wg-collect.service docker/wg/clusev-wg-collect.timer
git commit -m "feat(wg): clusev-wg-collect timer+service — 5s status collection"
```
---
## Task 3: install.sh — render + enable the collect timer
**Files:** Modify `install.sh` (`install_host_watchers()`)
- [ ] **Step 1: Render + enable the timer.** In `install_host_watchers()`, mirror the gate-unit handling. After the line `local tmp_gate; tmp_gate="$(mktemp)"` add:
```bash
local tmp_csvc tmp_ctimer; tmp_csvc="$(mktemp)"; tmp_ctimer="$(mktemp)"
```
After the gate sed line (`sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-gate.service" > "$tmp_gate"`) add:
```bash
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.service" > "$tmp_csvc"
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.timer" > "$tmp_ctimer"
```
In the `if install … && systemctl …; then` chain, add the two installs after the gate install line (`&& install -m 0644 "$tmp_gate" "${dst}/clusev-wg-gate.service" \`):
```bash
&& install -m 0644 "$tmp_csvc" "${dst}/clusev-wg-collect.service" \
&& install -m 0644 "$tmp_ctimer" "${dst}/clusev-wg-collect.timer" \
```
and add the enable+start after the gate enable line (`&& systemctl enable clusev-wg-gate.service \`) — the collector is safe to start now (read-only, emits `configured:false` until WG is set up):
```bash
&& systemctl enable --now clusev-wg-collect.timer \
```
Add the two temps to the cleanup `rm -f` line:
```bash
rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" "$tmp_gate" "$tmp_csvc" "$tmp_ctimer"
```
- [ ] **Step 2: Verify.** `bash -n install.sh` → exit 0. shellcheck `install.sh` → no NEW findings vs the committed version (capture the baseline first: `git show HEAD:install.sh > /tmp/i.sh; docker run --rm -v /tmp:/mnt -w /mnt koalaman/shellcheck:stable i.sh`). Eyeball: `daemon-reload` still runs after all installs; the timer enable is among the enables.
- [ ] **Step 3: Commit**
```bash
git add install.sh
git commit -m "feat(wg): install + enable the WireGuard status collector timer"
```
---
## Task 4: `WgStatus` reader service + `/wg-status.json` route
**Files:** Create `app/Services/WgStatus.php`; Modify `routes/web.php`; Test `tests/Feature/WgStatusTest.php`
- [ ] **Step 1: Write the failing test** (`tests/Feature/WgStatusTest.php`):
```php
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\WgStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WgStatusTest extends TestCase
{
use RefreshDatabase;
private string $file;
protected function setUp(): void
{
parent::setUp();
$dir = storage_path('app/restart-signal');
@mkdir($dir, 0775, true);
$this->file = $dir.'/wg-status.json';
@unlink($this->file);
}
protected function tearDown(): void
{
@unlink($this->file);
parent::tearDown();
}
public function test_absent_file_reads_as_unconfigured(): void
{
$s = app(WgStatus::class)->read();
$this->assertFalse($s['configured']);
$this->assertTrue($s['stale']);
}
public function test_parses_a_configured_status_with_peers(): void
{
file_put_contents($this->file, json_encode([
'at' => time(),
'configured' => true,
'gate' => ['marker' => true, 'chain' => true],
'wg_quick' => 'active',
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'],
'peers' => [
['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '5.6.7.8:41820', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200],
['name' => 'phone', 'pubkey' => 'p2', 'endpoint' => '', 'latest_handshake' => 0, 'rx' => 0, 'tx' => 0],
],
]));
$s = app(WgStatus::class)->read();
$this->assertTrue($s['configured']);
$this->assertFalse($s['stale']);
$this->assertTrue($s['gate']['marker']);
$this->assertSame(51820, $s['server']['port']);
$this->assertCount(2, $s['peers']);
$this->assertTrue($s['peers'][0]['online']); // fresh handshake
$this->assertFalse($s['peers'][1]['online']); // handshake 0
}
public function test_stale_when_at_is_old(): void
{
file_put_contents($this->file, json_encode(['at' => time() - 999, 'configured' => true, 'peers' => []]));
$this->assertTrue(app(WgStatus::class)->read()['stale']);
}
public function test_route_requires_auth_and_returns_status(): void
{
$this->get('/wg-status.json')->assertRedirect('/login'); // gated
file_put_contents($this->file, json_encode(['at' => time(), 'configured' => false]));
$this->actingAs(User::factory()->create(['must_change_password' => false]))
->getJson('/wg-status.json')->assertOk()->assertJson(['configured' => false]);
}
}
```
- [ ] **Step 2: Run it → fails** (`WgStatus` + route missing).
Run: `… php artisan test --filter=WgStatusTest`
Expected: FAIL (class not found / route 404).
- [ ] **Step 3: Write `app/Services/WgStatus.php`:**
```php
<?php
namespace App\Services;
/**
* Reads + validates the host-written status file run/wg-status.json (bind-mounted to
* storage/app/restart-signal). Single source for the WireGuard page + the /wg-status.json route.
* Contains NO secrets — the collector never writes a private key.
*/
class WgStatus
{
/** Status older than this (seconds) means the host collector is offline. */
private const STALE_AFTER = 20;
/** A peer with a handshake within this many seconds is considered online. */
private const ONLINE_WITHIN = 180;
private function path(): string
{
return storage_path('app/restart-signal/wg-status.json');
}
/**
* @return array{configured:bool, at:int, stale:bool, gate?:array, wg_quick?:string, server?:array, peers?:array}
*/
public function read(): array
{
$path = $this->path();
$data = is_file($path) ? json_decode((string) @file_get_contents($path), true) : null;
if (! is_array($data)) {
return ['configured' => false, 'at' => 0, 'stale' => true];
}
$at = (int) ($data['at'] ?? 0);
$stale = $at < (time() - self::STALE_AFTER);
if (empty($data['configured'])) {
return ['configured' => false, 'at' => $at, 'stale' => $stale];
}
$peers = [];
foreach ((array) ($data['peers'] ?? []) as $p) {
if (! is_array($p)) {
continue;
}
$hs = (int) ($p['latest_handshake'] ?? 0);
$peers[] = [
'name' => (string) ($p['name'] ?? ''),
'pubkey' => (string) ($p['pubkey'] ?? ''),
'endpoint' => (string) ($p['endpoint'] ?? ''),
'latest_handshake' => $hs,
'rx' => (int) ($p['rx'] ?? 0),
'tx' => (int) ($p['tx'] ?? 0),
'online' => $hs > (time() - self::ONLINE_WITHIN),
];
}
return [
'configured' => true,
'at' => $at,
'stale' => $stale,
'gate' => [
'marker' => (bool) ($data['gate']['marker'] ?? false),
'chain' => (bool) ($data['gate']['chain'] ?? false),
],
'wg_quick' => (string) ($data['wg_quick'] ?? 'inactive'),
'server' => [
'subnet' => (string) ($data['server']['subnet'] ?? ''),
'server_ip' => (string) ($data['server']['server_ip'] ?? ''),
'port' => (int) ($data['server']['port'] ?? 0),
'endpoint' => (string) ($data['server']['endpoint'] ?? ''),
'pubkey' => (string) ($data['server']['pubkey'] ?? ''),
],
'peers' => $peers,
];
}
}
```
- [ ] **Step 4: Add the route.** In `routes/web.php`, inside the `EnsureSecurityOnboarded` panel group (next to the other authed routes — it carries peer names/endpoints, so it is NOT public like `/version.json`), add:
```php
Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status');
```
- [ ] **Step 5: Run the test → passes.**
Run: `… php artisan test --filter=WgStatusTest` → PASS. Pint `app/Services/WgStatus.php tests/Feature/WgStatusTest.php`.
- [ ] **Step 6: Commit**
```bash
git add app/Services/WgStatus.php routes/web.php tests/Feature/WgStatusTest.php
git commit -m "feat(wg): WgStatus reader + auth-gated /wg-status.json route"
```
---
## Task 5: the `/wireguard` page
**Files:** Create `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/de/wireguard.php`, `lang/en/wireguard.php`; Modify `routes/web.php`, `resources/views/components/sidebar.blade.php`, `lang/de/shell.php`, `lang/en/shell.php`; Test `tests/Feature/WireguardPageTest.php`
- [ ] **Step 1: Write the failing test** (`tests/Feature/WireguardPageTest.php`):
```php
<?php
namespace Tests\Feature;
use App\Livewire\Wireguard\Index;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class WireguardPageTest extends TestCase
{
use RefreshDatabase;
private string $file;
protected function setUp(): void
{
parent::setUp();
$dir = storage_path('app/restart-signal');
@mkdir($dir, 0775, true);
$this->file = $dir.'/wg-status.json';
@unlink($this->file);
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
protected function tearDown(): void
{
@unlink($this->file);
parent::tearDown();
}
public function test_renders_unconfigured_state(): void
{
Livewire::test(Index::class)
->assertOk()
->assertViewHas('status', fn ($s) => $s['configured'] === false)
->assertSee(__('wireguard.not_configured_title'));
}
public function test_renders_peers_when_configured(): void
{
file_put_contents($this->file, json_encode([
'at' => time(), 'configured' => true,
'gate' => ['marker' => true, 'chain' => true], 'wg_quick' => 'active',
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'],
'peers' => [['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '5.6.7.8:41820', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200]],
]));
Livewire::test(Index::class)
->assertOk()
->assertViewHas('status', fn ($s) => $s['configured'] === true && count($s['peers']) === 1)
->assertSee('laptop')
->assertSee('10.99.0.0/24');
}
}
```
- [ ] **Step 2: Run it → fails** (component + view + lang missing).
- [ ] **Step 3: Write the component** (`app/Livewire/Wireguard/Index.php`):
```php
<?php
namespace App\Livewire\Wireguard;
use App\Services\WgStatus;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* WireGuard dashboard — live status (read-only, Phase 1). Renders the host-collected
* run/wg-status.json (via WgStatus) and wire:polls every 5s. No writes here (P3/P4).
*/
#[Layout('layouts.app')]
class Index extends Component
{
public function render(WgStatus $wg)
{
return view('livewire.wireguard.index', [
'status' => $wg->read(),
])->title(__('wireguard.title'));
}
}
```
- [ ] **Step 4: Write the lang files.** `lang/de/wireguard.php`:
```php
<?php
return [
'title' => 'WireGuard — Clusev',
'eyebrow' => 'Netzwerk',
'heading' => 'WireGuard',
'subtitle' => 'Tunnel-Zugang zum Panel — Live-Status.',
// Not-configured empty state
'not_configured_title' => 'WireGuard ist nicht eingerichtet',
'not_configured_body' => 'Auf dem Host einrichten: per SSH „sudo clusev wg setup". Danach erscheint hier der Live-Status.',
// Collector
'stale' => 'Status veraltet — der Host-Collector antwortet nicht. Läuft der Stack?',
// Gate
'gate' => 'Gate (Panel-Sperre)',
'gate_on' => 'aktiv — Panel nur über den Tunnel',
'gate_off' => 'aus — Panel öffentlich erreichbar',
'gate_escape' => 'Notausgang per SSH: „clusev wg down".',
// Server card
'server_title' => 'Server',
'subnet' => 'Subnetz',
'server_ip' => 'Server-IP',
'port' => 'Port',
'endpoint' => 'Endpoint',
'server_key' => 'Server-Schlüssel',
'service' => 'Dienst',
// Peers
'peers_title' => 'Peers',
'peers_count' => ':count Peer|:count Peers',
'no_peers' => 'Noch keine Peers — per SSH „clusev wg add-peer <name>".',
'online' => 'online',
'offline' => 'offline',
'last_handshake' => 'letzter Handshake',
'never' => 'nie',
'transfer' => 'Übertragung',
'down_up' => '↓ :rx · ↑ :tx',
];
```
`lang/en/wireguard.php` (identical keys, R9/R16):
```php
<?php
return [
'title' => 'WireGuard — Clusev',
'eyebrow' => 'Network',
'heading' => 'WireGuard',
'subtitle' => 'Tunnel access to the panel — live status.',
'not_configured_title' => 'WireGuard is not set up',
'not_configured_body' => 'Set it up on the host: over SSH run "sudo clusev wg setup". The live status appears here afterwards.',
'stale' => 'Status is stale — the host collector is not responding. Is the stack running?',
'gate' => 'Gate (panel lock)',
'gate_on' => 'on — panel only via the tunnel',
'gate_off' => 'off — panel publicly reachable',
'gate_escape' => 'Escape over SSH: "clusev wg down".',
'server_title' => 'Server',
'subnet' => 'Subnet',
'server_ip' => 'Server IP',
'port' => 'Port',
'endpoint' => 'Endpoint',
'server_key' => 'Server key',
'service' => 'Service',
'peers_title' => 'Peers',
'peers_count' => ':count peer|:count peers',
'no_peers' => 'No peers yet — over SSH run "clusev wg add-peer <name>".',
'online' => 'online',
'offline' => 'offline',
'last_handshake' => 'last handshake',
'never' => 'never',
'transfer' => 'Transfer',
'down_up' => '↓ :rx · ↑ :tx',
];
```
- [ ] **Step 5: Write the view** (`resources/views/livewire/wireguard/index.blade.php`). Token utilities only (R3), no inline style (R4), responsive (R7), DE/EN via `__()`, no emoji except the ↓/↑ glyphs which are text arrows (allowed — not status emoji), reuse `x-panel`/`x-badge`/`x-status-dot`:
```blade
@php
$fmtBytes = function (int $n): string {
$u = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
$v = (float) $n;
while ($v >= 1024 && $i < count($u) - 1) { $v /= 1024; $i++; }
return ($i === 0 ? (string) $n : number_format($v, 1)).' '.$u[$i];
};
$ago = function (int $ts): string {
if ($ts <= 0) return __('wireguard.never');
$d = max(0, time() - $ts);
if ($d < 60) return $d.'s';
if ($d < 3600) return intdiv($d, 60).'m';
if ($d < 86400) return intdiv($d, 3600).'h';
return intdiv($d, 86400).'d';
};
@endphp
<div class="space-y-5" wire:poll.5s>
{{-- Header --}}
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('wireguard.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('wireguard.heading') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('wireguard.subtitle') }}</p>
</div>
@if (! $status['configured'])
{{-- Not-configured empty state --}}
<x-panel :title="__('wireguard.not_configured_title')">
<p class="text-sm leading-relaxed text-ink-2">{{ __('wireguard.not_configured_body') }}</p>
<pre class="mt-3 overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] text-ink-2">sudo clusev wg setup</pre>
</x-panel>
@else
@if ($status['stale'])
<div class="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<span class="text-sm text-ink-2">{{ __('wireguard.stale') }}</span>
</div>
@endif
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
{{-- Peers --}}
<x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false">
<div class="divide-y divide-line">
@forelse ($status['peers'] as $peer)
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:px-5" wire:key="peer-{{ $peer['pubkey'] }}">
<span class="inline-flex items-center gap-2">
<x-status-dot :status="$peer['online'] ? 'online' : 'offline'" />
<span class="font-mono text-sm font-semibold text-ink">{{ $peer['name'] !== '' ? $peer['name'] : Str::limit($peer['pubkey'], 10, '…') }}</span>
</span>
<span class="font-mono text-[11px] text-ink-4">{{ $peer['online'] ? __('wireguard.online') : __('wireguard.offline') }}</span>
<span class="ml-auto font-mono text-[11px] text-ink-3">{{ __('wireguard.last_handshake') }}: {{ $ago($peer['latest_handshake']) }}</span>
<span class="w-full font-mono text-[11px] text-ink-3">
{{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }}
<span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span>
</span>
</div>
@empty
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('wireguard.no_peers') }}</p>
@endforelse
</div>
</x-panel>
{{-- Aside: gate + server --}}
<div class="space-y-5">
<x-panel :title="__('wireguard.gate')">
@if ($status['gate']['marker'] && $status['gate']['chain'])
<x-badge tone="cyan">{{ __('wireguard.gate_on') }}</x-badge>
@else
<x-badge tone="neutral">{{ __('wireguard.gate_off') }}</x-badge>
@endif
<p class="mt-2 font-mono text-[11px] text-ink-4">{{ __('wireguard.gate_escape') }}</p>
</x-panel>
<x-panel :title="__('wireguard.server_title')" :padded="false">
<dl class="space-y-2 px-4 py-4 font-mono text-[11px] sm:px-5">
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.subnet') }}</dt><dd class="text-ink-2">{{ $status['server']['subnet'] ?: '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.server_ip') }}</dt><dd class="text-ink-2">{{ $status['server']['server_ip'] ?: '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.port') }}</dt><dd class="text-ink-2">{{ $status['server']['port'] ?: '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.endpoint') }}</dt><dd class="truncate text-ink-2">{{ $status['server']['endpoint'] ?: '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.service') }}</dt><dd class="text-ink-2">{{ $status['wg_quick'] }}</dd></div>
</dl>
</x-panel>
</div>
</div>
@endif
</div>
```
- [ ] **Step 6: Add the route.** In `routes/web.php`, in the onboarded panel group (near `/system`), add:
```php
Route::get('/wireguard', Wireguard\Index::class)->name('wireguard');
```
and ensure the `use App\Livewire\Wireguard;` import is present at the top (add it alphabetically near the other `App\Livewire\*` imports).
- [ ] **Step 7: Add the sidebar nav + shell lang.** In `resources/views/components/sidebar.blade.php`, in the Fleet group, after the Audit nav item, add:
```blade
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
```
In `lang/de/shell.php` and `lang/en/shell.php`, add (both files, identical key):
```php
'nav_wireguard' => 'WireGuard',
```
- [ ] **Step 8: Run the test → passes.** Run: `… php artisan test --filter=WireguardPageTest` → PASS. Pint the new PHP files.
- [ ] **Step 9: Commit**
```bash
git add app/Livewire/Wireguard/ resources/views/livewire/wireguard/ lang/de/wireguard.php lang/en/wireguard.php routes/web.php resources/views/components/sidebar.blade.php lang/de/shell.php lang/en/shell.php tests/Feature/WireguardPageTest.php
git commit -m "feat(wg): /wireguard live-status page (read-only) + nav (DE/EN)"
```
---
## Task 6: verify + release
- [ ] **Step 1: shellcheck + Pint + full suite.** shellcheck `docker/wg/clusev-wg.sh install.sh docker/clusev/clusev` → clean. Pint `--dirty` clean. `… php artisan test` → all pass.
- [ ] **Step 2: R12 browser-verify** `/wireguard` (login via the gkonrad temp-password + puppeteer flow, CLAUDE.md R12). Because the dev host runs no collector, FIRST stub a populated status file so the configured state renders, then also test the unconfigured state by removing it:
- Stub: write a populated `storage/app/restart-signal/wg-status.json` (a configured payload with 12 peers, fresh `at`), load `/wireguard` at 1280 + 375 → HTTP 200, peers + gate + server render, zero console errors, no leaked tokens; screenshot.
- Then `rm` the file, reload → the "not configured" empty state renders 200, no errors.
- Switch locale to EN, reload → EN strings. Restore the gkonrad password + remove the stub file afterwards.
- [ ] **Step 3: Release.** Bump `config/clusev.php` to `0.9.34`; CHANGELOG entry under `## [0.9.34]` (Hinzugefügt: WireGuard-Dashboard — Live-Status (read-only); host-side collector; weitere Phasen folgen). Commit `chore: release 0.9.34`, tag `v0.9.34`, push branch + tag (token from `/home/nexxo/.env.gitea`, sanitised).
---
## Self-Review
**Spec coverage (P1 scope = spec §0 Bridge 1 + §1 + §2):**
- Collector subcommand + status JSON shape → Task 1. ✓
- `.timer`/`.service` periodic collection → Task 2. ✓
- install.sh render+enable → Task 3. ✓
- `/wg-status.json` route (auth-gated, whitelisted) → Task 4. ✓
- WgStatus reader (single source) → Task 4. ✓
- `/wireguard` page: gate badge + escape caveat, server card, peer list (online/handshake/endpoint/rx-tx), empty + stale states, `wire:poll`, sidebar nav, DE/EN → Task 5. ✓
- R12 + release → Task 6. ✓
- Out of P1 (deferred, per spec phases): write-bridge, peer add/remove, settings, history graph, chart lib. Not in this plan — correct.
**Placeholder scan:** none — every step has concrete code/commands.
**Type/name consistency:** `WgStatus::read()` returns the same array shape consumed by the route (Task 4), the component `render()` (Task 5), and asserted by both tests. The status JSON keys written by `cmd_collect` (Task 1: `at/configured/gate{marker,chain}/wg_quick/server{subnet,server_ip,port,endpoint,pubkey}/peers[{name,pubkey,endpoint,latest_handshake,rx,tx}]`) match exactly what `WgStatus::read()` parses (Task 4) and the view renders (Task 5). `cmd_collect`/`RUN_DIR` (Task 1) referenced consistently by the units (Task 2) + install (Task 3). `nav_wireguard`/`wireguard.*` lang keys used in the view exist in both lang files.
**Note (carried from spec):** the dev stack runs no collector, so P1's live state is only exercised in tests + the R12 stub; the real collector runs on the deployed host (verify there after `clusev update`). This mirrors the SP1 host-script reality.

View File

@ -1,672 +0,0 @@
# WireGuard dashboard — Phase 2 (traffic history) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a traffic-history graph to `/wireguard` — aggregate down/up throughput over a selectable window (1h / 24h / 7d) — from periodic per-peer samples persisted in the DB.
**Architecture:** A scheduled `clusev:wg-sample` command (every minute) reads the live status (`WgStatus`) and stores one `WgTrafficSample` row per peer (cumulative rx/tx counters), pruning rows older than 7 days. A `WgTraffic` service turns those rows into a bucketed down/up **throughput** series (per-peer deltas, negative-clamped for counter resets, summed into time buckets). The page renders it as a **server-side SVG polyline** chart (the established dashboard pattern — NO JS chart library) with a window selector.
**Deviation from the spec (justified):** SP2 spec §3 named ApexCharts. The codebase already charts via server-side SVG polylines (`resources/views/livewire/dashboard.blade.php`), so this plan reuses that pattern instead of adding a heavy JS dep + an Alpine island + prod-JS-env risk. YAGNI + house-consistency. Per-peer breakdown + interactive tooltips are deferred (P1 already lists per-peer cumulative rx/tx).
**Tech Stack:** Laravel 13 (migration/model/command/scheduler), Tailwind v4 tokens + inline SVG, Pint. No npm changes. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` §3.
**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=<X>'`; Pint `… vendor/bin/pint <files>`. Commit on `feat/v1-foundation`; push only at the release task.
---
## File Structure
- **`database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php`** (new) — the samples table.
- **`app/Models/WgTrafficSample.php`** (new) — the model (mirrors `BannedIp`: `$guarded=[]`, `$casts`).
- **`app/Console/Commands/WgSample.php`** (new) — `clusev:wg-sample`: persist a sample per peer + prune old rows.
- **`routes/console.php`** (modify) — `Schedule::command('clusev:wg-sample')->everyMinute()`.
- **`docker-compose.prod.yml`** + **`docker-compose.yml`** (modify) — add a `scheduler` service (`php artisan schedule:work`) — currently absent, so the existing `clusev:prune-audit` daily task does not run either; this fixes both.
- **`app/Services/WgTraffic.php`** (new) — window → bucketed down/up throughput series (one clear responsibility: the math).
- **`app/Livewire/Wireguard/Index.php`** (modify) — a `$window` property + pass the series to the view.
- **`resources/views/livewire/wireguard/index.blade.php`** (modify) — a Traffic panel: the SVG chart + window selector + totals.
- **`lang/{de,en}/wireguard.php`** (modify) — traffic strings.
- **Tests:** `tests/Feature/WgTrafficSampleTest.php`, `tests/Feature/WgSampleCommandTest.php`, `tests/Feature/WgTrafficTest.php`, extend `tests/Feature/WireguardPageTest.php`.
---
## Task 1: migration + model
**Files:** Create `database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php`, `app/Models/WgTrafficSample.php`; Test `tests/Feature/WgTrafficSampleTest.php`
- [ ] **Step 1: Write the failing test** (`tests/Feature/WgTrafficSampleTest.php`):
```php
<?php
namespace Tests\Feature;
use App\Models\WgTrafficSample;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WgTrafficSampleTest extends TestCase
{
use RefreshDatabase;
public function test_persists_and_casts_a_sample(): void
{
$s = WgTrafficSample::create([
'peer_pubkey' => 'abc',
'peer_name' => 'laptop',
'rx' => 1000,
'tx' => 2000,
'sampled_at' => now(),
]);
$this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'abc', 'rx' => 1000]);
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $s->fresh()->sampled_at);
}
}
```
- [ ] **Step 2: Run it → fails** (table/model missing).
Run: `… php artisan test --filter=WgTrafficSampleTest`
Expected: FAIL (no `wg_traffic_samples` table / `WgTrafficSample` class).
- [ ] **Step 3: Write the migration** (`database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php`):
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wg_traffic_samples', function (Blueprint $table) {
$table->id();
$table->string('peer_pubkey');
$table->string('peer_name')->nullable();
$table->unsignedBigInteger('rx')->default(0); // cumulative bytes received (server←peer)
$table->unsignedBigInteger('tx')->default(0); // cumulative bytes sent (server→peer)
$table->timestamp('sampled_at')->index();
$table->index(['peer_pubkey', 'sampled_at']);
});
}
public function down(): void
{
Schema::dropIfExists('wg_traffic_samples');
}
};
```
- [ ] **Step 4: Write the model** (`app/Models/WgTrafficSample.php`):
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WgTrafficSample extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = [
'rx' => 'integer',
'tx' => 'integer',
'sampled_at' => 'datetime',
];
}
```
- [ ] **Step 5: Run the test → passes.** Run: `… php artisan test --filter=WgTrafficSampleTest`. Pint the two files + test.
- [ ] **Step 6: Commit**
```bash
git add database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php app/Models/WgTrafficSample.php tests/Feature/WgTrafficSampleTest.php
git commit -m "feat(wg): wg_traffic_samples table + model"
```
---
## Task 2: `clusev:wg-sample` command + schedule
**Files:** Create `app/Console/Commands/WgSample.php`; Modify `routes/console.php`; Test `tests/Feature/WgSampleCommandTest.php`
- [ ] **Step 1: Write the failing test** (`tests/Feature/WgSampleCommandTest.php`):
```php
<?php
namespace Tests\Feature;
use App\Models\WgTrafficSample;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WgSampleCommandTest extends TestCase
{
use RefreshDatabase;
private string $file;
protected function setUp(): void
{
parent::setUp();
$dir = storage_path('app/restart-signal');
@mkdir($dir, 0775, true);
$this->file = $dir.'/wg-status.json';
@unlink($this->file);
}
protected function tearDown(): void
{
@unlink($this->file);
parent::tearDown();
}
private function writeStatus(array $peers, bool $configured = true): void
{
file_put_contents($this->file, json_encode([
'at' => time(), 'configured' => $configured,
'gate' => ['marker' => false, 'chain' => false], 'wg_quick' => 'active',
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => 'x:1', 'pubkey' => 'k'],
'peers' => $peers,
]));
}
public function test_stores_one_row_per_peer_when_configured(): void
{
$this->writeStatus([
['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200],
['name' => 'phone', 'pubkey' => 'p2', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 5, 'tx' => 6],
]);
$this->artisan('clusev:wg-sample')->assertExitCode(0);
$this->assertSame(2, WgTrafficSample::count());
$this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'p1', 'rx' => 100, 'tx' => 200]);
}
public function test_does_nothing_when_unconfigured_or_stale(): void
{
@unlink($this->file); // absent → unconfigured
$this->artisan('clusev:wg-sample')->assertExitCode(0);
$this->assertSame(0, WgTrafficSample::count());
}
public function test_prunes_samples_older_than_retention(): void
{
WgTrafficSample::create(['peer_pubkey' => 'old', 'peer_name' => 'x', 'rx' => 1, 'tx' => 1, 'sampled_at' => now()->subDays(8)]);
$this->writeStatus([['name' => 'a', 'pubkey' => 'p1', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 1, 'tx' => 1]]);
$this->artisan('clusev:wg-sample')->assertExitCode(0);
$this->assertDatabaseMissing('wg_traffic_samples', ['peer_pubkey' => 'old']); // pruned
$this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'p1']); // fresh kept
}
}
```
- [ ] **Step 2: Run it → fails** (command missing).
- [ ] **Step 3: Write the command** (`app/Console/Commands/WgSample.php`):
```php
<?php
namespace App\Console\Commands;
use App\Models\WgTrafficSample;
use App\Services\WgStatus;
use Illuminate\Console\Command;
class WgSample extends Command
{
protected $signature = 'clusev:wg-sample {--retention=7 : Days of history to keep}';
protected $description = 'Persist a WireGuard traffic sample per peer (and prune old samples)';
public function handle(WgStatus $wg): int
{
$status = $wg->read();
// Only sample a live, configured collector — never write rows from a stale/absent feed.
if (($status['configured'] ?? false) && ! ($status['stale'] ?? true)) {
$now = now();
$rows = [];
foreach ($status['peers'] ?? [] as $peer) {
$rows[] = [
'peer_pubkey' => (string) ($peer['pubkey'] ?? ''),
'peer_name' => ($peer['name'] ?? '') !== '' ? (string) $peer['name'] : null,
'rx' => (int) ($peer['rx'] ?? 0),
'tx' => (int) ($peer['tx'] ?? 0),
'sampled_at' => $now,
];
}
if ($rows !== []) {
WgTrafficSample::insert($rows);
}
}
$retention = max(1, (int) $this->option('retention'));
WgTrafficSample::where('sampled_at', '<', now()->subDays($retention))->delete();
return self::SUCCESS;
}
}
```
- [ ] **Step 4: Register the schedule.** In `routes/console.php`, after the existing `Schedule::command('clusev:prune-audit')->daily();` line, add:
```php
// Sample WireGuard peer traffic every minute (no-op when the collector is unconfigured/stale).
Schedule::command('clusev:wg-sample')->everyMinute();
```
- [ ] **Step 5: Run the test → passes.** Pint the command + test.
- [ ] **Step 6: Commit**
```bash
git add app/Console/Commands/WgSample.php routes/console.php tests/Feature/WgSampleCommandTest.php
git commit -m "feat(wg): clusev:wg-sample command (sample peers + prune) + schedule"
```
---
## Task 3: scheduler service in compose
The prod stack runs no scheduler, so neither `clusev:wg-sample` nor the existing `clusev:prune-audit` actually fire. Add a `scheduler` service running `php artisan schedule:work`.
**Files:** Modify `docker-compose.prod.yml`, `docker-compose.yml`
- [ ] **Step 1: Read `docker-compose.prod.yml`** and find the `queue:` service (it uses the YAML anchor `<<: *image` + `depends_on` mariadb/redis). Add a sibling `scheduler` service immediately after the `queue` service block, mirroring it exactly but with the schedule command:
```yaml
scheduler:
<<: *image
command: php artisan schedule:work
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
```
(Match the existing `queue` service's exact indentation, anchor name, `restart:` policy, and `depends_on` shape — copy them from the real `queue` block; the snippet above is the shape, not necessarily the exact keys. If `queue` has `env_file`/`networks`/`volumes`, the scheduler needs the same.)
- [ ] **Step 2: Do the same in `docker-compose.yml`** (dev) — add a `scheduler` service mirroring the dev `queue` service with `command: php artisan schedule:work`.
- [ ] **Step 3: Validate compose syntax.**
Run: `docker compose -f docker-compose.prod.yml config -q && echo PROD_OK`
Run: `docker compose -f docker-compose.yml config -q && echo DEV_OK`
Expected: both print OK with no error. (`config -q` parses + validates without starting anything.)
- [ ] **Step 4: Start the dev scheduler so history accrues locally (optional but useful for R12).**
Run: `docker compose --project-directory /home/nexxo/clusev up -d scheduler` and `docker compose --project-directory /home/nexxo/clusev ps scheduler` → state `running`.
- [ ] **Step 5: Commit**
```bash
git add docker-compose.prod.yml docker-compose.yml
git commit -m "feat(ops): add a scheduler service (schedule:work) — runs wg-sample + prune-audit"
```
---
## Task 4: `WgTraffic` series service
The math: per-peer cumulative counters → bucketed aggregate down/up **throughput**. Negative deltas (counter reset on wg restart) clamp to 0.
**Files:** Create `app/Services/WgTraffic.php`; Test `tests/Feature/WgTrafficTest.php`
- [ ] **Step 1: Write the failing test** (`tests/Feature/WgTrafficTest.php`):
```php
<?php
namespace Tests\Feature;
use App\Models\WgTrafficSample;
use App\Services\WgTraffic;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WgTrafficTest extends TestCase
{
use RefreshDatabase;
private function sample(string $pk, int $rx, int $tx, int $agoSeconds): void
{
WgTrafficSample::create([
'peer_pubkey' => $pk, 'peer_name' => $pk, 'rx' => $rx, 'tx' => $tx,
'sampled_at' => now()->subSeconds($agoSeconds),
]);
}
public function test_empty_when_no_samples(): void
{
$s = app(WgTraffic::class)->series(3600, 6);
$this->assertSame(0, $s['total_down']);
$this->assertSame(0, $s['total_up']);
$this->assertCount(6, $s['points']); // always `buckets` points
$this->assertSame(0, array_sum(array_column($s['points'], 'down')));
}
public function test_aggregates_positive_deltas_across_peers(): void
{
// peer A: rx 0→100→300 ; peer B: rx 0→50. Total down delta = 100+200+50 = 350.
$this->sample('A', 0, 0, 3000);
$this->sample('A', 100, 10, 1800);
$this->sample('A', 300, 30, 600);
$this->sample('B', 0, 0, 3000);
$this->sample('B', 50, 5, 600);
$s = app(WgTraffic::class)->series(3600, 6);
$this->assertSame(350, $s['total_down']);
$this->assertSame(45, $s['total_up']); // A:10+20 + B:5
$this->assertGreaterThan(0, $s['peak_down']);
}
public function test_counter_reset_clamps_to_zero(): void
{
// rx goes 500 → 20 (wg restart). The negative delta must NOT subtract.
$this->sample('A', 500, 0, 1800);
$this->sample('A', 20, 0, 600);
$s = app(WgTraffic::class)->series(3600, 6);
$this->assertSame(0, $s['total_down']);
}
}
```
- [ ] **Step 2: Run it → fails** (service missing).
- [ ] **Step 3: Write `app/Services/WgTraffic.php`:**
```php
<?php
namespace App\Services;
use App\Models\WgTrafficSample;
/**
* Turns per-peer cumulative traffic samples into a bucketed aggregate down/up THROUGHPUT series
* for the dashboard SVG chart. Per peer, consecutive samples give a delta; negative deltas
* (counter reset on a wg restart) clamp to 0; deltas are summed into time buckets across peers.
*/
class WgTraffic
{
/**
* @return array{window:int, buckets:int, points:array<int,array{t:int,down:int,up:int}>,
* total_down:int, total_up:int, peak_down:int, peak_up:int}
*/
public function series(int $windowSeconds, int $buckets = 60): array
{
$windowSeconds = max(60, $windowSeconds);
$buckets = max(1, $buckets);
$now = time();
$from = $now - $windowSeconds;
$width = $windowSeconds / $buckets;
$down = array_fill(0, $buckets, 0);
$up = array_fill(0, $buckets, 0);
// Pull samples from one window back; include a little extra so the first in-window delta has
// a predecessor. Ordered per peer + time so consecutive deltas are correct.
$rows = WgTrafficSample::query()
->where('sampled_at', '>=', now()->subSeconds($windowSeconds + (int) ceil($width) + 5))
->orderBy('peer_pubkey')
->orderBy('sampled_at')
->get(['peer_pubkey', 'rx', 'tx', 'sampled_at']);
$prev = []; // peer_pubkey => ['rx'=>int,'tx'=>int]
foreach ($rows as $r) {
$pk = $r->peer_pubkey;
$t = $r->sampled_at->getTimestamp();
if (isset($prev[$pk])) {
$dRx = max(0, (int) $r->rx - $prev[$pk]['rx']);
$dTx = max(0, (int) $r->tx - $prev[$pk]['tx']);
$idx = (int) floor(($t - $from) / $width);
if ($idx >= 0 && $idx < $buckets) {
$down[$idx] += $dRx;
$up[$idx] += $dTx;
}
}
$prev[$pk] = ['rx' => (int) $r->rx, 'tx' => (int) $r->tx];
}
$points = [];
for ($i = 0; $i < $buckets; $i++) {
$points[] = [
't' => (int) round($from + ($i + 0.5) * $width),
'down' => $down[$i],
'up' => $up[$i],
];
}
return [
'window' => $windowSeconds,
'buckets' => $buckets,
'points' => $points,
'total_down' => array_sum($down),
'total_up' => array_sum($up),
'peak_down' => $down === [] ? 0 : max($down),
'peak_up' => $up === [] ? 0 : max($up),
];
}
}
```
- [ ] **Step 4: Run the test → passes.** Pint the service + test.
- [ ] **Step 5: Commit**
```bash
git add app/Services/WgTraffic.php tests/Feature/WgTrafficTest.php
git commit -m "feat(wg): WgTraffic — bucketed down/up throughput series"
```
---
## Task 5: the chart in the page (SVG + window selector)
**Files:** Modify `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/de/wireguard.php`, `lang/en/wireguard.php`; Test extend `tests/Feature/WireguardPageTest.php`
- [ ] **Step 1: Extend the page test** — add to `tests/Feature/WireguardPageTest.php`:
```php
public function test_window_selector_switches_and_passes_traffic(): void
{
\App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 0, 'tx' => 0, 'sampled_at' => now()->subMinutes(10)]);
\App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 500, 'tx' => 100, 'sampled_at' => now()->subMinutes(1)]);
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->assertViewHas('traffic', fn ($t) => $t['total_down'] === 500)
->call('setWindow', 86400)
->assertSet('window', 86400)
->assertViewHas('traffic', fn ($t) => $t['window'] === 86400);
}
public function test_window_clamps_to_allowed_values(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->call('setWindow', 999) // not allowed
->assertSet('window', 3600); // falls back to default
}
```
- [ ] **Step 2: Run → fails** (no `window`/`setWindow`/`traffic`).
- [ ] **Step 3: Update the component** (`app/Livewire/Wireguard/Index.php`):
```php
<?php
namespace App\Livewire\Wireguard;
use App\Services\WgStatus;
use App\Services\WgTraffic;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* WireGuard dashboard — live status (P1) + traffic history (P2). Renders the host-collected
* status (WgStatus) and a bucketed throughput series (WgTraffic), wire:polls every 5s. Read-only.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Selected traffic window in seconds. One of WINDOWS. */
public int $window = 3600;
/** Allowed windows (seconds): 1h / 24h / 7d. */
public const WINDOWS = [3600, 86400, 604800];
public function setWindow(int $seconds): void
{
$this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600;
}
public function render(WgStatus $wg, WgTraffic $traffic)
{
$window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;
return view('livewire.wireguard.index', [
'status' => $wg->read(),
'traffic' => $traffic->series($window),
'windows' => self::WINDOWS,
])->title(__('wireguard.title'));
}
}
```
- [ ] **Step 4: Add the lang strings.** In BOTH `lang/de/wireguard.php` and `lang/en/wireguard.php`, add (DE then EN values):
```php
// Traffic chart (DE)
'traffic_title' => 'Traffic',
'window_1h' => '1 Std',
'window_24h' => '24 Std',
'window_7d' => '7 Tage',
'total_down' => 'Empfangen',
'total_up' => 'Gesendet',
'no_traffic' => 'Noch keine Verlaufsdaten — sie sammeln sich, sobald Peers verbunden sind.',
```
```php
// Traffic chart (EN)
'traffic_title' => 'Traffic',
'window_1h' => '1h',
'window_24h' => '24h',
'window_7d' => '7d',
'total_down' => 'Received',
'total_up' => 'Sent',
'no_traffic' => 'No history yet — it accrues once peers are connected.',
```
- [ ] **Step 5: Add the Traffic panel to the view.** In `resources/views/livewire/wireguard/index.blade.php`, INSIDE the `@else` (configured) branch, ABOVE the existing `<div class="grid grid-cols-1 gap-5 lg:grid-cols-[...]">` peers/aside grid, insert this block. It computes SVG polyline points in Blade (mirroring the dashboard chart) and uses token stroke colours via wrapper text colour (R3/R4 — the only allowed inline width-style is not used here; SVG strokes use `currentColor`):
```blade
@php
$win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) {
3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'),
}])->all();
$fmtB = function (int $n): string {
$u = ['B', 'KB', 'MB', 'GB', 'TB']; $i = 0; $v = (float) $n;
while ($v >= 1024 && $i < count($u) - 1) { $v /= 1024; $i++; }
return ($i === 0 ? (string) $n : number_format($v, 1)).' '.$u[$i];
};
// Build polyline point strings normalised to a 600x140 viewBox (y down). Peak scales both
// series together so down/up are comparable. Avoid div-by-zero with a floor of 1.
$pts = $traffic['points'];
$n = max(1, count($pts) - 1);
$peak = max(1, $traffic['peak_down'], $traffic['peak_up']);
$line = function (string $key) use ($pts, $n, $peak) {
$out = [];
foreach ($pts as $i => $p) {
$x = $n === 0 ? 0 : round($i / $n * 600, 1);
$y = round(140 - ($p[$key] / $peak) * 132, 1); // 4px top/bottom padding-ish
$out[] = $x.','.$y;
}
return implode(' ', $out);
};
$hasTraffic = $traffic['total_down'] > 0 || $traffic['total_up'] > 0;
@endphp
<x-panel :title="__('wireguard.traffic_title')" :padded="false">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
<div class="flex items-center gap-4 font-mono text-[11px]">
<span class="text-accent-text">{{ __('wireguard.total_down') }}: <span class="text-ink-2">{{ $fmtB($traffic['total_down']) }}</span></span>
<span class="text-cyan">{{ __('wireguard.total_up') }}: <span class="text-ink-2">{{ $fmtB($traffic['total_up']) }}</span></span>
</div>
<div class="flex items-center gap-1">
@foreach ($windows as $w)
<button type="button" wire:click="setWindow({{ $w }})"
@class([
'rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors',
'border-accent/40 bg-accent/15 text-accent-text' => $window === $w,
'border-line bg-raised text-ink-3 hover:border-accent/30 hover:text-ink' => $window !== $w,
])>{{ $win[$w] }}</button>
@endforeach
</div>
</div>
<div class="px-4 py-4 sm:px-5">
@if ($hasTraffic)
<svg viewBox="0 0 600 140" preserveAspectRatio="none" class="h-32 w-full" role="img" aria-label="{{ __('wireguard.traffic_title') }}">
<g class="text-cyan"><polyline points="{{ $line('up') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
<g class="text-accent"><polyline points="{{ $line('down') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
</svg>
@else
<p class="font-mono text-[11px] text-ink-3">{{ __('wireguard.no_traffic') }}</p>
@endif
</div>
</x-panel>
```
- [ ] **Step 6: Run the page tests → pass.** Run: `… php artisan test --filter=WireguardPageTest`. Pint the component.
- [ ] **Step 7: Commit**
```bash
git add app/Livewire/Wireguard/Index.php resources/views/livewire/wireguard/index.blade.php lang/de/wireguard.php lang/en/wireguard.php tests/Feature/WireguardPageTest.php
git commit -m "feat(wg): traffic-history SVG chart + window selector on /wireguard"
```
---
## Task 6: verify + release
- [ ] **Step 1: Pint + full suite + shellcheck (unchanged scripts).** `… vendor/bin/pint --dirty`; `… php artisan test` → all pass.
- [ ] **Step 2: R12 browser-verify** `/wireguard` with traffic. Because the dev collector may not run, seed the DB with samples directly (tinker) to populate the chart, then verify: load `/wireguard` (DE) at 1280 + 375 → HTTP 200, the chart renders (an `<svg>` with two `<polyline>`s), the window buttons switch (click 24h → `wire:click` re-renders), totals show, zero console errors, no leaked tokens; screenshot. Then EN. Restore the gkonrad password afterwards and clean up seeded rows.
- Seed example (tinker): insert ~20 `WgTrafficSample` rows for one pubkey with rising rx/tx over the last hour so `total_down>0`.
- [ ] **Step 3: Release.** Bump `config/clusev.php` to `0.9.35`; CHANGELOG `## [0.9.35]` (Hinzugefügt: WireGuard-Traffic-Verlauf — Graph mit Zeitfenster; Scheduler-Dienst). Commit `chore: release 0.9.35`, tag `v0.9.35`, push branch + tag (token sanitised).
---
## Self-Review
**Spec coverage (P2 = spec §3 traffic history + graph):**
- Per-peer samples table + model → Task 1. ✓
- Periodic sampling (scheduler) + prune/retention → Tasks 2, 3. ✓
- Throughput series (deltas, reset-clamp, buckets) → Task 4. ✓
- Chart + selectable window → Task 5. ✓
- Deviation: server-side SVG instead of ApexCharts (justified above) — no npm/Alpine/island. Per-peer breakdown deferred (documented). ✓
**Placeholder scan:** none — full code in every step.
**Type/name consistency:** `WgTrafficSample` columns (`peer_pubkey,peer_name,rx,tx,sampled_at`) are written by the migration (Task 1), inserted by `WgSample` (Task 2), and queried by `WgTraffic::series()` (Task 4) — all use the same names. `WgTraffic::series()` returns `window/buckets/points[{t,down,up}]/total_down/total_up/peak_down/peak_up`, consumed by the component (`traffic` view var, Task 5) and asserted in tests (Task 4 + the page test). `Index::WINDOWS`/`$window`/`setWindow()` used consistently in the component + view + tests. `clusev:wg-sample` signature matches the schedule registration + the test's `artisan('clusev:wg-sample')`.
**Risk note:** the chart only populates once the scheduler has run for a while on the deployed host (and WireGuard is set up with connected peers). On a fresh deploy the panel shows the `no_traffic` empty state until samples accrue — by design.

View File

@ -1,832 +0,0 @@
# WireGuard panel gate + `clusev wg` CLI (SP1) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an operator put the Clusev panel behind a WireGuard tunnel — the VM becomes a WG server, operator devices peer in, and TCP 80/443 is reachable only from the WG subnet — driven entirely from a host CLI `clusev wg setup|up|down|status|add-peer|remove-peer`, off by default and impossible to lock yourself out of.
**Architecture:** WireGuard (`wg0`, kernel) and the firewall live on the **host**; the Laravel app runs in a container with no host network access. So the work is **host shell scripts** invoked through the existing `clusev` wrapper (exactly like `update``update.sh`) — NOT an artisan command. The gate is a dedicated `CLUSEV-WG-GATE` iptables chain hung off `DOCKER-USER` (ufw can't filter Docker-published ports). The only application-side change is a Help topic. Spec: `docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md` (approved).
**Tech Stack:** bash (host), `wireguard-tools` (`wg`, `wg-quick`), `qrencode`, `iptables` (nft backend, Debian 13), systemd (`.service` oneshot), Laravel 13 + Livewire 3 (Help topic only), Pint, shellcheck.
**Why no PHPUnit for the scripts:** the test runner is the app container, which has no host WireGuard/firewall/kernel access. Host scripts are gated by **shellcheck** (automated) + a **manual runbook** on a throwaway Debian-13 VM (Task 8). Only the Help topic (Task 6) is PHPUnit-testable.
**Run shellcheck portably (it is not installed on host or in the app image):**
```bash
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <script-path>
```
---
## File Structure
**New files**
- `docker/wg/clusev-wg.sh` — the whole CLI: setup (interactive, collision-checked), up/down (the gate), status, add-peer/remove-peer, and the `gate-apply` subcommand the systemd unit calls. One file, one responsibility (host WG control).
- `docker/wg/clusev-wg-gate.service` — boot/Docker-restart persistence oneshot; `ExecStart` calls `clusev-wg.sh gate-apply`.
- `resources/views/livewire/help/content/de/wireguard.blade.php` + `.../en/wireguard.blade.php` — Help topic content (mirrors the `security` partial).
- `docs/superpowers/runbooks/wireguard-gate-runbook.md` — manual verification steps (the real test for the host scripts).
**Modified files**
- `install.sh``apt-get install -y wireguard-tools qrencode` in the package phase; render + `enable` the gate unit inside `install_host_watchers()`.
- `docker/clusev/clusev` (template) — a `wg)` case + a German usage line.
- `app/Livewire/Help/Index.php` — add `'wireguard'` to `TOPICS` + the `$labels` map.
- `lang/de/help.php`, `lang/en/help.php``topic_wireguard` key.
**Not touched:** `docker/caddy/Caddyfile`, `docker-compose.prod.yml` (host-firewall gate — Caddy keeps listening on `0.0.0.0:80/443`), `FirewallService`/`Fail2banService` (remote fleet, unrelated), `trustProxies`/domain/TLS.
**State files created at runtime (not in git):** `/etc/wireguard/wg0.conf` (source of truth for peers, `0600`), `/etc/clusev/wg.env` (subnet/port/endpoint/server-pubkey metadata, `0600`), `/etc/clusev/wg-gate.enabled` (gate marker).
---
## Task 1: `clusev-wg.sh` — the CLI
The whole host CLI in one file. Builds the gate logic, up/down, setup, add/remove-peer, status, and the `gate-apply` subcommand.
**Files:**
- Create: `docker/wg/clusev-wg.sh`
- [ ] **Step 1: Write the script**
```bash
#!/usr/bin/env bash
# Clusev WireGuard gate + peer CLI (HOST-side, root). Invoked via `clusev wg <cmd>` (the host
# wrapper execs this from the deployed repo tree). Puts the panel behind a WG tunnel: the VM is the
# WG server, operator devices peer in, and TCP 80/443 is filtered to the WG subnet by a dedicated
# iptables chain hung off DOCKER-USER. SSH (22) and the WG UDP port are NEVER matched — you can
# always SSH in and run `clusev wg down`. The gate is OFF until `clusev wg up`.
set -euo pipefail
WG_IF="wg0"
WG_DIR="/etc/wireguard"
WG_CONF="${WG_DIR}/${WG_IF}.conf"
GATE_CHAIN="CLUSEV-WG-GATE"
STATE_DIR="/etc/clusev"
WG_ENV="${STATE_DIR}/wg.env"
GATE_MARKER="${STATE_DIR}/wg-gate.enabled"
PANEL_PORTS="80,443" # see spec §3 post-DNAT note: matches the published container ports (Caddy 80/443)
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[33m ! %s\033[0m\n' "$*" >&2; }
die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
need_root() { [ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo clusev wg ${1}"; }
# ── subnet helpers (SP1 assumes the default /24; documented in the runbook) ───────────────────
subnet_first_ip() { local b="${1%%/*}"; printf '%s.%s\n' "${b%.*}" "$(( ${b##*.} + 1 ))"; }
# Heuristic collision check: warn+re-prompt if the chosen subnet's /24 prefix already appears in the
# host's routes or addresses. Not a formal CIDR-overlap proof — it is the practical guard the spec
# asks for (catch a subnet that clashes with an existing interface/route), so a default cannot slip
# past a LAN collision.
subnet_collides() {
local net="${1%%/*}" prefix
prefix="${net%.*}" # first three octets, e.g. 10.99.0
ip -o addr 2>/dev/null | awk '{print $4}' | grep -q "^${prefix}\." && return 0
ip -o route 2>/dev/null | awk '{print $1}' | grep -q "^${prefix}\." && return 0
return 1
}
detect_endpoint_ip() {
# Same lookup as install.sh: public IP via ipify, fall back to the first local address.
curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null \
|| hostname -I 2>/dev/null | awk '{print $1}'
}
require_setup() { [ -f "$WG_CONF" ] && [ -f "$WG_ENV" ] || die "Keine WG-Konfiguration — erst 'clusev wg setup' ausfuehren."; }
# ── the gate ──────────────────────────────────────────────────────────────────────────────────
gate_apply() {
# Called by `up` and by the systemd unit on boot/Docker-restart. No-op (success) unless the
# marker says the gate is enabled, so the boot unit never gates an operator who ran `down`.
[ -f "$GATE_MARKER" ] || { info "Gate-Marker fehlt — Gate nicht angewendet."; return 0; }
have iptables || die "iptables nicht gefunden."
# shellcheck disable=SC1090
[ -f "$WG_ENV" ] && . "$WG_ENV"
[ -n "${WG_SUBNET:-}" ] || die "WG_SUBNET unbekannt (${WG_ENV} fehlt) — Gate nicht angewendet."
iptables -nL DOCKER-USER >/dev/null 2>&1 || die "DOCKER-USER-Chain fehlt (laeuft Docker?) — Gate nicht angewendet."
iptables -N "$GATE_CHAIN" 2>/dev/null || iptables -F "$GATE_CHAIN"
iptables -A "$GATE_CHAIN" -i lo -j RETURN
iptables -A "$GATE_CHAIN" -s "$WG_SUBNET" -p tcp -m multiport --dport "$PANEL_PORTS" -j RETURN
iptables -A "$GATE_CHAIN" -p tcp -m multiport --dport "$PANEL_PORTS" -j DROP
# exactly one jump at the top of DOCKER-USER (idempotent)
iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true
iptables -I DOCKER-USER 1 -j "$GATE_CHAIN"
}
gate_remove() {
have iptables || return 0
iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true
iptables -F "$GATE_CHAIN" 2>/dev/null || true
iptables -X "$GATE_CHAIN" 2>/dev/null || true
}
# ── commands ──────────────────────────────────────────────────────────────────────────────────
cmd_up() {
need_root up
require_setup
[ -e "/sys/class/net/${WG_IF}" ] || die "${WG_IF} ist nicht aktiv. Erst Tunnel testen (siehe 'clusev wg status'), dann 'clusev wg up'."
warn "Vor dem Gate sicherstellen, dass der Tunnel das Panel erreicht. Notausgang ueber SSH: 'clusev wg down'."
mkdir -p "$STATE_DIR"
printf 'enabled\n' > "$GATE_MARKER"
gate_apply
bold "WireGuard-Gate aktiv — Panel (80/443) nur noch ueber den Tunnel erreichbar."
info "Notausgang (per SSH, falls der Tunnel nicht erreicht): clusev wg down"
}
cmd_down() {
need_root down
rm -f "$GATE_MARKER"
gate_remove
bold "WireGuard-Gate entfernt — Panel (80/443) wieder oeffentlich erreichbar (vorbehaltlich Cloud-/Host-Firewall)."
}
next_free_ip() {
# shellcheck disable=SC1090
. "$WG_ENV"
local prefix="${WG_SUBNET%%/*}"; prefix="${prefix%.*}" # first three octets (SP1: /24)
local used; used="$(awk -F= '/AllowedIPs/{gsub(/[ \t]/,"",$2);split($2,a,"/");print a[1]}' "$WG_CONF" 2>/dev/null || true)"
used="$(printf '%s\n%s\n' "$used" "${WG_SERVER_IP:-}")"
local i cand
for i in $(seq 2 254); do
cand="${prefix}.${i}"
printf '%s\n' "$used" | grep -qx "$cand" || { printf '%s\n' "$cand"; return 0; }
done
return 1
}
cmd_add_peer() {
need_root add-peer
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer <name>"
require_setup
# shellcheck disable=SC1090
. "$WG_ENV"
grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && die "Peer '${name}' existiert bereits."
local ip; ip="$(next_free_ip)" || die "Kein freier Adressraum im Subnetz ${WG_SUBNET}."
local cpriv cpub
cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)"
cat >> "$WG_CONF" <<EOF
# clusev-peer: ${name}
[Peer]
PublicKey = ${cpub}
AllowedIPs = ${ip}/32
EOF
wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32" # live, no restart
local client_conf
client_conf="$(cat <<EOF
[Interface]
PrivateKey = ${cpriv}
Address = ${ip}/32
DNS = 1.1.1.1
[Peer]
PublicKey = ${WG_SERVER_PUBKEY}
Endpoint = ${WG_ENDPOINT}
AllowedIPs = ${WG_SUBNET}
PersistentKeepalive = 25
EOF
)"
bold "Peer '${name}' angelegt (${ip})."
printf '%s\n' "$client_conf"
if have qrencode; then
printf '%s\n' "$client_conf" | qrencode -t ansiutf8 || warn "QR-Erzeugung fehlgeschlagen — nutze die Textkonfiguration oben."
else
warn "qrencode nicht installiert — nutze die Textkonfiguration oben."
fi
info "AllowedIPs=${WG_SUBNET} = Split-Tunnel (nur Panel ueber WG). Voll-Tunnel (0.0.0.0/0) moeglich, aber NICHT empfohlen — leitet allen Client-Verkehr ueber Clusev."
}
cmd_remove_peer() {
need_root remove-peer
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
require_setup
local pub
pub="$(awk -v n="# clusev-peer: ${name}" '$0==n{f=1;next} f&&/PublicKey/{gsub(/[ \t]/,"");sub(/PublicKey=/,"");print;exit}' "$WG_CONF")"
[ -n "$pub" ] || die "Peer '${name}' nicht gefunden."
wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true
# drop the block from the marker comment up to (and including) the trailing blank line
local tmp; tmp="$(mktemp)"
awk -v n="# clusev-peer: ${name}" '
$0==n {drop=1; next}
drop && /^[[:space:]]*$/ {drop=0; next}
drop {next}
{print}
' "$WG_CONF" > "$tmp"
install -m 0600 "$tmp" "$WG_CONF"; rm -f "$tmp"
bold "Peer '${name}' entfernt."
}
cmd_status() {
need_root status
bold "WireGuard (${WG_IF})"
if [ -e "/sys/class/net/${WG_IF}" ]; then wg show "$WG_IF" 2>/dev/null || true; else info "wg0 nicht aktiv (Setup/Tunnel noch nicht gestartet)."; fi
echo
bold "Gate"
if [ -f "$GATE_MARKER" ]; then info "Marker: aktiv (${GATE_MARKER})"; else info "Marker: aus"; fi
if iptables -nL "$GATE_CHAIN" >/dev/null 2>&1; then info "Chain ${GATE_CHAIN}: vorhanden"; else info "Chain ${GATE_CHAIN}: nicht vorhanden"; fi
echo
bold "Dienst wg-quick@${WG_IF}"
systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || true
}
cmd_setup() {
need_root setup
have wg || die "wireguard-tools nicht installiert (erwartet via install.sh)."
if [ -f "$WG_CONF" ]; then
warn "${WG_CONF} existiert bereits — bestehende Peers gingen bei Neukonfiguration verloren."
read -rp " Trotzdem neu konfigurieren? [reconfigure/abort] (abort): " ans
[ "${ans:-abort}" = "reconfigure" ] || die "Abgebrochen — bestehende Konfiguration unberuehrt."
fi
local subnet default_subnet="10.99.0.0/24"
while :; do
read -rp " WG-Subnetz (privates /24, darf NICHT mit LAN/VPN kollidieren) [${default_subnet}]: " subnet
subnet="${subnet:-$default_subnet}"
if subnet_collides "$subnet"; then warn "Subnetz ${subnet} ueberschneidet eine bestehende Route/Adresse — bitte ein anderes waehlen."; continue; fi
break
done
local server_ip default_ip port endpoint default_ep peer1
default_ip="$(subnet_first_ip "$subnet")"
read -rp " Server-Tunnel-IP [${default_ip}]: " server_ip; server_ip="${server_ip:-$default_ip}"
read -rp " Listen-Port (UDP) [51820]: " port; port="${port:-51820}"
default_ep="$(detect_endpoint_ip):${port}"
info "Hinweis: hinter NAT/Cloud-LB ist die erkannte IP evtl. NICHT die Wähl-Adresse der Clients — vor 'clusev wg up' pruefen."
read -rp " Oeffentlicher Endpoint (IP oder DNS:Port) [${default_ep}]: " endpoint; endpoint="${endpoint:-$default_ep}"
read -rp " Name des ersten Peers [client-1]: " peer1; peer1="${peer1:-client-1}"
umask 077; mkdir -p "$WG_DIR" "$STATE_DIR"
local srv_priv srv_pub prefix
srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)"
prefix="${subnet##*/}"
cat > "$WG_CONF" <<EOF
[Interface]
Address = ${server_ip}/${prefix}
ListenPort = ${port}
PrivateKey = ${srv_priv}
EOF
chmod 600 "$WG_CONF"
cat > "$WG_ENV" <<EOF
WG_SUBNET=${subnet}
WG_SERVER_IP=${server_ip}
WG_PORT=${port}
WG_ENDPOINT=${endpoint}
WG_SERVER_PUBKEY=${srv_pub}
EOF
chmod 600 "$WG_ENV"
systemctl enable --now "wg-quick@${WG_IF}" || die "wg-quick@${WG_IF} konnte nicht gestartet werden — 'journalctl -u wg-quick@${WG_IF}' pruefen."
cmd_add_peer "$peer1"
echo
bold "Setup fertig. Tunnel ist OBEN, aber das Gate ist AUS (Panel weiter oeffentlich)."
info "1) Client importieren (QR oben), 2) http://${server_ip} ueber den Tunnel oeffnen, 3) dann: clusev wg up"
}
usage() {
cat <<'EOF'
clusev wg — WireGuard-Zugang (Host)
clusev wg setup WG einrichten (interaktiv): Subnetz, Keys, erster Peer
clusev wg up Gate aktivieren — Panel (80/443) nur ueber den Tunnel
clusev wg down Gate entfernen — Panel wieder oeffentlich (Notausgang!)
clusev wg status Tunnel, Peers, Gate-Status anzeigen
clusev wg add-peer <name> neuen Client anlegen (+ QR-Code)
clusev wg remove-peer <name> Client entfernen
SSH (22) und der WG-Port bleiben IMMER offen. 'clusev wg down' per SSH ist der Notausgang.
EOF
}
cmd="${1:-help}"; shift 2>/dev/null || true
case "$cmd" in
setup) cmd_setup "$@" ;;
up) cmd_up "$@" ;;
down) cmd_down "$@" ;;
status) cmd_status "$@" ;;
add-peer) cmd_add_peer "$@" ;;
remove-peer) cmd_remove_peer "$@" ;;
gate-apply) need_root gate-apply; gate_apply ;; # called by clusev-wg-gate.service
help|-h|--help) usage ;;
*) printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;;
esac
```
- [ ] **Step 2: Make it executable**
```bash
chmod +x docker/wg/clusev-wg.sh
```
- [ ] **Step 3: Syntax check**
Run: `bash -n docker/wg/clusev-wg.sh`
Expected: no output, exit 0.
- [ ] **Step 4: shellcheck clean**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/wg/clusev-wg.sh`
Expected: no errors. (The intentional `# shellcheck disable=SC1090` lines cover the dynamic `.` sources.)
- [ ] **Step 5: Smoke the no-root / usage paths (safe on the dev host — they never touch iptables/wg)**
Run: `bash docker/wg/clusev-wg.sh help`
Expected: the usage block prints, exit 0.
Run: `bash docker/wg/clusev-wg.sh bogus; echo "exit=$?"`
Expected: "Unbekannter Befehl: bogus" + usage, `exit=64`.
Run (as non-root): `bash docker/wg/clusev-wg.sh status; echo "exit=$?"`
Expected: "Bitte mit sudo ausfuehren: sudo clusev wg status", non-zero exit. (Everything real is gated behind `need_root` + a Debian VM — see the runbook in Task 7.)
- [ ] **Step 6: Commit**
```bash
git add docker/wg/clusev-wg.sh
git commit -m "feat(wg): clusev-wg.sh — WireGuard gate + peer CLI (host)"
```
---
## Task 2: The gate persistence systemd unit
A oneshot that re-applies the gate on boot/Docker-restart — but only when `wg0` is up (`ConditionPathExists`) and the marker is present (checked inside `gate-apply`). Modelled on `docker/restart-sentinel/clusev-update.service`.
**Files:**
- Create: `docker/wg/clusev-wg-gate.service`
- [ ] **Step 1: Write the unit**
```ini
# Clusev WireGuard gate — persistence oneshot (HOST-side).
#
# Re-applies the CLUSEV-WG-GATE iptables chain after boot and after a Docker daemon restart (Docker
# flushes DOCKER-USER). It is a no-op unless BOTH are true:
# - wg0 is up (ConditionPathExists below — a failed wg0 on boot leaves the panel OPEN, not bricked)
# - the gate marker (/etc/clusev/wg-gate.enabled, checked inside `clusev-wg.sh gate-apply`)
# So `clusev wg down` (removes the marker) survives a reboot, and a tunnel that fails to come up never
# drops the panel with no way in. install.sh rewrites the /home/nexxo/clusev paths to the real tree.
[Unit]
Description=Clusev WireGuard gate — re-apply the panel firewall when wg0 is up
After=docker.service wg-quick@wg0.service
Wants=docker.service
ConditionPathExists=/sys/class/net/wg0
[Service]
Type=oneshot
User=root
Environment=CLUSEV_DIR=/home/nexxo/clusev
WorkingDirectory=/home/nexxo/clusev
ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh gate-apply
[Install]
# Pulled in when docker.service starts (boot AND `systemctl restart docker`), so a Docker restart
# that flushes DOCKER-USER re-applies the gate.
WantedBy=docker.service
```
- [ ] **Step 2: Sanity-check the unit syntax**
Run: `systemd-analyze verify docker/wg/clusev-wg-gate.service 2>&1 | grep -v 'Failed to prepare' || true`
Expected: no syntax errors reported about the `[Unit]`/`[Service]`/`[Install]` keys. (Path-not-found warnings for the not-yet-installed ExecStart are fine — the file is rendered at install time. If `systemd-analyze` is unavailable, just eyeball that the three sections + the directives match `clusev-update.service`.)
- [ ] **Step 3: Commit**
```bash
git add docker/wg/clusev-wg-gate.service
git commit -m "feat(wg): clusev-wg-gate.service — boot/docker-restart gate persistence"
```
---
## Task 3: `install.sh` — packages + install the gate unit
Two idempotent additions. Packages near the Docker/preflight block; the unit inside `install_host_watchers()` (reuses its `systemctl` guard + single `daemon-reload`).
**Files:**
- Modify: `install.sh` (package phase ~line 104; `install_host_watchers()` ~lines 269299)
- [ ] **Step 1: Add the WireGuard packages after the Docker/preflight block**
Find the end of the preflight section — the line that confirms openssl/Docker, just before `# ── [2/9] user setup`. Add a standalone apt install (guarded by `IS_APT`, idempotent — apt is a no-op if present), right before the `phase 2/9` line (currently `install.sh:94`):
```bash
# WireGuard gate (clusev wg ...) — present but inert until the operator runs `clusev wg setup`.
if [ "$IS_APT" = 1 ]; then
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools qrencode >/dev/null 2>&1 \
&& info "wireguard-tools + qrencode vorhanden" \
|| warn "wireguard-tools/qrencode nicht installiert — 'clusev wg' erst nach 'apt-get install wireguard-tools qrencode' nutzbar."
fi
```
(`set -euo pipefail` is active — the `&& … || …` keeps a failed apt from aborting the install.)
- [ ] **Step 2: Render + enable the gate unit inside `install_host_watchers()`**
In `install_host_watchers()`, add the gate unit alongside the existing four. After the `tmp_usvc="$(mktemp)"` line, add a temp var:
```bash
local tmp_gate; tmp_gate="$(mktemp)"
```
After the existing `sed ... clusev-update.service > "$tmp_usvc"` line, add:
```bash
# Gate unit keeps User=root; only the path is rewritten.
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-gate.service" > "$tmp_gate"
```
In the `if install -m 0644 ... && systemctl ...; then` block, add the install + an `enable` (NOT `--now`: the gate must not be applied at install time — it self-skips via the marker + `ConditionPathExists`, but `enable` registers it for boot/docker-restart). Add these two lines into the `&&` chain, before the final `; then`:
```bash
&& install -m 0644 "$tmp_gate" "${dst}/clusev-wg-gate.service" \
&& systemctl enable clusev-wg-gate.service \
```
And add `$tmp_gate` to the closing `rm -f` cleanup line:
```bash
rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" "$tmp_gate"
```
- [ ] **Step 3: Syntax + shellcheck**
Run: `bash -n install.sh`
Expected: exit 0.
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable install.sh`
Expected: no NEW errors versus the pre-change baseline (run it once before editing to capture the baseline; the additions must not introduce new findings).
- [ ] **Step 4: Commit**
```bash
git add install.sh
git commit -m "feat(wg): install wireguard-tools/qrencode + enable the gate unit (inert by default)"
```
---
## Task 4: Host CLI wiring — `clusev wg`
Add a `wg)` case + a usage line to the `clusev` wrapper template. The rendered `CLUSEV_DIR` makes the script path resolve on the host.
**Files:**
- Modify: `docker/clusev/clusev` (case switch ~line 47; usage ~line 27)
- [ ] **Step 1: Add the `wg)` case after `artisan)` and before `version)`**
Find the `artisan)` arm:
```bash
artisan) compose exec app php artisan "$@" ;;
```
Add directly below it:
```bash
wg) exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh" "$@" ;;
```
- [ ] **Step 2: Add the usage line after the `clusev artisan` line**
Find in `usage()`:
```bash
clusev artisan <...> beliebiges artisan-Kommando im app-Container
```
Add directly below it:
```bash
clusev wg <...> WireGuard-Zugang (setup|up|down|status|add-peer|remove-peer)
```
- [ ] **Step 3: Syntax + shellcheck the template**
Run: `bash -n docker/clusev/clusev`
Expected: exit 0. (The `__CLUSEV_DIR__` token is a literal here; bash parses the unquoted assignment fine.)
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/clusev/clusev`
Expected: no new errors.
- [ ] **Step 4: Commit**
```bash
git add docker/clusev/clusev
git commit -m "feat(wg): wire 'clusev wg' into the host CLI wrapper"
```
---
## Task 5: Help topic registration (PHPUnit-testable)
Register the `wireguard` topic + its label. This is the only part with real unit tests.
**Files:**
- Modify: `app/Livewire/Help/Index.php` (TOPICS ~line 20; `$labels` ~line 53)
- Modify: `lang/de/help.php`, `lang/en/help.php`
- Test: `tests/Feature/HelpTest.php` (extend if it exists, else create)
- [ ] **Step 1: Write the failing test**
Create or extend `tests/Feature/HelpTest.php`:
```php
<?php
namespace Tests\Feature;
use App\Livewire\Help\Index;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class HelpWireguardTopicTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
public function test_wireguard_is_a_known_topic_and_renders(): void
{
Livewire::test(Index::class, ['topic' => 'wireguard'])
->assertSet('topic', 'wireguard') // not clamped back to 'overview'
->assertSee('WireGuard'); // the localized label + content render
}
public function test_wireguard_label_exists_in_both_locales(): void
{
$this->assertSame('WireGuard-Zugang', __('help.topic_wireguard', [], 'de'));
$this->assertSame('WireGuard access', __('help.topic_wireguard', [], 'en'));
}
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=HelpWireguardTopicTest'`
Expected: FAIL — `topic` clamps to `overview` (wireguard not in TOPICS) and `topic_wireguard` is missing.
- [ ] **Step 3: Register the topic in `app/Livewire/Help/Index.php`**
In the `TOPICS` const, add `'wireguard'` immediately before `'recovery'`:
```php
private const TOPICS = [
'overview', 'domain-tls', 'security', 'updates', 'commands',
'servers', 'sessions', 'email', 'audit', 'wireguard', 'recovery',
];
```
In the `$labels` map, add the entry after `'audit'`:
```php
'audit' => __('help.topic_audit'),
'wireguard' => __('help.topic_wireguard'),
'recovery' => __('help.topic_recovery'),
```
- [ ] **Step 4: Add the label key to both lang files**
`lang/de/help.php` — after the `'topic_audit'` line:
```php
'topic_wireguard' => 'WireGuard-Zugang',
```
`lang/en/help.php` — after the `'topic_audit'` line:
```php
'topic_wireguard' => 'WireGuard access',
```
- [ ] **Step 5: Run the test (will still fail until Task 6 creates the content partials)**
Run: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=HelpWireguardTopicTest'`
Expected: `test_wireguard_label_exists_in_both_locales` PASSES; `test_wireguard_is_a_known_topic_and_renders` still FAILS at `assertSee('WireGuard')` because the content partial `livewire.help.content.de.wireguard` does not exist yet (the index view falls back to the DE partial, which is also missing → render error). This is expected — Task 6 completes it. Do NOT commit a red test; proceed straight to Task 6, then return here.
- [ ] **Step 6 (after Task 6): re-run + commit**
Run: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=HelpWireguardTopicTest'`
Expected: both PASS.
Run Pint: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'vendor/bin/pint app/Livewire/Help/Index.php tests/Feature/HelpTest.php'`
```bash
git add app/Livewire/Help/Index.php lang/de/help.php lang/en/help.php tests/Feature/HelpTest.php
git commit -m "feat(wg): register the WireGuard help topic (+ test)"
```
---
## Task 6: Help content partials (DE + EN)
Mirror the `security` partial exactly (the `@php` `$h/$p/$li/$code` vars + plain divs, no `x-` components). Content: what the gate does, that it sits on top of 2FA/Anmeldeschutz, the `clusev wg setup` walkthrough, importing via QR, testing the tunnel, `clusev wg up`, split-vs-full tunnel, and the escape hatch prominently. DE+EN identical structure, no emoji (R9/R16), token utilities only (R3/R4), no leaked tokens (R17).
**Files:**
- Create: `resources/views/livewire/help/content/de/wireguard.blade.php`
- Create: `resources/views/livewire/help/content/en/wireguard.blade.php`
- [ ] **Step 1: Create the DE partial**
`resources/views/livewire/help/content/de/wireguard.blade.php`:
```blade
@php
$h = 'font-display text-base font-semibold text-ink';
$p = 'text-sm leading-relaxed text-ink-2';
$li = 'text-sm leading-relaxed text-ink-2';
$code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text';
@endphp
<div class="space-y-3">
<h3 class="{{ $h }}">Was der WireGuard-Zugang macht</h3>
<p class="{{ $p }}">Stellt das <span class="text-ink">gesamte Panel hinter einen WireGuard-Tunnel</span>: Der Server wird zum WG-Server, deine Geräte verbinden sich als Peers, und das Panel (HTTP/HTTPS, Ports 80/443) ist <span class="text-ink">nur noch über den Tunnel</span> erreichbar. Eine Netzwerk-Sperre <span class="text-ink">zusätzlich</span> zu 2FA und Anmeldeschutz: Die schützen das <span class="text-ink">Login</span>, dies verbirgt das <span class="text-ink">ganze Panel</span> vor dem öffentlichen Internet.</p>
<p class="{{ $p }}">Alles läuft über die Host-CLI <code class="{{ $code }}">clusev wg …</code> (per SSH auf dem Server). Standardmäßig ist nichts aktiv — du entscheidest, wann der Tunnel und die Sperre eingeschaltet werden.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Einrichten — <code class="{{ $code }}">clusev wg setup</code></h3>
<p class="{{ $p }}">Interaktiv, jeder Wert mit sinnvoller Vorgabe und Erklärung:</p>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">WG-Subnetz</span> (Vorgabe <code class="{{ $code }}">10.99.0.0/24</code>) — ein privates Netz, das <span class="text-ink">nicht</span> mit deinem LAN/VPN kollidieren darf. Eine Kollision wird erkannt und abgewiesen.</li>
<li class="{{ $li }}"><span class="text-ink">Öffentlicher Endpoint</span> — die automatisch erkannte öffentliche IP. <span class="text-ink">Hinter NAT oder einem Cloud-Load-Balancer ist das evtl. nicht die Adresse, die Clients wählen müssen</span> — vor dem Aktivieren der Sperre prüfen.</li>
<li class="{{ $li }}"><span class="text-ink">Erster Peer</span> — wird gleich angelegt; Konfiguration und QR-Code werden ausgegeben.</li>
</ul>
<p class="{{ $p }}">Setup startet den Tunnel (übersteht Neustarts), aktiviert aber <span class="text-ink">nicht</span> die Sperre — das Panel bleibt zunächst öffentlich.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Client verbinden &amp; testen</h3>
<p class="{{ $p }}">Den QR-Code aus <code class="{{ $code }}">setup</code> (oder <code class="{{ $code }}">clusev wg add-peer &lt;name&gt;</code>) in der WireGuard-App scannen. Standard ist <span class="text-ink">Split-Tunnel</span>: nur Panel-Verkehr läuft über WG, dein normales Internet nicht. Voll-Tunnel (<code class="{{ $code }}">0.0.0.0/0</code>) ist möglich, aber <span class="text-ink">nicht empfohlen</span>.</p>
<p class="{{ $p }}">Verbinden, dann <code class="{{ $code }}">http://&lt;Server-Tunnel-IP&gt;</code> öffnen — erreicht das Panel? Erst wenn der Tunnel sicher funktioniert, die Sperre aktivieren.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Sperre ein/aus — <code class="{{ $code }}">clusev wg up</code> / <code class="{{ $code }}">down</code></h3>
<p class="{{ $p }}"><code class="{{ $code }}">clusev wg up</code> sperrt 80/443 auf das WG-Subnetz — von außen ist das Panel danach nicht mehr erreichbar. <code class="{{ $code }}">clusev wg status</code> zeigt Peers, Handshakes und den Sperr-Status.</p>
<p class="{{ $p }}"><span class="text-ink">Notausgang:</span> <code class="{{ $code }}">clusev wg down</code> (per SSH) entfernt die Sperre sofort — das Panel ist wieder öffentlich. <span class="text-ink">SSH (Port 22) und der WireGuard-Port sind von der Sperre nie betroffen</span>, du kommst also immer per SSH auf den Server.</p>
<p class="{{ $p }}">Schlägt <code class="{{ $code }}">wg0</code> nach einem Neustart fehl, wird die Sperre <span class="text-ink">nicht</span> angewendet — das Panel bleibt öffentlich erreichbar statt dich auszusperren.</p>
</div>
```
- [ ] **Step 2: Create the EN partial (identical structure)**
`resources/views/livewire/help/content/en/wireguard.blade.php`:
```blade
@php
$h = 'font-display text-base font-semibold text-ink';
$p = 'text-sm leading-relaxed text-ink-2';
$li = 'text-sm leading-relaxed text-ink-2';
$code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text';
@endphp
<div class="space-y-3">
<h3 class="{{ $h }}">What the WireGuard access does</h3>
<p class="{{ $p }}">Puts the <span class="text-ink">whole panel behind a WireGuard tunnel</span>: the server becomes a WG server, your devices peer in, and the panel (HTTP/HTTPS, ports 80/443) is reachable <span class="text-ink">only through the tunnel</span>. A network-layer gate <span class="text-ink">on top of</span> 2FA and the login protection: those guard the <span class="text-ink">login</span>; this hides the <span class="text-ink">whole panel</span> from the public internet.</p>
<p class="{{ $p }}">Everything runs through the host CLI <code class="{{ $code }}">clusev wg …</code> (over SSH on the server). Nothing is active by default — you decide when the tunnel and the gate go on.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Set up — <code class="{{ $code }}">clusev wg setup</code></h3>
<p class="{{ $p }}">Interactive, every value pre-filled with a sensible default and a hint:</p>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">WG subnet</span> (default <code class="{{ $code }}">10.99.0.0/24</code>) — a private network that must <span class="text-ink">not</span> clash with your LAN/VPN. A collision is detected and rejected.</li>
<li class="{{ $li }}"><span class="text-ink">Public endpoint</span> — the auto-detected public IP. <span class="text-ink">Behind NAT or a cloud load balancer this may not be the address clients should dial</span> — verify it before enabling the gate.</li>
<li class="{{ $li }}"><span class="text-ink">First peer</span> — created right away; its config and a QR code are printed.</li>
</ul>
<p class="{{ $p }}">Setup starts the tunnel (survives reboots) but does <span class="text-ink">not</span> enable the gate — the panel stays public for now.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Connect a client &amp; test</h3>
<p class="{{ $p }}">Scan the QR code from <code class="{{ $code }}">setup</code> (or <code class="{{ $code }}">clusev wg add-peer &lt;name&gt;</code>) in the WireGuard app. The default is <span class="text-ink">split tunnel</span>: only panel traffic goes through WG, your normal internet does not. Full tunnel (<code class="{{ $code }}">0.0.0.0/0</code>) is possible but <span class="text-ink">not recommended</span>.</p>
<p class="{{ $p }}">Connect, then open <code class="{{ $code }}">http://&lt;server-tunnel-ip&gt;</code> — does it reach the panel? Only once the tunnel reliably works, enable the gate.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Gate on/off — <code class="{{ $code }}">clusev wg up</code> / <code class="{{ $code }}">down</code></h3>
<p class="{{ $p }}"><code class="{{ $code }}">clusev wg up</code> restricts 80/443 to the WG subnet — from outside the panel is then unreachable. <code class="{{ $code }}">clusev wg status</code> shows peers, handshakes and the gate state.</p>
<p class="{{ $p }}"><span class="text-ink">Escape hatch:</span> <code class="{{ $code }}">clusev wg down</code> (over SSH) removes the gate immediately — the panel is public again. <span class="text-ink">SSH (port 22) and the WireGuard port are never affected by the gate</span>, so you can always reach the server over SSH.</p>
<p class="{{ $p }}">If <code class="{{ $code }}">wg0</code> fails to come up after a reboot, the gate is <span class="text-ink">not</span> applied — the panel stays publicly reachable rather than locking you out.</p>
</div>
```
- [ ] **Step 3: Return to Task 5 Step 6** — re-run `HelpWireguardTopicTest` (now both pass), Pint, and commit the partials together with the registration:
```bash
git add resources/views/livewire/help/content/de/wireguard.blade.php resources/views/livewire/help/content/en/wireguard.blade.php
git commit -m "feat(wg): WireGuard help topic content (DE + EN)"
```
- [ ] **Step 4: R12 browser-verify** `/help?topic=wireguard` in DE and EN: HTTP 200, zero console errors, 3 breakpoints (375/768/1280), and inspect the rendered DOM for leaked tokens (`@`, `{{ }}`, `$var`, `group.key`). Use the established puppeteer + temp-password flow (see CLAUDE.md R12). Record the result.
---
## Task 7: Manual runbook
The real verification for the host scripts (they cannot run under PHPUnit). Write the runbook a future operator/agent follows on a throwaway Debian-13 VM with the prod stack.
**Files:**
- Create: `docs/superpowers/runbooks/wireguard-gate-runbook.md`
- [ ] **Step 1: Write the runbook**
Content (each step states the expected result, mirroring spec §Testing):
```markdown
# WireGuard gate (SP1) — manual runbook
Run on a throwaway Debian-13 VM with the Clusev prod stack up. SSH session kept open the whole time
(the escape hatch). Records the expected result of each step.
## Pre
- [ ] `clusev wg help` prints usage; `clusev wg status` shows wg0 not active, gate off.
## setup
- [ ] `sudo clusev wg setup`, enter a subnet that overlaps the VM's LAN (e.g. the VM's own /24) →
**rejected**, re-prompts.
- [ ] Re-run, accept the default `10.99.0.0/24`**accepted**; server keys + first peer created;
client config + QR printed; `wg-quick@wg0` enabled + active.
- [ ] `sudo clusev wg setup` again → it **warns** that wg0.conf exists and does NOT silently clobber it
(abort unless you type `reconfigure`).
## tunnel (gate still OFF)
- [ ] Import the printed client (QR) into the WireGuard app; connect.
- [ ] Over the tunnel: `http://<server-tunnel-ip>` reaches the panel.
- [ ] Public `http://<public-ip>` still reachable (gate is off).
## gate up
- [ ] `sudo clusev wg up` → prints the escape reminder; refuses if wg0 is down.
- [ ] Public `http://<public-ip>` 80/443 now **refused/timed out**; the tunnel still serves the panel.
- [ ] **SSH still works** (port 22 untouched).
- [ ] `clusev wg status` shows the peer + gate on (marker + chain present).
- [ ] Add a manual rule: `sudo iptables -I DOCKER-USER -s 203.0.113.0/24 -j RETURN`. Run
`clusev wg down` then `clusev wg up` → the manual rule **survives** (only CLUSEV-WG-GATE is touched).
## persistence
- [ ] Reboot → wg0 + the gate come back (public still refused, tunnel still serves).
- [ ] Force wg0 to fail on boot (e.g. `sudo systemctl disable wg-quick@wg0` + reboot) → the gate is
**NOT applied** (panel publicly reachable, not bricked); SSH + `clusev wg down` recover.
## peers + down
- [ ] `clusev wg add-peer client-2` → new config + QR; `clusev wg remove-peer client-2` → peer gone
from `wg show` and from wg0.conf.
- [ ] A full-tunnel note is shown by add-peer.
- [ ] `clusev wg down` → public open again; marker + chain gone.
```
- [ ] **Step 2: Commit**
```bash
git add docs/superpowers/runbooks/wireguard-gate-runbook.md
git commit -m "docs(wg): manual runbook for the WireGuard gate (SP1)"
```
---
## Task 8: Final sweep + release
- [ ] **Step 1: shellcheck all touched scripts**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/wg/clusev-wg.sh install.sh docker/clusev/clusev`
Expected: clean (or only the explicit, justified disables).
- [ ] **Step 2: Pint + full test suite**
```bash
docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'vendor/bin/pint --dirty'
docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test'
```
Expected: Pint clean, all tests pass.
- [ ] **Step 3: R15 Codex review** over the app-side diff + the shell scripts: `/codex:review`. Fix + re-run until no errors / no security issues.
- [ ] **Step 4: Release** — bump `config/clusev.php` `'version'` to the next patch, add a CHANGELOG entry under a new version heading (Hinzugefügt: WireGuard-Gate + `clusev wg` CLI), commit `chore: release X.Y.Z`, tag `vX.Y.Z`, push branch + tag (token read only at push time from `/home/nexxo/.env.gitea`, sanitised in output). Note in the entry: the gate is OFF by default and host-side only; run `clusev wg setup` then `clusev wg up`.
---
## Self-Review
**Spec coverage:**
- §0 safety (never lock out): gate only filters 80/443 (Task 1 `gate_apply`), `down` escape (Task 1 `cmd_down`), isolated `CLUSEV-WG-GATE` chain (Task 1), reboot-with-failed-wg0 stays open (`ConditionPathExists` Task 2 + marker check Task 1), off by default (no marker until `up`), no Caddy/compose change (none touched). ✓
- §1 install present-but-inert: apt packages + gate unit `enable` (not `--now`), script runs from repo path via wrapper. Tasks 3, 4. ✓
- §2 setup (pre-filled, collision-checked, idempotent, keys/conf 0600, wg-quick enable, first peer, no silent clobber): Task 1 `cmd_setup`. ✓
- §3 gate up/down (DOCKER-USER chain, ordered RETURN/RETURN/DROP, single jump, marker, persistence unit, up-guards): Task 1 `gate_apply`/`cmd_up`/`cmd_down` + Task 2. ✓
- §4 status/add-peer/remove-peer (keypair, next IP, wg0.conf source of truth, live `wg set`, QR with fallback, split-tunnel default + full-tunnel warning, exit codes): Task 1. ✓
- §5 CLI wiring (`wg)` case after `artisan)`, German usage): Task 4. ✓
- §6 help topic (TOPICS + label + DE/EN partials, identical keys, route): Tasks 5, 6. ✓
- §Testing (shellcheck + runbook + R12 + R15): Tasks 18. ✓
**Deviations from the spec, justified:**
- Spec §3 derives the subnet implicitly; this plan persists it in `/etc/clusev/wg.env` at setup so `gate_apply` (and the boot unit) and `add-peer` have a single, reliable source without re-deriving a network from an IP/prefix in bash. `wg0.conf` remains the source of truth for **peers** as the spec requires.
- Spec §6 suggested the topic before `'recovery'`; implemented exactly there (after `'audit'`).
- Help label uses the spec's wording (`WireGuard-Zugang` / `WireGuard access`), not the scout's `WireGuard & VPN`.
**Placeholder scan:** none — every step has concrete code/commands.
**Type/name consistency:** `gate_apply`/`gate_remove`/`cmd_up`/`cmd_down`/`cmd_setup`/`cmd_add_peer`/`cmd_remove_peer`/`cmd_status`/`next_free_ip`/`subnet_collides`/`subnet_first_ip`/`detect_endpoint_ip`/`require_setup` are referenced consistently in the dispatch and the systemd `gate-apply` subcommand. `WG_SUBNET`/`WG_SERVER_IP`/`WG_PORT`/`WG_ENDPOINT`/`WG_SERVER_PUBKEY` are written by `cmd_setup` and read by `gate_apply`/`cmd_add_peer`/`next_free_ip`. ✓
**Known SP1 limitation (documented, not a gap):** the subnet helpers (`next_free_ip`, `subnet_collides`, `subnet_first_ip`) assume the default /24. Non-/24 subnets work for the tunnel but peer-IP allocation + the collision heuristic are /24-shaped; noted in the runbook. SP2 territory.

View File

@ -1,860 +0,0 @@
# WireGuard dashboard — Phase 3 (peer management) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use `- [ ]` checkboxes.
**Goal:** Create + remove WireGuard client peers from the `/wireguard` dashboard, with the new client config (private key) + QR shown ONCE in a modal and never persisted.
**Architecture:** The security-critical **write-bridge** (spec §0 Bridge 2, variant A): the app writes `run/wg-request.json` `{id,action,args}`; a new host `.path` watcher runs `clusev-wg.sh serve-request`, which **whitelists the action + re-sanitises every arg host-side**, dispatches to a fixed `cmd_*`, and writes `run/wg-result-<id>.json` (0600, owned by the app uid) + for add-peer a `run/wg-qr-<id>.svg` sidecar. The container's strings are DATA, never code. The Livewire component polls its own pending id for the result (no public route → automatic id-binding) and shows the config+QR once.
**Tech Stack:** bash (host bridge), systemd `.path`/`.service`, Laravel 13 + Livewire 3 + wire-elements/modal, Tailwind tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` §0/§4/§6.
**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc '… php artisan test --filter=<X>'`; Pint `… vendor/bin/pint <files>`; shellcheck `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <script>`. Commit on `feat/v1-foundation`; push only at release.
**Security invariants (must hold):** (1) container input never reaches a shell command unquoted; action is whitelisted; each arg passes a strict host-side regex/`valid_name`. (2) The result file (private key) is 0600 + owned by the app uid + pruned after 60 s, never DB-persisted/logged. (3) An invalid/oversized id → no result written (un-claimable), drop silently. (4) Destructive (remove-peer) goes through an R5 confirm modal in the UI.
---
## File Structure
- **`docker/wg/clusev-wg.sh`** (modify) — refactor `cmd_add_peer`/`cmd_remove_peer`/`cmd_up` to share `_peer_add`/`_peer_remove`/`_gate_up_now` cores; add `serve-request` + `_write_result` + `_json_str`; prune stale result/QR files in `cmd_collect`.
- **`docker/wg/clusev-wg-request.service`** + **`docker/wg/clusev-wg-request.path`** (new) — the request watcher.
- **`install.sh`** (modify) — render + enable the request watcher in `install_host_watchers()`.
- **`app/Services/WgBridge.php`** (new) — `request(action,args): id` (writes `run/wg-request.json`), `result(id): ?array` (reads `run/wg-result-<id>.json` + the QR sidecar). One responsibility: the app side of the write-bridge.
- **`app/Livewire/Wireguard/Index.php`** (modify) — add-peer form, remove-peer (confirm), pending-id polling, show-once result, audit + throttle.
- **`resources/views/livewire/wireguard/index.blade.php`** (modify) — add-peer input, per-peer remove button, the show-once modal.
- **`lang/{de,en}/wireguard.php`** (modify) — peer-mgmt strings.
- **Tests:** `tests/Feature/WgBridgeTest.php`, extend `WireguardPageTest`.
---
## Task 1: the host write-bridge (`serve-request`) + refactor
**Files:** Modify `docker/wg/clusev-wg.sh`
- [ ] **Step 1: Extract `_peer_add` core** (returns ONLY the client config text on stdout; no prints). Add it just above the existing `cmd_add_peer`:
```bash
# Core: create a peer + print ONLY the client config to stdout (errors to stderr, non-zero on fail).
# Shared by the interactive cmd_add_peer and the write-bridge serve-request.
_peer_add() {
local name="$1"
require_setup
# shellcheck disable=SC1090
. "$WG_ENV"
[ -n "${WG_SUBNET:-}" ] && [ -n "${WG_SERVER_PUBKEY:-}" ] && [ -n "${WG_ENDPOINT:-}" ] || { echo "wg.env unvollstaendig" >&2; return 1; }
grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && { echo "Peer existiert bereits" >&2; return 1; }
local ip cpriv cpub
ip="$(next_free_ip)" || { echo "kein freier Adressraum" >&2; return 1; }
cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)"
cat >> "$WG_CONF" <<EOF
# clusev-peer: ${name}
[Peer]
PublicKey = ${cpub}
AllowedIPs = ${ip}/32
EOF
wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32"
cat <<EOF
[Interface]
PrivateKey = ${cpriv}
Address = ${ip}/32
DNS = 1.1.1.1
[Peer]
PublicKey = ${WG_SERVER_PUBKEY}
Endpoint = ${WG_ENDPOINT}
AllowedIPs = ${WG_SUBNET}
PersistentKeepalive = 25
EOF
}
```
- [ ] **Step 2: Rewrite `cmd_add_peer`** to wrap the core (preserving its prior output: heading, config, QR, hint):
```bash
cmd_add_peer() {
need_root add-peer
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer <name>"
valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
local conf; conf="$(_peer_add "$name")" || die "Anlegen fehlgeschlagen: ${conf}"
# shellcheck disable=SC1090
[ -f "$WG_ENV" ] && . "$WG_ENV"
local ip; ip="$(printf '%s\n' "$conf" | awk '/^Address/{print $3; exit}')"
bold "Peer '${name}' angelegt (${ip%%/*})."
printf '%s\n' "$conf"
if have qrencode; then
printf '%s\n' "$conf" | qrencode -t ansiutf8 || warn "QR-Erzeugung fehlgeschlagen — nutze die Textkonfiguration oben."
else
warn "qrencode nicht installiert — nutze die Textkonfiguration oben."
fi
info "AllowedIPs=${WG_SUBNET:-} = Split-Tunnel (nur Panel ueber WG). Voll-Tunnel (0.0.0.0/0) moeglich, aber NICHT empfohlen — leitet allen Client-Verkehr ueber Clusev."
}
```
- [ ] **Step 3: Extract `_peer_remove` core** (no prints; non-zero on fail) and slim `cmd_remove_peer` to wrap it. Replace the body of `cmd_remove_peer` (everything after the `valid_name` line) with a call to the core, and add the core above it:
```bash
# Core: remove the named peer from the running interface + wg0.conf. Errors to stderr.
_peer_remove() {
local name="$1"
require_setup
local pub
pub="$(WANT="# clusev-peer: ${name}" awk '$0==ENVIRON["WANT"]{f=1;next} f&&/PublicKey/{gsub(/[ \t]/,"");sub(/PublicKey=/,"");print;exit}' "$WG_CONF")"
[ -n "$pub" ] || { echo "Peer '${name}' nicht gefunden" >&2; return 1; }
wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true
local tmp; tmp="$(mktemp)"
WANT="# clusev-peer: ${name}" awk '
$0==ENVIRON["WANT"] {drop=1; next}
drop && /^[[:space:]]*$/ {drop=0; next}
drop {next}
{print}
' "$WG_CONF" > "$tmp"
install -m 0600 "$tmp" "$WG_CONF"; rm -f "$tmp"
}
cmd_remove_peer() {
need_root remove-peer
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
_peer_remove "$name" || die "Entfernen fehlgeschlagen."
bold "Peer '${name}' entfernt."
}
```
- [ ] **Step 4: Extract `_gate_up_now` core** (marker + apply, no interactive warnings) and have `cmd_up` wrap it. Add the core above `cmd_up`, and replace `cmd_up`'s marker+gate_apply lines with a call:
```bash
# Core: enable the gate (write marker + apply). Refuses if wg0 is down. Errors to stderr.
_gate_up_now() {
require_setup
[ -e "/sys/class/net/${WG_IF}" ] || { echo "wg0 nicht aktiv" >&2; return 1; }
mkdir -p "$STATE_DIR"
printf 'enabled\n' > "$GATE_MARKER"
gate_apply
}
cmd_up() {
need_root up
require_setup
[ -e "/sys/class/net/${WG_IF}" ] || die "${WG_IF} ist nicht aktiv. Erst Tunnel testen (siehe 'clusev wg status'), dann 'clusev wg up'."
warn "Vor dem Gate sicherstellen, dass der Tunnel das Panel erreicht. Notausgang ueber SSH: 'clusev wg down'."
_gate_up_now
bold "WireGuard-Gate aktiv — Panel (80/443) nur noch ueber den Tunnel erreichbar."
info "Notausgang (per SSH, falls der Tunnel nicht erreicht): clusev wg down"
}
```
- [ ] **Step 5: Add the JSON-string escaper + result writer + serve-request.** Place these just before `cmd_collect` (or near the other helpers):
```bash
# Minimal-but-correct JSON string escaper (quotes, backslashes, newlines, tabs; strip CR + other
# control). Our values are config text / short messages — this covers them safely.
_json_str() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\r'/}"
s="${s//$'\t'/\\t}"
s="${s//$'\n'/\\n}"
printf '"%s"' "$s"
}
# Write run/wg-result-<id>.json (0600, owned by the run/ owner = the app uid). config is the client
# config text (add-peer only) or empty.
_write_result() {
local id="$1" ok="$2" msg="$3" config="$4"
local f="${RUN_DIR}/wg-result-${id}.json" cfg='null'
[ -n "$config" ] && cfg="$(_json_str "$config")"
mkdir -p "$RUN_DIR" 2>/dev/null || true
printf '{"id":"%s","ok":%s,"message":%s,"config":%s,"at":%s}\n' \
"$id" "$ok" "$(_json_str "$msg")" "$cfg" "$(date +%s 2>/dev/null || echo 0)" > "${f}.tmp"
mv -f "${f}.tmp" "$f" 2>/dev/null || { rm -f "${f}.tmp"; return 0; }
chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$f" 2>/dev/null || true
chmod 600 "$f" 2>/dev/null || true
}
# Process one UI write-request (run/wg-request.json). Whitelists the action + re-sanitises every arg
# HOST-SIDE (the container's strings are data, never code), runs the fixed cmd, writes the result.
cmd_serve_request() {
need_root serve-request
local req="${RUN_DIR}/wg-request.json" work="${RUN_DIR}/wg-request.processing"
[ -f "$req" ] || return 0
mv -f "$req" "$work" 2>/dev/null || return 0 # consume single-shot (re-arm can't double-run)
local json id action name
json="$(cat "$work" 2>/dev/null || true)"; rm -f "$work"
# Extract with STRICT regexes — a value with any other char simply won't match (→ empty → rejected).
id="$(printf '%s' "$json" | grep -oE '"id":"[A-Za-z0-9]{16,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
[ -n "$id" ] || return 0 # no valid id → result un-claimable → drop silently
action="$(printf '%s' "$json" | grep -oE '"action":"[a-z-]{1,24}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
name="$(printf '%s' "$json" | grep -oE '"name":"[A-Za-z0-9._-]{1,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
local ok=false msg='ok' config=''
case "$action" in
add-peer)
if valid_name "$name"; then
if config="$(_peer_add "$name" 2>/dev/null)"; then ok=true; else ok=false; msg='add-failed'; config=''; fi
else msg='invalid-name'; fi ;;
remove-peer)
if valid_name "$name"; then
if _peer_remove "$name" >/dev/null 2>&1; then ok=true; else msg='remove-failed'; fi
else msg='invalid-name'; fi ;;
gate-up) if _gate_up_now >/dev/null 2>&1; then ok=true; else msg='gate-up-failed'; fi ;;
gate-down) if cmd_down >/dev/null 2>&1; then ok=true; else msg='gate-down-failed'; fi ;;
*) msg='unknown-action' ;;
esac
_write_result "$id" "$ok" "$msg" "$config"
if [ "$ok" = true ] && [ "$action" = add-peer ] && [ -n "$config" ] && have qrencode; then
local qr="${RUN_DIR}/wg-qr-${id}.svg"
if printf '%s\n' "$config" | qrencode -t SVG -o "$qr" 2>/dev/null; then
chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$qr" 2>/dev/null || true
chmod 600 "$qr" 2>/dev/null || true
fi
fi
}
```
- [ ] **Step 6: Prune stale result/QR files in `cmd_collect`.** At the very top of `cmd_collect` (after `mkdir -p "$RUN_DIR"`), add:
```bash
# Drop result + QR sidecars older than 60 s (they carry a private key; the app reads them once).
find "$RUN_DIR" -maxdepth 1 -type f \( -name 'wg-result-*.json' -o -name 'wg-qr-*.svg' \) -mmin +1 -delete 2>/dev/null || true
```
- [ ] **Step 7: Add dispatch arms.** In the `case "$cmd"` switch, after the `collect)` arm add:
```bash
serve-request) cmd_serve_request ;; # called by clusev-wg-request.service
```
- [ ] **Step 8: Verify.** `bash -n docker/wg/clusev-wg.sh` → clean. shellcheck → clean (the SC1090 disables stay). Smoke the safe paths:
- `bash docker/wg/clusev-wg.sh help` → usage, exit 0.
- `CLUSEV_DIR="$PWD" bash docker/wg/clusev-wg.sh collect; cat run/wg-status.json; rm -f run/wg-status.json``configured:false`, exit 0 (collect still works after the prune line).
- Test `_json_str` in isolation: `bash -c 'source <(sed -n "/^_json_str/,/^}/p" docker/wg/clusev-wg.sh); _json_str "$(printf "a\"b\nc\\\\d")"'` → prints `"a\"b\nc\\d"` (quotes/newline/backslash escaped).
- [ ] **Step 9: Commit**
```bash
git add docker/wg/clusev-wg.sh
git commit -m "feat(wg): serve-request write-bridge + peer/gate cores (host-side validation)"
```
---
## Task 2: the request watcher units
**Files:** Create `docker/wg/clusev-wg-request.service`, `docker/wg/clusev-wg-request.path`
- [ ] **Step 1: `docker/wg/clusev-wg-request.service`:**
```ini
# Clusev WireGuard write-request handler — oneshot (HOST-side). Triggered by clusev-wg-request.path
# when the dashboard writes run/wg-request.json. Runs `clusev-wg.sh serve-request`, which consumes
# the request, whitelists the action + re-validates args, runs the fixed command, and writes a
# result file the app reads once. install.sh rewrites the /home/nexxo/clusev paths.
[Unit]
Description=Clusev WireGuard write-request handler
After=docker.service
[Service]
Type=oneshot
User=root
Environment=CLUSEV_DIR=/home/nexxo/clusev
WorkingDirectory=/home/nexxo/clusev
ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh serve-request
```
- [ ] **Step 2: `docker/wg/clusev-wg-request.path`:**
```ini
# Watches for a dashboard WireGuard write-request and fires the handler the moment it appears. The
# handler consumes the request, so a re-arm cannot double-run it. install.sh rewrites the path.
[Unit]
Description=Clusev WireGuard write-request — watch for a dashboard request
After=docker.service
Wants=docker.service
[Path]
PathExists=/home/nexxo/clusev/run/wg-request.json
Unit=clusev-wg-request.service
[Install]
WantedBy=multi-user.target
```
- [ ] **Step 3: Verify** `systemd-analyze verify docker/wg/clusev-wg-request.path docker/wg/clusev-wg-request.service 2>&1` (path-not-found is fine). Confirm the `.path` `Unit=` matches the `.service` filename + `PathExists=` matches `run/wg-request.json`.
- [ ] **Step 4: Commit**
```bash
git add docker/wg/clusev-wg-request.service docker/wg/clusev-wg-request.path
git commit -m "feat(wg): clusev-wg-request watcher (path+service) for the UI write-bridge"
```
---
## Task 3: install.sh — render + enable the request watcher
**Files:** Modify `install.sh` (`install_host_watchers()`)
- [ ] **Step 1:** Mirror the collector wiring for the request watcher (a `.path` + `.service`, enabled with `--now` so it arms immediately). After the collector temp vars line (`local tmp_csvc tmp_ctimer; …`) add:
```bash
local tmp_rsvc tmp_rpath; tmp_rsvc="$(mktemp)"; tmp_rpath="$(mktemp)"
```
After the collector sed lines add:
```bash
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.service" > "$tmp_rsvc"
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.path" > "$tmp_rpath"
```
In the `&&` chain, after the collector install lines add:
```bash
&& install -m 0644 "$tmp_rsvc" "${dst}/clusev-wg-request.service" \
&& install -m 0644 "$tmp_rpath" "${dst}/clusev-wg-request.path" \
```
and after the `systemctl enable --now clusev-wg-collect.timer` line add:
```bash
&& systemctl enable --now clusev-wg-request.path \
```
Append the two temps to the cleanup `rm -f` line.
- [ ] **Step 2: Verify.** `bash -n install.sh` → clean. shellcheck → no new findings vs `git show HEAD:install.sh`. Eyeball: `daemon-reload` still after all installs; the `.path` enabled among the enables.
- [ ] **Step 3: Commit**
```bash
git add install.sh
git commit -m "feat(wg): install + enable the WireGuard write-request watcher"
```
---
## Task 4: `WgBridge` app service
**Files:** Create `app/Services/WgBridge.php`; Test `tests/Feature/WgBridgeTest.php`
- [ ] **Step 1: Write the failing test** (`tests/Feature/WgBridgeTest.php`):
```php
<?php
namespace Tests\Feature;
use App\Services\WgBridge;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WgBridgeTest extends TestCase
{
use RefreshDatabase;
private string $dir;
protected function setUp(): void
{
parent::setUp();
$this->dir = storage_path('app/restart-signal');
@mkdir($this->dir, 0775, true);
foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
@unlink($f);
}
}
protected function tearDown(): void
{
foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
@unlink($f);
}
parent::tearDown();
}
public function test_request_writes_a_well_formed_request_and_returns_an_id(): void
{
$id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);
$this->assertMatchesRegularExpression('/^[A-Za-z0-9]{16,64}$/', $id);
$req = json_decode((string) file_get_contents($this->dir.'/wg-request.json'), true);
$this->assertSame($id, $req['id']);
$this->assertSame('add-peer', $req['action']);
$this->assertSame('laptop', $req['name']);
}
public function test_request_rejects_an_unknown_action(): void
{
$this->expectException(\InvalidArgumentException::class);
app(WgBridge::class)->request('rm -rf', ['name' => 'x']);
}
public function test_request_rejects_an_invalid_name(): void
{
$this->expectException(\InvalidArgumentException::class);
app(WgBridge::class)->request('add-peer', ['name' => 'bad name; rm']);
}
public function test_result_only_returns_for_the_matching_id(): void
{
$id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);
file_put_contents($this->dir."/wg-result-{$id}.json", json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "cfg", 'at' => time()]));
$this->assertNull(app(WgBridge::class)->result('different-id-totally'));
$r = app(WgBridge::class)->result($id);
$this->assertTrue($r['ok']);
$this->assertSame('cfg', $r['config']);
}
}
```
- [ ] **Step 2: Run → fails.**
- [ ] **Step 3: Write `app/Services/WgBridge.php`:**
```php
<?php
namespace App\Services;
use Illuminate\Support\Str;
/**
* The app side of the WireGuard write-bridge. `request()` writes a strictly-validated request to
* run/wg-request.json (a host watcher runs it); `result()` reads the host-written result for an id
* the app issued. Validation here is the FIRST gate — the host re-validates every value too.
*/
class WgBridge
{
/** Actions the UI may request; the host whitelists the same set. */
public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet'];
private function dir(): string
{
return storage_path('app/restart-signal');
}
/**
* Write a write-request; returns the request id. Throws InvalidArgumentException on a bad
* action or arg (defence-in-depth; the host re-validates).
*
* @param array<string,string> $args
*/
public function request(string $action, array $args = []): string
{
if (! in_array($action, self::ACTIONS, true)) {
throw new \InvalidArgumentException('unknown action');
}
$clean = $this->validateArgs($action, $args);
$id = Str::random(32);
$payload = array_merge(['id' => $id, 'action' => $action, 'at' => time()], $clean);
@mkdir($this->dir(), 0775, true);
file_put_contents($this->dir().'/wg-request.json', json_encode($payload, JSON_UNESCAPED_SLASHES));
return $id;
}
/**
* Read the host result for an id THIS app issued. Returns null until the result exists.
* Includes the QR sidecar SVG when present (add-peer). Never throws.
*
* @return array{ok:bool, message:string, config:?string, qr:?string}|null
*/
public function result(string $id): ?array
{
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $id) !== 1) {
return null;
}
$file = $this->dir()."/wg-result-{$id}.json";
if (! is_file($file)) {
return null;
}
$data = json_decode((string) @file_get_contents($file), true);
if (! is_array($data) || ($data['id'] ?? null) !== $id) {
return null;
}
$qrFile = $this->dir()."/wg-qr-{$id}.svg";
$qr = is_file($qrFile) ? (string) @file_get_contents($qrFile) : null;
return [
'ok' => (bool) ($data['ok'] ?? false),
'message' => (string) ($data['message'] ?? ''),
'config' => isset($data['config']) && is_string($data['config']) ? $data['config'] : null,
'qr' => $qr,
];
}
/**
* @param array<string,string> $args
* @return array<string,string>
*/
private function validateArgs(string $action, array $args): array
{
$name = (string) ($args['name'] ?? '');
switch ($action) {
case 'add-peer':
case 'remove-peer':
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
throw new \InvalidArgumentException('invalid name');
}
return ['name' => $name];
case 'gate-up':
case 'gate-down':
return [];
case 'set-endpoint': // P4
$ep = (string) ($args['endpoint'] ?? '');
if (preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $ep) !== 1) {
throw new \InvalidArgumentException('invalid endpoint');
}
return ['endpoint' => $ep];
case 'set-port': // P4
$port = (string) ($args['port'] ?? '');
if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) {
throw new \InvalidArgumentException('invalid port');
}
return ['port' => $port];
case 'set-subnet': // P4
$subnet = (string) ($args['subnet'] ?? '');
if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) {
throw new \InvalidArgumentException('invalid subnet');
}
return ['subnet' => $subnet];
}
return [];
}
}
```
(`set-*` are validated now so P4 only adds the host `cmd_*` + UI — the app gate is ready.)
- [ ] **Step 4: Run → passes.** Pint the service + test.
- [ ] **Step 5: Commit**
```bash
git add app/Services/WgBridge.php tests/Feature/WgBridgeTest.php
git commit -m "feat(wg): WgBridge — app side of the write-bridge (validate + request + result)"
```
---
## Task 5: peer management UI
**Files:** Modify `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/{de,en}/wireguard.php`; Test extend `tests/Feature/WireguardPageTest.php`
- [ ] **Step 1: Extend the page test** — add to `WireguardPageTest`:
```php
public function test_add_peer_writes_a_request_and_audits(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('newPeer', 'laptop')
->call('addPeer')
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.add-peer')->exists());
$this->assertFileExists(storage_path('app/restart-signal/wg-request.json'));
@unlink(storage_path('app/restart-signal/wg-request.json'));
}
public function test_add_peer_rejects_an_invalid_name(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('newPeer', 'bad name')
->call('addPeer')
->assertHasErrors('newPeer');
$this->assertSame(0, \App\Models\AuditEvent::where('action', 'wg.add-peer')->count());
}
public function test_poll_result_surfaces_the_config_once(): void
{
$c = \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)->set('newPeer', 'laptop')->call('addPeer');
$id = $c->get('pendingId');
file_put_contents(storage_path("app/restart-signal/wg-result-{$id}.json"), json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "[Interface]\nPrivateKey = x", 'at' => time()]));
$c->call('pollResult')
->assertSet('pendingId', null)
->assertSet('resultConfig', fn ($v) => str_contains((string) $v, 'PrivateKey'));
@unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
}
```
- [ ] **Step 2: Run → fails.**
- [ ] **Step 3: Update the component** — add to `app/Livewire/Wireguard/Index.php` (keep the existing `$window`/`setWindow`/`render`; add the peer-mgmt state + methods + inject `WgBridge`). The full updated class:
```php
<?php
namespace App\Livewire\Wireguard;
use App\Models\AuditEvent;
use App\Services\WgBridge;
use App\Services\WgStatus;
use App\Services\WgTraffic;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the
* host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public int $window = 3600;
public const WINDOWS = [3600, 86400, 604800];
// ── peer management ──
public string $newPeer = '';
/** id of an in-flight write-request we are polling for. */
public ?string $pendingId = null;
/** What we are waiting on ('add-peer'|'remove-peer'|…) — drives the toast/modal. */
public ?string $pendingAction = null;
/** Show-once add-peer result (config text + QR svg). Never persisted. */
public ?string $resultConfig = null;
public ?string $resultQr = null;
public function setWindow(int $seconds): void
{
$this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600;
}
public function addPeer(WgBridge $bridge): void
{
$this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [
'newPeer.regex' => __('wireguard.peer_name_invalid'),
]);
if (! $this->throttle()) {
return;
}
$name = $this->newPeer;
$this->pendingId = $bridge->request('add-peer', ['name' => $name]);
$this->pendingAction = 'add-peer';
$this->newPeer = '';
$this->audit('wg.add-peer', $name);
}
public function removePeer(WgBridge $bridge, string $name): void
{
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
$this->pendingAction = 'remove-peer';
$this->audit('wg.remove-peer', $name);
}
/** Polled (wire:poll) while a request is in flight; surfaces the host result once. */
public function pollResult(WgBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$res = $bridge->result($this->pendingId);
if ($res === null) {
return; // not ready yet
}
$id = $this->pendingId;
$this->pendingId = null;
if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) {
$this->resultConfig = $res['config']; // show once
$this->resultQr = $res['qr'];
} elseif (! $res['ok']) {
$this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error');
} else {
$this->dispatch('notify', message: __('wireguard.action_done'));
}
$this->pendingAction = null;
}
public function dismissResult(): void
{
$this->resultConfig = null;
$this->resultQr = null;
}
private function throttle(): bool
{
$key = 'wg-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 10)) {
$this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');
return false;
}
RateLimiter::hit($key, 60);
return true;
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(WgStatus $wg, WgTraffic $traffic)
{
$window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;
return view('livewire.wireguard.index', [
'status' => $wg->read(),
'traffic' => $traffic->series($window),
'windows' => self::WINDOWS,
])->title(__('wireguard.title'));
}
}
```
- [ ] **Step 4: Add lang strings** (BOTH files, identical keys). DE:
```php
'add_peer' => 'Peer hinzufügen',
'peer_name_placeholder' => 'Name (z. B. laptop)',
'peer_name_invalid' => 'Nur AZ az 09 . _ - (max. 64).',
'remove' => 'Entfernen',
'remove_confirm_title' => 'Peer entfernen?',
'remove_confirm_body' => 'Der Client verliert sofort den Zugang. Das lässt sich nicht rückgängig machen.',
'pending' => 'Wird angewendet …',
'action_done' => 'Aktion angewendet.',
'action_failed' => 'Aktion fehlgeschlagen — auf dem Host prüfen.',
'throttled' => 'Zu viele Aktionen — kurz warten.',
'new_peer_title' => 'Neuer Peer — Konfiguration',
'new_peer_once' => 'Diese Konfiguration wird NUR EINMAL angezeigt und nicht gespeichert. Jetzt importieren (QR scannen oder Text kopieren).',
'close' => 'Schließen',
```
EN:
```php
'add_peer' => 'Add peer',
'peer_name_placeholder' => 'Name (e.g. laptop)',
'peer_name_invalid' => 'Only AZ az 09 . _ - (max 64).',
'remove' => 'Remove',
'remove_confirm_title' => 'Remove peer?',
'remove_confirm_body' => 'The client loses access immediately. This cannot be undone.',
'pending' => 'Applying …',
'action_done' => 'Action applied.',
'action_failed' => 'Action failed — check on the host.',
'throttled' => 'Too many actions — wait a moment.',
'new_peer_title' => 'New peer — configuration',
'new_peer_once' => 'This configuration is shown ONCE and never stored. Import it now (scan the QR or copy the text).',
'close' => 'Close',
```
- [ ] **Step 5: Update the view.** In `resources/views/livewire/wireguard/index.blade.php`:
- Add `wire:poll.2s="pollResult"` is NOT global — instead, add a small polling element shown only while pending. Put this near the top of the configured branch (inside `@else`), above the Traffic panel:
```blade
@if ($pendingId)
<div class="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-4 py-3" wire:poll.2s="pollResult">
<svg class="h-4 w-4 shrink-0 animate-spin text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span class="font-mono text-[11px] text-ink-2">{{ __('wireguard.pending') }}</span>
</div>
@endif
```
- In the Peers panel header (the `<x-panel :title="__('wireguard.peers_title')" …>`), add an add-peer form. Change the panel to include a header action — put this inside the peers panel, just after its opening, as a sub-header row:
```blade
<form wire:submit="addPeer" class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-3 sm:px-5">
<input wire:model="newPeer" type="text" maxlength="64" placeholder="{{ __('wireguard.peer_name_placeholder') }}"
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="addPeer">
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.add_peer') }}
</x-btn>
@error('newPeer') <span class="w-full font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
</form>
```
- In each peer row, add a remove control (a destructive action → R5 wire-elements/modal confirm via the project's confirm pattern). Add to the peer row, in the `ml-auto` area:
```blade
<button type="button"
wire:click="$dispatch('open-modal', { component: 'modals.confirm-delete', arguments: { title: @js(__('wireguard.remove_confirm_title')), body: @js(__('wireguard.remove_confirm_body')), confirm: @js(__('wireguard.remove')), method: 'removePeer', params: @js([$peer['name']]) }})"
class="rounded-md border border-line bg-raised px-2 py-1 font-mono text-[10px] text-ink-3 transition-colors hover:border-offline/40 hover:text-offline">{{ __('wireguard.remove') }}</button>
```
NOTE: the implementer MUST first read how destructive confirms are actually wired in this project — find the existing `Modals\ConfirmDelete` (or equivalent) component and an existing call site (e.g. server delete or `clusev unban`), and replicate THAT exact pattern + arguments (component name, the dispatch event, how the confirmed method + params are invoked, the ConfirmToken if used per R5). Replace the snippet above with the project's real confirm-modal invocation calling `removePeer($peer['name'])`. Do not invent a modal API.
- Add the show-once result modal at the END of the root `<div>` (before its closing tag):
```blade
@if ($resultConfig)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-void/80 p-4" wire:key="wg-result-modal">
<div class="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-panel">
<h3 class="font-display text-lg font-semibold text-ink">{{ __('wireguard.new_peer_title') }}</h3>
<p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p>
@if ($resultQr)
<div class="mx-auto mt-4 h-44 w-44 [&>svg]:h-full [&>svg]:w-full">{!! $resultQr !!}</div>
@endif
<pre class="mt-4 max-h-48 overflow-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">{{ $resultConfig }}</pre>
<div class="mt-4 flex justify-end">
<x-btn variant="secondary" wire:click="dismissResult">{{ __('wireguard.close') }}</x-btn>
</div>
</div>
</div>
@endif
```
(The QR is host-generated SVG; `{!! !!}` is safe here — it is produced by `qrencode` on the host from our own config, not user HTML. The implementer should still confirm the SVG has no `<script>``qrencode -t SVG` emits only `<svg><rect/>…`.)
- [ ] **Step 6: Run the page tests → pass.** Pint. (If the confirm-modal pattern needs adjusting, fix until the remove path works in a Livewire test.)
- [ ] **Step 7: Commit**
```bash
git add app/Livewire/Wireguard/Index.php resources/views/livewire/wireguard/index.blade.php lang/de/wireguard.php lang/en/wireguard.php tests/Feature/WireguardPageTest.php
git commit -m "feat(wg): add/remove peers from the dashboard (show-once config + QR)"
```
---
## Task 6: review + R12 + release
- [ ] **Step 1: Adversarial security review** of the write-bridge (Task 1 + 4 + 5) — focus: can container input reach a shell command? is every arg whitelisted host-side AND app-side? is the result file un-leakable (0600, id-bound, pruned)? id-collision/claim-other-user's-result? the QR SVG injection surface? throttle + audit present? Fix every confirmed finding.
- [ ] **Step 2: shellcheck + Pint + full suite** all green.
- [ ] **Step 3: R12** — browser-verify `/wireguard` add-peer flow: stub a configured status, type a name → addPeer → (simulate the host by writing a `wg-result-<id>.json` with a config + a small `wg-qr-<id>.svg`) → pollResult surfaces the show-once modal with the config + QR; the remove confirm modal opens. 1280 + 375, DE+EN, no console errors, no leaked tokens. Restore the gkonrad password + clean stubs.
- [ ] **Step 4: Release** v0.9.36 — CHANGELOG (Hinzugefügt: Peer-Verwaltung aus dem Dashboard + Schreib-Brücke), bump, commit, tag, push.
---
## Self-Review
**Spec coverage (P3 = spec §4 + the §0 Bridge 2 + §6 security):**
- Write-bridge (request file → host watcher → serve-request whitelist+re-validate → result file) → Tasks 14. ✓
- add-peer (show-once config + QR, never persisted) → Tasks 1, 5. ✓
- remove-peer (R5 confirm) → Tasks 1, 5. ✓
- Host-side re-validation + app-side validation (defence in depth) → Tasks 1, 4. ✓
- Result 0600 + app-uid-owned + 60s prune + id-bound read → Tasks 1, 4. ✓
- Audit + throttle → Task 5. ✓
- gate-up/gate-down actions are wired in the bridge now (UI for them is P4). ✓
**Placeholder scan:** none, except Task 5 explicitly defers the confirm-modal markup to the project's real pattern (the implementer must read + replicate it) — flagged, not a silent gap.
**Type/name consistency:** `WgBridge::ACTIONS`/`request()`/`result()` shapes match the host serve-request whitelist (`add-peer/remove-peer/gate-up/gate-down/set-*`) and the component's calls. The request JSON keys (`id/action/name/at`) written by `WgBridge::request` are exactly what `cmd_serve_request` greps. The result keys (`id/ok/message/config`) written by `_write_result` match `WgBridge::result()`s parse + the component's use. `pendingId/pendingAction/resultConfig/resultQr/newPeer` consistent across component + view + tests.
**Risk note:** the confirm-modal wiring (Task 5) is the one place the plan can't fully prescribe without the project's modal API — the implementer reads the existing pattern. Everything else is fully specified. The host bridge is exercised by shellcheck + the runbook (P3 manual section) + the app-side tests with stubbed result files; the true end-to-end (container→host→container) only runs on a deployed host.

View File

@ -1,407 +0,0 @@
# WireGuard dashboard — Phase 4 (settings + gate) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use `- [ ]` checkboxes.
**Goal:** From `/wireguard`: toggle the panel gate on/off, and edit the server endpoint / listen port / subnet — destructive changes behind a hard confirm. Built on the P3 write-bridge.
**Architecture:** Reuses the P3 bridge end-to-end. `WgBridge` already validates `gate-up`/`gate-down`/`set-endpoint`/`set-port`/`set-subnet` (added in P3); the host already routes `gate-up`/`gate-down`. P4 adds the host `cmd_set_endpoint`/`cmd_set_port`/`cmd_set_subnet` + their `serve-request` case branches, and a Settings UI (gate toggle + 3 edit forms) gated by the project's `ConfirmToken` flow for the destructive ones. First-time bootstrap stays on the host (`clusev wg setup`); P4 edits an already-configured server.
**Tech Stack:** bash (host), Laravel 13 + Livewire 3 + ConfirmToken, Tailwind tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` §5.
**Run tooling (R8):** as prior phases. Commit on `feat/v1-foundation`; push only at release.
**Safety:** endpoint edit is non-destructive (affects only new peer configs). Port + subnet edits restart `wg-quick@wg0` and **break existing peers' connectivity** (port: clients must update the port; subnet: re-IPs the server → existing peers must be re-created) — both require a hard confirm with an explicit warning; subnet additionally re-runs the host collision check. Gate-down exposes the panel publicly (confirm); gate-up can lock out the public panel if the tunnel isn't verified (confirm + the SSH-escape note). The host re-validates every value (the container's strings are data, never code).
---
## File Structure
- **`docker/wg/clusev-wg.sh`** (modify) — `_set_endpoint`/`_set_port`/`_set_subnet` cores + `cmd_set_endpoint`/`cmd_set_port`/`cmd_set_subnet` CLI wrappers + the three `serve-request` case branches (extract+validate the arg host-side).
- **`app/Support/Confirm/ConfirmToken.php`** (modify) — add `wgGateToggle`, `wgSetPort`, `wgSetSubnet` to `ACTIONS`.
- **`app/Livewire/Wireguard/Index.php`** (modify) — `setEndpoint`, gate-toggle + the two destructive confirm/apply handlers (mirror P3's `confirmRemovePeer`/`applyRemovePeer`), audit + throttle.
- **`resources/views/livewire/wireguard/index.blade.php`** (modify) — a Settings panel: gate toggle, endpoint/port/subnet forms.
- **`lang/{de,en}/wireguard.php`** (modify) — settings strings.
- **Tests:** extend `WireguardPageTest`.
---
## Task 1: host `cmd_set_*` + serve-request branches
**Files:** Modify `docker/wg/clusev-wg.sh`
- [ ] **Step 1: Add the cores + CLI wrappers** (place above `cmd_collect`, after `_gate_up_now`/before the bridge helpers):
```bash
# ── settings mutators (cores: no prints; errors to stderr; non-zero on fail) ──────────────────
_set_endpoint() {
local ep="$1"; require_setup
case "$ep" in *[!A-Za-z0-9.:_-]*|'') echo "bad endpoint" >&2; return 1 ;; esac
sed -i "s#^WG_ENDPOINT=.*#WG_ENDPOINT=${ep}#" "$WG_ENV"
}
_set_port() {
local port="$1"; require_setup
case "$port" in ''|*[!0-9]*) echo "bad port" >&2; return 1 ;; esac
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "port out of range" >&2; return 1; }
sed -i "s#^ListenPort = .*#ListenPort = ${port}#" "$WG_CONF"
sed -i "s#^WG_PORT=.*#WG_PORT=${port}#" "$WG_ENV"
systemctl restart "wg-quick@${WG_IF}" || { echo "wg-quick restart failed" >&2; return 1; }
}
_set_subnet() {
local subnet="$1"; require_setup
case "$subnet" in *[!0-9./]*|'') echo "bad subnet" >&2; return 1 ;; esac
printf '%s' "$subnet" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' || { echo "bad subnet" >&2; return 1; }
subnet_collides "$subnet" && { echo "subnet collides with an existing route/address" >&2; return 1; }
local prefix server_ip
prefix="${subnet##*/}"
server_ip="$(subnet_first_ip "$subnet")"
sed -i "s#^Address = .*#Address = ${server_ip}/${prefix}#" "$WG_CONF"
sed -i "s#^WG_SUBNET=.*#WG_SUBNET=${subnet}#" "$WG_ENV"
sed -i "s#^WG_SERVER_IP=.*#WG_SERVER_IP=${server_ip}#" "$WG_ENV"
systemctl restart "wg-quick@${WG_IF}" || { echo "wg-quick restart failed" >&2; return 1; }
}
cmd_set_endpoint() { need_root set-endpoint; local v="${1:-}"; [ -n "$v" ] || die "Endpoint fehlt."; _set_endpoint "$v" || die "Endpoint setzen fehlgeschlagen."; bold "Endpoint gesetzt: ${v}"; }
cmd_set_port() { need_root set-port; local v="${1:-}"; [ -n "$v" ] || die "Port fehlt."; _set_port "$v" || die "Port setzen fehlgeschlagen."; bold "Port gesetzt: ${v} (Clients muessen den Port aktualisieren)."; }
cmd_set_subnet() { need_root set-subnet; local v="${1:-}"; [ -n "$v" ] || die "Subnetz fehlt."; _set_subnet "$v" || die "Subnetz setzen fehlgeschlagen."; bold "Subnetz gesetzt: ${v} (bestehende Peers muessen neu angelegt werden)."; }
```
- [ ] **Step 2: Extend `serve-request`** — extract the three new args (strict regexes) + add the case branches. After the `name="$(…)"` extraction line, add:
```bash
local endpoint port subnet
endpoint="$(printf '%s' "$json" | grep -oE '"endpoint":"[A-Za-z0-9.:_-]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
port="$(printf '%s' "$json" | grep -oE '"port":"[0-9]{1,5}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
subnet="$(printf '%s' "$json" | grep -oE '"subnet":"[0-9./]{1,18}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
```
In the `case "$action"` switch, add before the `*)` arm:
```bash
set-endpoint) if [ -n "$endpoint" ] && _set_endpoint "$endpoint" >/dev/null 2>&1; then ok=true; else msg='set-endpoint-failed'; fi ;;
set-port) if [ -n "$port" ] && _set_port "$port" >/dev/null 2>&1; then ok=true; else msg='set-port-failed'; fi ;;
set-subnet) if [ -n "$subnet" ] && _set_subnet "$subnet" >/dev/null 2>&1; then ok=true; else msg='set-subnet-failed'; fi ;;
```
- [ ] **Step 3: Add CLI dispatch arms** in the `case "$cmd"` switch (after `remove-peer)`):
```bash
set-endpoint) cmd_set_endpoint "$@" ;;
set-port) cmd_set_port "$@" ;;
set-subnet) cmd_set_subnet "$@" ;;
```
and add a usage line for them (after the remove-peer usage line):
```
clusev wg set-endpoint <ip:port> oeffentlichen Endpoint aendern
clusev wg set-port <udp> Listen-Port aendern (Clients neu)
clusev wg set-subnet <cidr> Subnetz aendern (Peers neu anlegen)
```
- [ ] **Step 4: Verify.** `bash -n` + shellcheck clean. Smoke: `bash docker/wg/clusev-wg.sh set-port 51821; echo $?` as non-root → hits `need_root`, non-zero (the guard fires before any mutation). `bash docker/wg/clusev-wg.sh help` shows the new usage lines.
- [ ] **Step 5: Commit**
```bash
git add docker/wg/clusev-wg.sh
git commit -m "feat(wg): set-endpoint/set-port/set-subnet host actions + serve-request branches"
```
---
## Task 2: ConfirmToken actions + component
**Files:** Modify `app/Support/Confirm/ConfirmToken.php`, `app/Livewire/Wireguard/Index.php`; Test extend `WireguardPageTest`
- [ ] **Step 1: Allowlist the new confirm actions.** In `app/Support/Confirm/ConfirmToken.php`, add to the `ACTIONS` const (alongside `wgPeerRemoved`):
```php
'wgGateToggle',
'wgSetPort',
'wgSetSubnet',
```
- [ ] **Step 2: Extend the page test** — add to `WireguardPageTest`:
```php
public function test_set_endpoint_writes_a_request(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('newEndpoint', 'vpn.example.com:51820')
->call('setEndpoint')
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.set-endpoint')->exists());
@unlink(storage_path('app/restart-signal/wg-request.json'));
}
public function test_set_endpoint_rejects_a_bad_value(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('newEndpoint', 'has spaces!!')
->call('setEndpoint')
->assertHasErrors('newEndpoint');
}
public function test_apply_gate_toggle_writes_the_right_action(): void
{
// applyGateToggle is the post-confirm handler; call it directly with on=false → gate-down.
$c = \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class);
$c->call('runGate', false); // helper invoked after confirm; writes gate-down
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.gate-down')->exists());
@unlink(storage_path('app/restart-signal/wg-request.json'));
}
```
- [ ] **Step 3: Extend the component** `app/Livewire/Wireguard/Index.php`. Add the properties + methods (keep all P1P3 code). Add near the peer-mgmt state:
```php
// ── settings (P4) ──
public string $newEndpoint = '';
public string $newPort = '';
public string $newSubnet = '';
```
Add these methods (mirror the P3 confirm/apply split + the existing throttle/audit/bridge):
```php
public function setEndpoint(WgBridge $bridge): void
{
$this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [
'newEndpoint.regex' => __('wireguard.endpoint_invalid'),
'newEndpoint.required' => __('wireguard.endpoint_invalid'),
]);
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('set-endpoint', ['endpoint' => $this->newEndpoint]);
$this->pendingAction = 'set-endpoint';
$this->audit('wg.set-endpoint', $this->newEndpoint);
$this->newEndpoint = '';
}
/** Confirm + apply a port change (destructive — restarts wg-quick). */
public function confirmSetPort(): void
{
$this->validate(['newPort' => ['required', 'regex:/^\d{1,5}$/']], ['newPort.regex' => __('wireguard.port_invalid'), 'newPort.required' => __('wireguard.port_invalid')]);
if ((int) $this->newPort < 1 || (int) $this->newPort > 65535) {
$this->addError('newPort', __('wireguard.port_invalid'));
return;
}
$token = \App\Support\Confirm\ConfirmToken::issue('wgSetPort', ['port' => $this->newPort], 'wg.set-port', $this->newPort);
$this->dispatch('openModal', component: 'modals.confirm-action', arguments: [
'confirmToken' => $token, 'heading' => __('wireguard.port_confirm_title'),
'body' => __('wireguard.port_confirm_body'), 'confirm' => __('wireguard.apply'), 'danger' => true, 'event' => 'wgSetPort',
]);
}
#[\Livewire\Attributes\On('wgSetPort')]
public function applySetPort(string $confirmToken, WgBridge $bridge): void
{
$p = \App\Support\Confirm\ConfirmToken::consume($confirmToken, 'wgSetPort');
if ($p === null || ! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('set-port', ['port' => (string) $p['port']]);
$this->pendingAction = 'set-port';
$this->newPort = '';
}
public function confirmSetSubnet(): void
{
$this->validate(['newSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#']], ['newSubnet.regex' => __('wireguard.subnet_invalid'), 'newSubnet.required' => __('wireguard.subnet_invalid')]);
$token = \App\Support\Confirm\ConfirmToken::issue('wgSetSubnet', ['subnet' => $this->newSubnet], 'wg.set-subnet', $this->newSubnet);
$this->dispatch('openModal', component: 'modals.confirm-action', arguments: [
'confirmToken' => $token, 'heading' => __('wireguard.subnet_confirm_title'),
'body' => __('wireguard.subnet_confirm_body'), 'confirm' => __('wireguard.apply'), 'danger' => true, 'event' => 'wgSetSubnet',
]);
}
#[\Livewire\Attributes\On('wgSetSubnet')]
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
{
$p = \App\Support\Confirm\ConfirmToken::consume($confirmToken, 'wgSetSubnet');
if ($p === null || ! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('set-subnet', ['subnet' => (string) $p['subnet']]);
$this->pendingAction = 'set-subnet';
$this->newSubnet = '';
}
/** Open the gate-toggle confirm. $on = the DESIRED state. */
public function confirmGate(bool $on): void
{
$token = \App\Support\Confirm\ConfirmToken::issue('wgGateToggle', ['on' => $on ? '1' : '0'], $on ? 'wg.gate-up' : 'wg.gate-down', $on ? 'on' : 'off');
$this->dispatch('openModal', component: 'modals.confirm-action', arguments: [
'confirmToken' => $token,
'heading' => $on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'),
'body' => $on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'),
'confirm' => __('wireguard.apply'), 'danger' => ! $on, 'event' => 'wgGateToggle',
]);
}
#[\Livewire\Attributes\On('wgGateToggle')]
public function applyGateToggle(string $confirmToken, WgBridge $bridge): void
{
$p = \App\Support\Confirm\ConfirmToken::consume($confirmToken, 'wgGateToggle');
if ($p === null) {
return;
}
$this->runGate(($p['on'] ?? '0') === '1', $bridge);
}
/** Write the gate request (post-confirm). Split out so it is unit-testable. */
public function runGate(bool $on, ?WgBridge $bridge = null): void
{
if (! $this->throttle()) {
return;
}
$bridge ??= app(WgBridge::class);
$this->pendingId = $bridge->request($on ? 'gate-up' : 'gate-down', []);
$this->pendingAction = $on ? 'gate-up' : 'gate-down';
$this->audit($on ? 'wg.gate-up' : 'wg.gate-down', $on ? 'on' : 'off');
}
```
(The `confirmToken`/heading/body/event argument names MUST match the real `Modals\ConfirmAction` component signature — the implementer confirms against the P3 `confirmRemovePeer` call site + `ConfirmAction.php` and adjusts arg names if they differ. The `audit` for port/subnet happens inside the ConfirmAction modal from the sealed descriptor, like remove-peer; gate + endpoint audit in the component as shown.)
- [ ] **Step 4: Run the test → passes.** Pint the component.
- [ ] **Step 5: Commit**
```bash
git add app/Support/Confirm/ConfirmToken.php app/Livewire/Wireguard/Index.php tests/Feature/WireguardPageTest.php
git commit -m "feat(wg): gate toggle + endpoint/port/subnet settings (write-bridge + confirms)"
```
---
## Task 3: Settings UI
**Files:** Modify `resources/views/livewire/wireguard/index.blade.php`, `lang/{de,en}/wireguard.php`
- [ ] **Step 1: Lang strings** (BOTH files, identical keys). DE:
```php
'settings_title' => 'Einstellungen',
'apply' => 'Anwenden',
'gate_toggle' => 'Gate',
'gate_turn_on' => 'Einschalten',
'gate_turn_off' => 'Ausschalten',
'gate_on_title' => 'Gate einschalten?',
'gate_on_body' => 'Das Panel ist danach nur noch über den Tunnel erreichbar. Vorher sicherstellen, dass der Tunnel das Panel erreicht — sonst nur per SSH („clusev wg down") zurück.',
'gate_off_title' => 'Gate ausschalten?',
'gate_off_body' => 'Das Panel wird wieder öffentlich erreichbar (nur 2FA + Anmeldeschutz schützen dann).',
'endpoint_label' => 'Öffentlicher Endpoint',
'endpoint_hint' => 'IP oder DNS:Port — betrifft nur künftige Peer-Configs.',
'endpoint_invalid' => 'Ungültiger Endpoint.',
'port_label' => 'Listen-Port (UDP)',
'port_hint' => 'Ändern startet den Tunnel neu — alle Clients müssen den Port aktualisieren.',
'port_invalid' => 'Port 165535.',
'port_confirm_title' => 'Port ändern?',
'port_confirm_body' => 'Der Tunnel wird neu gestartet. Alle Clients müssen den neuen Port in ihrer Config eintragen, sonst verlieren sie die Verbindung.',
'subnet_label' => 'Subnetz',
'subnet_hint' => 'Stark destruktiv — re-adressiert den Server; bestehende Peers müssen neu angelegt werden.',
'subnet_invalid' => 'Ungültiges Subnetz (CIDR).',
'subnet_confirm_title' => 'Subnetz ändern?',
'subnet_confirm_body' => 'Der Server bekommt eine neue Adresse und der Tunnel wird neu gestartet. ALLE bestehenden Peers verlieren den Zugang und müssen neu angelegt werden.',
```
EN: Settings / Apply / Gate / Turn on / Turn off / Turn gate on? / The panel will then only be reachable through the tunnel. Make sure the tunnel reaches the panel first — otherwise only SSH ("clusev wg down") gets you back. / Turn gate off? / The panel becomes publicly reachable again (only 2FA + login protection then guard it). / Public endpoint / IP or DNS:port — affects only future peer configs. / Invalid endpoint. / Listen port (UDP) / Changing this restarts the tunnel — all clients must update the port. / Port 165535. / Change port? / The tunnel restarts. All clients must set the new port in their config or they lose the connection. / Subnet / Highly destructive — re-addresses the server; existing peers must be re-created. / Invalid subnet (CIDR). / Change subnet? / The server gets a new address and the tunnel restarts. ALL existing peers lose access and must be re-created.
- [ ] **Step 2: Add the Settings panel** to the view, in the aside column of the configured branch (alongside the gate + server panels), or as a full-width panel under the grid. Add a Settings `<x-panel>`:
```blade
<x-panel :title="__('wireguard.settings_title')" :padded="false">
<div class="space-y-4 px-4 py-4 sm:px-5">
{{-- Gate toggle --}}
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="font-mono text-xs text-ink">{{ __('wireguard.gate_toggle') }}</p>
<p class="font-mono text-[11px] text-ink-4">{{ ($status['gate']['marker'] && $status['gate']['chain']) ? __('wireguard.gate_on') : __('wireguard.gate_off') }}</p>
</div>
@if ($status['gate']['marker'] && $status['gate']['chain'])
<button type="button" wire:click="confirmGate(false)" class="rounded-md border border-offline/40 bg-raised px-3 py-1.5 font-mono text-[11px] text-offline transition-colors hover:bg-offline/10">{{ __('wireguard.gate_turn_off') }}</button>
@else
<button type="button" wire:click="confirmGate(true)" class="rounded-md border border-accent/40 bg-accent/15 px-3 py-1.5 font-mono text-[11px] text-accent-text transition-colors hover:bg-accent/25">{{ __('wireguard.gate_turn_on') }}</button>
@endif
</div>
{{-- Endpoint (safe) --}}
<form wire:submit="setEndpoint" class="border-t border-line pt-3">
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.endpoint_label') }}</label>
<div class="mt-1 flex flex-wrap items-center gap-2">
<input wire:model="newEndpoint" type="text" maxlength="128" placeholder="{{ $status['server']['endpoint'] }}" class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<x-btn variant="secondary" type="submit">{{ __('wireguard.apply') }}</x-btn>
</div>
<p class="mt-1 font-mono text-[10px] text-ink-4">{{ __('wireguard.endpoint_hint') }}</p>
@error('newEndpoint') <p class="font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</form>
{{-- Port (destructive) --}}
<div class="border-t border-line pt-3">
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.port_label') }}</label>
<div class="mt-1 flex flex-wrap items-center gap-2">
<input wire:model="newPort" type="text" inputmode="numeric" maxlength="5" placeholder="{{ $status['server']['port'] }}" class="w-28 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<button type="button" wire:click="confirmSetPort" class="rounded-md border border-warning/40 bg-raised px-3 py-1.5 font-mono text-[11px] text-warning transition-colors hover:bg-warning/10">{{ __('wireguard.apply') }}</button>
</div>
<p class="mt-1 font-mono text-[10px] text-ink-4">{{ __('wireguard.port_hint') }}</p>
@error('newPort') <p class="font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
{{-- Subnet (very destructive) --}}
<div class="border-t border-line pt-3">
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.subnet_label') }}</label>
<div class="mt-1 flex flex-wrap items-center gap-2">
<input wire:model="newSubnet" type="text" maxlength="18" placeholder="{{ $status['server']['subnet'] }}" class="w-40 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<button type="button" wire:click="confirmSetSubnet" class="rounded-md border border-offline/40 bg-raised px-3 py-1.5 font-mono text-[11px] text-offline transition-colors hover:bg-offline/10">{{ __('wireguard.apply') }}</button>
</div>
<p class="mt-1 font-mono text-[10px] text-ink-4">{{ __('wireguard.subnet_hint') }}</p>
@error('newSubnet') <p class="font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
</div>
</x-panel>
```
Place it sensibly (e.g. in the aside `<div class="space-y-5">` after the Server panel, or as its own row). Keep it token-only (R3), no inline style (R4), responsive (R7).
- [ ] **Step 3: Run `… php artisan test --filter=WireguardPageTest` → all pass.** Pint.
- [ ] **Step 4: Commit**
```bash
git add resources/views/livewire/wireguard/index.blade.php lang/de/wireguard.php lang/en/wireguard.php
git commit -m "feat(wg): WireGuard settings UI — gate toggle + endpoint/port/subnet"
```
---
## Task 4: review + R12 + release
- [ ] **Step 1: Security spot-review** of the new host `cmd_set_*` (injection via the `sed` values — confirm each value is charset-validated before the sed; the subnet collision check; the `wg-quick` restart failure path) + the new confirm flows (port/subnet/gate sealed-token gated; endpoint is non-destructive so no confirm). Fix findings.
- [ ] **Step 2: shellcheck + Pint + full suite** green.
- [ ] **Step 3: R12**`/wireguard` with a configured stub: the Settings panel renders (gate toggle + 3 forms); clicking a destructive Apply opens the confirm modal; endpoint apply writes a request (poller appears). 1280 + 375, DE + EN, no console errors, no leaked tokens. Restore the gkonrad password + clean stubs.
- [ ] **Step 4: Release** v0.9.37 — CHANGELOG (Hinzugefügt: Gate-Schalter + Server-Einstellungen aus der UI; SP2 abgeschlossen), bump, commit, tag, push.
---
## Self-Review
**Spec coverage (P4 = spec §5):**
- Gate on/off toggle (confirm both directions, SSH-escape note) → Tasks 2, 3. ✓
- Endpoint edit (non-destructive) → Tasks 13. ✓
- Port edit (destructive, confirm + warning, restart) → Tasks 13. ✓
- Subnet edit (very destructive, confirm + warning, collision check, restart) → Tasks 13. ✓
- Host re-validates every value; container input is data → Task 1. ✓
- First-time UI bootstrap: intentionally OUT (host `clusev wg setup` + the unconfigured empty state) — documented scope reduction (lower lock-out risk).
**Placeholder scan:** none, except the ConfirmAction arg names are confirmed against the real component by the implementer (flagged).
**Type/name consistency:** `set-endpoint`/`set-port`/`set-subnet` actions match `WgBridge::ACTIONS` (already there) + the host `case` branches (Task 1) + the component calls (Task 2). `ConfirmToken` actions `wgGateToggle`/`wgSetPort`/`wgSetSubnet` registered (Task 2) + issued/consumed with matching names. `newEndpoint`/`newPort`/`newSubnet` consistent across component + view + tests.
**Risk note:** subnet/port changes genuinely break existing peers — surfaced via the hard confirm copy, not silently. The host `cmd_set_*` mutate via `sed` on charset-validated values (no injection). The true end-to-end runs only on a deployed host; app-side + shellcheck + R12 (with a stubbed result) cover the rest.

View File

@ -1,315 +0,0 @@
# WireGuard dashboard — Phase 5 (first-time setup from the UI) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use `- [ ]` checkboxes.
**Goal:** Set WireGuard up **entirely from the dashboard** — the `/wireguard` "not configured" state becomes a setup form (subnet / port / endpoint / first-peer name); on submit the host creates the tunnel + first peer and the dashboard shows the first peer's config + QR once. Closes the last gap so WireGuard is 100% dashboard-managed (the user's explicit requirement). The SSH `clusev wg setup` stays as an alternative.
**Architecture:** Reuses the P3 write-bridge. New host action `setup` (non-interactive) in `serve-request`: it refuses to clobber an existing config, re-validates every arg host-side, runs the host **collision check**, generates server keys, writes `wg0.conf`+`wg.env` (0600), enables `wg-quick@wg0`, creates the first peer, and returns its client config. **It does NOT enable the gate** (the panel stays publicly reachable until the operator toggles the gate on — no lock-out). `WgBridge` gains a validated `setup` action; the component gets a `setupWg()` + a setup form in the unconfigured branch; the result (first peer config + QR) reuses the P3 show-once modal.
**Tech Stack:** bash (host), Laravel 13 + Livewire 3, Tailwind tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` §5 (first-time setup, previously deferred — now in scope).
**Run tooling (R8):** as prior phases. Commit on `feat/v1-foundation`; push only at release.
**Security:** the four args (subnet/port/endpoint/name) are validated app-side (`WgBridge`) AND re-validated host-side (strict patterns + `valid_name` + the CIDR/range checks + `subnet_collides`). They flow into heredocs (config files) — the strict charsets exclude newlines so no extra config line can be injected. Server private key written 0600 under the `umask 077` already set in `serve-request`. No gate change.
---
## Task 1: host non-interactive setup
**Files:** Modify `docker/wg/clusev-wg.sh`
- [ ] **Step 1: Add `_setup_noninteractive` core** (place near `cmd_setup` / the other cores). It mirrors `cmd_setup`'s file writes but takes args, re-validates, refuses to clobber, does NOT gate, and prints the first peer's config to stdout:
```bash
# Non-interactive setup for the dashboard write-bridge. Args are re-validated here (defence in
# depth). Refuses to clobber an existing wg0.conf. Does NOT enable the gate. On success prints the
# first peer's client config to stdout (for the show-once modal). Errors to stderr, non-zero on fail.
_setup_noninteractive() {
local subnet="$1" server_ip="$2" port="$3" endpoint="$4" peer1="$5"
have wg || { echo "wireguard-tools fehlt" >&2; return 1; }
[ -f "$WG_CONF" ] && { echo "WireGuard ist bereits eingerichtet" >&2; return 1; }
printf '%s' "$subnet" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' || { echo "Ungueltiges Subnetz" >&2; return 1; }
case "$server_ip" in ''|*[!0-9.]*) echo "Ungueltige Server-IP" >&2; return 1 ;; esac
case "$port" in ''|*[!0-9]*) echo "Ungueltiger Port" >&2; return 1 ;; esac
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "Port ausserhalb 1-65535" >&2; return 1; }
case "$endpoint" in ''|*[!A-Za-z0-9.:_-]*) echo "Ungueltiger Endpoint" >&2; return 1 ;; esac
valid_name "$peer1" || { echo "Ungueltiger Peer-Name" >&2; return 1; }
subnet_collides "$subnet" && { echo "Subnetz kollidiert mit einer bestehenden Route/Adresse" >&2; return 1; }
umask 077; mkdir -p "$WG_DIR" "$STATE_DIR"
local srv_priv srv_pub prefix
srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)"
prefix="${subnet##*/}"
cat > "$WG_CONF" <<EOF
[Interface]
Address = ${server_ip}/${prefix}
ListenPort = ${port}
PrivateKey = ${srv_priv}
EOF
chmod 600 "$WG_CONF"
cat > "$WG_ENV" <<EOF
WG_SUBNET=${subnet}
WG_SERVER_IP=${server_ip}
WG_PORT=${port}
WG_ENDPOINT=${endpoint}
WG_SERVER_PUBKEY=${srv_pub}
EOF
chmod 600 "$WG_ENV"
systemctl enable --now "wg-quick@${WG_IF}" || { echo "wg-quick@${WG_IF} konnte nicht gestartet werden" >&2; return 1; }
_peer_add "$peer1"
}
```
- [ ] **Step 2: Add the `setup)` branch to `cmd_serve_request`.** In the `case "$action"` switch (it already extracts `name`, `endpoint`, `port`, `subnet`), add before the `*)` arm:
```bash
setup)
local sip ep pn
sip="$(subnet_first_ip "$subnet" 2>/dev/null || true)" # server IP = subnet's first address
ep="$endpoint"; [ -n "$ep" ] || ep="$(detect_endpoint_ip):${port}" # auto-detect endpoint if empty
pn="$name"; [ -n "$pn" ] || pn='client-1'
if [ -n "$subnet" ] && [ -n "$port" ]; then
local tmperr; tmperr="$(mktemp 2>/dev/null || echo /tmp/wgsetup.err)"
if config="$(_setup_noninteractive "$subnet" "$sip" "$port" "$ep" "$pn" 2>"$tmperr")"; then
ok=true
else
ok=false; config=''; msg="$(tr '\n' ' ' < "$tmperr" 2>/dev/null | head -c 120)"; [ -n "$msg" ] || msg='setup-failed'
fi
rm -f "$tmperr"
else
msg='setup-bad-args'
fi ;;
```
(The add-peer QR-sidecar block already fires for any `ok=true` action with a non-empty `config` — confirm it isn't restricted to `action=add-peer`. If it is, broaden its guard to `[ "$action" = add-peer ] || [ "$action" = setup ]` so setup's first peer also gets a QR.)
- [ ] **Step 3: Verify.** `bash -n` + shellcheck clean. Smoke: `bash docker/wg/clusev-wg.sh serve-request` (non-root) → hits `need_root`. `bash docker/wg/clusev-wg.sh help` still fine.
- [ ] **Step 4: Commit**
```bash
git add docker/wg/clusev-wg.sh
git commit -m "feat(wg): non-interactive setup action for the dashboard write-bridge"
```
---
## Task 2: WgBridge `setup` + component + UI
**Files:** Modify `app/Services/WgBridge.php`, `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/{de,en}/wireguard.php`; Test extend `WgBridgeTest` + `WireguardPageTest`
- [ ] **Step 1: WgBridge — add `setup`.** Add `'setup'` to `ACTIONS`, and a `validateArgs` case:
```php
case 'setup':
$subnet = (string) ($args['subnet'] ?? '');
$port = (string) ($args['port'] ?? '');
$endpoint = (string) ($args['endpoint'] ?? '');
$name = (string) ($args['name'] ?? '');
if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) {
throw new \InvalidArgumentException('invalid subnet');
}
if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) {
throw new \InvalidArgumentException('invalid port');
}
if ($endpoint !== '' && preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $endpoint) !== 1) {
throw new \InvalidArgumentException('invalid endpoint');
}
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
throw new \InvalidArgumentException('invalid name');
}
return ['subnet' => $subnet, 'port' => $port, 'endpoint' => $endpoint, 'name' => $name];
```
Add a `WgBridgeTest` case:
```php
public function test_setup_request_validates_and_writes(): void
{
$id = app(WgBridge::class)->request('setup', ['subnet' => '10.99.0.0/24', 'port' => '51820', 'endpoint' => '1.2.3.4:51820', 'name' => 'client-1']);
$this->assertMatchesRegularExpression('/^[A-Za-z0-9]{16,64}$/', $id);
$req = json_decode((string) file_get_contents(storage_path('app/restart-signal/wg-request.json')), true);
$this->assertSame('setup', $req['action']);
$this->assertSame('10.99.0.0/24', $req['subnet']);
}
public function test_setup_request_rejects_a_bad_subnet(): void
{
$this->expectException(\InvalidArgumentException::class);
app(WgBridge::class)->request('setup', ['subnet' => 'nope', 'port' => '51820', 'name' => 'c']);
}
```
- [ ] **Step 2: Component — add `setupWg()` + state.** In `app/Livewire/Wireguard/Index.php`, add properties (with defaults) + the method; and surface a failure message in `pollResult`. Properties:
```php
// first-time setup form
public string $setupSubnet = '10.99.0.0/24';
public string $setupPort = '51820';
public string $setupEndpoint = '';
public string $setupPeer = 'client-1';
```
Method:
```php
public function setupWg(WgBridge $bridge): void
{
$this->validate([
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
'setupEndpoint' => ['nullable', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/'],
'setupPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/'],
], [
'setupSubnet.regex' => __('wireguard.subnet_invalid'),
'setupPort.regex' => __('wireguard.port_invalid'),
'setupEndpoint.regex' => __('wireguard.endpoint_invalid'),
'setupPeer.regex' => __('wireguard.peer_name_invalid'),
]);
if ((int) $this->setupPort < 1 || (int) $this->setupPort > 65535) {
$this->addError('setupPort', __('wireguard.port_invalid'));
return;
}
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('setup', [
'subnet' => $this->setupSubnet, 'port' => $this->setupPort,
'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer,
]);
$this->pendingAction = 'setup';
$this->audit('wg.setup', $this->setupSubnet);
}
```
In `pollResult`, broaden the config-surfacing branch to include `setup`, and surface the host message on failure. Change the success/failure handling to:
```php
if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) {
$this->resultConfig = $res['config'];
$this->resultQr = $res['qr'];
} elseif (! $res['ok']) {
$msg = $res['message'] !== '' ? $res['message'] : __('wireguard.action_failed');
$this->dispatch('notify', message: $msg, level: 'error');
} else {
$this->dispatch('notify', message: __('wireguard.action_done'));
}
```
Add a `WireguardPageTest` case:
```php
public function test_setup_writes_a_request_and_audits(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('setupSubnet', '10.99.0.0/24')->set('setupPort', '51820')->set('setupEndpoint', '1.2.3.4:51820')->set('setupPeer', 'client-1')
->call('setupWg')
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.setup')->exists());
@unlink(storage_path('app/restart-signal/wg-request.json'));
}
public function test_setup_rejects_a_bad_subnet(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('setupSubnet', 'nope')->set('setupPort', '51820')->set('setupPeer', 'c')
->call('setupWg')
->assertHasErrors('setupSubnet');
}
```
- [ ] **Step 3: Lang** (BOTH files, identical keys):
```php
'setup_title' => 'WireGuard einrichten',
'setup_intro' => 'Tunnel direkt hier einrichten. Das Gate bleibt danach AUS — das Panel ist weiter öffentlich, bis du es einschaltest.',
'setup_subnet' => 'Subnetz',
'setup_port' => 'Listen-Port (UDP)',
'setup_endpoint' => 'Öffentlicher Endpoint (optional)',
'setup_endpoint_hint' => 'Leer = automatisch erkannte öffentliche IP. Hinter NAT/Cloud-LB manuell setzen.',
'setup_peer' => 'Name des ersten Clients',
'setup_submit' => 'Einrichten',
'setup_or_ssh' => 'Alternativ per SSH:',
```
EN: Set up WireGuard / Set the tunnel up right here. The gate stays OFF afterwards — the panel remains public until you turn it on. / Subnet / Listen port (UDP) / Public endpoint (optional) / Empty = auto-detected public IP. Behind NAT/cloud LB set it manually. / First client name / Set up / Or over SSH:
- [ ] **Step 4: UI — replace the unconfigured empty state** in `resources/views/livewire/wireguard/index.blade.php`. The current `@if (! $status['configured'])` block shows only the SSH hint. Replace its body with a setup form + the SSH line as the secondary option:
```blade
<x-panel :title="__('wireguard.setup_title')">
<p class="text-sm leading-relaxed text-ink-2">{{ __('wireguard.setup_intro') }}</p>
<form wire:submit="setupWg" class="mt-4 space-y-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_subnet') }}</label>
<input wire:model="setupSubnet" type="text" maxlength="18" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
@error('setupSubnet') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<div>
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_port') }}</label>
<input wire:model="setupPort" type="text" inputmode="numeric" maxlength="5" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
@error('setupPort') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<div>
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_endpoint') }}</label>
<input wire:model="setupEndpoint" type="text" maxlength="128" placeholder="auto" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('setupEndpoint') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<div>
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_peer') }}</label>
<input wire:model="setupPeer" type="text" maxlength="64" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
@error('setupPeer') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
</div>
<p class="font-mono text-[10px] text-ink-4">{{ __('wireguard.setup_endpoint_hint') }}</p>
<div class="flex items-center gap-3">
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="setupWg">
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.setup_submit') }}
</x-btn>
@if ($pendingId)
<span class="font-mono text-[11px] text-ink-3" wire:poll.2s="pollResult">{{ __('wireguard.pending') }}</span>
@endif
</div>
</form>
<p class="mt-4 font-mono text-[11px] text-ink-4">{{ __('wireguard.setup_or_ssh') }}</p>
<pre class="mt-1 overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] text-ink-2">sudo clusev wg setup</pre>
</x-panel>
```
(Keep the existing show-once result modal at the bottom of the root div — it already renders `$resultConfig`/`$resultQr` and now fires for setup too. Add a `wire:poll.2s="pollResult"` path for the unconfigured state via the pending span above so the result is picked up.)
- [ ] **Step 5: Run `… php artisan test --filter='WgBridgeTest|WireguardPageTest'` → all pass.** Pint.
- [ ] **Step 6: Commit**
```bash
git add app/Services/WgBridge.php app/Livewire/Wireguard/Index.php resources/views/livewire/wireguard/index.blade.php lang/de/wireguard.php lang/en/wireguard.php tests/Feature/WgBridgeTest.php tests/Feature/WireguardPageTest.php
git commit -m "feat(wg): set WireGuard up from the dashboard (setup form + bridge action)"
```
---
## Task 3: review + R12 + release
- [ ] **Step 1: Security spot-review** the `setup` action: arg re-validation host-side (subnet CIDR + collision, port range, endpoint charset, name); the heredoc config write (no injection — charsets exclude newline); refuses to clobber; does NOT touch the gate. App-side `WgBridge` validates the setup args; `setupWg` throttles + audits. Fix findings.
- [ ] **Step 2: shellcheck + Pint + full suite** green.
- [ ] **Step 3: R12**`/wireguard` in the UNCONFIGURED state (no wg-status.json or `configured:false`): the setup form renders; submitting it writes a `setup` request (poller appears); with the host simulated (write a `wg-result-<id>.json` with a config + QR), the show-once modal appears with the first peer config. 1280 + 375, DE + EN, no console errors, no leaked tokens. Restore the gkonrad password + clean stubs.
- [ ] **Step 4: Release** v0.9.38 — CHANGELOG (Hinzugefügt: WireGuard-Erst-Einrichtung aus dem Dashboard — SP2 jetzt wirklich vollständig; korrigiert die in 0.9.37 zu eng gefasste Funktion), bump, commit, tag, push.
---
## Self-Review
**Spec coverage:** spec §5 "first-time setup from the UI" — previously deferred, now implemented (Tasks 12). The user's explicit requirement (everything from the dashboard) is met: status (P1), traffic (P2), peers (P3), settings+gate (P4), **setup (P5)**.
**Placeholder scan:** none.
**Type/name consistency:** `setup` action in `WgBridge::ACTIONS` + `validateArgs` + the host `serve-request` `setup)` branch + `_setup_noninteractive` + the component `setupWg` + the form fields `setupSubnet/setupPort/setupEndpoint/setupPeer`. The result (first peer config + QR) reuses the P3 `resultConfig`/`resultQr`/show-once modal; `pollResult` now treats `setup` like `add-peer`.
**Safety:** setup does NOT enable the gate (no lock-out — the panel stays public until the operator toggles it). Host re-validates every arg + runs the collision check; a colliding subnet fails with the host message surfaced to the UI. The interactive SSH `clusev wg setup` remains as an alternative.

File diff suppressed because it is too large Load Diff

View File

@ -1,601 +0,0 @@
# Release-Promotion Phase A — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the URL-agnostic git/CI/CD infrastructure that lets Clusev be promoted dev (Gitea) → staging (GitHub private) → public (GitHub public) without any private repo URL in tracked files.
**Architecture:** The update source is read from `CLUSEV_REPOSITORY` (env), derived from the clone origin by a shared shell helper; tracked config defaults to empty (the public URL is injected later). GitHub Actions runs the test suite on `v*` tags and (optionally) deploys betas to staging; two `workflow_dispatch` workflows promote a tag to the public repo (beta channel) and re-tag it stable, using a `scripts/promote.sh` that copies the tag's tree (no `.git`/`.github`/`.env`) into the public repo.
**Tech Stack:** Laravel 13 config, Bash (POSIX-ish, shellcheck-clean), GitHub Actions, git, rsync-free `git archive`.
**Spec:** `docs/superpowers/specs/2026-06-22-3-repo-release-promotion-design.md`
---
## File Structure
- `config/clusev.php``repository` becomes env-driven (modify).
- `.env.example` — document `CLUSEV_REPOSITORY` (modify).
- `scripts/set-repository-url.sh` — derive `CLUSEV_REPOSITORY` from the clone origin, write to `.env` (create).
- `scripts/set-repository-url.test.sh` — its test (create).
- `scripts/promote.sh` — copy a tag's tree into the public repo + commit + tag (create).
- `scripts/promote.test.sh` — its test (create).
- `install.sh`, `update.sh` — call `scripts/set-repository-url.sh` (modify).
- `.github/workflows/ci-staging.yml` — tests on `v*`; optional staging deploy on `*-beta*` (create).
- `.github/workflows/promote-public.yml``workflow_dispatch` promote a tag to public (create).
- `.github/workflows/promote-stable.yml``workflow_dispatch` re-tag a beta as stable on public (create).
- `tests/Feature/ReleaseCheckerTest.php` — add a "no repository configured" degradation test (modify).
- `README.md` — document the 3-repo flow + where URLs/secrets plug in (modify).
**Conventions to follow:** run tooling in the container as uid 1002 (`docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app …`); shellcheck via `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <file>`; commit messages in English, end with the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` line; never echo the Gitea token (push via the sanitized pattern).
---
### Task 1: Repository URL is env-driven (no private URL in tracked config)
**Files:**
- Modify: `config/clusev.php:23`
- Modify: `.env.example`
- Test: `tests/Feature/ReleaseCheckerTest.php`
- [ ] **Step 1: Write the failing test** — append to `tests/Feature/ReleaseCheckerTest.php` (inside the class):
```php
public function test_no_update_check_when_no_repository_is_configured(): void
{
Http::fake(); // any network call would be a bug
config()->set('clusev.repository', '');
config()->set('clusev.version', '0.9.10');
Cache::flush();
$this->assertFalse(app(ReleaseChecker::class)->updateAvailable());
$this->assertNull(app(ReleaseChecker::class)->refresh('stable'));
Http::assertNothingSent();
}
```
- [ ] **Step 2: Run it — confirm it passes already OR fails meaningfully**
Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=test_no_update_check_when_no_repository_is_configured`
Expected: PASS (ReleaseChecker::fetchRemoteLatestTag already returns null when the repo URL doesn't match its regex — empty string never matches). If it FAILS, fix ReleaseChecker so an unparseable/empty `clusev.repository` returns null before any `Http::` call.
- [ ] **Step 3: Make the config env-driven** — in `config/clusev.php`, replace line 23:
```php
// Update source. Empty by default: the PUBLIC repo URL is injected via CLUSEV_REPOSITORY (env),
// which install.sh derives from the clone origin. Private (Gitea/GitHub-private) URLs live ONLY in
// the gitignored .env — never in tracked files, so the public repo exposes no private URL.
'repository' => env('CLUSEV_REPOSITORY', ''),
```
- [ ] **Step 4: Document it in `.env.example`** — add (near the other CLUSEV_* entries, or at the end):
```dotenv
# Update source for the in-app version check. install.sh derives this from the clone origin
# (git remote get-url origin). Leave empty to disable the check. Never put a private repo URL in a
# tracked file — only here in the per-deployment .env.
CLUSEV_REPOSITORY=
```
- [ ] **Step 5: Set it on THIS dev machine (runtime, NOT committed)** — append to the dev `.env` so the dev Versions page keeps checking Gitea:
Run: `grep -q '^CLUSEV_REPOSITORY=' /home/nexxo/clusev/.env && sed -i 's#^CLUSEV_REPOSITORY=.*#CLUSEV_REPOSITORY=https://git.bave.dev/boban/clusev#' /home/nexxo/clusev/.env || printf 'CLUSEV_REPOSITORY=https://git.bave.dev/boban/clusev\n' >> /home/nexxo/clusev/.env`
Then: `docker compose --project-directory /home/nexxo/clusev exec -u "1002:1002" -T app php artisan config:clear`
Expected: `.env` (gitignored) now pins the Gitea URL; config cache cleared.
- [ ] **Step 6: Run the suite**
Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleaseCheckerTest`
Expected: PASS (all ReleaseChecker tests incl. the new one).
- [ ] **Step 7: Commit**
```bash
git add config/clusev.php .env.example tests/Feature/ReleaseCheckerTest.php
git commit -m "refactor(config): repository URL is env-driven (no private URL in tracked files)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: Shared `set-repository-url.sh` — derive CLUSEV_REPOSITORY from the clone origin
**Files:**
- Create: `scripts/set-repository-url.sh`
- Create: `scripts/set-repository-url.test.sh`
- Modify: `install.sh`, `update.sh`
- [ ] **Step 1: Write the failing test** — create `scripts/set-repository-url.test.sh`:
```bash
#!/usr/bin/env bash
# Test: set-repository-url.sh writes CLUSEV_REPOSITORY (derived from the clone origin) into the .env,
# creating or replacing the line, and strips a trailing .git.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
git init -q "$work/proj"
git -C "$work/proj" remote add origin https://git.example.com/o/clusev.git
printf 'APP_ENV=local\n' > "$work/proj/.env"
bash "$here/set-repository-url.sh" "$work/proj" "$work/proj/.env"
grep -qx 'CLUSEV_REPOSITORY=https://git.example.com/o/clusev' "$work/proj/.env" || { echo "FAIL: append/strip-.git"; exit 1; }
# replace an existing line
printf 'CLUSEV_REPOSITORY=old\n' >> "$work/proj/.env"
git -C "$work/proj" remote set-url origin https://gitea.local/u/clusev
bash "$here/set-repository-url.sh" "$work/proj" "$work/proj/.env"
test "$(grep -c '^CLUSEV_REPOSITORY=' "$work/proj/.env")" = 1 || { echo "FAIL: duplicate line"; exit 1; }
grep -qx 'CLUSEV_REPOSITORY=https://gitea.local/u/clusev' "$work/proj/.env" || { echo "FAIL: replace"; exit 1; }
# no origin → no-op, no crash
git init -q "$work/noremote"; printf '' > "$work/noremote/.env"
bash "$here/set-repository-url.sh" "$work/noremote" "$work/noremote/.env"
grep -q 'CLUSEV_REPOSITORY' "$work/noremote/.env" && { echo "FAIL: wrote without origin"; exit 1; }
echo "PASS"
```
- [ ] **Step 2: Run it to verify it fails**
Run: `bash scripts/set-repository-url.test.sh`
Expected: FAIL (`set-repository-url.sh` does not exist yet → bash error).
- [ ] **Step 3: Write `scripts/set-repository-url.sh`:**
```bash
#!/usr/bin/env bash
# Derive the update source (CLUSEV_REPOSITORY) from the clone origin and write it into the given .env.
# Called by install.sh + update.sh. The private (Gitea/GitHub-private) URL therefore lives ONLY in the
# gitignored .env — never in a tracked file. No-op (exit 0) when there is no origin remote.
# usage: set-repository-url.sh <project-dir> <env-file>
set -euo pipefail
proj="${1:?project dir required}"
envfile="${2:?env file required}"
url="$(git -C "$proj" remote get-url origin 2>/dev/null || true)"
url="${url%.git}" # normalise — the API host is the same with or without .git
url="${url%/}" # drop a trailing slash
[ -n "$url" ] || exit 0 # no origin (e.g. tarball install) → leave .env untouched
if grep -q '^CLUSEV_REPOSITORY=' "$envfile" 2>/dev/null; then
sed -i "s#^CLUSEV_REPOSITORY=.*#CLUSEV_REPOSITORY=${url}#" "$envfile"
else
printf 'CLUSEV_REPOSITORY=%s\n' "$url" >> "$envfile"
fi
```
- [ ] **Step 4: Run the test + shellcheck**
Run: `chmod +x scripts/set-repository-url.sh && bash scripts/set-repository-url.test.sh`
Expected: `PASS`
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/set-repository-url.sh scripts/set-repository-url.test.sh`
Expected: no output (clean).
- [ ] **Step 5: Wire it into `install.sh`** — find where the `.env` is finalised (search for where secrets are generated / `.env` is written; near the app-key/secret generation). Add AFTER the `.env` exists, BEFORE the stack starts:
```bash
# Pin the update source to wherever this was cloned from (keeps private URLs out of tracked files).
[ -x "${proj}/scripts/set-repository-url.sh" ] && "${proj}/scripts/set-repository-url.sh" "$proj" "${proj}/.env" || true
```
(Use the variable install.sh already holds for the project root — it is `proj="$(pwd)"` in `install_host_watchers`; at the top level it is the script's CWD. Use the same project-root variable the surrounding code uses; if none, `"$(pwd)"`.)
- [ ] **Step 6: Wire it into `update.sh`** — after the `git pull` / before/after re-running install, add the same call so the URL is refreshed if the remote changed:
```bash
[ -x ./scripts/set-repository-url.sh ] && ./scripts/set-repository-url.sh "$(pwd)" ./.env || true
```
- [ ] **Step 7: shellcheck install.sh + update.sh**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable install.sh update.sh`
Expected: no NEW findings from the added lines (clean, or pre-existing only).
- [ ] **Step 8: Commit**
```bash
git add scripts/set-repository-url.sh scripts/set-repository-url.test.sh install.sh update.sh
git commit -m "feat(install): derive CLUSEV_REPOSITORY from the clone origin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 3: `promote.sh` — copy a tag's tree into the public repo (clean release commit)
**Files:**
- Create: `scripts/promote.sh`
- Create: `scripts/promote.test.sh`
- [ ] **Step 1: Write the failing test** — create `scripts/promote.test.sh`:
```bash
#!/usr/bin/env bash
# Test: promote.sh extracts a tag's tree into the public repo working dir (excluding .git + .github),
# commits "Release <tag>" and creates the tag. The public repo's own .git is preserved.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
# source repo with a tag, a .github dir and an .env.example
git init -q "$work/src"
mkdir -p "$work/src/.github/workflows"
printf 'app\n' > "$work/src/app.txt"
printf 'CLUSEV_REPOSITORY=\n' > "$work/src/.env.example"
printf 'name: ci\n' > "$work/src/.github/workflows/ci.yml"
git -C "$work/src" add -A && git -C "$work/src" commit -qm init && git -C "$work/src" tag v1.0.0
# empty public repo (its own history)
git init -q "$work/pub"
bash "$here/promote.sh" "$work/src" v1.0.0 "$work/pub" "Release v1.0.0"
test -f "$work/pub/app.txt" || { echo "FAIL: tree not copied"; exit 1; }
test -f "$work/pub/.env.example" || { echo "FAIL: .env.example missing"; exit 1; }
test ! -d "$work/pub/.github" || { echo "FAIL: .github leaked to public"; exit 1; }
test -d "$work/pub/.git" || { echo "FAIL: public .git clobbered"; exit 1; }
git -C "$work/pub" rev-parse v1.0.0 >/dev/null 2>&1 || { echo "FAIL: tag missing"; exit 1; }
git -C "$work/pub" log -1 --pretty=%s | grep -qx 'Release v1.0.0' || { echo "FAIL: commit message"; exit 1; }
echo "PASS"
```
- [ ] **Step 2: Run it to verify it fails**
Run: `bash scripts/promote.test.sh`
Expected: FAIL (`promote.sh` missing).
- [ ] **Step 3: Write `scripts/promote.sh`:**
```bash
#!/usr/bin/env bash
# Promote a tag's tree from the source repo into the PUBLIC repo working dir as one clean release
# commit + tag. Excludes .git (via git archive) and .github (private CI stays private). The public
# repo's own .git is preserved. The PUSH is done by the calling workflow (it holds the credentials).
# usage: promote.sh <src-repo> <tag> <public-repo-dir> <commit-message>
set -euo pipefail
src="${1:?src repo required}"
tag="${2:?tag required}"
pub="${3:?public repo dir required}"
msg="${4:?commit message required}"
git -C "$src" rev-parse "$tag" >/dev/null 2>&1 || { echo "unknown tag: $tag" >&2; exit 1; }
# Clear the public working tree but KEEP its .git.
find "$pub" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
# Extract exactly the tag's tree (git archive omits .git automatically).
git -C "$src" archive --format=tar "$tag" | tar -x -C "$pub"
# Private CI never goes public.
rm -rf "${pub:?}/.github"
git -C "$pub" add -A
git -C "$pub" commit -q -m "$msg"
git -C "$pub" tag "$tag"
```
- [ ] **Step 4: Run the test + shellcheck**
Run: `chmod +x scripts/promote.sh && bash scripts/promote.test.sh`
Expected: `PASS`
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/promote.sh scripts/promote.test.sh`
Expected: clean.
- [ ] **Step 5: Commit**
```bash
git add scripts/promote.sh scripts/promote.test.sh
git commit -m "feat(release): promote.sh — clean per-release tree copy to the public repo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 4: CI workflow — tests on every `v*` tag, optional staging deploy on betas
**Files:**
- Create: `.github/workflows/ci-staging.yml`
> Note: GitHub-hosted runners cannot reach an internal staging host (.165 has no public IP). The
> staging-deploy job is therefore GATED behind a repo variable `STAGING_ENABLED == 'true'` and meant
> for a **self-hosted runner** on the internal network. Until then it is a no-op and staging is updated
> via the Versions page (self-pull). Tests always run.
- [ ] **Step 1: Write `.github/workflows/ci-staging.yml`:**
```yaml
name: CI + staging
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --no-interaction --prefer-dist --no-progress
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test
- run: ./vendor/bin/pint --test
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
- name: shellcheck
run: |
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \
docker/wg/clusev-wg.sh install.sh update.sh scripts/*.sh
deploy-staging:
needs: test
# Only beta tags, and only when a self-hosted internal runner is wired up.
if: ${{ contains(github.ref_name, '-beta') && vars.STAGING_ENABLED == 'true' }}
runs-on: [self-hosted, clusev-staging]
environment: staging
steps:
- name: Update the staging stack
run: |
cd "${{ vars.STAGING_PROJECT_DIR }}"
sudo ./update.sh
```
- [ ] **Step 2: Lint with actionlint**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt rhysd/actionlint:latest -color`
Expected: no errors for `ci-staging.yml`. (Fix any reported syntax issues; the workflow must lint clean even though the secrets/vars/runner do not exist yet.)
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/ci-staging.yml
git commit -m "ci: test on v* tags, optional self-hosted staging deploy on betas
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 5: Promotion workflows — promote to public (beta) + promote stable
**Files:**
- Create: `.github/workflows/promote-public.yml`
- Create: `.github/workflows/promote-stable.yml`
> These are `workflow_dispatch` (manually / dashboard-triggered), bound to the `production`
> Environment (add a required reviewer in GitHub settings). They check out THIS (private) repo at the
> tag, then push a clean release into the public repo via `scripts/promote.sh`. `PUBLIC_REPO_URL` is a
> repo variable; `PUBLIC_REPO_TOKEN` a secret (a PAT/deploy token with push to the public repo). Both
> are added when the public repo exists — the workflow lints fine without them.
- [ ] **Step 1: Write `.github/workflows/promote-public.yml`:**
```yaml
name: Promote to public (beta channel)
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g. v0.10.0-beta1)'
required: true
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout private repo at the tag
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
path: src
- name: Checkout public repo
uses: actions/checkout@v4
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Promote (clean release commit + tag)
run: |
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "${{ inputs.tag }}" "$PWD/pub" "Release ${{ inputs.tag }}"
- name: Push to public
run: |
git -C pub push origin HEAD:main
git -C pub push origin "refs/tags/${{ inputs.tag }}"
```
- [ ] **Step 2: Write `.github/workflows/promote-stable.yml`:**
```yaml
name: Promote beta to stable
on:
workflow_dispatch:
inputs:
betaTag:
description: 'Beta tag to finalise (e.g. v0.10.0-beta3)'
required: true
stableTag:
description: 'Stable tag to publish (e.g. v0.10.0)'
required: true
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout private repo at the beta tag
uses: actions/checkout@v4
with:
ref: ${{ inputs.betaTag }}
fetch-depth: 0
path: src
- name: Tag the same commit as stable (in the private repo, for traceability)
run: |
git -C src tag "${{ inputs.stableTag }}" "${{ inputs.betaTag }}^{commit}"
- name: Checkout public repo
uses: actions/checkout@v4
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Promote stable (clean release commit + tag)
run: |
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "${{ inputs.stableTag }}" "$PWD/pub" "Release ${{ inputs.stableTag }}"
- name: Push to public
run: |
git -C pub push origin HEAD:main
git -C pub push origin "refs/tags/${{ inputs.stableTag }}"
```
- [ ] **Step 3: Lint with actionlint**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt rhysd/actionlint:latest -color`
Expected: no errors for either workflow.
- [ ] **Step 4: Commit**
```bash
git add .github/workflows/promote-public.yml .github/workflows/promote-stable.yml
git commit -m "ci: workflow_dispatch promotion to public (beta) + beta-to-stable
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 6: Document the flow + the wiring points
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Add a "Release pipeline" section to `README.md`** (near the Updating section). Use this exact prose:
```markdown
## Release pipeline (maintainers)
Clusev is promoted across three repositories, dev → staging → public:
1. **Gitea (private)** — sole development source. Commit + tag here.
2. **GitHub private** — a push-mirror of Gitea (Gitea → Settings → Mirror). Runs CI; betas are tested
on the staging server.
3. **GitHub public** — receives only released versions: one clean commit per release, beta and stable
tags. Public users install/update from here.
Channels follow semver tags: **beta** = `vX.Y.Z-betaN`, **stable** = `vX.Y.Z`.
No private repo URL is committed: `config/clusev.php` defaults `repository` to empty, `install.sh`
derives `CLUSEV_REPOSITORY` from the clone origin into the (gitignored) `.env`, so dev points at Gitea,
staging at GitHub-private, and public users at GitHub-public — automatically.
### One-time wiring (when the GitHub repos exist)
- Gitea push-mirror → the GitHub-private URL + a GitHub PAT.
- GitHub-private repo settings:
- Variable `PUBLIC_REPO_SLUG` = `owner/repo` of the public repo; `PUBLIC_REPO_URL` if needed.
- Secret `PUBLIC_REPO_TOKEN` = a token with push to the public repo.
- (Optional staging) Variable `STAGING_ENABLED=true`, `STAGING_PROJECT_DIR`, and a self-hosted
runner labelled `clusev-staging` on the internal network (GitHub-hosted runners cannot reach an
internal staging host).
- Environment `production` with a required reviewer (gates the promotion workflows).
- Public repo's clone command goes in its own README; set `CLUSEV_REPOSITORY` default in
`config/clusev.php` to the public URL once known.
### Promoting
- **To staging:** push a beta tag `vX.Y.Z-betaN` (CI tests; staging deploys if enabled, else update it
from the Versions page).
- **To public (beta):** run the `Promote to public (beta channel)` workflow with the beta tag.
- **To stable:** run the `Promote beta to stable` workflow with the beta tag + the stable tag.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: release pipeline (3-repo promotion, channels, wiring)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 7: Version bump + changelog + full verification
**Files:**
- Modify: `config/clusev.php` (version)
- Modify: `CHANGELOG.md`
- [ ] **Step 1: Run the whole suite + shellcheck + actionlint once more**
Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test`
Expected: all pass.
Run: `bash scripts/set-repository-url.test.sh && bash scripts/promote.test.sh`
Expected: `PASS` / `PASS`.
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh install.sh update.sh && docker run --rm -v "$PWD:/mnt" -w /mnt rhysd/actionlint:latest -color`
Expected: clean.
- [ ] **Step 2: Bump version** — in `config/clusev.php`, set `'version'` to the next patch (e.g. `0.9.57`).
- [ ] **Step 3: Changelog** — add a `## [0.9.57] - <date>` entry under `## [Unreleased]` describing the env-driven repository URL + the CI/promotion scaffolding (URL-agnostic; activated when the GitHub repos exist).
- [ ] **Step 4: Commit + tag + push (token-sanitized)**
```bash
git add config/clusev.php CHANGELOG.md
git commit -m "chore: release 0.9.57 — release-promotion phase A (CI/CD scaffolding)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
git tag -a v0.9.57 -m "v0.9.57 — release-promotion phase A"
set -a; . /home/nexxo/.env.gitea; set +a
TOKEN="${GIT_ACCESS_TOKEN:-${GITEA_TOKEN:-}}"
URL="https://oauth2:${TOKEN}@git.bave.dev/boban/clusev.git"
{ git push "$URL" feat/v1-foundation 2>&1; git push "$URL" v0.9.57 2>&1; } | sed -E "s#${TOKEN}#***#g; s#//[^@]*@#//***@#g"
```
---
## Self-Review
**Spec coverage:**
- URL handling (public default + derive from origin + .env-only private) → Task 1 + Task 2. ✓
- CI on tags + staging deploy → Task 4. ✓
- promote-public + promote-stable workflows → Task 5. ✓
- promote.sh clean tree copy → Task 3. ✓
- ReleaseChecker degradation → Task 1. ✓
- README docs → Task 6. ✓
- Phase B (dashboard buttons) → explicitly out of scope (own spec). ✓
**Placeholder scan:** every step has concrete code/commands. The GitHub secrets/vars/URLs are genuinely deferred config (documented in Task 6), not code placeholders — the workflows reference them via `${{ vars.* }}` / `${{ secrets.* }}` and lint clean without them. ✓
**Type/name consistency:** `scripts/set-repository-url.sh`, `scripts/promote.sh` (and `.test.sh` siblings) referenced identically across tasks; `CLUSEV_REPOSITORY`, `PUBLIC_REPO_TOKEN`, `PUBLIC_REPO_SLUG`, `STAGING_ENABLED` consistent throughout. ✓

View File

@ -1,58 +0,0 @@
# WireGuard gate (SP1) — manual runbook
The host scripts (`docker/wg/clusev-wg.sh`, `clusev-wg-gate.service`, the `install.sh` additions)
cannot be exercised by the PHPUnit suite — the test runner is the app container, with no host
WireGuard/firewall/kernel access. This runbook is their real verification.
Run on a **throwaway Debian-13 VM** with the Clusev prod stack up. **Keep an SSH session open the
whole time** — it is the escape hatch. Tick each step against its expected result.
## Pre
- [ ] `clusev wg help` prints usage; `clusev wg status` shows wg0 not active, gate off.
- [ ] `wireguard-tools` + `qrencode` present (installed by `install.sh`): `command -v wg qrencode`.
## setup
- [ ] `sudo clusev wg setup`, enter a subnet that overlaps the VM's LAN (e.g. the VM's own /24) →
**rejected**, re-prompts.
- [ ] Re-run, accept the default `10.99.0.0/24`**accepted**; server keys + first peer created;
client config + QR printed; `systemctl is-active wg-quick@wg0``active`.
- [ ] `/etc/wireguard/wg0.conf` and `/etc/clusev/wg.env` exist, mode `0600`.
- [ ] `sudo clusev wg setup` again → it **warns** that wg0.conf exists and does NOT silently clobber
it (aborts unless you type `reconfigure`).
## tunnel (gate still OFF)
- [ ] Import the printed client (QR) into the WireGuard app; connect.
- [ ] Over the tunnel: `http://<server-tunnel-ip>` reaches the panel.
- [ ] Public `http://<public-ip>` still reachable (gate is off).
## gate up
- [ ] `sudo clusev wg up` → prints the escape reminder; refuses (non-zero) if wg0 is down.
- [ ] Public `http://<public-ip>` 80/443 now **refused / times out**; the tunnel still serves the panel.
- [ ] **SSH still works** (port 22 untouched).
- [ ] `clusev wg status` shows the peer + gate on (marker `/etc/clusev/wg-gate.enabled` present, chain
`CLUSEV-WG-GATE` present).
- [ ] Add a manual rule: `sudo iptables -I DOCKER-USER -s 203.0.113.0/24 -j RETURN`. Run
`clusev wg down` then `clusev wg up` → the manual rule **survives** (only `CLUSEV-WG-GATE` and
its jump are touched). Remove it afterwards.
## persistence
- [ ] Reboot → wg0 + the gate come back (public still refused, tunnel still serves).
- [ ] Restart Docker: `sudo systemctl restart docker` → the gate is re-applied (Docker flushes
`DOCKER-USER`, the `clusev-wg-gate.service` re-runs via `WantedBy=docker.service`). Public still refused.
- [ ] Force wg0 to fail on boot (`sudo systemctl disable wg-quick@wg0` + reboot) → the gate is **NOT
applied** (`ConditionPathExists=/sys/class/net/wg0` fails the unit), the panel is publicly
reachable (NOT bricked); SSH + `clusev wg down` recover. Re-enable wg0 afterwards.
## peers + down
- [ ] `clusev wg add-peer client-2` → new config + QR; the split-vs-full-tunnel note is shown.
- [ ] `clusev wg remove-peer client-2` → peer gone from `wg show wg0` AND from `/etc/wireguard/wg0.conf`.
- [ ] A peer name with a space or odd character round-trips (add then remove) without corrupting wg0.conf.
- [ ] `clusev wg down` → public open again; marker + `CLUSEV-WG-GATE` chain gone (`clusev wg status`).
## Known SP1 limitations (by design — not bugs)
- Peer-IP allocation + the subnet collision check assume the default **/24** (`next_free_ip` scans
`.2.254`; the collision check matches the first three octets). Non-/24 subnets work for the tunnel
but allocation is /24-shaped. Wider prefixes are SP2 territory.
- The collision check is a heuristic (first-three-octets match against host routes/addresses), not a
formal CIDR-overlap proof — it catches the common LAN clash, which is the point.
- Peers are stored only in `wg0.conf` (no DB). SP2 adds the dashboard status page + peer models.

View File

@ -1,119 +0,0 @@
# Account Recovery — Design (Phase 1)
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved
Adds account-recovery to the custom auth: a **forgot-password** flow, **2FA backup
(recovery) codes**, and a **CLI lockout fallback**. WebAuthn/YubiKey is explicitly **out of
scope** here — it gets its own Phase-2 spec (needs `laragear/webauthn`, JS ceremonies, and
HTTPS, which the panel only has once a domain+TLS is configured).
Auth today is hand-rolled (not Fortify): `pragmarx/google2fa-qrcode` TOTP, Livewire
`Auth\{Login,TwoFactorSetup,TwoFactorChallenge,PasswordChange}`. Users have
`two_factor_secret` (encrypted), `two_factor_confirmed_at`, `must_change_password`. Mail is
`MAIL_MAILER=log` (no SMTP) and the panel runs HTTP on bare-IP until a domain is set — so the
primary recovery path must not depend on email. Password policy (reused everywhere):
`Password::min(12)->mixedCase()->numbers()`, `confirmed`.
---
## Data model
Migration `add_two_factor_recovery_codes_to_users_table`:
- `two_factor_recovery_codes``text()->nullable()->after('two_factor_secret')`.
`User`:
- Cast `'two_factor_recovery_codes' => 'encrypted:array'` (encrypted-at-rest JSON; plaintext
in memory so codes can be re-shown/downloaded in Settings — Fortify-style).
- `replaceRecoveryCodes(): array` — set 8 fresh codes `Str::random(10).'-'.Str::random(10)`,
save, return them.
- `recoveryCodes(): array` — current codes (or `[]`).
- `hasRecoveryCodes(): bool`.
- `useRecoveryCode(string $code): bool` — if `$code` is in the list, remove it, save, return
true; else false. Trim/normalize input.
## 1. 2FA backup (recovery) codes
- **Generation:** in `TwoFactorSetup::confirm()`, after saving the secret, call
`replaceRecoveryCodes()`, then `return $this->redirect(route('two-factor.recovery'))`
instead of straight to the dashboard.
- **Show-once view:** `Auth\RecoveryCodes` (route `two-factor.recovery`, auth middleware).
Lists the current codes prominently, a **Download** button (`GET /two-factor/recovery-codes/download`
`text/plain` attachment `clusev-recovery-codes.txt`, current user only), and a "Weiter"
button to the dashboard. Reachable both right after setup and from Settings.
- **Challenge accepts a backup code:** in `TwoFactorChallenge::verify()`, after the TOTP
`verifyKey` fails, try `$user->useRecoveryCode($input)`; if it succeeds, treat as verified
(clear rate-limit, login). Same 5/min rate-limit and `2fa.user` session gate. View hint:
"Authenticator verloren? Backup-Code eingeben."
- **Settings management:** in `Settings\Index` (account area), add view-current-codes +
**"Backup-Codes neu erzeugen"** (calls `replaceRecoveryCodes()`, re-shows + download).
Audited (`AuditEvent` `two_factor.recovery_regenerate`).
## 2. Forgot-password flow
- **Login link:** add "Passwort vergessen?" → `route('password.request')` (`/forgot-password`,
guest).
- **`Auth\ForgotPassword` (guest):**
- **Primary — 2FA-code reset (always available):** fields `email`, `code` (TOTP or backup),
`password`, `password_confirmation`. On submit: rate-limit by email+IP (5/min); look up the
user; require `hasTwoFactorEnabled()`; verify `code` as TOTP (`Google2FA::verifyKey`) **or**
`useRecoveryCode($code)`; on success set
`password = Hash::make(...)`, `must_change_password = false`, audit `password.reset`, then
redirect to login with a success notice. Validation: `password` =>
`['required','confirmed', Password::min(12)->mixedCase()->numbers()]`. Generic failure
messages (don't reveal whether the email exists; uniform "E-Mail oder Code ungültig").
- **Secondary — email link (only when SMTP configured):** show this option only if
`! in_array(config('mail.default'), ['log','array'], true)`. Uses Laravel's password broker
(`Password::sendResetLink`) + a `Auth\ResetPassword` component at
`/reset-password/{token}` (`password.reset`) that consumes the `password_reset_tokens`
row and sets the new password (same policy). A `ResetPasswordNotification` provides the
localized mail.
- If the user has **no 2FA** (onboarding incomplete) and SMTP is off → the page states recovery
needs the CLI (Feature 3) and links nothing actionable.
## 3. CLI lockout fallback
`App\Console\Commands\ResetAdmin``clusev:reset-admin`:
- Signature: `{--email= : admin email} {--password= : new password (random if omitted)} {--disable-2fa : also clear 2FA + recovery codes}`.
- Resolves the user by `--email` (or the sole user if one exists); if none, errors.
- Sets `password = Hash::make($pw)` (generate a strong random `$pw` + **print once** if
`--password` omitted — mirrors `Install`), `must_change_password = false`.
- With `--disable-2fa`: null `two_factor_secret`, `two_factor_confirmed_at`,
`two_factor_recovery_codes` (operator re-enrolls on next login).
- Prints a clear confirmation; never logs the password except the one-time stdout print.
## Routes
- guest: `GET /forgot-password``Auth\ForgotPassword` (`password.request`);
`GET /reset-password/{token}``Auth\ResetPassword` (`password.reset`).
- auth: `GET /two-factor/recovery-codes``Auth\RecoveryCodes` (`two-factor.recovery`);
`GET /two-factor/recovery-codes/download` → download closure/controller
(`two-factor.recovery.download`).
## i18n
All visible strings in `lang/{de,en}/auth.php` (R16): forgot-password copy, recovery-codes
copy (incl. "save these now" warning), challenge backup-code hint, settings regenerate, success
/ error notices. DE is the source of truth.
## Testing
- **Feature/TDD:**
- `User`: `replaceRecoveryCodes` returns 8 unique codes + persists; `useRecoveryCode` consumes
a valid code once (second use false) and rejects unknown.
- Challenge: a recovery code logs in and is then consumed; an invalid code fails + is
rate-limited; a TOTP still works.
- ForgotPassword: valid email+TOTP → password changed + can log in; valid email+backup code →
works + code consumed; wrong code → generic error, password unchanged; password policy
enforced; email-link option hidden when `mail.default = log`.
- CLI `clusev:reset-admin`: resets password (and with `--disable-2fa` clears 2FA/codes); no
user → non-zero exit.
- **R12 browser (DE+EN, 375/768/1280):** login→forgot-password→reset; setup→recovery-codes
(download works); challenge with a backup code; settings regenerate. HTTP 200, 0 console,
DOM scan (R17).
- **Pint**, **Codex** clean (R15).
## Out of scope (Phase-2 spec)
- WebAuthn/YubiKey as a login second factor (`laragear/webauthn`, registration + assertion
ceremonies, JS, credential storage, HTTPS/domain requirement). Tracked separately.
- Changing the existing TOTP mechanism or onboarding order.

View File

@ -1,113 +0,0 @@
# 0.9.0 — Accounts, Sessions, Auto-Restart, SMTP, Audit-Retention — Design
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved (build all, then release 0.9.0)
Operator-approved scope + decisions:
- **Multi-User:** additional accounts are **all equal admins** (no RBAC). The audit log already records
the actor, so "who did what" is covered.
- **Sessions:** active-session list + **per-user** and **global** "log out everywhere", remember-token
rotation. Requires switching the session store to the database (see §2).
- **Auto-Restart:** a **restart sentinel** — the app writes a request file, a host-side watcher
restarts the stack. No Docker socket in the container.
- **Settings extras:** **SMTP configuration** + **Audit-log retention** (no theme, no API tokens now).
Foundational refactor (enables clean parallel work + scalable Settings):
## §0. Settings → per-tab nested Livewire components
`Settings\Index` currently inlines Profil + Sicherheit in one big Blade with a `$tab` switch. Refactor
the tab bodies into **nested full components** so each area is isolated, testable, and independently
ownable:
- `Settings\Profile` (name/email/password — lift the existing profile + password forms out of Index).
- `Settings\Security` (the 2FA/TOTP card + recovery modal trigger + `<livewire:settings.webauthn-keys/>`).
- New `Settings\Users`, `Settings\Sessions`, `Settings\Email` (§3/§2/§4).
`Settings\Index` keeps only: the identity header, the `#[Url] $tab` tab nav (now: Profil · Sicherheit
· Benutzer · Sitzungen · E-Mail), and `@if($tab===…) <livewire:settings.x/> @endif`. The `tabs` array
gains the new keys + icons (`user-plus`, a sessions icon, `mail`). Each nested component owns its own
state/validation/audit — no more one-giant-class.
## §1. Auto-restart sentinel
Goal: replace the manual `docker compose -f docker-compose.prod.yml restart` notice with a one-click,
self-applying restart — without giving the container the Docker socket.
- **App:** `DeploymentService::requestRestart()` writes a sentinel file to a path on a **bind-mounted
shared volume** the host watches, e.g. `storage/app/restart.request` mapped to a host dir (or a
dedicated `./run/restart.request`). `restartRequested(): bool` checks it exists.
- **Host watcher (installed once by `install.sh`/documented):** a tiny script + **systemd path unit**
(`clusev-restart.path` → `clusev-restart.service`) that, when the sentinel appears, runs
`docker compose -f docker-compose.prod.yml restart` (or `up -d`) in the project dir and deletes the
sentinel. Ship the unit files + script under `docker/restart-sentinel/` + wire `install.sh`.
- **UI:** the "Neustart erforderlich" block loses the raw command; instead a button **"Jetzt neu
starten"** → `requestRestart()` → shows "Neustart wird ausgeführt …". If the host watcher isn't
installed (sentinel lingers > N s), fall back to a muted "falls nichts passiert: `… restart` auf dem
Host" hint. Domain/TLS/mode saves call `requestRestart()` automatically per operator confirm.
- Files: `app/Services/DeploymentService.php` (+ maybe `RestartService`), `docker/restart-sentinel/*`,
`install.sh`, `docker-compose.prod.yml` (the shared sentinel volume), the System view, lang/system.
## §2. Database sessions + session management
- **Switch the session store to the database** so sessions are per-row + enumerable. Add the standard
Laravel `sessions` table migration (id, user_id, ip_address, user_agent, payload, last_activity).
Set `SESSION_DRIVER=database` in `.env`/`.env.example`/compose. **One-time effect:** existing Redis
sessions are dropped → everyone re-logs-in once (documented in the changelog + the restart notice).
Redis stays the cache/queue store. Register `Illuminate\Session\Middleware\AuthenticateSession` so
password changes invalidate other sessions.
- **`Settings\Sessions` component:** lists the current user's active sessions from the `sessions` table
(this device flagged; ip, user-agent summarized, last activity relative). Actions:
- **"Andere Geräte abmelden"** (self): delete the user's other session rows + `Auth::logoutOtherDevices`
pattern (rotate so other sessions die), keep the current one.
- **(Admin) "Diesen Account überall abmelden"** per listed user (in `Settings\Users`): delete all that
user's session rows + rotate their `remember_token`.
- **(Admin) "Alle abmelden (global)"**: truncate `sessions` + rotate every user's `remember_token`
(logs everyone out incl. self). Confirm via wire-elements/modal (R5), audited.
- Files: migration, `app/Livewire/Settings/Sessions.php` + view, `app/Services/SessionService.php`
(the delete/rotate logic, testable), `bootstrap/app.php` (AuthenticateSession), `.env*`/compose,
`lang/{de,en}/settings.php`.
## §3. Multi-user accounts (all equal admins)
- **`Settings\Users` component:** lists all users (name, email, 2FA badge, last login if available);
**create** (name, email unique, a generated temp password shown once + `must_change_password=true`
so the new admin rotates on first login); **remove** (R5 modal; cannot remove yourself or the last
remaining user). Each action audited (`user.create` / `user.delete`, actor = current). Per-row
**"überall abmelden"** (→ §2). All created users are full admins (no role column needed now).
- New-user password: generate a strong temp password, show once in a modal (like recovery codes), do
NOT email it unless SMTP configured (then offer "Zugangsdaten per E-Mail senden").
- Files: `app/Livewire/Settings/Users.php` + view, maybe `app/Livewire/Modals/CreateUser.php` for the
temp-password reveal, `lang/{de,en}/settings.php`. The `User` factory/model already suffices.
## §4. SMTP configuration
- **`Settings\Email` component:** form for host, port, username, password (encrypted Setting),
encryption (none/tls/ssl), from-address, from-name. Persist as `Setting` keys (`mail_*`); the password
via an encrypted Setting. **Apply at runtime** by overriding `config(['mail.*' => …])` in
`AppServiceProvider::boot()` when the settings exist (mirrors the existing reverb override). A
**"Testmail senden"** button sends a test to the current user + surfaces success/failure. When SMTP is
unset, the app stays on the `log` mailer (current behaviour) and forgot-password keeps the 2FA/CLI path.
- Files: `app/Livewire/Settings/Email.php` + view, `app/Providers/AppServiceProvider.php` (runtime mail
override), `lang/{de,en}/settings.php`. Password stored encrypted; never rendered back (show a "set"
placeholder).
## §5. Audit-log retention
- **Setting `audit_retention_days`** (0/empty = keep forever; default keep-forever). A control in the
Audit page header (or a small Settings block) to set it. A scheduled command **`clusev:prune-audit`**
deletes `audit_events older than N days`; register it on the scheduler (daily). Audited that a prune
ran (count). Files: `app/Console/Commands/PruneAudit.php`, the scheduler (routes/console.php or
Kernel), a control in `audit/index` or `Settings`, `lang`.
## Security invariants
- New accounts are created by an authenticated admin only; temp password is `must_change_password`.
- Global logout / per-user logout rotate `remember_token` so stolen cookies die.
- SMTP password stored encrypted (APP_KEY), never rendered, never logged.
- The restart sentinel grants NO new container privilege (no Docker socket); the host watcher is the
only thing that can restart, scoped to the project dir.
- Cannot delete yourself or the last admin (no lock-out).
- All destructive actions: wire-elements/modal confirm (R5) + AuditEvent.
## Testing (per feature, TDD)
- Settings refactor: each nested component renders + its tab loads; Index hosts them by `$tab`.
- Sentinel: `requestRestart()` writes the file, `restartRequested()` reads it; UI button calls it.
- Sessions: list reflects `sessions` rows; per-user logout deletes that user's rows + rotates token;
global logout truncates + rotates all; self "other devices" keeps current.
- Users: create makes a `must_change_password` admin + audits; cannot delete self/last; remove audits.
- Email: settings persist (password encrypted), runtime override applies, test-send path.
- Retention: prune deletes only old rows, keeps recent; command is scheduled.
- Full suite green, Pint, Codex clean, R12 (DE+EN, 3 breakpoints), then bump + tag **v0.9.0**.
## Out of scope (later)
- RBAC/roles, API tokens, theme preference, per-event audit export, email templates beyond the test.

View File

@ -1,82 +0,0 @@
# External reverse-proxy TLS mode (Zoraxy in front) — Design
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved (build as 0.8.0)
Today Clusev's Caddy obtains a Let's Encrypt certificate **on demand** for the configured panel
domain (`/_caddy/ask` approves only that domain) and `PanelScheme` forces HTTPS for it. That fails or
is unwanted when the operator runs an **upstream reverse proxy (e.g. Zoraxy)** that already terminates
TLS — or for a purely local/internal domain. This feature adds a **TLS mode** where Caddy does **not**
issue certs and the panel is served over HTTP for the upstream proxy to wrap.
## Operator decisions (locked)
- Mode: **external reverse-proxy** (not self-signed).
- Topology: **Zoraxy terminates TLS → forwards to Clusev's existing Caddy HTTP port** (keeps the
Reverb/WebSocket routing `/app`, `/apps`). Caddy is configured to **honor the upstream's
`X-Forwarded-Proto`** so the app sees the real scheme.
## 1. Setting + DeploymentService
- New Setting key `tls_mode` ∈ {`caddy` (default), `external`}, via the existing `Setting` model.
- `DeploymentService::externalTls(): bool` = `Setting::get('tls_mode', 'caddy') === 'external'`.
- `DeploymentService::setTlsMode(string $mode): void` (validates the enum, persists, busts cache).
- `hasTls()` stays as-is for the `caddy` path; in `external` mode TLS is asserted by the upstream, so
HSTS/secure-cookie decisions follow the **real request scheme** (`$request->isSecure()` via the
honored `X-Forwarded-Proto`), not a static flag.
## 2. Caddy `/_caddy/ask` — no ACME in external mode
The `caddy.ask` route currently approves cert issuance for the configured domain. In `external` mode
it must **deny every host** (HTTP 403 / non-`ok` body) so Caddy never attempts ACME — the upstream
proxy owns the certificate. In `caddy` mode the behaviour is unchanged (approve the active domain only).
## 3. PanelScheme — defer HTTPS to the upstream in external mode
With a domain set and `external` mode:
- Keep the **host enforcement** (only the configured domain by hostname; the literal server IP stays
the plain-HTTP recovery path; `_caddy/*` + health exempt).
- **Skip the app-level `redirect → https://domain:443`** — the upstream proxy performs HTTP→HTTPS, and
Caddy here is HTTP-only, so a 443 redirect would be wrong/unreachable.
- Cookie `Secure` + HSTS still follow `$request->isSecure()`, which now reflects the upstream's
`X-Forwarded-Proto` (honored by Caddy — see §4) and Laravel's `trustProxies(at: '*')`.
In `caddy` mode `PanelScheme` is unchanged (still forces HTTPS for the active domain).
## 4. Caddyfile — honor the upstream X-Forwarded-Proto (env-gated)
Caddy's `reverse_proxy` overwrites `X-Forwarded-Proto` with the scheme **it** received (HTTP from
Zoraxy), losing the original `https`. To recover it, add a global
`servers { trusted_proxies static {$TRUSTED_PROXY_CIDR} }` block so Caddy trusts the upstream's
`X-Forwarded-*` (incl. Proto) from that CIDR. New env `TRUSTED_PROXY_CIDR` (default **empty** → no
trust, current behaviour). The operator sets it to Zoraxy's address/range and restarts the stack when
using external mode. (The feature still *functions* without it — the panel works over HTTP behind
Zoraxy — but the `Secure` cookie flag + HSTS are only correct once it's set.)
## 5. UI — System → Domain & TLS
In `App\Livewire\System\Index` (the domain editor), add a **TLS-Modus** control beside the domain:
`Caddy (Let's Encrypt, automatisch)` vs `Externer Reverse-Proxy (TLS davor)`. Saving persists
`tls_mode` and — like a domain change — surfaces the **"restart required"** notice (and, for external
mode, a hint to set `TRUSTED_PROXY_CIDR` to the proxy's address). Audited via the shared confirm modal,
mirroring `confirmDomain`.
## Files
- `app/Models/Setting.php` — n/a (generic key/value).
- `app/Services/DeploymentService.php``externalTls()`, `setTlsMode()`.
- the `caddy.ask` closure in `routes/web.php` — deny in external mode.
- `app/Http/Middleware/PanelScheme.php` — skip the forced redirect in external mode.
- `app/Livewire/System/Index.php` + its view — the TLS-mode control + restart/CIDR hint.
- `docker/caddy/Caddyfile``trusted_proxies static {$TRUSTED_PROXY_CIDR}` global option.
- `.env.example` / docker-compose — document `TRUSTED_PROXY_CIDR`.
- `lang/{de,en}/system.php` — the mode labels, restart + CIDR hints, audit copy.
## Testing
- `externalTls()` reflects the setting; `setTlsMode()` validates the enum.
- `caddy.ask`: external mode → deny (non-ok) for the configured domain; caddy mode → approve it.
- `PanelScheme`: external mode + domain + HTTP request → **no** redirect (200/pass), host check still
refuses a foreign host, bare-IP still served; caddy mode unchanged (still 301→https).
- System UI: switching mode persists `tls_mode`, shows the restart notice; DE+EN; R12 (200, no console
errors) at the 3 breakpoints; Codex clean.
## Safety invariants
- Never lock the operator out: the bare-IP HTTP recovery path stays reachable in **both** modes.
- External mode must not silently leave ACME on (cert-issuance spam) — `/_caddy/ask` denies.
- Honoring `X-Forwarded-Proto` is gated by `TRUSTED_PROXY_CIDR`; with it empty, no header from an
untrusted client is ever trusted (no scheme spoofing).
## Out of scope
- Self-signed certificates for bare-IP (operator chose external-proxy mode only).
- Auto-detecting the proxy IP (operator sets `TRUSTED_PROXY_CIDR`).

View File

@ -1,123 +0,0 @@
# Optional, Pluggable 2FA (TOTP and/or YubiKey) — Design
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved
Reworks 2FA from **forced TOTP (+ optional key)** to **optional, pluggable**: the operator decides
whether to secure the account, choosing **TOTP, a security key (YubiKey/WebAuthn), or both** — and
can turn 2FA off entirely. 2FA is *recommended*, not enforced. Builds on v0.5.0 (backup codes,
forgot-password) and v0.6.0 (WebAuthn). Supersedes: the forced-2FA onboarding, and the dedicated
recovery-codes **page** (now a modal).
Constraint that shapes everything: **WebAuthn needs domain + HTTPS** (rpId = domain), so on a
fresh bare-IP install only TOTP is available; a key becomes an option once a domain is configured.
Password rotation stays forced (`must_change_password`).
---
## 1. Factor semantics (`User`)
- `hasTotp(): bool` = `two_factor_secret` + `two_factor_confirmed_at` set (the old
`hasTwoFactorEnabled`).
- `hasWebauthnCredentials(): bool` (exists) — already present.
- **`hasTwoFactorEnabled(): bool` = `hasTotp() || hasWebauthnCredentials()`** — 2FA satisfied by
**either** factor.
- **`securityOnboarded(): bool` = `! must_change_password`** — drop the 2FA requirement (2FA is now
optional). Used by the broadcast channel gate.
Update every caller (already enumerated): `EnsureSecurityOnboarded`, `Login`, `TwoFactorChallenge`,
`WebauthnKeys`, `ForgotPassword`, `Settings\Index`, `routes/channels.php`.
## 2. Onboarding — 2FA no longer forced
`EnsureSecurityOnboarded`: keep the `must_change_password``password.change` redirect; **remove**
the `! hasTwoFactorEnabled()``two-factor.setup` redirect. After the password change,
`PasswordChange::update()` redirects to the **dashboard** (not `two-factor.setup`). 2FA is offered
in Settings with a "empfohlen" hint.
`routes/channels.php`: `metrics` channel = `fn (User $u) => $u->securityOnboarded()` still holds,
but `securityOnboarded()` now means "password rotated" — metrics stream to a password-rotated
session regardless of 2FA (which is optional).
## 3. Recovery (backup) codes — universal, and a MODAL (no dedicated page)
Backup codes remain the single recovery, but their UI becomes a **wire-elements/modal**:
- **New `Modals\RecoveryCodes`** (`LivewireUI\Modal\ModalComponent`): shows the current codes once,
a **Download** action (`two-factor.recovery.download` endpoint stays — it's not a view), and a
**Regenerate**. Opened via `$dispatch('openModal', { component: 'modals.recovery-codes' })`.
- **Remove** `Auth\RecoveryCodes` (full-page component), its view
`livewire/auth/recovery-codes.blade.php`, and the **`two-factor.recovery` route**. Keep the
download route.
- **Modal host:** the wire-elements/modal host lives in the **app layout** (not the auth layout).
Since 2FA is now a voluntary Settings action (not pre-onboarding), `TwoFactorSetup` moves to the
**app layout** so it can open the modal; alternatively the TOTP enroll becomes a Settings sub-flow.
(Decided in the plan; either way the recovery modal opens within the app shell.)
- **Generation:** when the **first** factor is enrolled and no codes exist yet, generate them
(`replaceRecoveryCodes()`) and open the modal (no full-page redirect — the modal renders over the
current app-shell page; its "Gespeichert" action returns to Settings):
- `TwoFactorSetup::confirm()` → if `! hasRecoveryCodes()` generate; then
`$dispatch('openModal', …recovery-codes)`.
- `WebauthnKeys::register()` → first key + no codes → generate + open the modal.
- **Settings → Security:** "Backup-Codes verwalten" → `$dispatch('openModal', …recovery-codes)`
(no page link).
- **Clearing:** when the **last** factor is removed (no TOTP and no keys), null
`two_factor_recovery_codes` (2FA fully off → no recovery to keep). A `User::resetIfNoFactor()`
helper centralizes this.
## 4. Login challenge — adapt to whichever factor(s) exist
`TwoFactorChallenge`:
- `verify()` (code path): only attempt the TOTP check when `hasTotp()`; always try
`useRecoveryCode()`. So a **key-only** user with no TOTP can still enter a backup code; a
TOTP user is unchanged.
- The **TOTP code field** renders only when the pending user `hasTotp()`. The
"Mit Security-Key anmelden" button renders when `webauthnAvailable()` (already gated). The
backup-code hint shows when the user has codes. A key-only user sees: key button + backup-code
entry, no TOTP field.
## 5. Settings → Security — manage factors
- **TOTP card:** `Einrichten` (→ `two-factor.setup`) when `! hasTotp()`; `Entfernen` when
`hasTotp()`. Removal is **always allowed** now (2FA optional). On removal → null
`two_factor_secret`/`two_factor_confirmed_at`, then `resetIfNoFactor()`.
- **Security-Keys card:** add/list/remove gated **only** on `WebauthnService::available()`
(domain+HTTPS) — drop the `hasTwoFactorEnabled()` requirement so a key can be the **first/only**
factor. Removing the last key → `resetIfNoFactor()`.
- **"2FA empfohlen" hint** when `! hasTwoFactorEnabled()`.
- `disableTwoFactor` handler reframed to "remove TOTP" (+ `resetIfNoFactor()`); it no longer nukes
keys (each factor is removed from its own card).
## 6. forgot-password
Unchanged logic (2FA-code or backup, email when SMTP). For a **key-only** user there is no TOTP →
they use a **backup code**. For a **no-2FA** user the 2FA-proof path is unavailable → the page
states recovery needs the email link (if SMTP) or the `clusev:reset-admin` CLI. `clusev:reset-admin`
already clears all factors.
## Files touched
`app/Models/User.php` (semantics + `resetIfNoFactor`), `EnsureSecurityOnboarded`,
`Auth\PasswordChange` (redirect), `Auth\TwoFactorSetup` (modal), `Auth\TwoFactorChallenge`
(conditional field/verify), `Settings\Index` (TOTP remove + reset, hint),
`Settings\WebauthnKeys` (gate, first-key codes + modal, last-key reset), new
`Modals\RecoveryCodes` + view, **remove** `Auth\RecoveryCodes` + its view + the `two-factor.recovery`
route, `routes/channels.php`, the relevant Blade (settings security tab, challenge, login),
`lang/{de,en}/auth.php`.
## Testing
- `hasTwoFactorEnabled` true for TOTP-only, key-only, both; false for neither.
- Onboarding: a password-rotated user with no 2FA reaches the dashboard (no forced 2FA redirect);
the broadcast gate passes on password rotation alone.
- Recovery modal: codes generated on the first factor (TOTP confirm and first key); cleared when the
last factor is removed; the modal lists/regenerates.
- Challenge: TOTP-only (field shown), key-only (no field; backup code works), both.
- Settings: TOTP removable; last-factor removal clears codes; recovery "verwalten" opens the modal.
- R12 (bare-IP, DE+EN): Settings shows the TOTP card + the "empfohlen" hint + the key card's
domain hint; the recovery modal opens. WebAuthn key E2E stays domain-deferred.
- Pint, Codex clean.
## Out of scope
- Changing the WebAuthn ceremony itself (v0.6.0) or password rotation enforcement.
- A key-or-TOTP **choice wizard** at onboarding — onboarding no longer forces 2FA, so the user just
enables what they want from Settings (simpler; avoids a bare-IP-only-TOTP wizard branch).

View File

@ -1,194 +0,0 @@
# Server-Details UX + Hardening Polish — Design
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved (design), pending spec review
Fixes seven issues reported against the Server-Details page (`servers.show`), the button
system, and the create-server flow. All UI copy stays bilingual (DE default + EN, R16); all
visible colors come from `@theme` tokens (R3); buttons go through the single `x-btn` component
(R10).
---
## 1. Unified button system (issues 6 + 7)
**Problem:** `x-btn` is the single source of truth, but two variants are *borderless*
`ghost` (no border/bg) and `ghost-danger` (no border/bg, turns red only on hover). Used for
"Entsperren", config gears, and "Löschen"/trash, they read as inconsistent next to the bordered
`secondary`/`accent` buttons. The delete button's hover-only red is jarring.
**Change** — `resources/views/components/btn.blade.php`:
- Every variant carries a **persistent** `border` + `bg`. No borderless variants in visible use.
- Variant map becomes:
- `primary``bg-accent text-void hover:bg-accent-bright` (filled CTA, unchanged).
- `accent``border border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15` (primary panel action, unchanged).
- `secondary``border border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink` (default row/toolbar **and** icon-only buttons, unchanged).
- `danger``bg-offline text-void hover:bg-offline/90` (filled; modal-confirm only, unchanged).
- **`danger-soft` (new)** — `border border-offline/25 bg-offline/10 text-offline hover:bg-offline/15`. Persistent red-tint border+bg for destructive row/toolbar actions (Löschen, trash, whitelist-remove). Mirrors the `accent` structure so the kit stays uniform.
- Remove `ghost` and `ghost-danger` from the map.
- The icon-only `size=sm` button stays `h-8 w-8` (≥ touch target handled by `lg`, R7) — unchanged.
**Usage migration** (grep `variant="ghost"` / `variant="ghost-danger"` across `resources/views`):
- `ghost``secondary` (config gear, Entsperren, unban).
- `ghost-danger``danger-soft` (Löschen credential, trash rule, trash SSH key).
- The whitelist chip's raw `<button>` "x" (`show.blade.php` ~line 417) stays a chip-x but gains a
consistent `text-ink-4 hover:text-offline` → keep (it is a tag affordance, not a button); no change required beyond confirming it reads as part of the chip.
> "One CSS variable for buttons" is satisfied by `x-btn` being the *single* definition point —
> the variant table is the one place button looks are declared; every button references it.
---
## 2. Firewall + fail2ban panels → grid, installed-only (issues 1 + 5)
**Problem:** Both panels render full-width and always — including a redundant "UFW ist nicht
installiert" box when the tool is absent. Too much vertical space for little content; the
not-installed box duplicates the checklist's "Aktivieren".
**Change** — `resources/views/livewire/servers/show.blade.php`:
- Compute visibility:
- `$showFw = ($fw['supported'] ?? false) && ($fw['installed'] ?? false)`
- `$showF2b = ($f2b['supported'] ?? false) && ($f2b['installed'] ?? false)`
- "Installed" already covers active-and-inactive; an installed-but-inactive tool still shows its
box (with its existing enable affordance for firewalld / fail2ban-inactive states).
- Layout:
- Both shown → wrap in `<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 lg:items-start"> … </div>`.
- Exactly one shown → that panel renders full-width (no grid wrapper).
- Neither shown → render nothing (the Sicherheit checklist rows carry "Aktivieren" to install).
- Delete the now-unreachable inline branches inside each panel that handled `! $installed`
/ not-supported (the box only renders when installed), keeping `readError` handling.
- `lg:items-start` so a short panel does not stretch to a tall neighbor's height.
Entry point to install when hidden is unchanged: the Sicherheit checklist rows for `firewall`
and `fail2ban` open the `hardening-action` modal with `enable: true`.
---
## 3. Auto-updates = operator preference, not a security gate (issue 2)
**Problem:** "Automatische Updates" is rendered as a security verdict (SICHER/OFFEN). A fresh
Debian ships `unattended-upgrades` enabled, so it reads "SICHER · aktiv" by default. Keeping a
server patched manually is a legitimate operator choice; Clusev should not imply auto-updates are
required, nor flag "off" as insecure. Default framing must be neutral, the operator decides.
**Change** — `app/Services/HardeningService.php::parseState()`, the `unattended` row only:
- `secure` for the auto-update row = **always `true`** (never an insecure/"OFFEN" verdict).
- The row's state still reflects the real host (`featureOn = installed && active`) so the toggle
shows the correct Aktivieren/Deaktivieren direction.
- Detection logic is **unchanged** (stays truthful about the host config). No new probe.
**Change** — `resources/views/livewire/servers/show.blade.php`, Sicherheit checklist:
- The state chip (`check_secure`/`check_open`, line ~197204) must show a **neutral** state for
the auto-update row instead of the green/orange SICHER/OFFEN pair. Render `common.active` /
`common.inactive` in a muted tone (`text-ink-3`) for this row. Drive this off a per-row flag so
only the auto-update row is reframed; SSH/fail2ban/firewall keep SICHER/OFFEN.
- The lock glyph tint (line ~185191) for the auto-update row uses the neutral
(`border-line bg-raised text-ink-4`) treatment when inactive — not the warning-orange — so an
off state never looks like a problem. When active, a calm online tint is fine.
Implementation note: add a marker to the row data — e.g. `'neutral' => true` on the `unattended`
row from `parseState()` — and branch on it in the Blade, rather than string-matching the key in
the view.
---
## 4. Create-server: verify SSH before save + "Initialisierung" status (issue 4)
### 4a. Verify the credential on creation
**Problem:** `CreateServer::save()` persists the server with `status='offline'` without ever
checking that the entered SSH login works. A wrong password yields a server that simply sits red.
**Change** — `app/Services/FleetService.php`: add
```php
/** Probe the stored credential: connect + trivial exec. Never throws. */
public function testConnection(Server $server): array // {ok: bool, error: ?string}
```
that builds an `SshClient` (timeout ~10s), `connect($server)`, runs `true`, returns
`['ok' => $code === 0, 'error' => null]`, and on any `Throwable` returns
`['ok' => false, 'error' => $e->getMessage()]`. (`secret` is an `encrypted` cast, so the probe
must run against a **persisted** credential — an in-memory one cannot be decrypted by the vault.)
**Change** — `app/Livewire/Modals/CreateServer.php::save()`:
- Inside the existing `DB::transaction`, after creating the server + credential, call
`app(FleetService::class)->testConnection($server)`.
- If `! ok`: throw a `ValidationException` for the `secret` field with a localized message that
includes the SSH reason (truncated) — this **rolls back the transaction** (no row persists) and
surfaces the error on the form. New key `modals.create_server.validation_ssh_failed` (`:error`).
- If ok: proceed (audit + notify + close), but create with `status='pending'` (see 4b).
- Tradeoff (documented): the SSH handshake runs inside the DB transaction; bounded by the 10s
client timeout. Acceptable for a single interactive create; the alternative (persist → test →
delete-on-fail) is non-atomic.
### 4b. "Initialisierung" status
**Problem:** A freshly created server shows red/Offline before it has ever been contacted.
**Change:**
- New status value **`pending`** → label **"Initialisierung"**, calm tone (cyan dot + soft ping,
cyan-tinted pill), never red. `status` is a free-form string column — **no migration needed**.
- `CreateServer::save()` sets `status => 'pending'` (the credential is already verified, so first
contact will promote it).
- Promotion paths already set `online`/`offline` on real contact (`Show::load()`,
`PollMetrics`), so `pending` resolves on the first poll/snapshot.
- Add `pending` to every status map:
- `resources/views/components/status-pill.blade.php``pending => 'text-cyan border-cyan/20 bg-cyan/10'`.
- `resources/views/components/status-dot.blade.php``pending => 'bg-cyan'`, ping enabled.
- `resources/views/components/server-item.blade.php` — label map + `:ping` includes pending.
- `resources/views/livewire/servers/index.blade.php``$label['pending']`, dot ping.
- `resources/views/livewire/servers/show.blade.php``$statusLabel` map + hero icon `@class`
(cyan branch) + suppress the "offline" banner while `pending`.
- New lang key `servers.status_pending => 'Initialisierung' / 'Initializing'`.
- KPI counters (`Index.php` groupBy status) are unchanged: `pending` counts in `total` only,
not in online/warning/offline. Acceptable — it is a transient state.
---
## 5. SSH key-only hint on the checklist (issue 3)
**Problem:** The confirm-modal copy `desc_ssh_password_disable` already states access becomes
key-only and needs a deposited key. But the **checklist row** itself gives no such hint before
the operator clicks.
**Change** — `resources/views/livewire/servers/show.blade.php`, Sicherheit checklist:
- For the `ssh_password` (and `ssh_root`) rows, append a muted one-line hint under the detail line
when the feature is still ON (password/ root login enabled), e.g.
`servers.ssh_key_only_hint => 'Nach dem Deaktivieren ist der Zugang nur per SSH-Key möglich — Key zuerst hinterlegen.'`
(EN: `'After disabling, access is SSH-key only — deposit a key first.'`).
- Rendered as `text-[11px] text-ink-4`, only on the SSH rows, only while the feature is on.
No backend change; this is presentational copy that mirrors the existing guard
(`ssh_password_no_key` / `ssh_password_self_lockout`).
---
## Files touched (summary)
| Area | File |
|---|---|
| Buttons | `resources/views/components/btn.blade.php`; all `ghost`/`ghost-danger` usages in `resources/views/**` |
| Boxes grid + visibility | `resources/views/livewire/servers/show.blade.php` |
| Auto-updates reframe | `app/Services/HardeningService.php`; `show.blade.php` |
| Create-verify | `app/Services/FleetService.php`; `app/Livewire/Modals/CreateServer.php`; `lang/{de,en}/modals.php` |
| Pending status | `status-pill`, `status-dot`, `server-item`, `index.blade.php`, `show.blade.php`; `lang/{de,en}/servers.php` |
| SSH key hint | `show.blade.php`; `lang/{de,en}/servers.php` |
## Testing
- **TDD (logic):**
- `HardeningService` — the `unattended` row reports `secure === true` regardless of active
state, and carries the neutral marker; other rows keep SICHER/OFFEN semantics.
- `FleetService::testConnection` — returns `ok:false,error` on connect failure (mock SshClient
boundary), `ok:true` on success.
- `CreateServer` — invalid SSH → `ValidationException`, **no** `Server` row persisted (rollback);
valid SSH → server persisted with `status='pending'`.
- **R12 browser (DE + EN, 375 / 768 / 1280):** Server-Details page — buttons uniform (border+bg,
delete red-tinted persistent), firewall/fail2ban grid + hidden-when-absent, auto-update row
neutral, SSH key hint visible; create modal blocks on bad password; new server shows
"Initialisierung". DOM scan for leaked `@`/`{{ }}`/`$var`/`group.key` (R17).
- **Pint** clean (`--dirty`), **Codex** (`codex review --uncommitted`) clean (R15).
## Out of scope (YAGNI)
- No change to firewall/fail2ban *detection* in their services (the checklist↔box installed
states already agree via the shared `command -v` arm).
- No new auto-update detection accuracy work (operator decided: reframe only).
- Dead `dualChart` removal, Caddy/ACME live test, firewalld rule mutation — tracked separately in
`docs/session-handoff.md` §3.

View File

@ -1,91 +0,0 @@
# Auto-provision SSH key & safely disable password login — Design
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved
Today, disabling SSH password login is a dead end unless the operator has *manually* switched the
panel's stored credential to key auth **and** installed an authorized key
(`HardeningService::apply('ssh_password', false)` refuses otherwise, with `ssh_password_self_lockout`
/ `ssh_password_no_key`). This feature does that work **automatically and safely** from one click:
generate a key, install it, prove key login works, switch the panel's own credential to it, and only
then turn password auth off — never locking out the operator or the control plane.
## Decision (operator-approved)
Full safe auto-flow; the generated private key is shown once **and** kept encrypted in the vault.
## Flow (`SshKeyProvisioner::enableKeyOnlyAccess(Server): Result`)
Abort on any failure; password auth stays ON until a key login is proven.
1. **Preconditions:** server reachable, a credential exists. If the credential is already `key` and
`PasswordAuthentication` is already `no`, return a no-op "already key-only" result.
2. **Generate** an ed25519 keypair in the panel (phpseclib `EC::createKey('ed25519')`, same as the
Add-SSH-Key modal). Keep the OpenSSH public string and the private PEM.
3. **Install** the public key into the SSH user's `~/.ssh/authorized_keys` via
`FleetService::addAuthorizedKey()` (idempotent, additive — harmless while password auth is on).
4. **Switch + verify (atomic, rollback-safe):** snapshot the current credential
(`auth_type`, `secret`, `passphrase`), update it to `auth_type='key'`, `secret=<private PEM>`,
`passphrase=null`, then run `FleetService::testConnection($server->fresh('credential'))`
(a fresh login + exec probe using the new key). Password auth is still on, so this is risk-free.
- **On failure:** restore the snapshot credential, do NOT disable password auth, return an error
(`ssh_key_verify_failed`). The installed public key is left in place (harmless).
5. **Disable password auth:** call `HardeningService::apply($server, 'ssh_password', enable: false)`.
Its lock-out guard now PASSES (credential is `key`, an authorized key exists), writes
`PasswordAuthentication no` to the `/etc/ssh/sshd_config.d/00-clusev.conf` drop-in, and reloads
sshd. If this step fails, the operator still has key access (credential already switched) — report
the error but do not roll the credential back.
6. **Reveal once:** return the private PEM so the UI shows it in a modal (download + "save now"
warning). It also stays encrypted in the vault (the panel uses it to connect).
7. **Audit** every materialized step: `ssh_key.autoprovision` (key installed + credential switched)
and the existing `ssh_password` hardening audit from `HardeningService`.
## Result shape
`array{ok: bool, privateKey?: string, publicKey?: string, error?: string, alreadyKeyOnly?: bool}`
## UI integration
The hardening panel's **SSH-Passwort-Login** row, when `open` (PasswordAuthentication yes): add a
primary action **"Key erstellen & Passwort-Login deaktivieren"** that opens a confirm modal
(`modals.ssh-key-provision`, wire-elements/modal, R5) explaining the safe sequence. On confirm it
runs `enableKeyOnlyAccess()`:
- success → swap the modal body to the **one-time private-key reveal** (readonly textarea + a
Download action using a streamed-download route, mirroring the recovery-codes download) + a
warning; the hardening row reloads to `geschlossen` (PasswordAuthentication no).
- `alreadyKeyOnly` → a short "already key-only" notice.
- failure → an inline error (`ssh_key_verify_failed` etc.), nothing changed on the host beyond a
harmless installed key.
Keep the existing manual toggle for operators who already manage their own keys (the row still shows
the standard enable/disable when a key credential is already in place).
## Files
- New `app/Services/SshKeyProvisioner.php` (orchestration; depends on `FleetService` + `HardeningService`).
- New `app/Livewire/Modals/SshKeyProvision.php` + `resources/views/livewire/modals/ssh-key-provision.blade.php`.
- New streamed-download route `servers.ssh-key.download` (session-flashed one-time key, like
`two-factor.recovery.download`) — OR pass the key only to the modal and offer a client-side download
blob (decided in the plan; prefer the client-side blob to avoid persisting the plaintext key in the
session).
- `resources/views/livewire/servers/show.blade.php`: the SSH-password row gains the new action.
- `app/Models/Server.php` / `SshCredential`: no schema change (fields already exist).
- `lang/{de,en}/servers.php` (+ `backend.php` if new backend strings): the new action label, modal
copy, success/already/failure messages, the private-key warning.
## Safety invariants (non-negotiable)
- Password auth is disabled **only after** a fresh key login is verified.
- The panel's own credential is switched to the key **before** the cutover and rolled back if
verification fails — the control plane can never lock itself out.
- Never touch `10.10.90.x` style control-plane self-management beyond this server's own credential.
- The plaintext private key is shown once and stored only encrypted (vault, APP_KEY) — never logged,
never echoed, never committed.
## Testing
- Unit/feature with a mocked `FleetService`/`HardeningService`:
- happy path: keypair generated, `addAuthorizedKey` called, credential switched to `key`,
`testConnection` ok → `apply('ssh_password', false)` called → result carries the private key.
- verify-fail path: `testConnection` returns not-ok → credential restored to the original
password auth, `apply('ssh_password', …)` NOT called, error returned.
- already-key-only: short-circuits with `alreadyKeyOnly`.
- the modal: confirm runs the service, success reveals the key, failure shows the error; R5 modal.
- R12 (real browser): the row's action opens the modal; on a reachable test host the cutover works
and the row flips to `geschlossen`; the key reveal + download work; DE+EN; 200, no console errors.
- Pint, Codex clean (special attention: no plaintext key in logs/session/argv; the rollback path).
## Out of scope
- Rotating an existing key, multiple keys management (Add-SSH-Key already lists/adds/removes).
- Re-enabling password auth (the standard hardening toggle already does that).

View File

@ -1,121 +0,0 @@
# WebAuthn / YubiKey as a Login 2nd Factor — Design (Phase 2)
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved
Adds **WebAuthn security keys (YubiKey / platform authenticators)** as an *optional* second
factor at login, on top of the existing TOTP + backup-code recovery (v0.5.0). It is **gated on a
domain + HTTPS**: WebAuthn requires a secure context and a domain-based Relying-Party ID — a bare
IP literal is not a valid rpId, so the feature is hidden (with a hint) until the panel runs under
a domain with a valid certificate. TOTP + backup codes remain the required 2FA and the only
recovery; a security key never weakens recovery.
Auth is hand-rolled (not Fortify): Livewire `Auth\{Login,TwoFactorChallenge,...}`, TOTP via
`pragmarx/google2fa`. Domain/scheme state comes from `DeploymentService::domain()` (active
domain, null on bare-IP) and `Request::isSecure()` (real scheme behind Caddy/TrustProxies).
## Library
`web-auth/webauthn-lib ^5.3` — framework-agnostic, actively maintained, resolves cleanly on
Laravel 13.8 / PHP 8.3 (verified by `composer require --dry-run`). Chosen over `laragear/webauthn`
(abandoned) and `laravel/passkeys` (oriented to passwordless passkey *login*, not a step-up 2nd
factor). It provides the ceremony primitives; we wire them into the existing custom auth.
## Gating — `WebauthnService::available()`
`available(): bool` = `app(DeploymentService::class)->domain() !== null && request()->isSecure()`.
The **rpId = the active domain**; rpName = "Clusev". When `available()` is false:
- Settings shows a muted hint ("Security-Keys sind nur unter einer Domain mit HTTPS verfügbar")
and no register button.
- The login challenge shows no "Security-Key" option.
- All WebAuthn routes/actions `abort(404)` (defense in depth — never run a ceremony with an IP rpId).
## Data model
Migration `create_webauthn_credentials_table`:
- `id`, `user_id` (fk, cascade), `name` (operator label), `credential_id` (binary, unique),
`public_key` (text), `aaguid` (nullable), `transports` (json nullable), `sign_count`
(unsigned big int, default 0), `last_used_at` (nullable), timestamps.
`WebauthnCredential` model (`user_id`, `name`, `credential_id`, `public_key`, `aaguid`,
`transports`, `sign_count`, `last_used_at`); `User hasMany(WebauthnCredential)` +
`hasWebauthnCredentials(): bool`.
The library's `PublicKeyCredentialSource` is mapped to/from this row (credential_id, public key,
sign count, transports). A small mapper lives in `WebauthnService`.
## `WebauthnService` (the one place that touches the lib)
- `available(): bool` — the gate above.
- `registrationOptions(User $user): array` — build `PublicKeyCredentialCreationOptions`
(rpId=domain, user handle = stable per-user id, `excludeCredentials` = the user's existing
credentials, `residentKey=discouraged`, `userVerification=preferred`), serialize to a
JSON-ready array via the lib's Webauthn serializer; persist the challenge in the session
(`webauthn.register.challenge`).
- `verifyRegistration(User $user, array $response, string $name): WebauthnCredential` — validate
the attestation against the session challenge with `AuthenticatorAttestationResponseValidator`
(attestation type `none` accepted — for a 2nd factor we verify the credential, not provenance),
store the resulting source as a `WebauthnCredential` named `$name`. Throws on mismatch.
- `assertionOptions(User $user): array` — build `PublicKeyCredentialRequestOptions`
(rpId=domain, `allowCredentials` = the user's credentials); persist `webauthn.login.challenge`.
- `verifyAssertion(User $user, array $response): bool` — validate with
`AuthenticatorAssertionResponseValidator` against the matching stored credential + session
challenge; on success bump `sign_count` + `last_used_at` and return true; false otherwise.
Serialization uses the lib's `WebauthnSerializerFactory` (symfony/serializer) for options → JSON
and incoming JSON → response objects.
## Flows
### Registration (Settings → Security, only when `available()` and TOTP already enrolled)
1. Operator clicks "Security-Key hinzufügen" → JS fetches `registrationOptions` (Livewire action
returns the JSON options) → `navigator.credentials.create({publicKey})`.
2. JS base64url-encodes the attestation response and posts it back (Livewire action) with a label.
3. `verifyRegistration()` stores the credential. The key appears in the list (name, added date,
last used) with a remove action (confirm modal, R5; audited `webauthn.register` /
`webauthn.remove`).
### Login (TwoFactorChallenge, only when `available()` and the user has ≥1 credential)
1. Alongside the existing code field, show "Mit Security-Key anmelden".
2. JS fetches `assertionOptions``navigator.credentials.get({publicKey})` → posts the assertion.
3. `verifyAssertion()` true → complete login exactly like a TOTP success (clear rate-limit,
`Auth::login($user, $remember)`, regenerate session, redirect intended). TOTP code and backup
codes remain fully usable in parallel.
The `2fa.user`/`2fa.remember` session gate and the 5/min rate-limit are reused unchanged.
## JS
A small `resources/js/webauthn.js` module: base64url encode/decode helpers + `register(options)`
and `authenticate(options)` wrappers around `navigator.credentials`. Wired to the Livewire
components via dispatched browser events / `$wire` calls. No new build system; bundled by Vite.
## Security
- rpId strictly = the active domain (never an IP); ceremonies refuse to run otherwise (the gate).
- Challenges are single-use, stored server-side in the session, compared by the lib.
- `sign_count` regression check (the lib flags a cloned authenticator).
- Registration requires an authenticated, already-2FA-enrolled session; removal + registration are
audited. Removing the last key never removes TOTP/backup-code 2FA (recovery preserved).
- User verification `preferred` (PIN/biometric when the authenticator supports it).
## Testing
- **Unit/feature (now):**
- `WebauthnService::available()` across domain×scheme combos (mock `DeploymentService` +
request scheme): true only for domain + secure.
- `registrationOptions`/`assertionOptions` produce options with rpId = the domain and the right
allow/exclude credential lists; challenge persisted in session.
- Credential storage + `User::hasWebauthnCredentials` + remove.
- Challenge integration: with `WebauthnService` **mocked** so `verifyAssertion` returns true, a
posted assertion completes login (`assertAuthenticatedAs`); when false, it does not.
- Routes/actions abort when `available()` is false (bare-IP).
- **R12 (now, bare-IP):** Settings + challenge correctly **hide** the WebAuthn option and show the
hint; no console errors.
- **Deferred to a domain+HTTPS host:** real YubiKey register + login end-to-end (the cryptographic
ceremony cannot run with an IP rpId). Documented in the handoff as the remaining manual check.
## Out of scope
- Passwordless / passkey first-factor login (this is a 2nd factor only).
- Changing onboarding (TOTP + backup codes stay required) or the recovery flows.
- Attestation-provenance / MDS validation (not needed for a 2nd factor; attestation `none`).

View File

@ -1,331 +0,0 @@
# Confirm-Action token hardening — design
**Date:** 2026-06-15
**Scope:** `app/Livewire/Modals/ConfirmAction.php` + its 14 call sites/handlers across 8 components.
**Driver:** Codex v0.9.0 review (P2) on the generic R5 confirm modal.
---
## 1. Problem
`ConfirmAction` is the shared R5 confirm modal, reused by every confirm flow. It carries
client-mutable public properties — `event`, `params`, `auditAction`, `auditTarget`, `serverId`
that it dispatches and audits on `confirm()`. An authenticated user (all accounts are equal
admins — no RBAC) can:
- **(a) Bypass the modal** — dispatch a `#[On(...)]` apply handler directly (e.g. `keyRemoved`)
from the browser, skipping the human confirmation step entirely.
- **(b) Tamper with routing** — substitute `event` / `params` / `auditTarget` to retarget the
destructive action or forge a misleading audit row.
This is **not** a privilege-boundary crossing: the apply handlers already re-validate the real
invariants (can't delete self/last user, channel/tls/domain are allowlisted and re-checked). The
fix is therefore scoped to **integrity / confirmation**, not authorization tiers:
1. Replace arbitrary client-supplied `event` dispatch with a **server-side allowlist**.
2. Mark the modal's routing inputs **`#[Locked]`**.
3. Bind each confirm to a **signed, single-use token** issued server-side when the modal opens, so
a direct apply-handler call without a valid token is rejected and the audit descriptor cannot
be forged.
**Out of scope (flagged as follow-up):** the sibling modals with their own client-mutable routing —
`HardeningAction` (mutable `serverId`/`action`/`enable`) and `Fail2banBan` (mutable `serverId`).
Same class of exposure, lower severity (each does its own work in `apply()`/`save()` and
re-validates the server). Tracked separately.
---
## 2. The 14 ConfirmAction flows (inventory)
| Event | Opener | Handler (component) | Params | Audit written in |
|---|---|---|---|---|
| `serviceConfirmed` | Services/Index | `applyService` (Services/Index) | op, name | confirm() |
| `userRemoved` | Settings/Users | `remove` (Settings/Users) | userId | handler (deferred) |
| `userLoggedOut` | Settings/Users | `logout` (Settings/Users) | userId | handler (deferred) |
| `domainChanged` | System/Index | `applyDomain` (System/Index) | domain | confirm() |
| `channelChanged` | System/Index | `applyChannel` (System/Index) | channel | confirm() |
| `tlsModeChanged` | System/Index | `applyTlsMode` (System/Index) | mode | handler (own audit) |
| `fileConfirmed` | Files/Index | `deleteEntry` (Files/Index) | name | confirm() |
| `keyRemoved` | Servers/Show | `removeKey` (Servers/Show) | fingerprint | confirm() |
| `credentialDeleted` | Servers/Show | `deleteCredential` (Servers/Show) | — | confirm() |
| `firewallRuleDelete` | Servers/Show | `deleteFirewallRule` (Servers/Show) | spec, label | handler (deferred) |
| `webauthnKeyRemoved` | Settings/WebauthnKeys | `remove` (Settings/WebauthnKeys) | id | handler (own audit) |
| `sessionsLogoutOthers` | Settings/Sessions | `logoutOthers` (Settings/Sessions) | — | confirm() |
| `sessionsLogoutAll` | Settings/Sessions | `logoutAll` (Settings/Sessions) | — | confirm() |
| `twoFactorDisabled` | Settings/Security | `disableTwoFactor` (Settings/Security) | — | confirm() |
The split (audit in `confirm()` vs. in the handler) is the **existing** architecture and is
**preserved** — `confirm()` writes audit only when the opener supplied `auditAction`; deferred
flows audit in the handler after the remote op succeeds.
---
## 3. New unit: `App\Support\Confirm\ConfirmToken`
A small stateless service. Lives at `app/Support/Confirm/ConfirmToken.php`; companion exception
`app/Support/Confirm/InvalidConfirmToken.php` (`extends \RuntimeException`).
### Responsibilities
- **Allowlist** of permitted confirm events (the 14 above) as a `public const ACTIONS`.
- **issue** — server-side token minting at opener time.
- **verify** — decode + validate without consuming (modal display + `confirm()`).
- **consume** — verify + single-use burn + event match (apply handlers — the security gate).
- **peek** — signature-only decode for display, never throws.
### Token shape
Payload (JSON) → `Crypt::encryptString(...)`:
```
{ nonce, event, params, auditAction, auditTarget, serverId, uid }
```
- `Crypt` (APP_KEY, AES-256 + HMAC) gives **tamper-evidence** — a forged/edited token fails to
decrypt or fails the MAC.
- **Single-use** via cache: `issue` plants `confirm-token:{nonce}` (Redis, TTL 600s);
`consume` does `Cache::pull` (atomic get+delete). A replay finds nothing → rejected.
- `uid` is bound to `Auth::id()` and re-checked on decode → no cross-user replay.
### API (signatures)
```php
public const ACTIONS = [ /* the 14 event names */ ];
public static function issue(
string $event, array $params = [], string $auditAction = '',
?string $auditTarget = null, ?int $serverId = null,
): string; // throws \InvalidArgumentException if event ∉ ACTIONS
public static function verify(string $token): array; // throws InvalidConfirmToken
public static function consume(string $token, string $event): array; // throws InvalidConfirmToken
public static function peek(string $token): ?array; // null on any failure
```
### Validation rules (decode)
- decrypts cleanly (catch `DecryptException`) and is valid JSON (catch `JsonException`);
- `event``ACTIONS`;
- `uid` === `Auth::id()`.
`verify` adds: nonce still present in cache. `consume` adds: `event` arg matches payload `event`,
and `Cache::pull` returns truthy (single-use). On any failure → `InvalidConfirmToken`.
---
## 4. Modal rewrite: `ConfirmAction`
- **Remove** public props `event`, `params`, `auditAction`, `auditTarget`, `serverId`.
- Add `#[Locked] public string $token = ''`.
- Keep display props `heading`, `body`, `confirmLabel`, `danger`, `icon`, `notify`.
- Replace `boot()` with `mount()`: assign `$token` + display copy, apply the localized fallbacks
(`default_heading`, `common.confirm`, `default_notify`) that `boot()` did. Setting `$token` in
`mount()` is the initial mount → allowed by `#[Locked]`; subsequent client round-trips (the
confirm click) cannot re-tamper it.
- Audit-target display line → `#[Computed] public function targetLabel(): ?string` using
`ConfirmToken::peek($this->token)['auditTarget'] ?? null`. Not client state → no tamper surface.
```php
public function confirm(): void
{
try {
$payload = ConfirmToken::verify($this->token); // non-consuming
} catch (InvalidConfirmToken) {
$this->dispatch('notify', message: __('modals.confirm_action.rejected'));
$this->closeModal();
return;
}
if (($payload['auditAction'] ?? '') !== '') {
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $payload['serverId'] ?? null,
'actor' => Auth::user()?->name ?? 'system',
'action' => $payload['auditAction'],
'target' => $payload['auditTarget'] ?? null,
'ip' => request()->ip(),
]);
}
$this->dispatch($payload['event'], confirmToken: $this->token);
if ($this->notify !== '') {
$this->dispatch('notify', message: $this->notify);
}
$this->closeModal();
}
```
**Blade change** (`resources/views/livewire/modals/confirm-action.blade.php`): the audit-target
line switches from `@if ($auditTarget) ... {{ $auditTarget }}` to `@if ($this->targetLabel) ...
{{ $this->targetLabel }}`. No other markup changes.
**Implementation risk to verify first:** confirm wire-elements/modal passes the `arguments`
through to `mount()` (it does — same path the other modals use). If a `#[Locked]` public prop set
from `arguments` ever threw, the `mount()` assignment avoids it; both paths covered.
---
## 5. Openers (14, across 8 files)
Replace the routing arguments (`event`, `params`, `auditAction`, `auditTarget`, `serverId`) with a
single `'token'`. Display copy is unchanged. Example (`Servers/Show::confirmRemoveKey`):
```php
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('servers.confirm_remove_key_heading'),
'body' => __('servers.confirm_remove_key_body', ['name' => $comment, 'server' => $this->server->name]),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
'notify' => __('servers.notify_key_removed', ['name' => $comment]),
'token' => ConfirmToken::issue(
'keyRemoved',
['fingerprint' => $fingerprint],
'ssh_key.remove',
"{$comment} · {$this->server->name}",
$this->server->id,
),
],
);
```
Openers that passed no `auditAction` (deferred flows) pass `''`; no `serverId``null`.
---
## 6. Apply handlers (14)
Replace domain params with `string $confirmToken`; consume + read params from the verified
payload. Injected services (by type) coexist with the token param (by name). Example:
```php
#[On('keyRemoved')]
public function removeKey(string $confirmToken, FleetService $fleet): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'keyRemoved');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — silent no-op
}
$fingerprint = $payload['params']['fingerprint'];
// ... unchanged ...
}
```
Rules:
- Handlers read **only** `$payload['params']` — any client-passed value is ignored.
- Handlers that already re-validate (`applyChannel`, `applyTlsMode`, `applyDomain`, `remove`,
`logout`) keep those checks — defense in depth.
- Handlers stay **silent** on a rejected token (no toast) — no oracle for a prober. The
user-facing rejection notice is shown by the modal's `confirm()` only.
---
## 7. Security model
- **Single-use consume at the handler** — the destructive op is the gate. A direct `#[On]` call
without a valid, unburned token is a no-op. → Closes vector (a).
- **event / params / auditTarget come only from the signed token** — they can't be retargeted and
the audit descriptor can't be forged. → Closes vector (b).
- `confirm()` verifies non-consuming and writes audit from the verified payload; the handler burns
the token single-use.
- **Residual (documented, accepted):** a scripted `confirm()` replay *before* the handler round-trip
consumes the nonce could write duplicate audit rows — but they are **correctly attributed** to
the actor for an action they authorized. Negligible under the equal-admins model. After the
handler burns the nonce, all replays fail `verify`.
---
## 8. Tests
**Unit — `tests/Unit/Support/Confirm/ConfirmTokenTest.php`** (`actingAs` a user, `array` cache):
- `issue``verify`/`consume` round-trips and returns the payload.
- `issue` with an event ∉ `ACTIONS` throws `\InvalidArgumentException`.
- `consume` twice → second throws `InvalidConfirmToken` (single-use).
- `consume($token, 'otherEvent')` → throws (cross-action / event mismatch).
- garbage / edited token → `verify` & `consume` throw; `peek` returns null.
- token issued as user A, `Auth::id()` = user B → throws (uid binding).
**Feature — `tests/Feature/Modals/ConfirmActionTest.php`** (`RefreshDatabase`):
- happy path: mount with a valid token → `call('confirm')` → asserts the event was dispatched with
`confirmToken` and (for an `auditAction` flow) exactly one `AuditEvent` row written from the
payload.
- forged token: `call('confirm')` → no `AuditEvent`, event not dispatched, `notify` =
`modals.confirm_action.rejected`.
- `->set('token', '...')` on the mounted modal throws (Locked).
**Feature — handler bypass** (representative, no SSH): `tests/Feature/Settings/SecurityConfirmTest.php`:
- dispatch `twoFactorDisabled` with no / invalid `confirmToken` → 2FA stays enabled, no audit.
- with a valid issued token → 2FA disabled; a second dispatch with the same token → no-op
(single-use).
All tests use `RefreshDatabase` + factories — **never** seed the dev DB (per project rule).
---
## 9. Localization (R16)
Add to `lang/de/modals.php` and `lang/en/modals.php` under `confirm_action`:
- `rejected` — de: `Bestätigung ungültig oder abgelaufen.` · en: `Confirmation invalid or expired.`
(Keys identical in both files.)
---
## 10. Verification (R8 / R12 / R15)
1. Run the suite in-container:
`docker compose exec app php artisan test --filter='ConfirmToken|ConfirmAction|SecurityConfirm'`
then the full suite.
2. **R12 browser probe** every touched route — HTTP 200, zero console/network errors, loaded
state (not skeleton), at 375 / 768 / 1280: `servers/show`, `services`, `files`, `system`,
`settings/security`, `settings/sessions`, `settings/users`, `settings/webauthn-keys`. Open a
confirm modal on each and confirm one action end-to-end.
3. **R15** `/codex:review` over the diff → fix + re-run until 0 errors / 0 security findings.
4. `git status` for stray secrets → commit in sensible steps.
---
## 11. File touch list
**New:** `app/Support/Confirm/ConfirmToken.php`, `app/Support/Confirm/InvalidConfirmToken.php`,
3 test files.
**Edit:** `app/Livewire/Modals/ConfirmAction.php`,
`resources/views/livewire/modals/confirm-action.blade.php`,
`app/Livewire/Services/Index.php`, `app/Livewire/Settings/Users.php`,
`app/Livewire/Settings/Security.php`, `app/Livewire/Settings/Sessions.php`,
`app/Livewire/Settings/WebauthnKeys.php`, `app/Livewire/System/Index.php`,
`app/Livewire/Files/Index.php`, `app/Livewire/Servers/Show.php`,
`lang/de/modals.php`, `lang/en/modals.php`.
---
## 12. Post-review revisions (Codex R15)
The first Codex pass REJECTED the initial design (§3§7). Three classes of finding were
valid and are now folded into the implementation:
**A. Two-phase lifecycle (was: single nonce checked at confirm + consume).**
The original token was consumable the instant `issue()` returned, so an attacker who invoked
an opener could grab the token and call the apply handler directly — still skipping the modal
(and, for confirm-audited flows, skipping the audit). The nonce now carries state:
`issue()``pending`; `ConfirmToken::confirm($token)` (called only by the modal's `confirm()`)
flips `pending``confirmed`; `consume($token, $event)` requires `confirmed`, then deletes.
A never-confirmed token is rejected by the handler — the modal must run. This also removes the
§7 "duplicate-audit on replay" residual: `confirm()` transitions at most once.
**B. Atomic transitions.** `Cache::pull` is get-then-forget (not atomic), so concurrent handler
requests could double-apply. Both `confirm()` and `consume()` now run their check-and-mutate
inside `Cache::lock($key.':lock', 10)->get(...)` (array + Redis both provide locks).
**C. Server-scope binding.** Server-scoped actions seal the server id and the handler verifies it
against live state before acting — closing token retargeting to another host/path:
- `Servers/Show` `removeKey`/`deleteCredential`/`deleteFirewallRule`: reject unless
`$payload['serverId'] === $this->server->id` (firewall opener now seals `serverId`).
- `Files/Index` `deleteEntry`: opener seals the **full path** + active server id; handler rejects
on server mismatch and deletes the **sealed** path (never a client-recombined one).
- `Services/Index` `applyService`: opener seals the active server id; handler rejects on mismatch.
Method shape became: `issue()`, `confirm()` (modal), `consume()` (handler), `peek()` (display).
`verify()` was removed. Added tests: `ConfirmServerScopeTest`, plus two-phase / unconfirmed-token
cases in the unit + Security/Modal feature tests.

View File

@ -1,189 +0,0 @@
# de-Claude the repo + git history (beta-prep #2) — Design / Runbook
**Date:** 2026-06-15 · **Branch:** `feat/v1-foundation` · **Status:** approved, execution DEFERRED to
beta/staging cut · Beta-prep #2 (the final, irreversible roadmap item)
Strip every trace of AI-assisted development from the repository **and from its entire git history**,
so the codebase that ships to beta/staging looks like an ordinary, human-authored product repo. This
is a **history rewrite + force-push** — irreversible, and it rewrites the shared branch. It runs
**once, last**, after all content is complete and tagged, and **only on the operator's explicit go**
for the force-push.
> **Timing:** the operator scoped this to beta/staging time ("in der dev Variante ist das nicht so
> schlimm … später wenn es in beta oder staging muss das weg sein"). During dev the AI traces stay.
> This document is the prepared runbook; it is **not** executed now.
## Operator-approved decisions
- **File scope — purge ALL internal process docs from every commit:** `CLAUDE.md`, `rules.md`,
`handoff.md`, `docs/session-handoff.md`, and the whole `docs/superpowers/` tree. Product code,
`README.md`, `CHANGELOG.md`, `install.sh`, `docker/`, etc. stay.
- **Keep on disk, gitignore:** after purging from git, the files remain on disk (dev keeps using
`rules.md`/`CLAUDE.md` as references); they are added to `.gitignore` so they never re-enter git.
- **Message scrub — trailers only:** strip the 167 `Co-Authored-By: Claude …` trailer lines. Keep all
descriptive subjects/bodies (they describe real changes). The 23 "Codex" / 19 "spec" message
mentions are **left as-is** (operator's call). Commits that only added a now-purged doc become empty
and are pruned automatically.
## Ground truth (scanned 2026-06-15)
- **170 commits, single author** `boban <boban@git.bave.dev>` — no "Claude" author/committer identity
to fix.
- **167/170** commits carry a `Co-Authored-By: Claude …` trailer; **0** "Generated with Claude Code"
lines.
- Tracked artifacts to purge: `CLAUDE.md`, `rules.md`, `handoff.md`, `docs/session-handoff.md`,
`docs/superpowers/**` (16 spec/plan files).
- **No** `.claude/`, `.cursor/`, `AGENTS.md`, `.mcp.json` anywhere in history.
- **One branch** (`feat/v1-foundation`) + tags `v0.1.0 … v0.9.2`. No `main`.
- `git-filter-repo` is **not** installed; no `pip3`; `python3` 3.13 is present → use the single-file
script via `python3` (no sudo needed).
## Tool: git-filter-repo (single-file, no install)
`git filter-repo` is the modern, git-recommended history-rewrite tool (correct tag rewriting, empty-
commit pruning, ref-safe — unlike the deprecated `filter-branch`). It is one self-contained Python
script. Fetch it once into the repo-adjacent tooling dir and run via `python3` — no `pip`, no `sudo`:
```bash
# from a machine with internet; the script is pure Python, ~250 KB, MIT-licensed
curl -fsSL https://raw.githubusercontent.com/newren/git-filter-repo/main/git-filter-repo \
-o /tmp/git-filter-repo && chmod +x /tmp/git-filter-repo
python3 /tmp/git-filter-repo --version # sanity
```
(Alternative if preferred and sudo is available interactively: `sudo apt-get install git-filter-repo`.)
## Runbook (execute top-to-bottom AT BETA TIME)
### Phase 0 — preconditions (abort if any fails)
- This is the **last** operation. All feature work merged, all releases tagged, `CHANGELOG` current.
- **Working tree clean**`git status --short` empty. (In particular the parallel timing-fix
refinement to `app/Livewire/Auth/ForgotPassword.php` must already be committed.)
- Full suite green; app boots (R12).
- No open Gitea PRs against the branch (a force-push invalidates them). Solo repo → expected none.
### Phase 1 — backup (the safety net for an irreversible op)
```bash
TS=$(date +%Y%m%d-%H%M%S)
# 1a. complete mirror of all refs (commits, branches, tags) — the real recovery artifact
git bundle create "$HOME/clusev-pre-declaude-$TS.bundle" --all
# 1b. on-disk copies of the to-be-kept files (filter-repo will delete them from the work tree)
mkdir -p "$HOME/clusev-keep-$TS"
cp -a CLAUDE.md rules.md handoff.md docs/session-handoff.md "$HOME/clusev-keep-$TS/"
mkdir -p "$HOME/clusev-keep-$TS/superpowers" && cp -a docs/superpowers/. "$HOME/clusev-keep-$TS/superpowers/"
# verify the bundle is restorable BEFORE proceeding
git bundle verify "$HOME/clusev-pre-declaude-$TS.bundle"
```
Do **not** delete these backups until Phase 6 confirms a fresh clone of the rewritten remote builds.
### Phase 2 — rewrite history (in-place, --force; backups exist)
```bash
python3 /tmp/git-filter-repo \
--path CLAUDE.md \
--path rules.md \
--path handoff.md \
--path docs/session-handoff.md \
--path docs/superpowers \
--invert-paths \
--message-callback '
import re
return re.sub(rb"(?im)^\s*Co-authored-by:\s*Claude.*\n?", b"", message).rstrip() + b"\n"
' \
--force
```
- `--invert-paths` deletes the listed paths from **every** commit; commits that become empty are
pruned. Tags pointing at rewritten commits are rewritten automatically.
- The `--message-callback` strips any `Co-authored-by: Claude …` line (case-insensitive) and tidies the
trailing blank line. Bodies/subjects are otherwise untouched.
- filter-repo **removes the `origin` remote** afterward (a deliberate guard) and `reset --hard`s the
work tree to the rewritten HEAD — so `CLAUDE.md` etc. now vanish from disk too (restored in Phase 4).
### Phase 3 — verify the rewrite
```bash
git log --all -i --grep='co-authored-by: claude' --oneline | wc -l # expect 0
for f in CLAUDE.md rules.md handoff.md docs/session-handoff.md; do \
echo -n "$f in history: "; git log --all --oneline -- "$f" | wc -l; done # expect 0 each
git log --all --oneline -- docs/superpowers | wc -l # expect 0
git rev-list --count HEAD # < 170 (empty doc-only commits pruned)
git log -1 --format='%an <%ae>' # still boban — author identity unchanged
```
### Phase 4 — restore kept files on disk + gitignore them
```bash
# Phase 1 copied these in by basename, so $KEEP holds: CLAUDE.md, rules.md, handoff.md,
# session-handoff.md, and superpowers/. Restore to their repo paths:
KEEP="$HOME/clusev-keep-$TS"
cp -a "$KEEP/CLAUDE.md" "$KEEP/rules.md" "$KEEP/handoff.md" .
mkdir -p docs && cp -a "$KEEP/session-handoff.md" docs/session-handoff.md
mkdir -p docs/superpowers && cp -a "$KEEP/superpowers/." docs/superpowers/
```
Append to `.gitignore` (idempotently):
```
# Internal AI-assisted-dev docs — kept locally, never committed (de-Claude'd for beta)
/CLAUDE.md
/rules.md
/handoff.md
/docs/session-handoff.md
/docs/superpowers/
```
Commit just the ignore change (and any tidy), **without** a Co-Authored-By trailer:
```bash
git add .gitignore && git commit -m "chore: ignore internal development docs"
git status --short # clean — the kept files are now invisible to git
```
### Phase 5 — re-add origin, rebuild, GATED force-push
```bash
git remote add origin https://git.bave.dev/boban/clusev.git # filter-repo dropped it
```
Rebuild + run the suite on the rewritten tree (R12 — app boots, tests green) before pushing.
**>>> EXPLICIT-CONFIRMATION GATE <<<** — the force-push is irreversible and rewrites the shared
branch/tags. Do not run it without the operator's clear, in-the-moment "yes, force-push". Token is
read inline from `~/.env.gitea` and **all** output is sanitized (never echo the token):
```bash
TOKEN=$(grep -E '^GIT_ACCESS_TOKEN=' "$HOME/.env.gitea" | head -1 | cut -d= -f2- | tr -d '\r\n')
URL="https://boban:${TOKEN}@git.bave.dev/boban/clusev.git"
# branch — plain --force to the explicit URL (filter-repo dropped origin and the local
# origin/* tracking ref is stale from earlier explicit-URL pushes, so --force-with-lease
# has no reliable lease to check here; the Phase-1 bundle is the safety net instead)
git push "$URL" --force feat/v1-foundation 2>&1 | sed -E 's#https://[^@]*@#https://<redacted>@#g; s/[0-9a-f]{40,}/<redacted>/g'
# tags moved to new commits — force-update them too
git push "$URL" --force --tags 2>&1 | sed -E 's#https://[^@]*@#https://<redacted>@#g; s/[0-9a-f]{40,}/<redacted>/g'
unset TOKEN URL
```
(Enumerate any additional remote branches first with `git ls-remote --heads "$URL"` — currently only
`feat/v1-foundation` exists. Do **not** use `--mirror`: it would prune refs unexpectedly.)
### Phase 6 — post-checks, then clean up
- Fresh-clone check: clone the remote into a temp dir, confirm it builds and contains **no**
`Co-Authored-By: Claude`, no `CLAUDE.md`/`rules.md`/`handoff.md`/`docs/superpowers`.
- Gitea: releases follow the force-updated tags; old commit SHAs in any links/PRs break (expected,
solo repo).
- Only **after** the fresh clone verifies: the Phase-1 backups (`bundle` + keep dir) may be archived
or removed.
## Going forward (post-execution rule)
Once de-Claude has run, **stop appending the `Co-Authored-By: Claude` trailer** to new commits, and do
not re-add the gitignored docs to git — otherwise the traces re-enter the history this rewrite cleaned.
(This overrides the default commit-footer convention, per the operator's beta requirement.)
## Recovery (if something goes wrong)
The remote is force-pushed, but the Phase-1 `--all` bundle holds the complete pre-rewrite state:
```bash
git clone "$HOME/clusev-pre-declaude-<TS>.bundle" clusev-restored # full history back
# or, in-place: git fetch "$HOME/…bundle" '+refs/*:refs/*' then reset/force-push the old refs
```
## Security
- Token read only at push time from `~/.env.gitea`; never echoed/committed; push output sanitized.
- The rewrite never touches `.env*` (gitignored, not in history) or any secret.
- filter-repo's `origin`-drop + `--force` requirement are guards; the mandatory pre-backup is the
recovery path for the one irreversible step (the force-push).
## Notes / self-reference
- This spec lives under `docs/superpowers/` — the rewrite purges it from git but Phase 4 restores it on
disk (gitignored), so it survives as the executed-runbook reference.
- 170 commits is small; filter-repo completes in seconds. `filter-branch` is a zero-dependency
fallback but slower and deprecated — prefer filter-repo.
## Out of scope
- Re-writing commit-message bodies beyond the Co-Authored-By trailer (operator chose trailers-only).
- Squashing/reordering history (we preserve the real commit-by-commit story, minus traces).
- Anything in beta-prep #3 (README, done), #4 (installer, done), #5 (MOTD, done).

View File

@ -1,81 +0,0 @@
# SMTP-aware forgot-password (15-min e-mail link + 2FA fallback) — Design
**Date:** 2026-06-15 · **Branch:** `feat/v1-foundation` · **Status:** approved
Rework "Passwort vergessen" so the **e-mail reset link** is the primary path when the operator has
configured SMTP, falling back to the existing **2FA-possession reset** when no mailer is available
(a self-hosted box may have no SMTP). The public forgot screen no longer advertises the
`clusev:reset-admin` CLI (anyone could read it pre-login) — that last-resort recovery is documented in
**Settings → Security** (admin-only) and the **README**.
This is sub-project #1 of a larger batch; #2#5 (de-Claude the repo + history, professional README,
installer overhaul, themed MOTD) are separate beta-prep specs, tackled after this.
## 1. Mail availability
`ForgotPassword::mailEnabled()` already returns whether a real mailer is set (`config('mail.default')`
not `log`/`array`/null). With the 0.9.0 in-panel SMTP override applied at boot, `mail.default` becomes
`smtp` once configured — so `mailEnabled()` is the single source of truth. (No new method needed.)
## 2. Flow — `Auth\ForgotPassword` (full-page, auth-layout)
- **SMTP set (`mailEnabled()` true):**
- Default view: **email only** + "Reset-Link senden" → `Password::sendResetLink(['email' => …])` (the
Laravel broker; sends the `ResetPassword` notification through the configured mailer). Always show
the generic "Falls die E-Mail existiert, wurde ein Reset-Link gesendet." (no account enumeration).
- A small secondary toggle **"Kein E-Mail-Zugang? Mit 2FA-Code zurücksetzen"** reveals the inline
2FA-proof form (so a user with their authenticator but no inbox access still recovers).
- **No SMTP (`mailEnabled()` false):**
- Show ONLY the 2FA-proof inline reset (email + TOTP/backup-code + new password) — the current
`resetPassword()` logic (rate-limited, remember-token rotation, audited, generic errors). No
e-mail-link option (nothing can be delivered).
- The two paths reuse the existing `sendResetLink()` and `resetPassword()` methods; only the **view**
becomes mail-aware (Blade `@if ($this->mailEnabled())`), plus an Alpine toggle for the secondary form.
## 3. Reset link target — `Auth\ResetPassword`
Unchanged: the token link lands here to set the new password. **Token expiry → 15 minutes:** set
`config/auth.php` `passwords.users.expire = 15` (was Laravel's default 60). Confirm the
`password_reset_tokens` table migration exists (it ships with the users-table migration).
## 4. Remove the CLI hint from the public screen
- Delete the `reset_no_2fa_note` line (it names `clusev:reset-admin`) from
`resources/views/livewire/auth/forgot-password.blade.php`. Keep the lang key or repurpose it.
- **Settings → Security** (`Settings\Security` view, admin-only): add a discreet recovery note —
"Konto-Wiederherstellung (letzter Ausweg): per SSH auf den Host und `clusev:reset-admin` ausführen."
(admin-only context, so showing the command is fine here.) New lang key.
- **README:** add a short "Wiederherstellung / Account recovery" section documenting `clusev:reset-admin`
(the full professional README is sub-project #3; for #1 add the minimal recovery section so the doc
trail exists).
## 5. Copy
The forgot subtitle should reflect the SMTP-on default. With SMTP: "Wir senden dir einen Reset-Link
(15 Minuten gültig)." With the 2FA fallback toggle present. Without SMTP: the current "mit 2FA-Code
oder Backup-Code" subtitle. Drive the subtitle off `mailEnabled()`. German + English (R16).
## Files
- `app/Livewire/Auth/ForgotPassword.php` — subtitle helper / no logic change to the two existing
actions (sendResetLink / resetPassword); maybe a `public bool $showCodeReset` toggle for the
secondary form when SMTP is on.
- `resources/views/livewire/auth/forgot-password.blade.php` — mail-aware layout + Alpine/Livewire
toggle; remove the CLI hint.
- `config/auth.php``passwords.users.expire = 15`.
- `app/Livewire/Settings/Security.php` + its view — the admin-only recovery note.
- `lang/{de,en}/auth.php` — subtitle variants, the "use 2FA code" toggle label, drop/repurpose
`reset_no_2fa_note`.
- `lang/{de,en}/settings.php` (or `auth`) — the Settings recovery note.
- `README.md` — a minimal recovery section.
- Tests.
## Testing
- SMTP on (`config(['mail.default' => 'smtp'])`): the forgot view shows the email-only form (assertSee
the send-link button, assertDontSee the new-password fields by default); `sendResetLink` dispatches a
`ResetPassword` notification (use `Notification::fake()` + `assertSentTo`); the secondary toggle
reveals the 2FA form.
- SMTP off: the view shows the 2FA-proof inline form; `resetPassword` with a valid TOTP/backup code
resets + rotates remember-token (existing tests still pass).
- Token expiry is 15 minutes (assert `config('auth.passwords.users.expire') === 15`).
- The public forgot view contains NO `clusev:reset-admin` string; the Settings → Security view DOES
(admin-only).
- Pint, full suite green, Codex clean.
## Out of scope (separate beta-prep specs)
- #2 de-Claude repo + git history, #3 professional README, #4 installer overhaul, #5 themed MOTD.
- Changing the WebAuthn/TOTP mechanics or the SMTP config itself (0.9.0).

View File

@ -1,81 +0,0 @@
# Installer overhaul + themed MOTD — Design
**Date:** 2026-06-15 · **Branch:** `feat/v1-foundation` · **Status:** approved · Beta-prep #4 (+ #5 MOTD)
Make `install.sh` a true one-command bootstrap on a fresh Debian/Ubuntu host: install Docker, create
a dedicated `clusev` user, check the domain's DNS, bring the stack up, and end with a clean banner
(dedicated-user creds + admin creds + dashboard URL only). Also install a product-themed MOTD shown at
every host login. Builds on the existing 7-phase idempotent installer (secrets, stack, migrate, admin,
sentinel) — extend, don't rewrite.
## Operator-approved decisions
- **Docker auto-install:** Debian/Ubuntu (apt, official Docker repo) only; other OS → clear message + manual link.
- **Dedicated user:** login user `clusev` with a random password, in the `docker` group, owns the install dir.
- **Run mode:** `sudo ./install.sh` (root required for Docker install + user creation).
- **Domain:** DNS-check → if it resolves to this server, proceed with TLS; else warn + offer "take the domain anyway (HTTP until DNS points here)" or "continue on the IP".
## Flow (extends the current phases)
**Phase 0 — root + OS.** Require root (`[ "$(id -u)" = 0 ]` else `die "Bitte mit sudo ausführen: sudo ./install.sh"`).
Read `/etc/os-release`; set `OS_FAMILY` from `ID`/`ID_LIKE` (debian|ubuntu → apt). Non-apt + Docker missing → die with a manual-install hint.
**Phase 1 — preflight + Docker.** If `docker` + `docker compose` present → skip. Else on apt:
install via the official repo — `install -m0755 -d /etc/apt/keyrings`, fetch
`https://download.docker.com/linux/$ID/gpg` → keyring, add the `deb [signed-by=…]` repo, `apt-get update`,
`apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin`,
`systemctl enable --now docker`. Verify `docker compose version` after. Keep `openssl` check.
**Phase 2 — dedicated `clusev` user.** If `id clusev` fails → `useradd -m -s /bin/bash clusev`, set a
random password (`CLUSEV_USER_PW=$(openssl rand -base64 18)`, `chpasswd`), `usermod -aG docker clusev`.
Always: `chown -R clusev:clusev` the install dir. Set `HOST_UID`/`HOST_GID` in `.env` to clusev's
uid/gid (so containers run as clusev). Idempotent: existing user kept, password NOT regenerated (only
shown if freshly created — track `USER_CREATED=1`).
**Phase 3 — inputs + domain DNS check.** Keep the interactive domain/email prompts (+ env + bare-IP
fallback). When a domain is given: resolve it (`getent ahosts "$domain" | awk '{print $1}' | sort -u`)
and get the server's public IP (`curl -fsS https://api.ipify.org` with a `hostname -I` fallback). If the
domain's A/AAAA set contains the server IP → `DOMAIN_OK=1`, proceed with TLS. Else warn with the
resolved-vs-server IPs and offer (interactive): [1] take the domain anyway — "Cert kommt automatisch,
sobald DNS hierher zeigt; bis dahin nur HTTP", [2] continue on the IP (clear the domain). Non-interactive
default: take the domain anyway (on-demand TLS will issue once DNS is correct) — but print the warning.
**Phases 4-8 — unchanged in spirit:** `.env`/secrets (idempotent), session hardening, build image, start
stack, restart-sentinel units (render `User=clusev` + the real path now), wait for DB, migrate + caches,
first admin (one-time random password). The sentinel `chown`/unit-user becomes `clusev`.
**Phase 9 — MOTD (beta-prep #5).** Install a themed dynamic MOTD: write an executable
`/etc/update-motd.d/00-clusev` (Debian/Ubuntu run-parts MOTD) that prints the Clusev wordmark in the
product's signal-orange ANSI (256-color `\033[38;5;208m`), a hairline, and the live access line
(`https://<domain>` or `http://<server-ip>`), plus "verwaltet vom Benutzer clusev · docker compose -f
docker-compose.prod.yml ps". Ship the template under `docker/motd/00-clusev` and `install`-copy it
(chmod +x), substituting the URL. Idempotent (overwrite our file only; never touch other MOTD parts).
On non-Debian (no update-motd.d) write `/etc/motd` static as a fallback.
**Closing banner.** Print ONLY (signal-orange, no phase noise): "Installation erfolgreich." then a boxed
summary — Dashboard URL/IP · Admin-Login (email + the one-time admin password) · (if freshly created)
the `clusev` host user + its one-time password · "Diese Passwörter werden nur jetzt angezeigt." No other
output after. Secrets appear ONLY in this banner (never logged/echoed elsewhere).
## Files
- `install.sh` — phases 03 + 9 added, sentinel rendering → `clusev`, closing banner reworked.
- `docker/motd/00-clusev` (NEW) — the themed MOTD template (with a `__CLUSEV_URL__` placeholder).
- `.env` writes — `HOST_UID`/`HOST_GID` = clusev's; existing keys unchanged.
- (README documents all of this — beta-prep #3, separate.)
## Security
- Docker installed from the official GPG-verified apt repo (no `curl|bash get.docker.com`).
- `clusev` is in the `docker` group (docker = root-equivalent) — this IS the dedicated admin user, by design; documented.
- Random passwords via `openssl rand`, shown once in the closing banner, never logged.
- Root is required up-front and clearly stated; the script stays idempotent + `set -euo pipefail`.
- DNS check uses the resolved A/AAAA vs the server IP; a mismatch never silently enables a wrong-host cert (on-demand TLS already gates issuance to the configured domain).
## Testing
- `shellcheck install.sh` + `docker/motd/00-clusev` clean (no errors).
- The MOTD script renders the banner with a substituted URL (run it locally, check ANSI + the URL line).
- Idempotency: re-running on an already-installed host must not regenerate secrets/passwords or recreate
the user (dry-reason: the closing banner shows the freshly-created creds only on first run).
- A full root install can only be validated on a fresh Debian/Ubuntu VM (out of CI) — the operator runs
it there; the build verifies via shellcheck + a structured read-through + the MOTD render.
## Out of scope
- #3 professional README (documents this), #2 de-Claude history rewrite (last). Non-apt Docker
auto-install (RHEL/dnf). Unattended/cloud-init variants.

View File

@ -1,176 +0,0 @@
# Design — Airtight show-once recovery codes
Date: 2026-06-15
Status: Approved (brainstorming) — ready for implementation plan
Scope: low-priority hardening of the "show backup codes only once" feature
## 1. Problem
The recovery-codes reveal (`app/Livewire/Modals/RecoveryCodes.php` + view) gates display
behind a `#[Locked] public bool $revealed` prop, a one-time `2fa.codes_fresh` session flag,
and a `2fa.download_grant`-gated download route.
A Codex review noted a residual, low-severity vector: `#[Locked]` blocks property-update
payloads but NOT Livewire snapshot hydration. A client that captured the `revealed=true`
snapshot during the legitimate one-time reveal could replay that signed snapshot to
re-render the (same) stored codes.
Marginal exposure is ~zero — the replayer already saw those codes at the legitimate reveal,
same user, no privilege escalation — so it was accepted as an inherent Livewire-statelessness
limitation. This design removes the vector entirely for teams that want the show-once
guarantee to be structural rather than UX-level.
## 2. Threat model
Kill vector: a captured `revealed=true` snapshot replayed to `/livewire/update` re-renders
stored codes server-side.
Fix principle: the plaintext codes and the reveal-state must NEVER enter any persisted
Livewire property or snapshot.
- `mount()` does NOT run on hydrate — only on the initial component mount. So a replayed
snapshot never re-triggers mount-time logic.
- PHP-side dispatched events (`$this->dispatch(...)`) ride in the one-time response
`effects.dispatches`, NOT in the snapshot. A replayed snapshot carries no dispatch.
Therefore: if codes are delivered only via a transient dispatched event fired from
`mount()`/`regenerate()`, and the component holds no codes/`revealed` state, a replayed or
stale snapshot has literally nothing to re-render and fires no event.
## 3. Approach (selected)
Transient dispatch → Alpine client-side render. Chosen over (a) a server-rendered design
gated by a single-use hydrate nonce (more moving parts, doesn't cleanly kill the same-window
race) and (b) a non-modal full-page reveal (breaks the wire-elements/modal UX across all four
open sites). Approach below has the smallest blast radius and is idiomatic Livewire 3 + Alpine.
All four modal-open sites go through a Livewire `openModal` roundtrip (wire-elements/modal),
so `mount()` always runs inside a Livewire request and the dispatched event always rides back
in that response. Open sites confirmed:
- `app/Livewire/Auth/TwoFactorSetup.php` (first-factor enrollment → `put('2fa.codes_fresh')`
+ flash `open_recovery_modal`, redirect to settings)
- `app/Livewire/Settings/Index.php` + `resources/views/livewire/settings/index.blade.php`
(`x-init="$dispatch('openModal', ...)"` on the post-enrollment auto-open)
- `app/Livewire/Settings/WebauthnKeys.php` (`$this->dispatch('openModal', ...)` after setting
the flags)
- `resources/views/livewire/settings/security.blade.php` (manage button — no fresh flag → hidden)
## 4. Component — `app/Livewire/Modals/RecoveryCodes.php`
- Delete `#[Locked] public bool $revealed`. No codes property. The component holds zero
secret state.
- `mount()`: `if (session()->pull('2fa.codes_fresh', false)) { $this->dispatchCodes(); }`
- `regenerate()`: `replaceRecoveryCodes()` + `AuditEvent::create([...])` (unchanged) +
`session()->put('2fa.download_grant', true)` + `$this->dispatchCodes()` + notify. Drop the
`$this->revealed = true` line.
- New `protected function dispatchCodes(): void`:
`$this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes());`
- `render()`: returns the view with NO codes payload (drop the `'codes' => ...` array).
Codes leave the server only inside the transient `reveal-codes` event detail — same
visibility to the legitimate user, absent from the snapshot and from the server-rendered HTML.
## 5. View — `resources/views/livewire/modals/recovery-codes.blade.php`
- Root element: `x-data="{ codes: [] }" @reveal-codes.window="codes = $event.detail.codes"`,
plus `x-cloak` to suppress any raw-template flash before Alpine initializes.
- Codes grid: replace the Blade `@foreach ($codes as $c)` with
`<template x-for="c in codes" :key="c"><span class="font-mono text-sm tracking-wide text-ink" x-text="c"></span></template>`.
- Reveal panel (warning + grid + download button + close) wrapped in `x-show="codes.length"`.
- Hidden-notice panel (notice + regenerate button + close) wrapped in `x-show="!codes.length"`.
- Dependency: ensure `[x-cloak] { display: none }` exists in `resources/css/app.css`; add it if
missing.
Known minor UX: on a fresh reveal there is a ~1-tick window where the hidden-notice panel can
flash before the `reveal-codes` event lands and flips `codes`. No secret is exposed by this;
accepted.
## 6. Download route — `routes/web.php`
Append `->block(5, 5)` to the `two-factor.recovery.download` route. Laravel session blocking
(Redis-backed atomic lock — Redis is in the stack) serializes concurrent same-session requests
so the `session()->pull('2fa.download_grant')` cannot be double-read by a racing request. The
`array`/`file` cache stores are also lock-capable, so the test suite is unaffected. Add a
one-line comment noting the lock closes the concurrent-download race (true concurrency cannot
be unit-tested).
Grant TTL (added after Codex review — F2 defense-in-depth): the grant is stored as a Unix
timestamp instead of a bool, and the route rejects any grant that is not an integer or is older
than 10 minutes (600s):
```php
$grantedAt = session()->pull('2fa.download_grant');
abort_unless(is_int($grantedAt) && now()->timestamp - $grantedAt <= 600, 403);
```
This bounds an unused grant's replay window to 10 minutes instead of the whole authenticated
session. All three grant setters store `now()->timestamp` (see §8).
CSRF (added after Codex review — F4): consuming the one-time grant is a state change, so the
route is `POST` (not `GET`) and carries Laravel's CSRF protection. The download control in the
view is a small `<form method="POST">` with `@csrf` wrapping the button — behaves identically to
a download link, but a cross-site request can no longer burn the victim's grant.
## 7. Tests — `tests/Feature/RecoveryCodesModalTest.php`
- Remove `test_revealed_property_is_locked_against_client_tampering` (the `revealed` prop no
longer exists) and the now-unused `CannotUpdateLockedPropertyException` import.
- `manage view (no flag)`: `assertNotDispatched('reveal-codes')` + `assertDontSee($codes[0])`
+ `assertSee(__('auth.recovery_hidden_notice'))`.
- `fresh flag reveals once`: `assertDispatched('reveal-codes', codes: $codes)` AND
`assertDontSee($codes[0])` (proves codes are absent from the server-rendered HTML/snapshot)
AND `assertFalse(session()->has('2fa.codes_fresh'))` (one-time).
- Replay proof: after the fresh mount, `->call('$refresh')`
`assertNotDispatched('reveal-codes')` + `assertDontSee($codes[0])`; PLUS a second fresh
`Livewire::test(RecoveryCodes::class)` (the flag is already pulled) → same assertions. This
models a replayed/stale snapshot yielding zero codes (mount does not re-run on hydrate, and
the one-time flag is already consumed).
- `regenerate`: `assertDispatched('reveal-codes', codes: $new)` + `assertDontSee($old[0])` +
`assertDontSee($new[0])` (codes never in HTML) + `assertTrue(session()->has('2fa.download_grant'))`.
- Keep `test_download_requires_a_fresh_grant` and `test_old_recovery_route_is_gone` unchanged —
`->block()` leaves the happy path green.
## 8. Grant setters + untouched
- Grant setters (updated for the TTL — F2): `Auth/TwoFactorSetup.php`,
`Settings/WebauthnKeys.php`, and `Modals/RecoveryCodes::regenerate()` now store
`session()->put('2fa.download_grant', now()->timestamp)` instead of `true`.
- Reveal-flag setters (updated for symmetry — F5): `Auth/TwoFactorSetup.php` and
`Settings/WebauthnKeys.php` store `2fa.codes_fresh` as `now()->timestamp` too, and
`RecoveryCodes::mount()` only reveals when that flag is within `[now-600s, now]` — a stale
flag (enrollment whose modal never opened) reveals nothing.
- Both timestamp checks (route grant + `mount()` flag) validate the window as
`$age !== null && $age >= 0 && $age <= 600`, which also rejects a future-dated value (F6).
- `Settings/Index.php` is unchanged; the modal now consumes `codes_fresh` by dispatching the
codes instead of flipping a `revealed` bool.
- No i18n changes: no new visible strings (the heading, subtitle, warning, hidden-notice, and
button labels already exist under `auth.recovery_*` / `common.*`). The `x-for` renders raw
code strings, which are not translatable. R9 / R16 not applicable.
## 9. Verification (R12 + R15)
- Browser-load both states at 375 / 768 / 1280:
- fresh-enrollment reveal (codes render via Alpine),
- manage view (hidden notice, no codes).
- HTTP 200, zero console/network errors; for the lazy modal check the loaded state.
- Confirm in the openModal network response that the codes are ABSENT from the snapshot and the
server-rendered HTML, present only in `effects.dispatches`. Inspect the rendered DOM for
leaked `@`/`{{ }}`/`$var`/`group.key` text (R17).
- All tooling runs in-container (R8). Destructive/confirm UI stays wire-elements/modal (R5).
- Run `/codex:review` over the change; fix and re-run until it reports no errors and no security
issues (R15). The task is not done until Codex passes.
## 10. Files touched
1. `app/Livewire/Modals/RecoveryCodes.php` — remove prop, add `dispatchCodes()`, adjust
`mount()` / `regenerate()` / `render()`; grant stored as timestamp.
2. `resources/views/livewire/modals/recovery-codes.blade.php` — Alpine `x-data` + `x-for` +
`x-show` panels + `x-cloak`.
3. `routes/web.php` — download route changed `GET`→`POST` (CSRF) + `->block(5, 5)` +
timestamp-grant TTL (≤600s).
4. `tests/Feature/RecoveryCodesModalTest.php` — replace the locked-prop test; add dispatch +
replay + snapshot-inspection + regenerate + stale-grant assertions.
5. `app/Livewire/Auth/TwoFactorSetup.php`, `app/Livewire/Settings/WebauthnKeys.php` — grant
setters store `now()->timestamp` (TTL, F2).
`resources/css/app.css` is NOT touched — `[x-cloak] { display: none }` is already present.

View File

@ -1,116 +0,0 @@
# „Zertifikat anfordern"-Button (Dashboard, On-Demand-TLS auslösen) — Design
**Date:** 2026-06-17 · **Branch:** `feat/v1-foundation` · **Status:** approved
Add a System → Domain & TLS control that lets the operator **explicitly request the Let's-Encrypt
certificate from the dashboard** (with a DNS pre-check and a clear status), for the common case where
a domain was configured (at install or in the dashboard) before its DNS pointed at the server.
## Why this is even needed (current behaviour)
Caddy already issues certificates **on demand**: on the first HTTPS handshake for the configured
(active) panel domain, Caddy asks `app:80/_caddy/ask`, the app approves ONLY the active domain
([routes/web.php:35], [docker/caddy/Caddyfile:19]), and Caddy obtains the cert automatically. So once
DNS points here, the cert is fetched on the first `https://<domain>` visit — no action required, and
it self-heals (Caddy retries on later handshakes until DNS is correct).
The gap is **visibility + control**, not capability: the auto-flow is invisible (the operator can't
tell whether the cert exists without opening `https://` and inspecting), and nothing lets them trigger
it proactively. This feature surfaces a DNS check, a proactive trigger, and a status.
## Trigger mechanism (the core decision — approved)
The app triggers Caddy's on-demand issuance by performing an **internal HTTPS handshake to the `caddy`
service with SNI = the domain**:
```
curl --connect-to <domain>:443:caddy:443 https://<domain>/up (cert-verifying)
```
`--connect-to` keeps SNI/Host = the real domain while dialing the internal `caddy` service (same Docker
network, [docker-compose.prod.yml] `caddy` + `app` share a network). Caddy sees SNI=domain → on-demand
path → asks `/_caddy/ask` (approves) → runs ACME. The ACME challenge itself still needs the PUBLIC
domain to resolve here with 80/443 reachable — the trigger starts issuance, public reachability
completes it. (Rejected alternatives: passive status-only — doesn't actively trigger; exposing Caddy's
admin API — extra attack surface.)
The app image has `curl`, `openssl`, and PHP `dns_get_record` (verified), so no new dependency.
## Flow on click — `DeploymentService::requestCertificate(string $domain): array`
1. **Guard:** if `externalTls()` or the domain is empty → return a `not_applicable` status (the button
is hidden in those cases anyway; this is belt-and-suspenders).
2. **DNS pre-check** (`domainResolvesHere`): resolve the domain's A/AAAA via `dns_get_record` and
compare to the server's public IP (`serverPublicIp()`). On a clear mismatch → return
`status: 'dns_mismatch'` with the resolved-vs-server IPs and DO NOT trigger ACME (this is what
protects the Let's-Encrypt failed-validation rate limit from being burned). If the public IP can't
be determined, skip the comparison and proceed (the trigger result is the real source of truth).
3. **Trigger:** the `curl --connect-to <domain>:443:caddy:443 https://<domain>/` handshake (short
timeout, ~15s). The certificate is established during the TLS handshake itself, so the HTTP path is
irrelevant — `/` (which redirects to login) is fine; no special endpoint is required.
4. **Verify:** confirm a *trusted* cert is now served for the domain (the cert-verifying curl returns
2xx/3xx, or an `openssl s_client` check shows a valid chain). → `status: 'issued'`. Otherwise
`status: 'failed'` with a hint ("Ports 80/443 vom Internet erreichbar? ACME-Challenge fehlgeschlagen.").
### Supporting `DeploymentService` methods
- `serverPublicIp(): ?string` — the server's public IP (`curl -fsS https://api.ipify.org`, ~3s timeout,
cached ~5 min). Mirrors the installer's approach. Best-effort; null on failure.
- `domainResolvesHere(string $domain): array{ok: ?bool, resolved: string[], serverIp: ?string}`
A/AAAA vs public IP. `ok`: true = matches, false = clear mismatch, **null = unknown** (public IP not
determinable → caller treats unknown as "proceed", since the handshake result is authoritative).
**Status display — persisted, NOT a live page-load probe.** A naive "is a cert active?" check would
itself be an internal handshake for the domain, which (if no cert exists) makes Caddy attempt on-demand
issuance — so auto-probing on every page load could burn the ACME failed-validation rate limit when DNS
is wrong. Instead, `requestCertificate()` **persists its outcome** (a `Setting` `tls_cert_status`:
`{status, checkedAt, detail}`); the System page reads that persisted value for the status line ("zuletzt
geprüft … — ausgestellt ✓ / DNS zeigt woanders / ausstehend", or "noch nicht geprüft"). Only the button
performs a live check. No page-load side effects, no surprise ACME calls.
## UI — `System/Index` (Domain & TLS panel)
- A **TLS-Zertifikat** status line + a **„DNS prüfen & Zertifikat anfordern"** button.
- **Visible only** when `tlsMode === 'caddy'` AND an **active** domain exists AND not bare-IP. In
**external** TLS mode show a short note instead ("Zertifikat über den externen Reverse-Proxy"); on a
bare IP / no domain, omit the row.
- The button is a direct server action with a **`wire:loading` spinner** (the trigger is slow: DNS +
handshake + ACME). Result surfaced via the existing toaster (`notify`, with `level: 'error'` on
failure) and a refreshed status line.
- **Pending-domain nuance:** the button operates on the **active** domain (what Caddy serves). If a
domain change is saved but awaiting a restart (`configuredDomain() !== domain()`), show the existing
"restart to apply" hint and act on the active domain (or disable until restarted) — never imply a cert
can be issued for a not-yet-active domain.
- German copy, no emoji (R16); only `@theme` token utilities (R3); no inline styles (R4).
## Abuse / rate-limiting (fits the just-shipped hardening)
`requestCertificate` is an authenticated admin action but it triggers outbound ACME, so throttle it
**per user**: `RateLimiter` key `cert-request:<id>`, e.g. **5 / 10 min**, auto-expiring (never a
control-plane lockout). The DNS pre-check additionally avoids triggering ACME when DNS is obviously
wrong. Audit the request (`AuditEvent`, action `tls.cert_request`).
## Constraints honoured
- **Control plane never locked out:** no new permanent state; the throttle auto-expires; bare-IP
recovery + `clusev:reset-admin` untouched.
- **No cert-issuance abuse:** `/_caddy/ask` still approves only the active domain; the per-user throttle
+ DNS pre-check bound ACME calls.
- **External-TLS mode** is respected (button hidden; Caddy issues nothing there).
## Files
- `app/Services/DeploymentService.php``serverPublicIp`, `domainResolvesHere`, `requestCertificate`
(the latter persists `tls_cert_status`). No live page-load cert probe (see status note above).
- `app/Livewire/System/Index.php``requestCertificate()` action (throttled + audited; persists the
outcome to `Setting` `tls_cert_status`), a status property read from that Setting, visibility gating.
- `resources/views/livewire/system/index.blade.php` — status line + button (loading spinner), mode gating.
- `lang/{de,en}/system.php` — status/button/result strings.
- Tests: `domainResolvesHere` match/mismatch/unknown; `requestCertificate` state machine (dns_mismatch /
issued / failed) with the curl + IP lookups mocked; the throttle.
## Testing
- Unit: mock DNS + public IP → assert `domainResolvesHere` ok/mismatch/unknown; assert
`requestCertificate` returns `dns_mismatch` WITHOUT triggering the handshake on a mismatch, and the
throttle blocks after the cap.
- R12 browser: the status line + button render in caddy mode with an active domain; hidden in external
mode / bare IP; the spinner shows on click. (Real ACME issuance can only be confirmed on a host with
correct public DNS — verify the UI states + the no-trigger-on-mismatch path.)
- Pint, full suite green, Codex clean (no errors / security issues — especially no SSRF via the domain
string: the handshake target is pinned to the `caddy` service via `--connect-to`, and the domain is
the operator-validated panel domain, not arbitrary user input).
## Out of scope
- Moving long SSH operations off the request path (separate UX/perf item).
- Cert renewal UI (Caddy renews automatically; on-demand re-issues as needed).
- Manual cert upload / custom CA.

View File

@ -1,76 +0,0 @@
# `clusev` Host-CLI + kurze Befehle + Tab-URL — Design
**Goal:** Operatoren rufen kurze, professionelle Befehle auf (`sudo clusev update`, `clusev reset-admin`, …) statt langer `docker compose -f docker-compose.prod.yml …`-Kommandos. Alle user-sichtbaren Stellen zeigen die Kurzform; die Hilfe erklärt ausführlich, was jeder Befehl real tut. Der Help-Tab landet wie die Settings-Tabs in der URL.
**Architecture:** Ein generiertes Host-Skript `/usr/local/bin/clusev` kapselt die Prod-Stack-Kommandos (Install-Verzeichnis eingebacken). Die UI/Hilfe/Lang-Strings zeigen nur noch `clusev <sub>`. Keine Datei-Umbenennung (`docker-compose.prod.yml` bleibt intern, wird aber nirgends mehr angezeigt).
**Tech Stack:** Bash (Wrapper), Laravel 13 / Livewire 3 (Help-Komponente `#[Url]`), Blade-Hilfe-Partials (DE/EN), `install.sh` (Rendern + Installieren).
---
## Teil A — `clusev` Host-Befehl
- **Template im Repo:** `docker/clusev/clusev` (Bash) mit Platzhalter `__CLUSEV_DIR__` für das Install-Verzeichnis. Muster wie MOTD/Sentinel (Template + `install.sh` substituiert).
- **Installation:** in einer `install.sh`-Phase rendern (`__CLUSEV_DIR__` → `$(pwd)`) und mit `install -m 0755` nach `/usr/local/bin/clusev` legen. Best-effort: schlägt es fehl (z. B. read-only), nur warnen, nie den Installer abbrechen.
- **Robustheit:** das Skript nutzt eine Funktion `compose() { docker compose -f "$CLUSEV_DIR/docker-compose.prod.yml" "$@"; }` (sauberer als Wort-Splitting bei Pfaden).
- **Unterbefehle:**
| Host-Befehl | führt real aus |
|---|---|
| `sudo clusev update` | `"$CLUSEV_DIR/update.sh"` (git pull --ff-only + Rebuild + Migrate; erzwingt root) |
| `clusev reset-admin` | `compose exec app php artisan clusev:reset-admin` |
| `clusev restart` | `compose up -d` |
| `clusev logs` | `compose logs -f "$@"` |
| `clusev ps` / `clusev status` | `compose ps` |
| `clusev migrate` | `compose exec app php artisan migrate --force` |
| `clusev artisan <…>` | `compose exec app php artisan "$@"` (Power-User) |
| `clusev version` | liest die Version aus `$CLUSEV_DIR/config/clusev.php` (Host-Datei, kein Container nötig) |
| `clusev help` / `-h` / `--help` / (leer) | Usage-Übersicht (alle Befehle + Einzeiler) |
- **Verhalten:** unbekannter Befehl → Usage + Exit 64. `update` erzwingt root über `update.sh`; die übrigen Befehle brauchen nur Docker-Zugriff (Operator ist root oder docker-group). Argumente werden durchgereicht (`"$@"`).
- **Naming:** der **Host**-Befehl ist `clusev reset-admin` (Leerzeichen, Standard wie git/docker). Der zugrunde liegende artisan-Befehl bleibt `clusev:reset-admin` (unverändert).
## Teil B — Tab-URL für die Hilfe
- `app/Livewire/Help/Index.php`: `use Livewire\Attributes\Url;` + `#[Url] public string $topic = 'overview';` (genau wie `Settings\Index::$tab`). Der Default `overview` erscheint nicht als Param; andere Themen schon (`/help?topic=security`). Reload/Lesezeichen/Teilen behalten das Thema.
- Der bestehende `render()`-Clamp (unbekanntes Thema → `overview`) bleibt und schützt vor `?topic=bogus`.
- Die `mount(?string $topic)`-Methode **bleibt** (harmlos; bestehende Tests übergeben `topic` als mount-Param) — `#[Url]` ergänzt nur die Query-Bindung; der render()-Clamp validiert beides.
## Teil C — Alle user-sichtbaren Befehle umstellen
`docker-compose.prod.yml` und lange `docker compose -f …`-Kommandos verschwinden aus der UI:
- **Version → „Aktualisierung"** (`resources/views/livewire/versions/index.blade.php` + `lang/{de,en}/versions.php` `update_hint`): der 3-Zeilen-Block (`pull` / `up -d` / `migrate --force`) → einzeiliger Hinweis + `sudo clusev update`.
- **Hilfe → Wiederherstellung** (`content/{de,en}/recovery.blade.php`): `docker compose … clusev:reset-admin``clusev reset-admin`.
- **Hilfe → Updates** (`content/{de,en}/updates.blade.php`): `sudo ./update.sh``sudo clusev update`.
- **Lang-Strings** mit reset-admin (`lang/{de,en}/system.php`, `lang/{de,en}/settings.php`): den langen Befehl → `clusev reset-admin`.
- **MOTD** (`docker/motd/00-clusev`): „Verwalten"-Zeile → `clusev ps | logs | restart`; „Reset"-Hinweis → `clusev reset-admin`.
## Teil D — Hilfe-Thema „Befehle" + ausführliche Erklärungen
- **Neues Thema** `commands` (Schlüssel) in `Help\Index::TOPICS`, Label `help.topic_commands` (DE „Befehle / CLI", EN „Commands / CLI"). Einsortiert nach `updates`.
- **Partials** `content/{de,en}/commands.blade.php`: pro Befehl eine Karte/Zeile mit
- der **Kurzform** (`clusev <sub>`, `font-mono`),
- einer **ausführlichen Erklärung**, was er tut und wann man ihn braucht,
- dem **echten Kommando** darunter (das vollständige `docker compose …` bzw. `update.sh`), damit Fortgeschrittene verstehen, was im Hintergrund passiert.
- Die Erklärungen in „Updates" und „Wiederherstellung" verweisen knapp auf „Befehle".
## Teil E — Dateistruktur
- Neu: `docker/clusev/clusev` (Wrapper-Template).
- Neu: `resources/views/livewire/help/content/{de,en}/commands.blade.php`.
- Ändern: `install.sh` (Render+Install des Wrappers), `app/Livewire/Help/Index.php` (`#[Url]`, TOPICS += `commands`, render-Labels), `resources/views/livewire/help/content/{de,en}/{recovery,updates}.blade.php`, `resources/views/livewire/versions/index.blade.php`, `lang/{de,en}/help.php` (`topic_commands`), `lang/{de,en}/versions.php` (`update_hint`), `lang/{de,en}/system.php` + `lang/{de,en}/settings.php` (reset-admin-Strings), `docker/motd/00-clusev`.
- README darf die Kurzform ebenfalls erwähnen (optional, konsistent).
## Teil F — Test & Verifizierung
- **Wrapper:** `bash -n` auf das gerenderte Skript; auf der VM real `clusev help`, `clusev ps`, `clusev version` ausführen (read-only, kein Reset). `install.sh` legt `/usr/local/bin/clusev` mit Mode 0755 an.
- **Hilfe/Lang (Livewire-Test, `HelpPageTest`):** das Thema `commands` rendert (Marker `clusev`); Wiederherstellung zeigt `clusev reset-admin` und **nicht** `docker-compose.prod.yml`; `Help\Index` hat `#[Url]` auf `$topic` (Query-Param-Verhalten testbar).
- **R12 Browser (Domain):** `/help` lädt 200, Themen-Wechsel inkl. neuem „Befehle"; `?topic=commands` per Reload bleibt; DE/EN; keine Konsolenfehler/Leaks.
- **R15 Codex** über den Diff.
## Teil G — Bewusst NICHT enthalten (YAGNI)
- Keine Umbenennung der Compose-Dateien (Kollision dev/prod + Risiko auf laufenden Stacks; der Wrapper versteckt den Namen).
- Kein Bash-Completion für `clusev` (kann später kommen).
- Keine Änderung der internen Skripte (`install.sh`/`update.sh`/`watch.sh` nutzen weiter `docker compose -f …` — das ist Implementierung, nicht user-sichtbar).

View File

@ -1,100 +0,0 @@
# Hilfe-Seite (In-Panel-Dokumentation) — Design
**Goal:** Eine eigene, bilinguale Hilfe-Seite im Panel, die alle Einstellungen und Abläufe erklärt — inklusive einer generischen Schritt-für-Schritt-Anleitung, wie man Clusev hinter einem externen Reverse-Proxy ("Proxy Manager") betreibt.
**Architecture:** Eine neue Full-Page-Livewire-Komponente unter `/help` mit linker Themen-Navigation (wie die Einstellungen). Kurze Chrome-Strings (Nav-Labels, Seitentitel) liegen in `lang/{de,en}/help.php`; der lange Fließtext liegt als Blade-Partials pro Sprache und wird nach aktiver Locale eingebunden. Keine neuen Tabellen, kein State außer dem gewählten Thema.
**Tech Stack:** Laravel 13, Livewire 3 (class-based), Tailwind v4 `@theme`-Tokens, bestehende Blade-Komponenten (`x-panel`, `x-icon`, …).
---
## 1. Platzierung & Navigation
- **Komponente:** `App\Livewire\Help\Index` (Ordnerkarte R6), View `resources/views/livewire/help/index.blade.php`.
- **Route:** `Route::get('/help', Help\Index::class)->name('help')` in der onboarded-Gruppe von `routes/web.php` (gleiche Middleware-Gruppe wie Settings/System; hinter Auth + `EnsureSecurityOnboarded`).
- **Sidebar:** neuer Eintrag **„Hilfe"** unten (nach „Version"), Icon z. B. `help-circle` (Lucide, inline via `x-icon`). Label lokalisiert (`__('shell.nav_help')`).
- **Command-Palette:** Eintrag in `resources/views/components/command-palette.blade.php` (`$nav`) + Chord `g h` in `CMDK_GO` (analog zu den bestehenden `g <key>`).
- **Layout:** `layouts.app`.
- **Route-Pfad englisch (R13)**, sichtbares Label deutsch/englisch (R9/R16).
## 2. Aufbau der Seite
- Öffentliche Property `public string $topic = 'overview';` — der gewählte Themenschlüssel.
- `mount()` akzeptiert optional ein `?topic`-Query/Param, validiert gegen die bekannte Themenliste, fällt sonst auf `overview` zurück (kein Lockout, kein 404 für unbekannte Keys → Default).
- Links: Themenliste (Buttons `wire:click="$set('topic', '<key>')"`, aktives Thema hervorgehoben — gleiche Optik wie die Settings-Tabs in `settings/index.blade.php`).
- Rechts: `@include('livewire.help.content.'.app()->getLocale().'.'.$topic)` mit Existenzprüfung; fehlt das Locale-Partial, Fallback auf `de`.
- Responsive (R7): links Themen-Nav, die auf schmal über dem Inhalt als horizontale/aufklappbare Liste erscheint (gleiche Technik wie Settings-Tabs).
### Themenliste (Reihenfolge)
| Key | Label (de) | Inhalt-Kurzfassung |
|---|---|---|
| `overview` | Überblick / Erste Schritte | Was Clusev ist, Erst-Login (`admin@clusev.local` / `clusev`, Zwangswechsel), Navigation, Bare-IP vs. Domain. |
| `domain-tls` | Domain, TLS & Reverse-Proxy | Drei Modi (Bare-IP, eingebautes TLS/Let's Encrypt, externer Proxy); **Schritt-für-Schritt-Proxy-Anleitung**; `TRUSTED_PROXY_CIDR`; Neustart-Boundary. |
| `security` | Sicherheit & 2FA | TOTP, Security-Keys (YubiKey), Backup-Codes; **2FA-Zugangspfad-Hinweis** (s. u.); Passwort-Manager-Extension-Hinweis (Bitwarden/Kaspersky). |
| `updates` | Updates & Versionen | Auto-Check, „Jetzt aktualisieren"-Knopf, CLI `sudo ./update.sh`, was beim Update passiert (Build/Migrate). |
| `servers` | Server & SSH | Server hinzufügen, SSH-Credential-Vault, Hardening, SSH-Key-Provisioning. |
| `sessions` | Sitzungen & Mehrbenutzer | Aktive Sitzungen sehen/widerrufen, weitere Admins, Audit-Attribution. |
| `email` | E-Mail (SMTP) | SMTP für Passwort-Reset konfigurieren + Testmail. |
| `audit` | Audit-Log | Was protokolliert wird, Retention. |
| `recovery` | Konto-Wiederherstellung | Forgot-Password (E-Mail-Link / 2FA-Proof), Bare-IP-Recovery, `clusev:reset-admin`. |
## 3. Inhalt zweisprachig
- **Chrome (kurz):** `lang/de/help.php` + `lang/en/help.php` — Seitentitel, Eyebrow, Untertitel, die Themen-Labels (`topic_overview`, `topic_domain_tls`, …), `nav_help` in `shell.php`.
- **Lange Texte:** Blade-Partials je Sprache:
- `resources/views/livewire/help/content/de/<topic>.blade.php`
- `resources/views/livewire/help/content/en/<topic>.blade.php`
- Reines Markup mit Überschriften, Absätzen, Listen und Code-Blöcken (für Proxy-/CLI-Beispiele). **Nur `@theme`-Token-Utilities (R3), keine Inline-Styles (R4), kein Emoji (R9).** Native Tokens (`X-Forwarded-Proto`, `TRUSTED_PROXY_CIDR`, `sudo ./update.sh`) bleiben als `font-mono`.
- Locale-Auflösung im View über `app()->getLocale()`; Fallback `de`, wenn das EN-Partial (noch) fehlt — so bricht nichts, falls eine Sprache nachgezogen wird.
## 4. Schlüssel-Inhalte (verbindlich)
### 4a. Proxy-Anleitung (in `domain-tls`) — generisch, OHNE Produktnamen
1. **Im Panel:** System → Domain & TLS → Domain eintragen → TLS-Terminierung **„Externer Reverse-Proxy"** → speichern → **Stack neu starten** (Knopf).
2. **Im Proxy Manager:** neuen Host/Eintrag anlegen:
- Eingehende Domain = `panel.<deine-domain>`
- Ziel/Backend = `http://<Clusev-Server-IP>:80` (**HTTP**, nicht HTTPS)
- **Host-Header durchreichen** und **`X-Forwarded-Proto: https` setzen**
- **WebSocket-Weiterleitung aktivieren** (für Realtime: Pfade `/app/*` und `/apps/*`)
3. **In der `.env`** (Clusev-Server): `TRUSTED_PROXY_CIDR` auf die Adresse/CIDR des Proxys setzen → korrekte `Secure`-Cookies + echte Besucher-IP im Audit-Log; danach `sudo ./update.sh` (oder Caddy-Neustart).
4. **Firewall:** den HTTP-Port (80) des Clusev-Servers so absichern, dass er **nur vom Proxy** erreichbar ist.
5. Hinweis: In diesem Modus stellt Clusev **kein** eigenes Zertifikat aus — TLS kommt vom Proxy.
### 4b. 2FA-Zugangspfade (in `security`, Querverweis in `recovery`)
- **Security-Key (YubiKey)** funktioniert **nur über die HTTPS-Domain** (WebAuthn braucht einen sicheren Kontext + die Domain als rpId). Über die **Domain** wird die Security-Key-Anmeldung angeboten und funktioniert.
- Über den **Bare-IP-/HTTP-Recovery-Pfad** (`http://<Server-IP>`) lässt sich **nicht** per Security-Key anmelden — dort muss ein **Backup-Code** verwendet werden.
- **TOTP / „Google Authenticator"** funktioniert **überall** — auch über Bare-IP/HTTP. Wer TOTP nutzt, braucht für den Bare-IP-Pfad **keinen** Backup-Code.
- Praxis-Empfehlung: Wer ausschließlich einen Security-Key nutzt, sollte die **Backup-Codes aufbewahren** (einziger Weg über den Bare-IP-Recovery-Pfad). Wer zusätzlich/alternativ TOTP hat, ist auf allen Pfaden abgedeckt.
### 4c. Passwort-Manager-Extensions (in `security`)
- Browser-Erweiterungen wie Bitwarden/Kaspersky können die Security-Key-Registrierung abfangen („Passkey speichern"). Clusev signalisiert korrekt einen Hardware-Key (cross-platform, non-resident, `hints: ['security-key']`), aber Erweiterungen lassen sich seitenseitig nicht abschalten. Lösung: die Passkey-Übernahme der Erweiterung für die Seite deaktivieren, oder die Registrierung in einem Browser/Fenster ohne diese Erweiterung durchführen (z. B. privates Fenster).
## 5. Komponenten-Logik (klein halten)
`App\Livewire\Help\Index`:
- `public string $topic`
- `mount(?string $topic = null)`: validiert gegen `self::TOPICS` (Liste der Keys), Default `overview`.
- `render()`: übergibt die Themenliste (Keys + lokalisierte Labels) + den aktiven `$topic`; die View bindet das passende Locale-Partial ein.
- Keine Persistenz, keine externen Aufrufe, keine Berechnungen — reine Anzeige.
## 6. Dateistruktur
- Neu: `app/Livewire/Help/Index.php`
- Neu: `resources/views/livewire/help/index.blade.php`
- Neu: `resources/views/livewire/help/content/{de,en}/{overview,domain-tls,security,updates,servers,sessions,email,audit,recovery}.blade.php` (18 Partials)
- Neu: `lang/de/help.php`, `lang/en/help.php`
- Ändern: `routes/web.php` (Route), `resources/views/partials/sidebar.blade.php` (Nav-Eintrag), `lang/{de,en}/shell.php` (`nav_help`), `resources/views/components/command-palette.blade.php` + `CMDK_GO` (cmdk-Eintrag + `g h`).
## 7. Test & Verifizierung
- **Livewire-Test** (`tests/Feature/HelpPageTest.php`): Default-Thema = `overview`; `$set('topic', 'domain-tls')` rendert den Proxy-Abschnitt (assertSee eines bekannten Strings, z. B. `X-Forwarded-Proto`); unbekannter Topic-Key fällt auf `overview` zurück; Seite lädt für einen eingeloggten Nutzer (HTTP 200).
- **R12 Browser** (auf der Domain): `/help` lädt mit HTTP 200, keine Konsolenfehler, keine geleakten `{{ }}`/`group.key`-Tokens; Themenwechsel funktioniert; DE/EN-Umschalter zeigt den jeweiligen Inhalt; 375/768/1280.
- **R15 Codex** über den Diff.
## 8. Bewusst NICHT enthalten (YAGNI)
- Keine Volltextsuche in der Hilfe (Themen-Nav reicht).
- Keine kontextuellen „?"-Sprungmarken aus jeder Einstellung (kann später kommen).
- Keine aus Markdown gerenderte Doku-Engine — statische Blade-Partials genügen.
- Keine Versionierung/Changelog der Hilfe-Inhalte (das CHANGELOG deckt das ab).

View File

@ -1,215 +0,0 @@
# 2FA Challenge — Backup Code as its own button + view — Design
**Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved
Splits the backup (recovery) code out of the combined two-factor challenge screen. Today
`TwoFactorChallenge` renders the primary factor **and** a backup-code field on the same view (key
button + "ODER" + backup field for a key user; TOTP field + recovery hint for a TOTP user). The
backup code becomes a deliberate, separate step: a **"Backup-Code verwenden"** button on the main
screen that leads to a **dedicated backup-only view/route**. The main screen shows only the primary
factor (TOTP field *or* security-key button).
Why: a backup code is a sensitive, rarely-used recovery secret — surfacing it next to the everyday
factor invites accidental use and shoulder-surfing. Separating it also makes the "only backup is
usable" path (a key-only user reaching the panel over **http + bare IP**, where WebAuthn has no
secure context) land directly on the backup view instead of a half-disabled combined screen.
The IP-vs-domain routing the user asked about is **already correct** and unchanged:
`WebauthnService::available()` returns true only when the request host equals the configured domain
(HTTPS enforced at the front door), so the security-key button is offered on a domain and absent on
bare IP; TOTP works on any host. This spec only restructures the **UI** around that existing logic.
This is **Spec 1 of 2**. Spec 2 (auth-failure → fail2ban hard IP ban on the firewall) is a
separate, infrastructure-layer change, planned next; see *Out of scope*.
---
## 1. New component + route
- **New full-page Livewire component** `App\Livewire\Auth\TwoFactorBackup` + view
`resources/views/livewire/auth/two-factor-backup.blade.php` (R1/R2/R6, `#[Layout('layouts.auth')]`).
- **New route** in the existing `guest` group in `routes/web.php`, immediately after the
`two-factor.challenge` line:
`Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup');`
English path + name (R13); sibling of `two-factor.challenge`, same `guest` middleware (the 2FA
flow runs before the session is authenticated).
## 2. Shared trait — `App\Livewire\Concerns\CompletesTwoFactorChallenge`
Extract the logic currently inline in `TwoFactorChallenge` into a trait used by **both** challenge
components, so there is no duplication and **one shared rate-limit bucket set**:
- `pendingUser(): ?User` — the memoised `User::find(session('2fa.user'))` resolver (move the existing
one; keep its backing state `private` — an internal memoisation detail; each component gets its
own copy, and Livewire persists only public props, so the visibility is runtime-irrelevant).
- `getPendingHasTotpProperty(): bool` — **keep the existing Livewire computed-property magic getter**
(move it verbatim into the trait, do **not** turn it into a plain `pendingHasTotp()` method). This
keeps every blade's `$this->pendingHasTotp` usage working unchanged — **no property→method rewrites
in the challenge blade**. `webauthnAvailable(): bool` stays a plain method (the blade already calls
it with parens); it delegates to `WebauthnService::available()` + `hasWebauthnCredentials()`. Both
views thus agree on what is offered.
- Rate limiting — **preserve the exact current bucket keys verbatim** so the counter is genuinely
shared across both components (a divergent key format would silently break the shared-limit
guarantee): with `$uid = (string) session('2fa.user')`, the buckets are
`'two-factor:'.md5($uid.'|'.request()->ip())` [5/60s] and `'two-factor-acct:'.md5($uid)` [20/900s];
plus `rateLimitBuckets()`, `assertNotRateLimited()`, `hitRateLimit()`, `clearRateLimit()` moved
unchanged from `TwoFactorChallenge` lines 5689.
- `completeLogin(User $user)` — the shared success tail: read `2fa.remember`, forget `2fa.*`,
`Auth::login($user, $remember)`, `session()->regenerate()`, `redirectIntended(route('dashboard'),
navigate: true)`.
**Stays on `TwoFactorChallenge` only:** `assertionOptions()` and `verifyWebauthn()`. The frontend
`window.clusevWebauthn.login($wire)` (`resources/js/webauthn.js`) calls exactly these two Livewire
methods; their names/signatures must not change, and the backup view has no key button so it needs
neither the methods nor `webauthn.js`.
## 3. Main challenge view — backup field removed, button added
`TwoFactorChallenge` / `two-factor-challenge.blade.php`:
- **Gate the code form to TOTP users.** Wrap the entire `<form wire:submit="verify">` block in
`@if ($this->pendingHasTotp())` and **delete** the backup-code text field plus the inline backup
hints (`challenge_recovery_hint` / `challenge_backup_only_hint`) that lived inside it. Result: a
TOTP user sees the `000000` code field (label `auth.code`); a key-only user sees **no** text field —
just the security-key button. The `$this->pendingHasTotp` blade syntax is **unchanged** (the
computed property moves to the trait, §2), so these are **structural** edits only — no
property→method rewrites of the existing usages.
- `verify()` is **unchanged** — it keeps `verifyTotp($code) || useRecoveryCode($code)`; a backup code
pasted into the TOTP field still works as a forgiving fallback, it is just no longer the advertised
affordance here.
- **Add** a subordinate **"Backup-Code verwenden"** button (`auth.challenge_use_backup`) navigating
(`wire:navigate`) to `route('two-factor.challenge.backup')`, placed after the `</form>`, visually
below the primary factor and **less prominent** than the primary button (a link/ghost or
low-emphasis treatment — it must not compete with the security-key button, which is itself
`secondary` on the TOTP+key view).
- **"Only backup is usable" guard** — in `mount()`, after the existing `session('2fa.user')` guard:
`if (! $this->pendingHasTotp() && ! $this->webauthnAvailable()) return $this->redirect(route('two-factor.challenge.backup'), navigate: true);`.
This fires **only** for a user with no TOTP **and** no usable security key — i.e. a key-only user on
http + bare IP (no secure context). It can never strand a TOTP user (`pendingHasTotp() == true`) nor
a key-on-domain user (`webauthnAvailable() == true`). Safe because `session('2fa.user')` is set only
after `Login` verifies `hasTwoFactorEnabled()` (`app/Livewire/Auth/Login.php`, the
redirect-to-challenge branch), so "neither factor usable" can only mean key-only-without-secure-context,
never "no factor at all".
## 4. Backup view — `two-factor-backup.blade.php`
Backup-only screen rendered into the `layouts.auth` `$slot`:
- `#[Validate('required|string')] public string $code` and a `render()` titled `auth.title_challenge`
(mirror `TwoFactorChallenge`). `mount()`: if `! session()->has('2fa.user')``redirect(route('login'))`
(same guard as the main view; runs before anything else — no rate-limit needed at mount, the upstream
`Login` already throttled this session).
- **Input UX parity with the main view** (so R12 quality matches): heading `auth.backup_heading`,
subtitle `auth.backup_subtitle`; one text field reusing `auth.challenge_backup_label` +
`auth.challenge_backup_placeholder` with `autofocus`, `autocomplete="one-time-code"`,
`inputmode="text"` and the main view's `$fieldText` mono style; a `Bestätigen` (`common.confirm`)
submit carrying the same `wire:loading` spinner + `auth.checking` (`Prüfe…`) state.
- `verify()`: `validate()``assertNotRateLimited()` → resolve the pending user → require
`hasTwoFactorEnabled()` (else forget `2fa.*` + `auth.session_expired`) → attempt **only**
`$user->useRecoveryCode($this->code)` (**not** `verifyTotp` — this view is backup-only) → on success
`clearRateLimit()` + `completeLogin()`; on failure `hitRateLimit()` + `auth.invalid_code`. Shares the
§2 buckets, so a wrong backup code here throttles the same counter as the main view and vice-versa.
- **Return links (redirect-loop guard):**
- "Zurück zu Anmelde-Optionen" (`auth.back_to_options`) → `route('two-factor.challenge')`, shown
**only when `$this->pendingHasTotp() || $this->webauthnAvailable()`**. For the key-only-on-http+IP
user neither holds, so the link is hidden — otherwise it would bounce straight back here via the §3
mount guard. (Anyone who reached this view via the button has at least one of the two true, so the
link is present for them.)
- "Zurück zur Anmeldung" (`auth.back_to_login`) → `route('login')`, always shown.
## 5. Behaviour matrix (after the change)
| User / context | Main challenge screen | Backup reached via |
|---|---|---|
| TOTP (any host) | TOTP field + "Backup-Code verwenden" | button |
| Key-only, domain + HTTPS | Security-key button + "Backup-Code verwenden" | button |
| Key-only, http + bare IP | — (mount redirects) | **auto-redirect** |
| TOTP + key, domain | TOTP field + key button + "Backup-Code verwenden" | button |
| TOTP + key, http + IP | TOTP field + "Backup-Code verwenden" (key absent) | button |
## 6. Security notes
- **Method labels are not an information leak** (the user's point 6). Anyone seeing this screen has
already passed the password and is on the account; which factors exist is observable from the
rendered HTML/JS (the WebAuthn assertion options, the field type) regardless of button wording.
Hiding "Mit Security-Key anmelden" would be security theatre with no protection, so the explicit,
clear labels are kept — matching GitHub/Google. The real wins are the separate backup view
(reduces accidental/shoulder-surfed backup use) and Spec 2 (a true IP ban).
- **Rate limiting is unchanged and already covers backup codes** (the user's point 2): the backup
view shares the exact same buckets via the trait, so backup attempts count against the same
per-(user+IP) 5/60s and per-user 20/900s caps as TOTP and the key path. This remains a *soft,
self-expiring throttle*, never a permanent lockout (`clusev:reset-admin` stays open) — a hard
firewall-level IP ban is **Spec 2**, not this change.
- No change to the WebAuthn ceremony, the rate-limit buckets, or `WebauthnService::available()`.
## 7. i18n (`lang/{de,en}/auth.php`)
New keys (DE / EN), terse register, no emoji (R9/R16):
| Key | German | English |
|---|---|---|
| `challenge_use_backup` | Backup-Code verwenden | Use backup code |
| `backup_heading` | Backup-Codes | Backup codes |
| `backup_subtitle` | Gib einen Backup-Code ein, um fortzufahren. | Enter a backup code to continue. |
| `back_to_options` | Zurück zu Anmelde-Optionen | Back to sign-in options |
Reused unchanged: `challenge_backup_label`, `challenge_backup_placeholder`, `two_factor`,
`back_to_login`, `too_many_attempts`, `invalid_code`, `session_expired`, `checking`,
`title_challenge`, `common.confirm`. The `challenge_recovery_hint` / `challenge_backup_only_hint`
hints are removed from the main view and are **not** reused by the backup view (it uses
`backup_subtitle`); after a `grep -rn` over `resources/` confirms no remaining references, delete both
keys from `lang/{de,en}/auth.php`.
## Files touched
`routes/web.php` (new route), new `app/Livewire/Auth/TwoFactorBackup.php` + view, new
`app/Livewire/Concerns/CompletesTwoFactorChallenge.php`, `app/Livewire/Auth/TwoFactorChallenge.php`
(use trait, drop the extracted methods, add the mount redirect + the backup button),
`resources/views/livewire/auth/two-factor-challenge.blade.php` (remove backup field/hints, add
button; structural only — no `pendingHasTotp` syntax change), `lang/de/auth.php`, `lang/en/auth.php`,
and `tests/Feature/ChallengeFactorAdaptTest.php` (its key-only case asserts `auth.challenge_backup_label`
is visible on the **main** view — that field moves out, so flip that assertion to `assertDontSee` and
cover the backup-code entry in a new `TwoFactorBackup` test). No model, service, or JS changes.
## Testing
Feature tests (PHPUnit, `RefreshDatabase`, `Cache::flush()` in `setUp`), mirroring the existing 2FA
tests (`TwoFactorChallengeRecoveryTest`, `ChallengeFactorAdaptTest`); seed pending state via
`session()->put('2fa.user', $user->id)`; build factors with `two_factor_secret`+`two_factor_confirmed_at`
(TOTP), `WebauthnCredential::create([...])` (key), `replaceRecoveryCodes()` (backup):
- **Backup view happy path:** `Livewire::test(TwoFactorBackup::class)->set('code', $code)->call('verify')`
`assertAuthenticatedAs($user)`, redirect to `dashboard`, code consumed
(`$user->fresh()->useRecoveryCode($code)` now false).
- **Backup view wrong code:** `->assertHasErrors('code')` + `assertGuest()`.
- **Backup view rate limit:** the shared buckets trip after the same threshold (6th wrong attempt
refused) — assert a `TwoFactorBackup` failure also throttles a `TwoFactorChallenge` attempt and
vice-versa (shared bucket).
- **Backup view session guard:** no `2fa.user` → redirect to `login`.
- **Main view redirect:** key-only pending user with `webauthnAvailable()` mocked **false** (bare IP)
`mount` redirects to `two-factor.challenge.backup`; with it **true** (domain) → no redirect,
key button + backup link shown.
- **Main view content:** TOTP user sees the code field + the backup link and **not** a backup field;
key-only-on-domain user sees the key button + backup link and **no** text field.
- **Regression:** the existing 2FA suite (`TwoFactorChallengeRecoveryTest`, `ChallengeFactorAdaptTest`,
`BruteForceHardeningTest`, `TwoFactorWebauthnTest`) stays green — in particular flip
`ChallengeFactorAdaptTest`'s main-view `assertSee(auth.challenge_backup_label)` for the key-only user
to `assertDontSee`, with the backup-code path re-asserted on the new `TwoFactorBackup` test.
- **Return-link guard:** backup view for a TOTP/key user shows `back_to_options`; for a
key-only-no-secure-context user it does **not** (only `back_to_login`).
- **R12 browser verify** (DE + EN) on **both** `/two-factor-challenge` and
`/two-factor-challenge/backup`: HTTP 200, zero console/network errors, rendered-DOM scan for
leaked `@`/`{{ }}`/`$var`/`group.key` text (R17), the 3 breakpoints (375/768/1280). Bare-IP key
E2E stays domain-deferred (WebAuthn needs the domain).
- **R15:** `/codex:review` clean (no errors, no security issues); Pint clean.
## Out of scope
- **Spec 2 — auth-failure → fail2ban hard IP ban.** A real firewall-level block (Clusev writes
failed-auth events with the client IP to a watched log; a fail2ban jail + filter bans the IP;
lockout-safety / whitelist so the operator can't permanently self-ban). Separate
infrastructure-layer spec, brainstormed next.
- A full **method-chooser** landing screen (chosen against: one extra click for the 90% case; the
primary factor stays the default and backup is one deliberate step away).
- Any change to the WebAuthn ceremony, the rate-limit bucket sizes, TOTP verification, or
`WebauthnService::available()` host logic.

View File

@ -1,237 +0,0 @@
# Control-plane brute-force IP ban (Anmeldeschutz) — Design
**Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved
Adds a **persistent, app-level IP ban** that protects Clusev's **own** login (the control-plane),
fail2ban-style: too many failed auth attempts from one IP → that IP is hard-blocked for unauthenticated
requests for a while, configurable from a new **"Anmeldeschutz"** settings tab that also lists and
clears active bans.
This is **distinct** from the existing `Fail2banService` (`app/Services/Fail2banService.php`), which
manages fail2ban on **remote fleet servers** over SSH — that stays untouched and shares no state,
config, or audit events with this feature. A real host fail2ban daemon is not an option here: the app
runs in a container with no host firewall / iptables / systemd access, so it can never write host
firewall rules, and a host daemon's ban list could not be shown or managed from the panel. The ban
therefore lives **in the application**: counted in cache, persisted in the DB, enforced in HTTP
middleware.
Chosen defaults (operator-tunable in the tab): **enabled**, **10 failures / 10 min → 60 min ban**.
## 0. IP trust model — the security assumption everything rests on
The ban is only as correct as the client IP it sees. In prod, [`bootstrap/app.php`](../../../bootstrap/app.php)
calls `trustProxies(at: '*')` **only when `APP_ENV=production`**; the app container is internal-only
(`expose: 80`, no host port — `docker-compose.prod.yml`), so its **sole** direct peer is Caddy.
Caddy's `trusted_proxies` (the `TRUSTED_PROXY_CIDR` env, default `127.0.0.1/32``docker/caddy/Caddyfile`)
means Caddy does **not** trust a client's forged `X-Forwarded-For` and rewrites it to the real peer.
So `request()->ip()` = the real client, and XFF spoofing is prevented **as long as Caddy is the single
front proxy and its trusted_proxies is correct (the default)**.
- **Stated assumption (put in the tab help + the `clusev:unban` docs):** Caddy must be the only
intermediary. If the operator puts a second, untrusted proxy in front of Caddy that blindly forwards
XFF, the app can be made to ban a forged/victim IP. The whitelist + the host CLI escape (§7) bound
the damage.
- **Dev:** `trustProxies` is not called, so `request()->ip()` is the Docker gateway, not a real client.
This is a **production feature**; dev/unit tests exercise the logic by injecting the IP (mock
`request()->ip()` / `Request::create(...)`), not by relying on real forwarding.
---
## 1. Two layers, clearly separated
- **Layer 1 — existing soft throttle (unchanged).** The `RateLimiter` buckets in
[`Login`](../../../app/Livewire/Auth/Login.php) and
[`CompletesTwoFactorChallenge`](../../../app/Livewire/Concerns/CompletesTwoFactorChallenge.php)
(5/60 s etc.) stay exactly as they are — a short, self-expiring, per-(email+IP) friction layer.
- **Layer 2 — new persistent IP ban (this spec).** A longer-horizon escalation: count failures per IP
within `findtime`; at `maxretry` create a **persisted** ban for `bantime` that blocks **unauthenticated**
requests from that IP via early middleware (see §4 for why guests-only). Survives restarts; enumerable
and clearable from the panel + a host CLI.
## 2. Data model — `BannedIp`
New model `App\Models\BannedIp` + migration `banned_ips`:
| column | type | note |
|---|---|---|
| `id` | bigint PK | |
| `ip` | string(45), **unique, indexed** | stored in **canonical form** (`inet_ntop(inet_pton($ip))`) |
| `banned_until` | timestamp | ban auto-expires at this instant (app timezone via `now()`) |
| `reason` | string, nullable | `login` or `2fa` |
| `attempts` | unsigned int | failures that triggered the ban (for the UI) |
| timestamps | | `created_at` = banned-at |
"Active ban" = row where `banned_until > now()`. Ban **history** is the existing `AuditEvent` stream
(`auth.ip_banned` / `auth.ip_unbanned`), not a second table.
## 3. `BruteforceGuard` service — record, ban, exempt
`App\Services\BruteforceGuard` owns all decision logic.
- **`config()`** reads the `Setting` keys with casts (Setting stores **strings only** — see
[`Setting`](../../../app/Models/Setting.php)): `enabled = Setting::get('bruteforce_enabled', '1') === '1'`,
`maxretry = (int) Setting::get('bruteforce_maxretry', '10')`, `findtime = (int) …('bruteforce_findtime', '10')`,
`bantime = (int) …('bruteforce_bantime', '60')` (both in **minutes**), `whitelist` = the parsed CIDR list.
- **`record(?string $ip, string $reason): void`** — the failure recorder:
1. Return immediately if the feature is disabled, `$ip` is empty / not `filter_var(...FILTER_VALIDATE_IP)`,
or `isExempt($ip)` is true (no counting of exempt/loopback/whitelisted IPs).
2. Register a hit on a rolling counter and read the count:
`RateLimiter::hit('bruteforce:'.md5($ip), $findtime * 60); $hits = RateLimiter::attempts('bruteforce:'.md5($ip));`
(`hit()` returns void in this Laravel version — the count comes from `attempts()`; `findtime`
is minutes → ×60 for the second/decay arg, matching the existing RateLimiter usage).
3. If `$hits >= $maxretry`: **atomically** upsert the ban (race-safe — two concurrent requests at the
threshold must not collide on the unique `ip` index):
`BannedIp::upsert([['ip' => $canonical, 'banned_until' => now()->addMinutes($bantime), 'reason' => $reason, 'attempts' => $hits]], ['ip'], ['banned_until', 'reason', 'attempts'])`,
then `RateLimiter::clear(...)` the counter, `Cache::forget('bruteforce:banned:'.$canonical)`,
and write `AuditEvent` `auth.ip_banned` (payload in §5).
- **`isBanned(string $ip): bool`** — `false` if `isExempt($ip)` (exempt short-circuits, so whitelisting
an IP unblocks it **immediately** even while a row lingers); else a cached lookup of an active,
non-expired `BannedIp` for the canonical IP. Cache: `Cache::remember('bruteforce:banned:'.$canonical, 30, fn () => BannedIp::where('ip', $canonical)->where('banned_until', '>', now())->exists())`
— a 30 s per-IP boolean, busted (`Cache::forget`) on every ban and unban so a banned IP is visible
≤30 s after creation and an unbanned IP is free immediately.
- **`isExempt(string $ip): bool`** — **hard, non-negotiable safety.** Always true for loopback
(`127.0.0.0/8`, `::1`), the configured trusted-proxy CIDR, and any whitelist entry. Matching uses an
**`inet_pton`-based CIDR helper** (`matchesCidr(string $ip, string $cidr): bool`) that normalises both
sides to packed bytes and compares the network prefix — IPv4, IPv6, and IPv4-mapped-IPv6
(`::ffff:127.0.0.1`) all handled, mirroring `Fail2banService`'s `inet_pton` comparison so spellings
like `::1` vs `0:0:0:0:0:0:0:1` can't bypass it.
## 4. `BlockBannedIp` middleware — enforcement (guests only)
New `App\Http\Middleware\BlockBannedIp`, **appended to the `web` group** (last, after
`AuthenticateSession`) in [`bootstrap/app.php`](../../../bootstrap/app.php). It must be **appended, not
prepended**: the guests-only check needs `Auth::guest()`, which requires the session middleware
(`StartSession`) to have already run — a prepended middleware runs before it and would see every
request as a guest (blocking authenticated operators too). `request()->ip()` is still correct because
`trustProxies` is a global middleware that runs before the whole web group (§0).
- If the feature is disabled → pass (one cached `Setting` read; no DB hit).
- **Block guests only:** if `Auth::guest()` **and** `BruteforceGuard::isBanned(request()->ip())`
return a minimal **403** and **do not call `$next()`** (no downstream middleware/route runs). An
**authenticated** request always passes — an operator already logged in is past the login brute-force
surface, is never cut off mid-session, and can reach the Anmeldeschutz tab to unban their own IP even
while it is banned. The login + 2FA pages are guest requests, so the brute-force surface itself is
still fully covered.
- **403 response:** a dedicated, tiny `errors.blocked` view (no app shell, no Livewire, no JS),
rendered in the saved locale. Generic copy only — "Zugriff vorübergehend gesperrt … später erneut
versuchen" — it must **not** reveal the exact expiry time, the reason, or whether the IP was flagged
(no info leak / no confirmation to an attacker).
## 5. Hooks — feed the guard
Centralise one call so all failure sites share it; each runs **after** the existing
`RateLimiter::hit`/`assertNotRateLimited` (additive, never changes the throttle):
- [`Login::authenticate()`](../../../app/Livewire/Auth/Login.php) on a wrong password →
`app(BruteforceGuard::class)->record(request()->ip(), 'login')` + `AuditEvent`:
`actor='system'`, `action='auth.login_failed'`, `target=$email`, `ip=request()->ip()` (failed logins
are not audited today — this closes that gap).
- The 2FA failure path in `CompletesTwoFactorChallenge` (the three `hitRateLimit()` sites:
`TwoFactorChallenge::verify`/`verifyWebauthn`, `TwoFactorBackup::verify`) → `record(request()->ip(), '2fa')`
+ `AuditEvent` `action='auth.2fa_failed'`, `target=(string) session('2fa.user')`, `ip=…`.
- **Audit payloads:** `auth.ip_banned``actor='system'`, `target=$ip`, `ip=$ip`, `meta=['reason'=>$reason,'attempts'=>$hits]`.
`auth.ip_unbanned``actor=<operator email>`, `target=$ip`, `ip=request()->ip()`, `meta=['scope'=>'single'|'all'|'whitelist']`.
## 6. Settings tab — "Anmeldeschutz"
New top-level settings tab, key **`login-protection`** (English per R13), placed **after** the existing
`security` tab so related settings group together. Label „Anmeldeschutz" / "Login protection".
- **Component** `App\Livewire\Settings\LoginProtection` + view `livewire.settings.login-protection`;
add the tab object to the `$tabs` array **and** the `@elseif ($tab === 'login-protection')` arm in
[`settings/index.blade.php`](../../../resources/views/livewire/settings/index.blade.php). While there,
add a safe `@else <livewire:settings.profile />` default to the tab switch (today an unknown `?tab=`
renders nothing).
- **Config form** — typed component props loaded with casts and saved stringified:
load `$this->enabled = Setting::get('bruteforce_enabled','1') === '1'`, `$this->maxretry = (int) …`;
save `Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0')`, `Setting::put('bruteforce_maxretry', (string) $this->maxretry)`, etc.
Fields: enabled (toggle) · maxretry (int) · findtime (min) · bantime (min) · whitelist (textarea of
IP/CIDR). Whitelist validated by a new **`app/Rules/ValidIpOrCidr`** rule (rejects junk; default value
= the private ranges `10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16`). On save, **delete any `BannedIp`
rows now covered by the (new) whitelist** so widening it clears matching bans immediately.
- **Current-IP self-service (lockout mitigation):** the tab shows the operator's current
`request()->ip()` with its ban/whitelist status and a one-click **„Meine IP zur Whitelist hinzufügen"**
— so an operator can exempt themselves before/while enabling the feature.
- **Active-bans table:** IP · reason · attempts · banned-until (relative + absolute) · **„Entbannen"**.
A **„Alle entbannen"** action. Empty-state when none. Reads `BannedIp::where('banned_until','>',now())`.
Both unban actions are **destructive → wire-elements/modal confirm (R5)**, matching the sessions /
webauthn-keys tabs (no Alpine `confirm()`). Unban deletes the row(s), `Cache::forget` the IP key(s),
and writes `auth.ip_unbanned`.
## 7. Lockout safety (it ships enabled — these are the guarantees)
- **Guests-only enforcement (§4):** an authenticated operator is never blocked and can unban their own
IP from the tab even while it is banned. This is the primary self-lockout safeguard.
- **Always-exempt** loopback + trusted-proxy CIDR (§3) — non-configurable; prevents the proxy-collapse
total lockout.
- **Self-whitelist** of the current IP from the tab (§6); the default whitelist already covers private
ranges, so a LAN/VPN operator is exempt out of the box.
- **Bans are always time-bounded** (`banned_until`); they expire on their own. No permanent ban.
- **Host escape hatch:** new artisan command `clusev:unban {ip?} {--all}` (clears one or all bans,
bypasses HTTP) **plus** an `unban) compose exec app php artisan clusev:unban "$@" ;;` case added to the
host `clusev` wrapper (`docker/clusev/clusev`) and its usage text — so `sudo clusev unban <ip>` works
from the VM shell, same philosophy as `clusev:reset-admin`.
- **Residual risk to surface (tab help + CHANGELOG):** an operator on a **public / CGNAT / shared** IP
who is **not** logged in can be locked out (by their own fumbling or by an attacker sharing the IP)
until the ban expires or they run the CLI. Mitigations above bound it; the help text states it and
points at the CLI.
- **Disable behaviour:** turning the feature off does **not** delete `BannedIp` rows; re-enabling resumes
any still-valid bans. Documented in the tab; covered by a test.
## 8. i18n
- Tab/config/table/unban strings → `lang/{de,en}/settings.php` (`tab_*` nav label + a `login_protection_*`
group). The **403 block-page** strings → `lang/{de,en}/errors.php` (error-page group per R16, mirroring
the view tree), e.g. `blocked_title` / `blocked_body`. DE + EN, identical keys, terse, no emoji (R9/R16).
## Files touched
New: `app/Models/BannedIp.php`, `database/migrations/xxxx_create_banned_ips_table.php`,
`app/Services/BruteforceGuard.php`, `app/Http/Middleware/BlockBannedIp.php`, `app/Rules/ValidIpOrCidr.php`,
`app/Console/Commands/UnbanIp.php` (`clusev:unban`), `app/Livewire/Settings/LoginProtection.php` +
`resources/views/livewire/settings/login-protection.blade.php`, `resources/views/errors/blocked.blade.php`,
the feature tests. Modify: `bootstrap/app.php` (append `BlockBannedIp` to the web group),
`app/Livewire/Auth/Login.php` + `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` (record + audit
hooks), `resources/views/livewire/settings/index.blade.php` (new tab + `@else` default), `lang/de/settings.php`,
`lang/en/settings.php`, `lang/de/errors.php`, `lang/en/errors.php`, `docker/clusev/clusev` (add `unban`).
**Not touched:** `Fail2banService`, `FirewallService`, the existing RateLimiter logic, `trustProxies`.
## Testing
Feature tests (PHPUnit, `RefreshDatabase`, `Cache::flush()` in `setUp`); inject the client IP where
needed (mock `request()->ip()` / build the request) since dev has no real forwarding (§0):
- **Guard:** `maxretry` failures within `findtime` create a `BannedIp` with the right `banned_until` and
`auth.ip_banned`; fewer do not; the counter resets after a ban; `findtime` minutes→seconds is correct.
- **Exemptions:** whitelisted, loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`), and trusted-proxy IPs
are **never** counted and **never** banned, however many failures; IPv6 spellings of the same address
are treated as one.
- **Concurrency:** the upsert is race-safe — a second ban attempt at the threshold updates, never throws
on the unique `ip` index.
- **Middleware:** a **guest** request from a banned IP gets 403 on `/login` and another guest route; an
**authenticated** request from the same banned IP **passes**; a non-banned guest passes; feature
disabled ⇒ a banned-row IP passes; an **expired** ban passes.
- **Cache:** ban then unban → the next request from that IP is **not** blocked (cache busted, no stale
positive); a fresh ban is visible within the 30 s window.
- **Hooks:** a real failed `Login` and a failed 2FA `verify` (both `TwoFactorChallenge` and
`TwoFactorBackup`) each drive the guard to a ban at the threshold (through the actual components).
- **Settings tab:** saving persists+casts the `Setting` keys; `ValidIpOrCidr` rejects junk; the table
lists active bans; single + bulk unban (via the confirm modal) remove rows + bust cache + audit;
widening the whitelist deletes now-covered bans; the "whitelist my IP" action exempts the current IP;
toggling disabled stops enforcement; re-enabling resumes a still-valid ban.
- **CLI:** `clusev:unban <ip>` and `--all` clear bans.
- **R12** browser-verify (DE+EN): the new tab loads (200, no console errors, DOM leak scan scoped to the
tab, 3 breakpoints). The 403 `errors.blocked` view is asserted in a feature test (it returns 403, not
200) for content + i18n + no leaks, not via the R12 200-page path.
- **R15** `/codex:review` clean; Pint clean.
## Out of scope
- The optional **host-fail2ban logfile hook** (emit a fail2ban-format auth-failure log + ship a jail
template for an operator-run host daemon). Explicitly deferred.
- Any change to the remote-fleet `Fail2banService` / `FirewallService`, or to `trustProxies`.
- Banning on anything other than failed control-plane auth (no WAF, no per-route rules, no geo-blocking).
- A separate ban-history table or analytics — history is the `AuditEvent` stream.

View File

@ -1,117 +0,0 @@
# WireGuard dashboard (SP2) — full in-UI management — Design
**Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved · **Follows:** SP1 (`2026-06-20-wireguard-gate-cli-design.md`, shipped v0.9.33)
Bring **all** WireGuard administration into the dashboard: see who is connected (live peer status + traffic), view traffic history, create/remove client peers, and edit every server setting + toggle the gate — **without leaving the UI**. Built on SP1's host CLI (`clusev wg …`), bridged through the `./run` bind-mount (the container still has no host network/kernel/firewall access — same hard constraint as SP1).
**Decided scope (user):** full management. Client config (private key + QR) is shown **once in the UI** then discarded (never persisted) — the 2FA-backup-code pattern. **All** server settings are editable from the UI, with hard confirmation for destructive ones.
**Built in phases (each independently shippable):** P1 live status → P2 traffic history + graph → P3 peer management → P4 server settings + gate. This spec defines the shared architecture + all four phases; each phase gets its own implementation plan.
## 0. The two bridges (container ↔ host)
The container cannot run `wg`/`iptables`. SP1 already established `./run``storage/app/restart-signal` (bind mount) + host watchers. SP2 adds two bridges over it.
### Bridge 1 — status collector (READ, periodic)
- **New `clusev-wg.sh collect` subcommand** writes `run/wg-status.json` (atomic: temp file + `mv`). Content (whitelisted, no secrets — never a private key):
```json
{
"at": 1750000000,
"configured": true,
"gate": {"marker": true, "chain": true},
"wg_quick": "active",
"server": {"subnet":"10.99.0.0/24","server_ip":"10.99.0.1","port":51820,"endpoint":"1.2.3.4:51820","pubkey":"abc…"},
"peers": [
{"name":"laptop","pubkey":"def…","endpoint":"5.6.7.8:41820","latest_handshake":1750000000,"rx":12345,"tx":67890}
]
}
```
Source: `wg show wg0 dump` (counters + handshakes + endpoints), the `# clusev-peer: <name>` comments in `wg0.conf` (pubkey→name map), and the gate marker/chain + `wg-quick@wg0` state. If WG is not configured (`wg0.conf` absent), it emits `{"configured": false, "at": …}` — never errors.
- **New systemd units** `clusev-wg-collect.timer` (`OnBootSec=10s`, `OnUnitActiveSec=5s`, `AccuracySec=1s`) → `clusev-wg-collect.service` (oneshot, root, `ExecStart=clusev-wg.sh collect`). Cheap: `wg show` is instant, and a no-WG host exits immediately. Rendered + enabled in `install_host_watchers()` (like the gate unit). The `.timer` is the only new "periodic" host pattern (SP1 used only `.path` event watchers).
- **New route `GET /wg-status.json`** reads `storage_path('app/restart-signal/wg-status.json')`, validates/whitelists the shape, returns it (or `{"configured": false}` if the file is absent/stale). Public, no secrets (mirrors `/update-status.json`). The page treats a stale `at` (older than ~20s) as "collector offline".
### Bridge 2 — write requests (WRITE, parameterized) — **the central design choice**
SP1's sentinels are parameterless triggers. UI-driven mutation needs a parameterized, whitelisted request. **Chosen: variant A** (extends the proven sentinel/`.path` pattern; no long-running daemon; container input is never `eval`'d):
1. The app writes `run/wg-request.json` = `{"id":"<random-token>","action":"<whitelisted>","args":{…},"at":…}` via a new `DeploymentService`-style writer. `action` ∈ a fixed whitelist: `add-peer | remove-peer | gate-up | gate-down | set-endpoint | set-port | set-subnet | setup`. The app **also** records the request (`id`, action) in an `AuditEvent` (like an update request).
2. A new host watcher `clusev-wg-request.path` (`PathExists=run/wg-request.json`) → `clusev-wg-request.service` (oneshot, root) → `clusev-wg.sh serve-request`:
- reads + **consumes** the request (move to a `.processing` temp, so a re-arm can't double-run),
- **validates `action` against the host-side whitelist** (unknown → error result), maps it to a fixed `cmd_*`,
- **re-sanitises every arg host-side** (peer name via the existing `valid_name`; endpoint/port/subnet via format checks; subnet via the existing collision check) — the container's strings are data, never code,
- runs the operation (non-interactive variants of `cmd_add_peer`/`cmd_remove_peer`/`cmd_up`/`cmd_down` + new `cmd_set_endpoint`/`cmd_set_port`/`cmd_set_subnet`/non-interactive `setup`),
- writes `run/wg-result-<id>.json` = `{"id":…,"ok":true|false,"message":"…", …}`. For `add-peer` only, it additionally carries `"client_config":"<wg conf text>"` and `"qr":"<ascii/SVG>"` — the **only** secret-bearing result.
3. **Result handling.** The app polls `GET /wg-result.json?id=<id>` (validates the id is a token it just issued, server-side, against the cached pending id — no arbitrary file read) → shows the outcome. For `add-peer`, the config + QR render **once** in a modal and are **never** written to the DB. Result files: mode **0600**, owned by the app-read uid (the `run/` owner that `install.sh` chowns), so a private key is never world-readable; the collector prunes `wg-result-*.json` older than 60 s.
This is the riskiest surface in SP2; §5 details the safety rules.
## 1. The page
- **`App\Livewire\Wireguard\Index`** (full-page class-based component, R1/R2) at route `/wireguard` (`Route::get('/wireguard', Wireguard\Index::class)->name('wireguard')`, in the onboarded panel group). English route, German+English labels (R13/R9/R16).
- **Sidebar nav** entry in the **Fleet** group (after Audit), `<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>` (`shell.nav_wireguard` = "WireGuard" both locales). (If a more fitting Lucide glyph is wanted, add one to `x-icon`; `lock`/`shield` already exist.)
- Live data via `wire:poll.5s` calling a component method that reads `/wg-status.json` (or reads the file directly server-side). Token-only styling (R3), no inline style except a possible bar width (R4), responsive 375/768/1280 (R7).
- **Empty/edge states:** WG not configured → a "Set up WireGuard" panel (P4's setup form / link to `clusev wg setup`); collector stale → a muted "status unavailable" note; gate-off-from-UI caveat shown near the gate toggle (SSH `clusev wg down` is the authoritative escape).
## 2. Phase P1 — live status (read-only)
Foundation. Deliverables: `clusev-wg.sh collect` + the `.timer`/`.service` + `install.sh` wiring + `GET /wg-status.json` + the page rendering:
- **Gate state** badge (an/aus) + the SSH-escape caveat.
- **Server card:** subnet, tunnel IP, listen port, endpoint, server pubkey (short), `wg-quick@wg0` state.
- **Peer list** (cards or a responsive table): name, online dot (handshake within ~3 min), endpoint, "last handshake vor X", rx/tx (mono, human bytes). Empty state if no peers.
- Stale-collector indicator. No writes, no chart, no DB. Reuses the `/update-status.json` read-bridge pattern + the existing `x-panel`/`x-badge`/`x-status-dot` kit (R10).
## 3. Phase P2 — traffic history + graph
- **`wg_traffic_sample` table + model:** `id, peer_pubkey (string, idx), peer_name (string, null), rx (bigint), tx (bigint), sampled_at (timestamp, idx)`. Cumulative counters; the chart computes deltas (throughput).
- **Sampling:** a scheduled app job (Laravel scheduler, ~every 60 s) reads `wg-status.json` and inserts one row per peer. Independent of whether the page is open. Requires the prod scheduler to run (`schedule:work` / a cron in the stack — a deploy note if not present). **Retention:** prune samples older than 7 days in the same job.
- **Chart:** first chart in the project. Add **ApexCharts** (npm) wired as an **Alpine island** in a Blade partial (CLAUDE.md §2), respecting the prod-JS-env rules (Vite-baked; top-level JS must not throw → guard init; data passed via a `wire:` payload / `window.__clusev`-style hand-off, not a baked secret). Per-peer throughput over a selectable window (1h/24h/7d). Tokens only for colours (R3) — feed ApexCharts the `@theme` accent/cyan values via CSS vars read in JS.
- Degrades cleanly if there is no data yet (empty chart + hint).
## 4. Phase P3 — peer management (create / remove)
- **Add peer:** a name input (client-side + server-side charset validation mirroring `valid_name`) → the app writes an `add-peer` request via Bridge 2 → polls the result → a **show-once modal** renders the client config + QR (never persisted). wire-elements/modal.
- **Remove peer:** a per-peer remove control → **R5 confirm modal + ConfirmToken** → a `remove-peer` request → status toast + the peer drops from the live list on the next poll.
- Both audited. The host re-validates the name; the app never constructs a shell command.
## 5. Phase P4 — server settings + gate
- **Gate toggle:** an `up`/`down` switch → `gate-up`/`gate-down` requests. Turning **on** is safe; turning **off** from the UI works only while the UI is reachable — show the caveat + the SSH escape prominently. Gate-off → R5 confirm (it exposes the panel publicly).
- **Endpoint** edit (safe — affects only new peers): a form → `set-endpoint` request.
- **Port** edit (destructive — restarts wg-quick, all clients must update): form → **R5 confirm + warning**`set-port`.
- **Subnet** edit (highly destructive — re-IPs everything, breaks all peers): form → **R5 confirm + a typed-confirmation warning**`set-subnet`. The host re-runs the collision check.
- **First-time setup from the UI:** when unconfigured, a guided form (subnet/server-IP/port/endpoint/first-peer name) → a non-interactive `setup` request; the host runs the same collision check + key/conf generation as `clusev wg setup` and returns the first peer's config in a show-once modal. (The interactive `clusev wg setup` on the host stays the alternative.)
- New host subcommands: `cmd_set_endpoint`, `cmd_set_port`, `cmd_set_subnet`, and a non-interactive `setup` path driven by the request args (collision check still enforced; never silently clobbers an existing `wg0.conf` unless the request explicitly says reconfigure).
## 6. Security & safety (cross-cutting)
- **Container input is data, never code.** `serve-request` whitelists `action` and re-sanitises every arg host-side. No request value is ever interpolated into a shell command unquoted; peer names pass `valid_name`; ports must be `165535`; subnets must match a CIDR pattern + the collision check.
- **Private keys** (add-peer / setup result) are show-once, mode 0600, owned by the app-read uid, auto-pruned after 60 s, and **never** stored in the DB or logged. The status feed never carries a private key.
- **Destructive actions** (remove-peer, gate-off, set-port, set-subnet) use wire-elements/modal + ConfirmToken (R5). set-subnet also requires typed confirmation.
- **Never lock out:** the gate still only filters 80/443; SSH + the WG port are untouched; `clusev wg down` over SSH remains the authoritative escape and is documented in the UI. A gate toggled on from the UI persists via SP1's `clusev-wg-gate.service`.
- **Audit:** every write request is an `AuditEvent` (actor, action, args summary, ip) — like the deploy/update requests.
- **Throttle:** write requests are per-user rate-limited (auto-expiring, never a lockout), as `requestUpdate` is.
- **Result-id binding:** `GET /wg-result.json` only returns a result for an `id` the app issued (held in cache/session), so the route can't be used to read arbitrary `run/` files.
## 7. Data flow summary
- **Read:** host `.timer``clusev-wg.sh collect``run/wg-status.json``GET /wg-status.json` → page (`wire:poll`). History: app scheduler → reads status → `wg_traffic_sample` → chart.
- **Write:** page action → `DeploymentService::requestWg($action,$args)` writes `run/wg-request.json` + audits → `clusev-wg-request.path``clusev-wg.sh serve-request` (validate + run + result) → `run/wg-result-<id>.json``GET /wg-result.json?id` → page (toast / show-once modal).
## 8. Files
**New (host):** `clusev-wg.sh` gains `collect`, `serve-request`, `cmd_set_endpoint`, `cmd_set_port`, `cmd_set_subnet`, non-interactive setup; `docker/wg/clusev-wg-collect.{timer,service}`, `docker/wg/clusev-wg-request.{path,service}`.
**New (app):** `app/Livewire/Wireguard/Index.php` + `resources/views/livewire/wireguard/index.blade.php`; a peer/result modal component; `app/Models/WgTrafficSample.php` + a migration; a sampling+prune job/command; `lang/{de,en}/wireguard.php`; an ApexCharts Alpine-island partial; `lang/{de,en}/shell.php` (`nav_wireguard`).
**Modified:** `routes/web.php` (`/wireguard`, `/wg-status.json`, `/wg-result.json`), `app/Services/DeploymentService.php` (the request writer + result reader), `resources/views/components/sidebar.blade.php` (nav), `install.sh` (render+enable the collect timer + request watcher), `package.json`/`resources/js` (ApexCharts island), the host runbook (extend for P3/P4).
**Not touched:** Caddy/compose (host-firewall gate), SP1's gate logic (reused), the remote-fleet Firewall/Fail2ban services.
## 9. Testing
- **Host scripts:** `shellcheck` clean; the manual VM runbook extended for collect/serve-request + the P3/P4 operations (real host needed — not PHPUnit-testable).
- **App:** PHPUnit for the page render, the `/wg-status.json` + `/wg-result.json` route parsing/validation, the request writer + audit + throttle + result-id binding, the `WgTrafficSample` model + sampling/prune job, the show-once modal grant. R12 browser-verify the page (DE+EN, 200, no console errors, 3 breakpoints, no leaked tokens — incl. the chart island not throwing). R15 Codex over the app diff + the shell additions; Pint clean.
## 10. Out of scope
- Clusev as a WG **client** joining an existing/corporate WG (SP1 was server-only; unchanged).
- Multi-server WG (per fleet server) — SP2 is the local control-plane host only.
- Live push (Reverb) for status — polling is sufficient and matches the existing pattern; Reverb can come later if the cadence proves too coarse.

View File

@ -1,191 +0,0 @@
# WireGuard panel gate + `clusev wg` CLI (SP1) — Design
**Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved
Lets an operator put the Clusev panel **behind a WireGuard tunnel**: the Clusev VM becomes a WG
server, operator devices peer in, and the panel's HTTP(S) ports are reachable **only** from the WG
subnet. A network-layer gate **on top of** the existing app-layer protection — 2FA + **Anmeldeschutz**
(the v0.9.27 brute-force IP ban: `App\Services\BruteforceGuard` + the login-protection settings tab).
Those guard the *login*; this hides the *whole panel* from the public internet. Driven entirely from a
host CLI: `clusev wg setup | up | down | status | add-peer | remove-peer`.
**SP1 of 2.** SP2 (a dashboard WireGuard page: live "who is connected", traffic history, peer
management + server settings from the UI, bridged via the `./run` bind-mount + sentinels) is a
separate later spec; see *Out of scope*.
**The constraint that shapes everything:** WireGuard (`wg0`, kernel) and the firewall live on the
**host**; the Laravel app runs in a container with no host network/kernel access. So this is **host
shell scripts**, invoked through the existing `clusev` host wrapper (like `update``update.sh`) —
**not** an artisan command. The only application-side change is a help topic.
## 0. Safety model — never get locked out
- **The gate only ever filters TCP 80/443.** SSH (22) and the WireGuard UDP listen port are never
matched by any gate rule — they are always reachable (an operator can always SSH to the host).
- **`clusev wg down` is the escape hatch.** It removes the gate → the panel is publicly reachable on
80/443 again (subject to any upstream cloud/host firewall). Printed by `wg up` and prominent in help.
- **The gate is isolated in its own iptables chain (`CLUSEV-WG-GATE`)**`down` removes only that
chain, never the operator's other `DOCKER-USER` rules (§3).
- **Reboot with a failed `wg0` does NOT brick access.** The persistence unit only re-applies the gate
when `wg0` is actually up (`ConditionPathExists=/sys/class/net/wg0`). If `wg-quick@wg0` fails on
boot, the gate is **not** applied → the panel stays publicly reachable rather than dropped-with-no-
tunnel. (And SSH + `clusev wg down` is always the fallback — documented in help, tested in the runbook.)
- **Setup never gates automatically; the gate is off by default.** Nothing about WG is enabled until
the operator runs `clusev wg setup`, verifies the tunnel reaches the panel, then explicitly runs
`clusev wg up`.
- **No Caddy / compose change.** The gate is a host-firewall rule, so Caddy keeps listening on
`0.0.0.0:80/443` and no image rebuild is needed to toggle it.
## 1. Installation — present but inert
`install.sh` (the host bootstrap) gains two idempotent additions, configuring **nothing** (no `wg0`,
no keys, no gate — WG is available but off):
- **Packages:** install `wireguard-tools` + `qrencode` (a small `apt-get install` in the package phase
near the Docker install around install.sh:85; idempotent — apt is a no-op if already present).
- **The gate systemd unit:** install `docker/wg/clusev-wg-gate.service` to `/etc/systemd/system`
**inside the existing `install_host_watchers()`** (install.sh:255286), reusing its `systemctl`-
presence guard + single `daemon-reload`. (`enable` it so it runs on boot, but it self-skips unless
the marker + `wg0` are present.)
**The `clusev-wg.sh` script is NOT copied to disk.** It runs from its repo path via the wrapper's
`CLUSEV_DIR` (`exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh"`, exactly like `update) exec
"${CLUSEV_DIR}/update.sh"`). So it is always the version in the deployed tree, and `clusev update`
(git pull + idempotent install.sh) ships changes to it for free — no separate refresh step.
## 2. `clusev wg setup` — interactive, pre-filled, collision-checked, idempotent
Runs as root on the host. Each value is **pre-filled with a sensible default + an explaining hint**,
editable, and confirmed — nothing silently chosen:
- **WG subnet** — default `10.99.0.0/24` (hint: a private /24 that must not clash with your LAN/VPN).
**Collision check:** compare against the host's routes/addresses (`ip -o route`, `ip -o addr` — host
namespace, since this runs on the host); on overlap, warn and **re-prompt** (a default can't slip
past a LAN collision — the check is the real protection, not the absence of a default).
- **Server tunnel IP** — default the subnet's first address (`10.99.0.1`).
- **Listen port (UDP)** — default `51820` (the WG de-facto standard).
- **Public endpoint** — default the auto-detected outbound IP `:port`, using the same lookup as
install.sh:131 (`curl -fsS --max-time 5 https://api.ipify.org`, fallback `hostname -I`/manual).
Hint: editable to a DNS name; **if the host is behind NAT / a cloud LB this may not be the address
clients should dial — verify it before `wg up`.**
- **First peer name** — prompt (default e.g. `client-1`); setup then calls `add-peer` (§4) for it.
**Idempotency / partial-failure safety:** if `/etc/wireguard/wg0.conf` already exists, setup does
**not** silently overwrite it (that would drop existing peers) — it warns and asks to reconfigure or
abort. Setup writes keys + `wg0.conf` (`0600`), enables + starts `wg-quick@wg0` (systemd, survives
reboot), then creates the first peer + prints its config + QR. Setup **does not** write the gate
marker and **does not** gate. Closing message: test the tunnel, then `clusev wg up`.
## 3. The gate — `clusev wg up` / `clusev wg down`
Goal: TCP 80/443 reachable only from the WG subnet (+ loopback); everything else dropped — **without**
touching SSH, the WG UDP port, or the operator's own firewall rules.
- **Mechanism: a dedicated `CLUSEV-WG-GATE` iptables chain hung off `DOCKER-USER`** (iptables-nft on
Debian 13). Docker publishes 80/443 via DNAT and **ufw cannot reliably filter Docker-published
ports** (a known trap), so ufw/firewalld are deliberately avoided; `DOCKER-USER` is the chain Docker
evaluates before its own forward rules. `clusev wg up`:
1. creates the chain `CLUSEV-WG-GATE` (idempotent: flush if it exists),
2. fills it, **in order**: `-i lo -j RETURN` (loopback) · `-s <wg_subnet> -p tcp -m multiport
--dport 80,443 -j RETURN` (allow the tunnel) · `-p tcp -m multiport --dport 80,443 -j DROP`
(drop everyone else). **No rule matches tcp 22 or any UDP** — SSH and the WG handshake pass
untouched by definition.
3. inserts a single jump at the top of `DOCKER-USER`: `-j CLUSEV-WG-GATE`.
- **Post-DNAT note:** `DOCKER-USER` runs after DNAT, so `--dport 80,443` matches the *container*
listening port — which is 80/443 here because Caddy is published `80:80` / `443:443`. If that
mapping ever changed, the dport here would need to follow.
- **`clusev wg down`:** delete the jump from `DOCKER-USER`, then flush + delete `CLUSEV-WG-GATE`.
Any other `DOCKER-USER` rules the operator added are untouched.
- **Persistence:** `up` writes the marker `/etc/clusev/wg-gate.enabled`; `down` removes it.
`clusev-wg-gate.service` (oneshot, `After=docker.service wg-quick@wg0.service`,
`ConditionPathExists=/sys/class/net/wg0`) re-applies the chain on boot/Docker-restart **only** when
the marker is present *and* `wg0` is up (the condition guarantees the latter — see §0).
- **`up` guards:** refuses (non-zero exit) if `wg0` is not up; prints the escape reminder
("verify the tunnel reaches the panel first — escape: `clusev wg down` over SSH") before applying.
## 4. `status` / `add-peer` / `remove-peer`
- **`status`** — `wg show wg0` (peers, latest handshake, endpoints, transfer) + the gate state
(marker present? chain present?) + the `wg-quick@wg0` service state.
- **`add-peer <name>`** — generate a client keypair, assign the next free address in the WG subnet,
append the `[Peer]` to `wg0.conf` + `wg set` (live, no restart), and print the client config + a
`qrencode` QR (**if `qrencode` fails, print the text config and continue — never abort**). The
client's `AllowedIPs` defaults to the **WG subnet only** (split tunnel — only panel traffic goes
through WG, the operator's normal internet does not). A hint notes full tunnel (`0.0.0.0/0`) is
possible but **not recommended** (it routes all the client's traffic through Clusev). `wg0.conf` is
the source of truth for peers (no DB in SP1).
- **`remove-peer <name>`** — remove the peer from `wg0.conf` + `wg set ... remove`.
- **Exit codes:** `0` success; non-zero on usage error / validation failure / system error, so the
CLI is scriptable.
## 5. Host CLI wiring
In the `docker/clusev/clusev` **template** (the file with `CLUSEV_DIR=__CLUSEV_DIR__`, which
install.sh:323326 renders into `/usr/local/bin/clusev`), add a `wg)` case to the `case "$cmd"` switch
(after `artisan)`, before `version)`):
```bash
wg) exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh" "$@" ;;
```
and a German usage line (`clusev wg setup|up|down|status|add-peer|remove-peer WireGuard-Zugang (host)`).
The rendered `CLUSEV_DIR` makes the script path resolve on the host.
## 6. Help topic "WireGuard-Zugang"
- `app/Livewire/Help/Index.php`: add `'wireguard'` to the `TOPICS` const (before `'recovery'`) and a
`__('help.topic_wireguard')` entry to the `$labels` map.
- `lang/de/help.php`: `'topic_wireguard' => 'WireGuard-Zugang'`; `lang/en/help.php`:
`'topic_wireguard' => 'WireGuard access'` (identical keys, R9/R16).
- `resources/views/livewire/help/content/de/wireguard.blade.php` + `.../en/wireguard.blade.php`
(mirrors the existing `security` topic partial). Content: what the gate does + that it sits on top of
2FA/Anmeldeschutz, the `clusev wg setup` walkthrough (collision hint, endpoint/NAT caveat), importing
a client via QR, testing the tunnel, `clusev wg up` to gate, split-vs-full-tunnel, and the **escape
hatch prominently** (`clusev wg down` over SSH; reboot-with-wg0-failure leaves the panel open).
DE+EN, terse, no emoji (R9/R16), only `@theme` token utilities, no inline styles (R3/R4), no leaked
tokens (R17). URL is `/help?topic=wireguard` (English path; German label).
## Files touched
New: `docker/wg/clusev-wg.sh` (the CLI + collision check + ipify endpoint detect + key/config/QR
generation + the `CLUSEV-WG-GATE` apply/remove logic), `docker/wg/clusev-wg-gate.service` (the
persistence oneshot; may call a tiny `clusev-wg.sh gate-apply` subcommand so the rule logic lives in
one place), `resources/views/livewire/help/content/de/wireguard.blade.php` +
`.../en/wireguard.blade.php`, `docs/superpowers/runbooks/wireguard-gate-runbook.md`. Modify:
`install.sh` (apt `wireguard-tools qrencode`; install the gate unit inside `install_host_watchers()`),
`docker/clusev/clusev` (template: the `wg)` case + usage), `app/Livewire/Help/Index.php` (topic +
label), `lang/de/help.php`, `lang/en/help.php`. **Not touched:** `docker/caddy/Caddyfile`,
`docker-compose.prod.yml` (host-firewall gate, no Caddy/compose change), the Laravel app beyond the
help topic, `FirewallService`/`Fail2banService` (remote fleet, unrelated), `trustProxies`/domain/TLS.
## Testing
Host shell scripts cannot be exercised by the PHPUnit suite (the runner is the container, with no host
WireGuard/firewall):
- **`shellcheck`** clean on `docker/wg/clusev-wg.sh` and the touched `install.sh` sections.
- **Manual runbook** (`docs/superpowers/runbooks/wireguard-gate-runbook.md`) on a throwaway Debian-13
VM with the prod stack, recording each expected result:
- `clusev wg setup`: collision check **rejects** a colliding subnet, **accepts** a clean one; the
first client config + QR print; re-running setup does not silently clobber `wg0.conf`.
- Import the client; the tunnel reaches `http://<server-tunnel-ip>`; public `http://<public-ip>` is
still open (gate off).
- `clusev wg up`: public 80/443 now **refused**, the tunnel still serves the panel, **SSH still
works**; `clusev wg status` shows the peer + gate on; a manually-added `DOCKER-USER` rule survives
`up`+`down`.
- **Reboot:** `wg0` + gate persist; and a forced `wg0` failure on boot leaves the gate **unapplied**
(panel publicly reachable, not bricked) — SSH + `clusev wg down` recover.
- `clusev wg down`: public open again; `add-peer`/`remove-peer` work; full-tunnel client warning shown.
- **Help topic:** R12 browser-verify `/help?topic=wireguard` (DE+EN, HTTP 200, no console errors, 3
breakpoints, no leaked tokens). R15 `/codex:review` over the app-side diff + the shell scripts; Pint
clean for the PHP/blade.
## Out of scope (= SP2)
- A dashboard WireGuard page: live connected-peer status, **traffic history/graph**, add/remove peers
and edit server settings **from the UI** — needs the `./run` bind-mount status bridge (a host
`wg show` collector writing a status file the app reads) + request sentinels + host watchers +
`wg_peer`/`wg_traffic` models. Separate spec.
- Clusev **joining an existing** (corporate) WireGuard as a client. SP1 is Clusev-as-server only.
- An `install.sh` "gate now?" prompt — setup is post-install via `clusev wg setup` (no first-install
lockout risk).

View File

@ -1,97 +0,0 @@
# 3-Repo Release-Promotion + Release-Steuerung — Design Spec
**Datum:** 2026-06-22
**Status:** entworfen (GitHub-Repos existieren noch nicht — alles URL-agnostisch)
## Ziel
Clusev über drei Repositories befördern — **Gitea (dev) → GitHub privat (staging) → GitHub public (release)** — vollständig **vom Dev-Dashboard aus per Buttons** gesteuert, mit GitHub-Actions-CI, **manuellem Gate** (jeder Promotions-Klick = bewusste R5-Bestätigung) und **sauberer Public-Historie** (ein Commit pro Release). **Keine private Repo-URL** in versionierten Dateien.
## Kanäle & Tags (Semver)
- **Beta:** Tag `vX.Y.Z-betaN` (z. B. `v0.10.0-beta1`).
- **Stable:** Tag `vX.Y.Z` (z. B. `v0.10.0`).
- `ReleaseChecker` kann das bereits: **Stable-Kanal** sieht nur `vX.Y.Z`, **Beta-Kanal** auch `-betaN`.
- **Mapping:** Staging fährt Betas (interner Test). Das Public-Repo bedient **beide** Kanäle — Beta-Tags für Beta-Tester, Stable-Tags für alle.
## Release-Fluss (alles vom Dev-Dashboard)
1. **Dev:** du committest auf Gitea (Auto-Mirror nach GitHub-privat). Lokal testen.
2. **Deploy to Staging** → schneidet eine **Beta** `vX.Y.Z-betaN` → CI (GitHub-privat) testet → Staging (.165) fährt die Beta. Du + Staging prüfen.
3. **Deploy to Public** → befördert die Beta ins **GitHub-public** (Beta-Kanal) → Beta-Tester weltweit.
4. **Promote beta → stable** → finalisiert als `vX.Y.Z` auf public (Stable-Kanal) → alle.
## Repos & Rollen
| Repo | Sichtbarkeit | Rolle |
|---|---|---|
| Gitea `git.bave.dev` | privat | **Einzige Dev-Quelle.** Du pushst/taggst (auch via Dashboard-Brücke). Nie direkt in die GitHub-Spiegel committen. |
| GitHub privat | privat | Exakter **Push-Mirror** von Gitea. Trägt CI + Staging-Deploy. |
| GitHub public | öffentlich | **Nur Releases** — ein sauberer Commit pro Release + Tag (Beta + Stable). |
## Repository-URL-Handhabung (Sicherheits-Kern)
Anforderung: dev/staging dürfen alle URLs in ihrer **gitignorierten `.env`** halten; das **Public-Repo** trägt in versionierten Dateien **ausschließlich die Public-URL** — damit niemand über Public die Dev-/Staging-Repos findet oder dorthin pusht.
1. **Getrackter Default = Public-URL** (sicher, weil öffentlich). `config/clusev.php`: `'repository' => env('CLUSEV_REPOSITORY', '<public-repo-url>')`. Aktuell ist die Gitea-URL hart eingetragen (`'repository' => 'https://git.bave.dev/boban/clusev'`) → durch die Public-URL ersetzen (Platzhalter, bis das Repo existiert). Einzige Repo-URL in versionierten Dateien — und die ist öffentlich.
2. **Private URLs nur in `.env`.** Gitea-/GitHub-privat-URL stehen ausschließlich in der gitignorierten `.env` von dev/staging.
3. **Auto-Ableitung aus dem Klon-Ursprung.** `install.sh`/`update.sh`: `git -C <proj> remote get-url origin``CLUSEV_REPOSITORY=<das>` in `.env`. → dev bekommt Gitea, staging GitHub-privat, Public-Nutzer Public — automatisch, ohne Hardcode.
4. **Graceful Degradation.** Leeres `CLUSEV_REPOSITORY` + kein Default → kein Update-Check, kein Crash, kein Badge.
**Ergebnis:** versionierte Dateien tragen höchstens die Public-URL.
## CI-Pipeline (GitHub Actions, GitHub-privat)
- **Trigger:** Push eines Tags `v*` (Beta + Stable).
- **Jobs (Container):** `composer install``php artisan test` → shellcheck (`docker/wg/*.sh`, `install.sh`, `update.sh`) → `./vendor/bin/pint --test``npm ci` + `npm run build`.
- **Bei `*-beta*`-Tag & grün:** Staging-Deploy auf .165 (per SSH-Deploy-Key als Actions-Secret → `sudo ./update.sh`). Rot → kein Deploy, Status meldet rot.
## Promotion-Workflows (per `workflow_dispatch`, vom Dashboard aufrufbar)
- **`promote-public.yml`** (Eingabe: `tag`): checkt GitHub-privat am Tag aus → Baum **ohne `.git`/`.github`/staging-only Dateien** in einen Checkout von GitHub-public → Commit „Release `<tag>`" → Tag → Push (Push-Token-Secret). Beta-Tags landen so im Beta-Kanal.
- **`promote-stable.yml`** (Eingabe: `betaTag`): nimmt den Commit der Beta, legt den **Stable-Tag** `vX.Y.Z` an + pusht ins Public → Stable-Kanal.
- Beide an ein GitHub-**Environment „production" mit Pflicht-Reviewer** gebunden (zweite Sicherung zusätzlich zum Dashboard-Confirm).
- **Hygiene:** Baum ohne `.env*` (gitignored); Tokens nur als Actions-Secret, nie im Log.
## Phase B — Dashboard-Release-Steuerung (eigene Spec, NACH Phase A + Repos)
Neue **„Release"-Seite** im Dev-Dashboard:
- Zeigt installierte Version + letzte Tags (Beta/Stable) je Kanal.
- **Buttons (jeder R5-Confirm):** „Deploy to Staging" · „Deploy to Public" · „Promote to Stable".
- **Tag-Erzeugung** (Beta/Stable) über eine **Host-Git-Brücke** analog der WG-/Restart-Brücke (`./run`-Bind-Mount → Host-Skript `git tag` + `git push` zu Gitea; Container bekommt nie Git-Credentials).
- **Public-Promotion** (Deploy to Public / Promote to Stable): Dashboard ruft die **GitHub-API** (`workflow_dispatch`) auf; der **GitHub-Deploy-Token liegt im vorhandenen verschlüsselten Credential-Vault** (`App\Support\Ssh\CredentialVault`-Muster). Direktes Feedback im Dashboard.
- Jede Aktion ins Audit-Log (`deploy.*`).
## Artefakte
**Phase A (jetzt baubar/testbar, URL-agnostisch):**
1. `config/clusev.php`: Gitea-Hardcode → `env('CLUSEV_REPOSITORY', '<public-url-platzhalter>')`.
2. `install.sh`/`update.sh`: `CLUSEV_REPOSITORY` aus Klon-Ursprung ableiten → `.env`.
3. `.github/workflows/ci-staging.yml` (Tests bei `v*`; Staging-Deploy bei `*-beta*`).
4. `.github/workflows/promote-public.yml` + `promote-stable.yml` (`workflow_dispatch`, Environment „production").
5. `scripts/promote.sh` (Baum-Kopie + Commit + Tag + Push) — vom Workflow genutzt, lokal shell-testbar.
6. README: 3-Repo-Flow + Kanäle + wo die URLs/Tokens beim Repo-Anlegen eingetragen werden.
7. `ReleaseChecker`/Versions: „keine Repository-URL → kein Update, kein Crash" verifizieren.
**Phase B (eigene Spec, später):** Release-Seite (Livewire), Host-Git-Tag-Brücke, GitHub-Deploy-Token im Vault, GitHub-API-Client, Audit.
## Platzhalter für später (nur Einstellungen/Secrets, kein Code)
- Gitea Push-Mirror-Ziel = GitHub-privat-URL + GitHub-PAT.
- GitHub-Actions-Secrets: `STAGING_SSH_KEY`, `PUBLIC_REPO_TOKEN`; Variable `PUBLIC_REPO_URL`.
- Public-Default in `config/clusev.php`; Dev-/Staging-`.env`.
- (Phase B) GitHub-Deploy-Token im Vault.
## Tests
- `install.sh`-URL-Ableitung: shell-testbar.
- `config/clusev.php`: liest `CLUSEV_REPOSITORY`, Default = Public-URL, degradiert sauber.
- `ReleaseChecker`: „keine Repo-URL → false, kein Netz, kein Crash".
- `scripts/promote.sh`: shell-testbar (Baum-Kopie ohne `.git`/`.env`, Commit/Tag in einem temp-Repo).
- Workflows: `actionlint`; echte Verifikation erst mit existierenden Repos.
## Nicht-Ziele
- GitHub-Repos jetzt anlegen (existieren nicht).
- Keine selbstgebaute Webhook-Infra (Gitea-Mirror + GitHub Actions + GitHub-API genügen).
- Phase B nicht in diesem Plan umsetzen (eigene Spec, sobald A + Repos stehen).

View File

@ -1,141 +0,0 @@
# Phase B1 — Deploy to Staging (Dashboard Release control) — Design Spec
**Datum:** 2026-06-22
**Status:** entworfen
**Voraussetzung:** Phase A (Release-Pipeline-Infrastruktur, v0.9.58) ist gebaut. Dies ist das erste
Sub-Projekt von Phase B (Dashboard-Release-Steuerung). B2 (Deploy-to-Public / Promote-to-Stable über
die GitHub-API + Token) ist eine eigene, spätere Spec.
## Ziel
Einen **„Deploy to Staging"-Button** in einer neuen, dev-only **Release-Seite** des Dashboards, der
eine **Beta** schneidet: Version bumpen → committen → `vX.Y.Z-betaN` taggen → nach **Gitea** pushen.
Gitea spiegelt nach GitHub-privat, CI läuft, Staging fährt die Beta. Alle Git-/Token-Operationen
laufen **host-seitig** (Container bekommt nie Credentials) — gleiches Isolationsmodell wie die
WireGuard-Brücke.
## Designentscheidungen (vom Nutzer bestätigt)
1. **Zuschnitt:** B1 = nur Deploy-to-Staging. B2 (Public-Promotion) folgt als eigene Spec.
2. **Button bumpt + taggt:** Die Host-Brücke schreibt die neue Version in `config/clusev.php`,
committet, taggt, pusht. Folge-Klicks an derselben Ziel-Version zählen `-betaN` hoch.
3. **Brücke:** Eigene Host-Release-Brücke (Request-File → systemd `.path`-Watcher → `clusev-release.sh`),
analog zur WG-/Restart-Brücke.
4. **Gating:** Explizit per `CLUSEV_RELEASE_CONTROLS=true` (nur in der gitignorierten Dev-`.env`).
Standard aus → Seite/Route/Host-Units existieren auf Nicht-Dev-Installs gar nicht.
## Token-Landschaft (Klarstellung — B1 braucht KEINEN GitHub-Token)
| Token | Wer nutzt | Wohin | Zweck |
|---|---|---|---|
| **Gitea-Token** (`GIT_ACCESS_TOKEN`) | Host-Brücke | `/home/nexxo/.env.gitea` (host-seitig) | **B1**: Push Branch+Tag → Gitea |
| **Mirror-PAT** | Gitea | Gitea → Mirror → Authorization | Gitea → push GitHub-privat (boksbc/clusev-staging) |
| `GIT_STAGING_ACCESS_TOKEN` | Dashboard | `.env` jetzt / Vault geplant | **B2**: GitHub-Actions triggern (`workflow_dispatch`) |
| `PUBLIC_REPO_TOKEN` | GitHub Actions | Secret auf clusev-staging | Actions → push clusev/clusev |
B1 pusht nur nach Gitea (vorhandener Gitea-Token, host-seitig). `GIT_STAGING_ACCESS_TOKEN` ist für B2.
## Architektur & Datenfluss
**Bausteine:**
- `config/clusev.php`: `'release_controls' => env('CLUSEV_RELEASE_CONTROLS', false)`.
- Route `/release` + `app/Livewire/Release/Index.php` + `resources/views/livewire/release/index.blade.php`
— nur registriert/erreichbar wenn der Flag an ist.
- `app/Services/ReleasePlanner.php` — reine Semver-Arithmetik (vorgeschlagene Ziel-Versionen).
- `app/Services/ReleaseBridge.php` (Container-Seite) — schreibt Request, liest Result (Sentinel-Muster).
- `docker/release/clusev-release.sh` (Host-Seite) — `serve-request`: validiert, bumpt, committet, taggt, pusht.
- systemd `clusev-release.path` + `clusev-release.service` (Host-Watcher) — von `install.sh` **nur bei
gesetztem Flag** installiert (host-seitig, wie WG/Restart).
- `lang/{de,en}/release.php`; Audit-Event `deploy.staging_release` (+ Label in `lang/{de,en}/audit.php`).
**Datenfluss „Deploy to Staging":**
1. Operator auf `/release` wählt ein Ziel (patch/minor/major; bei laufender Beta zusätzlich „Nächste
Beta") → Button → **R5-Confirm-Modal** mit dem exakten Tag, der entsteht.
2. `Release\Index::deployStaging($target)`: Flag-Guard · Ziel **server-seitig neu ableiten** und prüfen
`target ∈ erlaubte` · per-User-Throttle (auto-expiring) · `ReleaseBridge->requestStaging($target)`
schreibt `release-request.json` `{action:"stage", target:"X.Y.Z"}` in den bind-gemounteten
Signal-Ordner · Redis-„läuft"-Marker + Audit (angefordert).
3. Host-`.path`-Watcher → `clusev-release.sh serve-request` (siehe unten) → Result-File.
4. Dashboard pollt (`wire:poll` während „läuft", Timeout wie Update-Flow) → Erfolgs-Toast
(„Beta `vX.Y.Z-betaN` gepusht → Mirror → CI → Staging") oder lesbarer Fehler; Audit des Ergebnisses.
**Sicherheits-Kern:** Container schreibt nur ein validiertes Ziel; Git + Token laufen host-seitig.
## Versionslogik
- Dashboard rechnet die Ziel-Version, Host nur die Beta-Nummer. Request trägt `target:"X.Y.Z"`.
- `cur` = aktuelle config-Version; `base` = `cur` ohne `-betaN`.
- `ReleasePlanner::proposedTargets(cur)`:
- **stabil** (`cur` = `X.Y.Z`): `{patch: bumpPatch(base), minor: bumpMinor(base), major: bumpMajor(base)}`.
- **Beta** (`cur` = `X.Y.Z-betaN`): zusätzlich `{continueBeta: base}` (weitere Beta derselben Ziel-Version).
- Host: `betaN` = höchster vorhandener `vTARGET-beta*` Tag + 1 (sonst 1) → neue Version `X.Y.Z-betaN`.
## Host-Skript `clusev-release.sh serve-request`
**Härtung (wie `clusev-wg.sh`):** `set -euo pipefail`; Feld-Extraktion mit `|| true`; Handler in
Subshell `( … )`; Whitelist `action=stage`; `target` muss `^[0-9]+\.[0-9]+\.[0-9]+$` sein **und ≥ base**
(kein Downgrade).
**Preconditions (sonst Abbruch → Result-Error, keine Mutation):**
- Repo unter `PROJ` (`/home/nexxo/clusev`), auf dem erwarteten Branch.
- `git status --porcelain` leer (Arbeitsbaum sauber).
- `git fetch` + lokaler HEAD == `origin/<branch>` (nichts ungepusht/divergiert).
- Ziel-Tag `vX.Y.Z-betaN` existiert noch nicht.
**Aktion:**
1. `config/clusev.php` Version → `X.Y.Z-betaN` (sed auf die `'version' => '…'`-Zeile).
2. `git commit -am "chore(release): X.Y.Z-betaN"`.
3. `git tag vX.Y.Z-betaN`.
4. `git push <gitea> <branch>` + `git push <gitea> vX.Y.Z-betaN` (Token aus `/home/nexxo/.env.gitea`,
Ausgabe sanitisiert — nie der Token im Log/Result).
5. Result `{ok:true, tag:"vX.Y.Z-betaN"}`.
**Rollback:** Schlägt Schritt 4 fehl (Netz/Token), wird der lokale Stand zurückgesetzt
(`git tag -d vX.Y.Z-betaN`; `git reset --hard origin/<branch>`), damit kein halber Commit/Tag bleibt;
Result `{ok:false, error:"…"}`.
**Edge-Cases:** dirty → „erst committen/aufräumen" · ungepusht → „erst pushen" · Ziel-Tag existiert →
Beta hochzählen (kein Fehler) · Push scheitert → Rollback + lesbarer Fehler.
## Gating (dreifach, safe-by-default)
- `config/clusev.php` Flag `release_controls` (Default false).
- Route `/release` in `routes/web.php` nur registriert wenn `config('clusev.release_controls')`.
- `Release\Index::mount()` + Sidebar-Eintrag prüfen den Flag zusätzlich (`abort(404)` falls aus).
- `install.sh` installiert die systemd-Release-Units nur bei gesetztem Flag.
## Fehlerbehandlung
- Request nicht schreibbar (Bind-Mount nicht beschreibbar) → sofortiger lesbarer Fehler, kein „läuft".
- Host-Skript-Fehler → Result-Error, im Dashboard angezeigt + auditiert.
- Poll-Timeout (analog Update-Flow) → „Release hängt/fehlgeschlagen", Marker geräumt.
- Out-of-range/ungültiges Ziel → server-seitig abgelehnt (nie an die Brücke gereicht).
- Throttle erschöpft → lesbarer Hinweis (auto-expiring, nie Lockout).
## Tests
- `docker/release/serve-request.test.sh` (shell, host-Logik isoliert): temp-Repo + bare-Remote →
Bump+Commit+Tag+Push verifizieren · Beta-Inkrement (zweiter Lauf → `-beta2`) · Refusals (dirty /
ungepusht / Downgrade-Target / unbekannte action) · Rollback bei Push-Fehler (unerreichbares Remote).
- `ReleasePlannerTest` (pure): patch/minor/major aus stabil; `continueBeta` aus Beta; Strip von `-betaN`.
- `Release\Index`-Test (Livewire): Targets korrekt gerendert · `deployStaging` schreibt Request +
auditiert + setzt „läuft" · out-of-range-Target abgelehnt · Throttle blockt · Result-Handling
(Erfolg/Fehler) · Request-nicht-schreibbar → Fehler ohne „läuft".
- Gating-Test: `/release` 404 bei Flag aus, 200 bei an; Sidebar-Eintrag nur bei an.
## Nicht-Ziele (B1)
- Keine CI-/Staging-Status-Anzeige im Dashboard (Operator prüft GitHub Actions; evtl. späteres B3).
- Keine Public-Promotion / GitHub-API / Vault-Token (B2).
- Kein Changelog-Editieren (Betas akkumulieren; der Changelog zählt beim Stable-Release in B2).
- Kein Auto-Deploy auf Staging aus diesem Schritt (das macht die CI bzw. die Versions-Seite).
## Artefakte
1. `config/clusev.php` (Flag), `routes/web.php` (bedingte Route), Sidebar-Partial (bedingter Eintrag).
2. `app/Services/ReleasePlanner.php` + Test.
3. `app/Services/ReleaseBridge.php` (Request/Result) + Anteil im Component-Test.
4. `app/Livewire/Release/Index.php` + `resources/views/livewire/release/index.blade.php` + R5-Confirm-Modal.
5. `docker/release/clusev-release.sh` + `docker/release/serve-request.test.sh` + systemd
`clusev-release.path`/`.service`.
6. `install.sh` (bedingte Unit-Installation), `lang/{de,en}/release.php`, Audit-Label in `lang/{de,en}/audit.php`.

View File

@ -1,163 +0,0 @@
# Clusev v1 — Konsolidierte Fix-Liste
Alle Punkte gegen die echten Dateien geprüft. Duplikate (Focus-States, Touch-Targets, KPI-Balken, Ringe, mobile CPU/RAM-Anzeige) sind zusammengeführt. Reine Bereitschafts-/Konventionshinweise ohne Datei-Defekt sind unter „Bewusst weggelassen" gelistet.
---
## MUST-FIX (Regelverstoß / echter Bug / A11y-Blocker)
### A11y — Tastatur-Fokus unsichtbar
- **Dateien:** `nav-item.blade.php`, `topbar.blade.php` (Z. 3, 16), `sidebar.blade.php` (Z. 10, 19, 48), `server-item.blade.php` (Z. 5), `livewire/dashboard.blade.php` (Z. 69)
- **Änderung:** Auf jeder interaktiven Basisklasse einen Token-Fokusring ergänzen: `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface`. Alternativ eine Base-Layer-Regel in `app.css`: `*:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px }`.
- **Grund:** Null Fokus-Styles im gesamten View-Layer (per grep bestätigt) — Tastaturbedienung auf der dunklen UI ist unmöglich.
### A11y — Kontrast `ink-3` unter WCAG AA
- **Datei:** `resources/css/app.css:36` (`--color-ink-3: #69757F`)
- **Änderung:** Auf ca. `#7E8A95` aufhellen (≈4,6:1 auf surface).
- **Grund:** 3,95:1 auf surface — `ink-3` ist Default für fast jeden Sekundärtext (IPs, Subtitles, Unit-Labels, systemd-Beschreibungen) bei `text-[11px]`/`text-xs`, also gilt 4,5:1.
### A11y — Kontrast `ink-4` unter WCAG AA (für Text)
- **Datei:** `resources/css/app.css:37` (`--color-ink-4: #495159`)
- **Änderung:** Für Textnutzung auf ca. `#707B85` (≈4,5:1) aufhellen, oder `ink-4` strikt auf Hairlines/Nicht-Text beschränken und betroffene Textstellen (`dashboard.blade.php:107,109`, `server-item.blade.php:13,19`) auf `text-ink-3` umstellen.
- **Grund:** 2,31:1 — fällt sogar unter AA-large (3:1), wird aber für echten Lesetext (Audit-Timestamps/Targets, CPU/RAM-Spaltenlabels) genutzt.
### A11y — Off-Canvas-Drawer nicht aus Tab-Order entfernt
- **Datei:** `resources/views/components/sidebar.blade.php:2` (`<aside>`)
- **Änderung:** Geschlossenen Zustand mit `x-bind:inert="!nav"` (plus `:aria-hidden`) binden, auf `lg` immer interaktiv halten; Fokus beim Öffnen auf den Schließen-Button setzen.
- **Grund:** Nur `-translate-x-full` schiebt den Drawer aus dem Bild — Tastatur/Screenreader tabben weiter in unsichtbare Off-Canvas-Links.
### R7 — Touch-Targets unter 44px
- **Dateien/Änderungen:**
- `topbar.blade.php:3` und `:16` — Menü- und Glocken-Button `h-9 w-9``min-h-11 min-w-11` (grid `place-items-center` behalten).
- `sidebar.blade.php:10` (Schließen) und `:48` (Abmelden-Link `<a>`) — `h-9 w-9``min-h-11 min-w-11`.
- `livewire/dashboard.blade.php:69` — „Hinzufügen"-Button `min-h-9``min-h-11`.
- **Grund:** R7 fordert ≥44px; diese realen Tap-Ziele sind 36px (Switcher/Nav-Items nutzen bereits korrekt `min-h-11`).
### Bug — NaN-Guard greift an der falschen Stelle
- **Datei:** `resources/js/app.js:34-37` (`push()`)
- **Änderung:**
```js
push(v) { const n = Number(v); if (!Number.isFinite(n)) return; this.last = n; this.points.push(n); if (this.points.length > this.max) this.points.shift(); }
```
- **Grund:** `Number(e.cpu)` bei fehlendem/falschem Key liefert `NaN`; die Klammer in `coords()` lässt `NaN` durch → `<polyline>` wird ungültig (Sparkline verschwindet) und der Zähler zeigt `NaN%`.
### Bug/Leak — Reverb-Channel wird nie abgebaut
- **Datei:** `resources/js/app.js:26-32` (`init()`)
- **Änderung:** Channel-Referenz halten und `destroy() { window.Echo?.leave('metrics'); }` zum Alpine-Objekt hinzufügen; `state_change`-Handler ebenfalls per Referenz unbinden.
- **Grund:** Kein `Echo.leave`/`unbind` vorhanden (grep bestätigt). Heute durch Full-Reload maskiert, aber sobald `wire:navigate` für die geplanten Server/Dienste/Dateien-Seiten kommt, stapeln sich Subscriptions auf dem Singleton `window.Echo`.
### R10 — KPI-Fortschrittsbalken ignoriert den Status-Triad
- **Datei:** `resources/views/components/kpi.blade.php:23`
- **Änderung:** Balkenfarbe aus `$tone` ableiten, z. B. `$barColor = ['ink'=>'bg-accent','online'=>'bg-online','warning'=>'bg-warning','offline'=>'bg-offline','accent'=>'bg-accent','cyan'=>'bg-cyan'][$tone] ?? 'bg-accent';` und `bg-accent` ersetzen. Width-Inline-Style bleibt (R4).
- **Grund:** Balken ist hart `bg-accent`; die `tone`-Prop färbt nur die Zahl — eine getonte KPI mit `pct` zeigt orangen Balken unter grüner/roter Zahl.
### Datenintegrität — Ressourcen-Ringe zeigen hartkodierte Konstanten
- **Datei:** `resources/views/livewire/dashboard.blade.php:57-60`
- **Änderung:** Ringe an echte Daten binden (mind. CPU/RAM aus denselben `$avgCpu`/`$avgMem` wie die KPIs) und Ton schwellenwertgesteuert in `x-ring` setzen (grün <75, amber 7590, rot >90) statt fixem `tone` je Metrik.
- **Grund:** `:value="34/61/48"` mit statischen Tönen — „Disk" rendert amber bei 48% nur wegen `tone="warning"`, irreführend in einem Monitoring-View und entkoppelt vom Datenstrom.
### R7/UX — Keine Empty-/Loading-States in den Listen
- **Datei:** `resources/views/livewire/dashboard.blade.php` (Server `73-78`, systemd `82-93`, Audit `98-112`)
- **Änderung:** Jedes `@foreach` zu `@forelse … @empty` mit zentrierter, gedämpfter DE-Empty-Zeile (z. B. `<div class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Server im Cluster</div>`); `wire:loading`-Skeleton für die per SSH/Queue hydrierten Panels.
- **Grund:** Bare `@foreach` ohne Fallback (grep: 0 forelse/@empty/wire:loading) — leere Daten rendern blanke Boxen.
---
## NICE-TO-HAVE (Polish)
### A11y — `prefers-reduced-motion` fehlt
- **Datei:** `resources/views/components/status-dot.blade.php:7`
- **Änderung:** `animate-ping``motion-safe:animate-ping`, oder Base-Layer-Regel in `app.css`, die `.animate-ping` bei reduzierter Bewegung deaktiviert.
- **Grund:** Dauerhafte Ping-Animation auf jedem Online-Dot ohne Reduced-Motion-Guard (grep: 0).
### A11y — `<nav>` ohne zugänglichen Namen
- **Datei:** `resources/views/components/sidebar.blade.php:31`
- **Änderung:** `<nav aria-label="Hauptnavigation" …>`.
- **Grund:** Generisches „navigation"-Landmark für Screenreader.
### A11y — Live-Chart Disconnected-Zustand kaum wahrnehmbar
- **Datei:** `resources/views/livewire/dashboard.blade.php:46`
- **Änderung:** Dot `aria-hidden="true"` (Text daneben trägt die Info) und Disconnected-Farbe `bg-ink-4``bg-warning`: `:class="connected ? 'bg-online' : 'bg-warning'"`.
- **Grund:** `bg-ink-4` (2,3:1) ist als Statusindikator praktisch unsichtbar.
### R7/Responsive — Per-Server CPU/RAM auf Mobile komplett ausgeblendet
- **Datei:** `resources/views/components/server-item.blade.php:11`
- **Änderung:** Kompakte Mono-Zeile nur `<sm` einblenden (z. B. `CPU 34% · RAM 61%`), ab `sm` auf die Balken umschalten.
- **Grund:** `hidden sm:flex` lässt unter 375px die wichtigste At-a-glance-Metrik ersatzlos verschwinden.
### R7 — Server-Item-Zeile ohne Mindesthöhe
- **Datei:** `resources/views/components/server-item.blade.php:5`
- **Änderung:** `min-h-11` zur `<a>`-Klasse ergänzen.
- **Grund:** Nur `py-2.5`, keine Mindesthöhe — in der einzeiligen `<sm`-Variante kann die Tap-Höhe unter 44px fallen.
### R10 — Drei verschiedene Gauge-Track-Tokens
- **Dateien:** `kpi.blade.php:22` (`bg-inset`), `server-item.blade.php:14,20` (`bg-line`), `ring.blade.php:16` (`text-line`)
- **Änderung:** Ein einheitliches Track-Treatment wählen (`bg-inset` liest als vertiefter Kanal am besten) für alle Progress-/Gauge-Hintergründe; Ring-Track ggf. mit kräftigerem `line-strong`.
- **Grund:** Gleicher „Gauge-Hintergrund"-Begriff, drei verschiedene Tokens — `bg-line` (rgba 0.10) ist als Track fast unsichtbar.
### Polish — Ring-Cap bei 0% und kein Übergang
- **Datei:** `resources/views/components/ring.blade.php:17-18`
- **Änderung:** `stroke-linecap="butt"` bzw. den Wert-Kreis per `@if($v > 0)` schützen, Track-Stroke verstärken, `transition-[stroke-dashoffset] duration-500` ergänzen.
- **Grund:** Bei Wert 0 malt `stroke-linecap="round"` einen Streu-Punkt; Live-Updates springen ohne Transition.
### Polish — Sparkline ohne Baseline/Marker, verzerrter Stroke
- **Datei:** `resources/views/livewire/dashboard.blade.php:36-45`
- **Änderung:** `vector-effect="non-scaling-stroke"` auf die Polylines (oder `preserveAspectRatio="none"` entfernen), dezente Baseline/Gridline via `border-line`, Accent-Punkt am letzten Wert.
- **Grund:** `preserveAspectRatio="none"` streckt den 1,5px-Stroke ungleichmäßig; kein Bezugsrahmen/aktueller Wert auf der Linie.
### Polish — Inkonsistentes Eyebrow-Tracking
- **Dateien:** `dashboard.blade.php:14` (`tracking-[0.2em]`), `kpi.blade.php:10` (`tracking-wider`), `sidebar.blade.php:32` (`tracking-widest`); `server-item.blade.php:13,19` (kein Tracking)
- **Änderung:** Alle uppercase-Mono-Mikrolabels auf einen Wert vereinheitlichen (`tracking-widest`), arbitrary `tracking-[0.2em]` ersetzen; ggf. `x-eyebrow`-Helper.
- **Grund:** Gleiche Rolle (Mono-Eyebrow), vier verschiedene Letter-Spacings.
### Polish — Notifications-Dot kollidiert mit Brand-Accent
- **Datei:** `resources/views/components/topbar.blade.php:20`
- **Änderung:** Unread-Dot von `bg-accent` (orange) auf `bg-cyan` oder `bg-warning`.
- **Grund:** Orange liest als „aktiv/Brand", nicht als „Aufmerksamkeit"; Token-Intent für Accent wahren.
### API/R11 — `server-item` und Abmelden-Link hart `href="#"`
- **Dateien:** `server-item.blade.php:5`, `sidebar.blade.php:48`
- **Änderung:** `$href`-Prop ergänzen (`@props([… 'href' => '#'])`, `<a href="{{ $href }}">`), später `:href="route('servers.show', $s['id'])"` übergeben.
- **Grund:** Server-Liste nicht navigierbar und kein Slot für den von R11 geforderten UUID-Route-Key.
### API — `kpi`-`tone` themt Widget nur halb
- **Datei:** `resources/views/components/kpi.blade.php:22-24`
- **Änderung:** Identisch zum Must-Fix oben (Balkenfarbe aus `$tone`). Hier separat als API-Konsistenz notiert.
- **Grund:** `tone` impliziert das ganze Widget, färbt aber nur die Zahl.
### Datenform — Mock divergiert vom MetricsTicked/SSH-Kontrakt
- **Datei:** `app/Livewire/Dashboard.php:28-50`
- **Änderung:** Jedem Server eine `id` (UUID) und ein verschachteltes `metrics => ['cpu','mem','disk','series']` geben, damit Panel und Ringe aus `$servers[$active]` lesen und `MetricsTicked.server` 1:1 auf eine Zeile mappt.
- **Grund:** Globale `$metrics`-Serie ohne Server-Key und Rows ohne stabile id überleben den Broadcast/SSH-Kontrakt nicht.
### Security-Readiness — öffentlicher `metrics`-Channel
- **Datei:** `resources/js/app.js:20` (+ `app/Events/MetricsTicked.php`)
- **Änderung:** TODO setzen; mit Auth auf `PrivateChannel('fleet.metrics')` + `routes/channels.php`-Gate migrieren, in `app.js` auf `window.Echo.private(...)` umstellen.
- **Grund:** Öffentlicher Channel (kein `routes/channels.php`) — jeder Browser kann Flotten-CPU abonnieren; akzeptabel in der Mock-Phase, vor echten Daten zu flaggen.
### R9-Klarheit — englische systemd-Beschreibungen im Mock
- **Datei:** `app/Livewire/Dashboard.php:36-40`
- **Änderung:** Entweder Code-Kommentar „spiegelt reales Unit-`Description=`" (zulässig unter §6) oder bis zur echten SSH-Schicht auf knappe deutsche Beschreibungen umstellen.
- **Grund:** Handgeschriebene Mock-Copy rendert als sichtbarer Sekundärtext (`dashboard.blade.php:88`) und kann als englische UI-Copy gelesen werden.
### R3/R6-Sauberkeit — totes `welcome.blade.php`
- **Datei:** `resources/views/welcome.blade.php`
- **Änderung:** Datei löschen.
- **Grund:** Einzige Quelle roher Hex-Farben in `resources/views`, nicht geroutet/referenziert — verschmutzt jedes R3-Audit.
### Defensive Formatierung — `x-text="last + '%'"`
- **Datei:** `resources/views/livewire/dashboard.blade.php:51`
- **Änderung:** `x-text="Math.round(last) + '%'"` (nach dem `push()`-Guard).
- **Grund:** Verhindert lange Dezimalstellen aus dem künftigen SSH-Poller; sichtbare Oberfläche des NaN-Bugs oben.
### Optional — Progressbar-Semantik
- **Dateien:** `kpi.blade.php:22`, `server-item.blade.php` (Balken)
- **Änderung:** `role="progressbar" aria-valuenow="{{ $pct }}" aria-valuemin="0" aria-valuemax="100"` am Balken-Wrapper.
- **Grund:** Server-Item-Balken haben sichtbar keinen zugeordneten Zahlenwert; niedrige Prio, da KPI die Zahl textlich zeigt.
---
## Bewusst weggelassen (kein Datei-Defekt)
- **R1R11 Architektur:** Keine echten Verstöße gefunden (Routing, Livewire-3-Klassen, Token-only Farben, German Copy, Component-Kit alle verifiziert).
- **`config/livewire.php` fehlt:** Reiner Explizitheits-/Bootstrap-Hinweis — die Livewire-3-Defaults matchen die §4-Konvention; kein funktionaler Defekt.
- **`server-item` aria-valuenow / KPI-Progress-Semantik:** Als optional behalten, nicht als Blocker (siehe oben).

View File

@ -1,306 +0,0 @@
# Clusev — Project Handoff
> **Purpose of this file.** This is the single source of truth for the project's
> rules and decisions. In the next session, generate two files from it:
> 1. **`rules.md`** — concise, enforceable coding rules (the "STRICT RULES" section, expanded with examples).
> 2. **`CLAUDE.md`** — project context, stack, commands, conventions, folder map (so any agent ramps up fast).
>
> Do not lose any rule below. If something is ambiguous, ask the user before coding.
---
## 1. Product
**Clusev** is a **self-hosted control panel to administer a fleet of Linux servers from one
dashboard.** It connects **agentless over SSH**. The operator sees and steers the whole fleet
in one UI — metrics, services, files, audit — security-first (2FA, full audit log).
- **Audience:** developers, self-hosters, sysadmins. End-beneficiaries are broad.
- **Differentiator:** multi-server fleet management with a modern, polished UI (vs Cockpit/aaPanel single-host + dated UX).
- **Backend reality:** Laravel is the **control-plane** (UI + API + provisioning). It does NOT
reimplement SSH/daemons — it orchestrates real servers over SSH (phpseclib for exec + SFTP).
### v1 scope (build this first — do NOT boil the ocean)
Foundation (build once): auth + **2FA**, **audit log**, encrypted SSH-credential vault,
SSH layer (exec + SFTP via phpseclib), Reverb realtime channel, queue workers, multi-server switcher.
Features: **(1) Dashboard/live metrics**, **(2) systemd services** (list + start/stop/restart + logs),
**(3) SFTP file manager**. Plus the **Server-Details** page (identity, resource gauges, specs,
volumes, interfaces, security hardening, SSH keys).
### Deferred (later versions — keep out of v1)
- **Web terminal** (xterm.js Alpine island + small ws↔SSH sidecar) — PHP can't hold an interactive PTY.
- **Package management** (apt/dnf) — write-risk + distro abstraction.
- Firewall / users / cron, one-click app store, optional push-metrics agent.
### Monetization & license (decided)
- **Multi-server is FREE** (it's the differentiator — never paywall it).
- **Paid = Team/Enterprise** (SSO/LDAP, granular RBAC, audit retention/export, alerting,
auto-backups, support) + optional **Managed/Hosted** control-plane.
- **License:** AGPL for the core (protects against SaaS rip-offs, stays OSS) + commercial
license for Pro modules. Architect open-core from day 1 (clean core, Pro as separate modules/flags).
---
## 2. Name & branding
- **Project name:** `Clusev` (all handles/domains free: `clusev.*`, `get…`, `use…`, `.sh`).
- **No `.com` required.** GitHub repo + README + screenshots is the homepage for now. No marketing
website yet (comes later, with the Pro/Managed tier).
- **Design origin:** the visual system was prototyped under the codename **"BASTION"** (see §11).
Rename the brandmark to **Clusev** (the accented letter in the wordmark becomes the brand-orange accent).
- **UI copy language: German.** Terse, operational, factual (console/status-page register).
No emoji. Status = color/dots/pills, never emoji. Technical identifiers stay native
(`nginx.service`, `systemd`, `chmod 600`, `SSH`, `2FA`).
---
## 3. Tech stack (hard requirements)
> **Update 2026-06-11 (scaffold):** Laravel **13** is used instead of 12 — Laravel 13 became
> the current release after this handoff was written; chosen by user decision. The rest of this
> table stands (Livewire 3 class-based, Tailwind 4, Reverb, …); PHP pinned to 8.3.
| Layer | Choice |
|---|---|
| Framework | **Laravel 12** |
| UI/interactivity | **Livewire v3** (class-based — **NOT Volt**) |
| CSS | **Tailwind v4** (CSS-first, `@theme` in `app.css` — no `tailwind.config.js`) |
| Build | **Vite** |
| Modals | **wire-elements/modal** (`composer require wire-elements/modal`) |
| Realtime | **Laravel Reverb** + Echo (live metrics, broadcasts) |
| Queue/cache | **Redis** |
| DB | **MariaDB** (default; Postgres acceptable) |
| SSH | **phpseclib** (exec + SFTP) |
| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade |
| Icons | **Lucide**, rendered inline as SVG (Blade component) |
| Fonts | Chakra Petch (display) · Space Grotesk (sans) · JetBrains Mono (numbers/paths). Self-host the `.woff2` (don't rely on CDN in prod). |
---
## 4. STRICT RULES (non-negotiable)
> These are the user's explicit, mandatory rules. Expand each into `rules.md` with a short
> rationale and a ✅/❌ code example.
- **R1 — Pages are full-page Livewire components, mapped directly as routes.**
Every page = a class-based Livewire component referenced in `routes/web.php`
(`Route::get('/x', SomeComponent::class)`). **No controllers returning Blade views for pages.**
The view belongs to the Livewire component, not to a controller.
- **R2 — Class-based Livewire only. Volt is FORBIDDEN.**
Each component = a PHP class in `app/Livewire/...` **plus a separate** Blade view in
`resources/views/livewire/...`. Never single-file Volt. Keep class and view as separate files.
Use `php artisan make:livewire ...` (class). Never `make:volt`.
- **R3 — All colors/design tokens live in `resources/css/app.css` under `@theme`.**
Tailwind v4 placeholders like `--color-accent`, `--color-surface`, used in markup as
`text-accent`, `bg-surface`, `bg-accent/50` (opacity modifier), `border-line`, etc.
No hard-coded hex in markup — only theme utilities.
- **R4 — Inline styles are FORBIDDEN.**
The **only** allowed inline style is a **progress bar's width** (to show a dynamic percentage,
e.g. `style="width: {{ $pct }}%"`). Everything else = Tailwind utility classes.
- **R5 — Destructive/confirm actions use a Modal, never an Alpine dialog.**
No Alpine `confirm()`/`x-data` confirm popups for delete/destructive actions.
Always a **wire-elements/modal** component (e.g. `ConfirmDelete`). Open via the package API.
- **R6 — Clean, conventional folder structure** (see §5). Organized, predictable, no mess.
- **R7 — Fully responsive: mobile + tablet + desktop.**
Every screen adapts. Sidebar → drawer/off-canvas on mobile; dense tables → stacked cards or
horizontal scroll on small screens; KPI grid reflows; touch targets ≥ 44px. Test all 3 breakpoints.
- **R8 — Docker-first, host stays clean.**
The target server has **only Docker installed** + a sudo user — nothing else (no PHP/Node/Composer
on the host). Everything runs in containers. Dev and prod both via Docker, deployed as a plain
Docker Compose stack — no Portainer, no external orchestrator (see §8).
- **R9 — German UI copy, no emoji** (see §2).
- **R10 — Reuse the existing design system** (tokens + components from the BASTION mockup,
orange accent already softened). See §6 and §11.
---
## 5. Folder structure
```
app/
Livewire/ # class-based components (full-page = routes, + nested)
Dashboard.php
Servers/
Index.php Show.php # Show = the Server-Details page
Services/Index.php
Files/Index.php
Audit/Index.php
Auth/{Login.php, TwoFactor.php}
Modals/ # wire-elements/modal components (ConfirmDelete, ServerForm, ...)
Concerns/ # shared traits (e.g. WithFleetContext)
Models/ # Server, AuditEvent, User, ...
Support/
Ssh/ # phpseclib wrappers: SshClient, Sftp, CredentialVault
Services/ # domain services: FleetService, MetricsPoller, Provisioner
Events/ # broadcast events (MetricsTicked, ...)
Jobs/ # queued ops (long installs/upgrades later)
resources/
views/
livewire/ # mirrors app/Livewire (kebab-case)
dashboard.blade.php
servers/{index,show}.blade.php
services/index.blade.php
files/index.blade.php
audit/index.blade.php
modals/...
components/ # Blade UI components: panel, kpi, status-pill, status-dot,
... # badge, sidebar, topbar, server-item, nav-item, icon, ring
layouts/app.blade.php
css/app.css # @import "tailwindcss"; + @theme { tokens } + base layer
js/app.js # Echo/Reverb bootstrap, Alpine islands (charts, xterm later)
routes/web.php # full-page Livewire routes
config/livewire.php # class_namespace=App\Livewire, view_path=views/livewire
docker/ # Dockerfile, php-fpm/nginx config, entrypoint
docker-compose.yml # DEV (build, bind-mount, Vite)
docker-compose.prod.yml # PROD (image from GHCR, no mount) — deployed via plain docker compose
.dockerignore
handoff.md rules.md CLAUDE.md
```
---
## 6. Design system → Tailwind v4 `@theme`
Visual direction: **"Tactical Terminal"** — dark graphite, **signal-orange** brand (softened on
selection/active tints), **cyan** counter-accent, ops status triad. Mono for every number/IP/path.
Port the mockup tokens (in `bastion/bastion.css`) into `resources/css/app.css`:
```css
@import "tailwindcss";
@theme {
/* surfaces */
--color-void: #06080A; --color-base: #0A0D10; --color-surface: #0F1318;
--color-raised: #141A20; --color-inset: #0B0E12;
/* brand (use opacity utilities for tints: bg-accent/10 instead of a separate --accent-dim) */
--color-accent: #FF6B2C; --color-accent-bright: #FF8A4D; --color-accent-deep: #E25617;
--color-accent-text: #FF9259;
--color-cyan: #43C1D8; --color-cyan-bright: #74D7E8;
/* status */
--color-online: #35D07F; --color-warning: #E8B931; --color-offline: #FF5247;
/* text ramp → text-ink / text-ink-2 ... */
--color-ink: #E9EEF3; --color-ink-2: #9DAAB6; --color-ink-3: #69757F; --color-ink-4: #495159;
/* hairline borders → border-line ... */
--color-line: rgba(255,255,255,.10); --color-line-soft: rgba(255,255,255,.055);
--color-line-strong: rgba(255,255,255,.17);
/* fonts (self-host the woff2) */
--font-display: "Chakra Petch", sans-serif;
--font-sans: "Space Grotesk", sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* radii → rounded-* */
--radius-xs: 3px; --radius-sm: 6px; --radius-md: 8px; --radius-lg: 10px;
}
```
Usage in markup: `bg-surface`, `text-ink`, `text-accent`, `bg-accent/10`, `border-line`,
`font-mono`, `rounded-lg`, `text-online`. The old `--accent-dim`/`--accent-line` become
`bg-accent/10` and `border-accent/25`.
**Components to build as Blade components** (from the mockup markup, see §11):
`x-panel`, `x-kpi`, `x-status-pill`, `x-status-dot`, `x-badge`, `x-icon` (Lucide), `x-ring`,
`x-server-item`, `x-nav-item`, plus `sidebar` + `topbar` partials. Live metrics via Reverb events
pushed into the Livewire Dashboard component (`wire:stream` or broadcast → JS chart update).
**Selection/active orange already softened:** `::selection` uses `bg-accent/25`; active nav/server
tints use `bg-accent/10` + `border-accent/25`. Keep them subtle.
---
## 7. Responsive requirements (R7 detail)
- **Sidebar:** fixed 272px on desktop; **off-canvas drawer** (hamburger in topbar) on mobile/tablet.
- **Topbar:** on narrow widths collapse the server-meta badges + uptime (already done in mockup CSS).
- **KPI grid:** 4-up desktop → 2-up tablet → 1-up mobile.
- **Tables (services, volumes, interfaces):** horizontal scroll OR stack into cards on mobile.
- **Server-Details columns:** 2-col desktop → 1-col below ~1080px.
- Touch targets ≥ 44px; test mobile (375), tablet (768), desktop (1280+).
---
## 8. Infrastructure: Docker + deploy target (no Portainer)
**Target server (deploy + realistic dev):** Debian 13 VM, **`10.10.90.136`**, **only Docker
installed** + a sudo user. Nothing else on the host — everything runs in containers, deployed as a
plain Docker Compose stack. **No Portainer, no external orchestrator.**
**Two compose files:**
- `docker-compose.yml` (DEV): services `app` (php-fpm + nginx or FrankenPHP), `vite`, `reverb`,
`queue` (worker), `mariadb`, `redis`. Uses `build:`, **bind-mounts the source**, runs Vite dev
server. Configure Vite for remote HMR: `server.host = '0.0.0.0'`, `hmr.host = '10.10.90.136'`.
- `docker-compose.prod.yml` (PROD): `image: ghcr.io/<user>/clusev:<tag>`,
**no bind-mount**, built assets baked into the image, no Vite. Env via a `.env` file on the VM.
**Deploy workflow (plain Docker Compose — no Portainer):**
- SSH to the VM and run:
`docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`,
then migrate: `docker compose exec app php artisan migrate --force`.
- Wrap it in a small `deploy.sh` (or a CI job that SSHes in and runs the above).
- Image flow: push → CI builds + pushes to **GHCR**`deploy.sh` pulls the new image onto the VM.
(Bootstrap before CI exists: build on the VM with `docker compose -f docker-compose.prod.yml build`.)
- Reverse proxy + TLS (Caddy or Traefik) sits in front — also just a service in the stack.
- Build **multi-arch** (`docker buildx`) if any dev happens on Apple Silicon (arm64); the prod VM is amd64.
- *Note:* managing Docker/containers may later become a **feature of Clusev itself** — but Clusev's
own deployment never depends on any external panel.
---
## 9. Dev workflow (build in the VM)
Decision: **develop directly on the Debian 13 VM** (prod-parity, no arch mismatch, same host as
prod). Connect via **VS Code Remote-SSH** (or CLI). Everything runs in containers — the host only
has Docker. Bootstrap Laravel **inside a container** (host has no PHP/Composer/Node):
```
# one-time bootstrap (run in a throwaway php/composer container or the app image)
composer create-project laravel/laravel .
composer require livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb
# Tailwind v4 + Vite plugin
npm install -D tailwindcss @tailwindcss/vite
php artisan livewire:publish --config # ensure class-based, view_path set
```
Then: scaffold layout + `app.css` `@theme`, build the Blade UI components, wire the full-page
Livewire routes, hook Reverb for live metrics.
---
## 10. Next session — what to produce
1. Read this `handoff.md` end to end.
2. Generate **`rules.md`**: the §4 STRICT RULES, each expanded with a one-line rationale and a
✅correct / ❌forbidden code snippet (especially R1R5). Keep it short and enforceable.
3. Generate **`CLAUDE.md`**: product summary (§1), stack (§3), folder map (§5), key commands
(§9), conventions (German UI, tokens, responsive), and a "before you code" checklist that
references `rules.md`.
4. Then scaffold per §8/§9 (Docker-first, in the VM) and start v1 (§1 scope).
---
## 11. Reference assets (the mockup)
The working visual prototype lives in **`../bastion/`**:
- `bastion.css` — all design tokens + component CSS (source for the `@theme` mapping).
- `bastion-ui.js` — Lucide icon paths, mock data (servers/services/files/audit), metric helpers,
sidebar/topbar markup. Reuse the icon set + mock data shapes.
- `index.html` — Dashboard screen (fleet, KPIs, live chart, systemd table, files, audit).
- `details.html` — Server-Details screen (hero, resource rings, specs, volumes, interfaces, security, keys).
These are the pixel reference. Recreate them as Blade components + Livewire pages (don't copy the
prototype's internal JS structure where Livewire/Reverb fits better). Live data is mock for now;
later it comes from the SSH layer.
## 12. Open questions / TODO for the user
- Final confirm name **Clusev** + grab GitHub org + `clusev.sh` (or chosen TLD).
- DB choice confirm (MariaDB assumed).
- GHCR namespace / registry for prod images.
- Self-host the 3 webfonts (download woff2 into the repo).

View File

@ -1,45 +0,0 @@
Lies zuerst `handoff.md` in diesem Verzeichnis KOMPLETT (oben bis unten). Das ist die
verbindliche Quelle für Produkt, Stack, Regeln, Ordnerstruktur und Deployment des Projekts
**Clusev** (self-hosted Multi-Server-Control-Panel, agentless über SSH).
Kontext dieser Maschine: Debian 13, `10.10.90.136`, **nur Docker + ein sudo-User** installiert —
sonst nichts (kein PHP/Node/Composer auf dem Host). Alles läuft in Containern. Deployment = plain
Docker Compose, kein Portainer.
Arbeite in dieser Reihenfolge und stoppe an den markierten Freigabe-Punkten:
1. **`handoff.md` lesen.** Bestätige mir in 58 Stichpunkten, dass du Produkt, Stack und die
STRICT RULES (R1R10, §4) verstanden hast. Wenn die Mockup-Referenz `../bastion` existiert,
sieh sie dir an (Pixel-Vorlage + Tokens).
2. **Offene Punkte (§12) abfragen, BEVOR du baust:** finaler Name (Clusev?), DB (MariaDB?),
GHCR-Namespace, ob `../bastion` vorhanden ist. Bei Unklarheit fragen — nicht raten.
3. **`rules.md` erzeugen** — die STRICT RULES R1R10 aus §4, jede mit kurzer Begründung +
✅korrekt / ❌verboten Code-Beispiel (v.a. R1R5: full-page Livewire-Routen, class statt Volt,
Tokens in `@theme`, keine Inline-Styles außer Progressbar-width, Löschen nur per Modal).
4. **`CLAUDE.md` erzeugen** — Produkt (§1), Stack (§3), Ordnermap (§5), Commands (§9),
Konventionen (UI Deutsch/kein Emoji, Tokens, responsive Mobile+Tablet+Desktop) + eine
"Before-you-code"-Checkliste, die auf `rules.md` verweist.
**FREIGABE-PUNKT:** Zeig mir `rules.md` + `CLAUDE.md`. Erst nach meinem OK weiter.
5. **Scaffold (Docker-first, §8/§9).** Host hat nur Docker → alles im Container bootstrappen:
- `Dockerfile` (php8.3-fpm + composer + node), `docker-compose.yml` (dev: app, vite, reverb,
queue, mariadb, redis — `build:`, bind-mount, Vite remote-HMR `host=0.0.0.0`,
`hmr.host=10.10.90.136`), `docker-compose.prod.yml`, `.dockerignore`.
- Laravel 12 + Livewire 3 (**class, kein Volt**) + Tailwind 4 + Vite.
- `composer require livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb`.
- `resources/css/app.css` mit `@theme`-Tokens aus §6 (aus `bastion.css`).
- Saubere Ordnerstruktur exakt nach §5.
**FREIGABE-PUNKT:** App startet im Container, leere Seite rendert, Vite-HMR läuft. Zeig mir das.
6. **v1 starten (§1 Scope).** Layout + Blade-Components (Sidebar, Topbar, Panel, Kpi, StatusPill,
StatusDot, Badge, Icon[Lucide], Ring), Dashboard als full-page Livewire-Route mit Live-Metriken
über Reverb. Mock-Daten zuerst, SSH-Layer (phpseclib) danach. Alles responsive.
Halte dich strikt an `rules.md`. Konflikt zwischen meiner Anweisung und den Regeln → nachfragen.
Verifiziere jeden Schritt (Container baut, Seite rendert, keine Console-Errors) bevor du
"fertig" sagst. Arbeite auf einem Branch, nicht auf main; committe in sinnvollen Schritten.

527
rules.md
View File

@ -1,527 +0,0 @@
# Clusev — STRICT RULES
> **Non-negotiable.** Generated from `handoff.md` §4. These are the user's explicit,
> mandatory rules. If any instruction (mine, yours, or a future agent's) conflicts with a
> rule here, **STOP and ask** — do not "work around" it. Keep this file open while coding.
>
> *Stack note (2026-06-11): built on **Laravel 13** (handoff said 12; updated by user decision). The rules below are version-agnostic.*
---
## Quick gate — run through this before every commit
- [ ] **R1/R2** — Page is a **full-page, class-based Livewire component** mapped directly in `routes/web.php`.
- [ ] **R2** — Livewire **class** in `app/Livewire/…` **and** a matching **separate Blade view** in `resources/views/livewire/…`. **No Volt.**
- [ ] **R3** — No hard-coded hex/rgb in markup — only `@theme` token utilities (`bg-surface`, `text-accent`, …).
- [ ] **R4** — No inline `style="…"` **except** a progress bar's `width`.
- [ ] **R5** — Destructive/confirm action runs through a **wire-elements/modal**, never Alpine `confirm()`.
- [ ] **R6** — Files placed exactly per the §5 folder map.
- [ ] **R7** — Verified at **375 / 768 / 1280 px**; touch targets ≥ 44px.
- [ ] **R8** — Every PHP/Composer/Node/artisan command ran **inside the container**.
- [ ] **R9** — UI copy is **German**, **no emoji**; status via color/dots/pills.
- [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets).
- [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK.
- [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton).
- [ ] **R13** — route **paths + names are English** (`/settings`, not `/einstellungen`); German only in the visible label.
- [ ] **R14** — fonts are **self-hosted locally** (`resources/fonts/*.woff2` bundled by Vite + `@font-face` in `app.css`) — **no Google Fonts / CDN `<link>` or `@import`**.
- [ ] **R15****Codex reviewed the change** (`/codex:review`) and reported **no errors and no security issues** — a task is NOT done until Codex passes.
- [ ] **Secrets**`git status` shows **no** `.env*`, token, key, or home-dir dotfile staged.
---
## R1 — Pages are full-page Livewire components, mapped directly as routes
**Why.** One page = one component = one route. No controller indirection, no orphan Blade views.
Keeps routing, state, and view co-located and predictable.
✅ **Correct**
```php
// routes/web.php
use App\Livewire\Dashboard;
use App\Livewire\Servers\Show;
Route::get('/', Dashboard::class)->name('dashboard');
Route::get('/servers/{server}', Show::class)->name('servers.show');
```
❌ **Forbidden**
```php
// Controller returning a Blade view for a PAGE
Route::get('/', [DashboardController::class, 'index']);
class DashboardController {
public function index() {
return view('dashboard'); // ❌ pages do not belong to controllers
}
}
```
---
## R2 — Class-based Livewire only. Volt is FORBIDDEN.
**Why.** Class + separate view is explicit, testable, and conventional. Volt single-file
components hide logic in the view and are banned for this project.
**Correct**`php artisan make:livewire Servers/Show` (creates class **and** view)
```php
// app/Livewire/Servers/Show.php
namespace App\Livewire\Servers;
use App\Models\Server;
use Livewire\Component;
class Show extends Component
{
public Server $server;
public function render()
{
return view('livewire.servers.show'); // ← separate Blade file
}
}
```
```blade
{{-- resources/views/livewire/servers/show.blade.php --}}
<div>{{ $server->hostname }}</div>
```
❌ **Forbidden**
```blade
{{-- Volt single-file component — NEVER. Do not run `php artisan make:volt`. --}}
<?php
use function Livewire\Volt\{state};
state(['count' => 0]);
?>
<div>{{ $count }}</div>
```
---
## R3 — All colors / design tokens live in `resources/css/app.css` under `@theme`
**Why.** Single source of truth for the visual system (Tailwind v4 CSS-first, no
`tailwind.config.js`). Markup references **tokens**, never raw colors — so a token change
restyles the whole app.
✅ **Correct**
```css
/* resources/css/app.css */
@import "tailwindcss";
@theme {
--color-surface: #0F1318;
--color-accent: #FF6B2C;
--color-online: #35D07F;
--color-line: rgba(255,255,255,.10);
}
```
```blade
<div class="bg-surface border border-line">
<span class="text-accent">CPU</span>
<span class="text-online">online</span>
<div class="bg-accent/10">soft accent tint</div> {{-- opacity modifier, not a separate token --}}
</div>
```
❌ **Forbidden**
```blade
<div class="bg-[#0F1318] border-[#ffffff1a]" style="color:#FF6B2C"> {{-- ❌ raw hex in markup --}}
```
---
## R4 — Inline styles are FORBIDDEN (one exception)
**Why.** Inline styles bypass the token system and Tailwind. The **only** legitimate dynamic
value that can't be a utility class is a progress bar's percentage width.
**Correct** — the ONE allowed inline style
```blade
<div class="h-1.5 w-full rounded-full bg-line">
<div class="h-full rounded-full bg-accent" style="width: {{ $pct }}%"></div>
</div>
```
❌ **Forbidden**
```blade
<div style="margin-top:12px; display:flex; color:#FF6B2C"> {{-- ❌ use utilities: mt-3 flex text-accent --}}
```
---
## R5 — Destructive / confirm actions use a Modal, never an Alpine dialog
**Why.** Destructive actions (delete server, remove SSH key, stop a service) must be
deliberate, styled, and auditable. Use **wire-elements/modal** components — never native
`confirm()` or an `x-data` popup.
✅ **Correct**
```blade
{{-- trigger: open the modal component (exact payload per installed wire-elements/modal version) --}}
<button type="button"
wire:click="$dispatch('openModal', { component: 'modals.confirm-delete', arguments: { server: {{ $server->id }} } })"
class="rounded-md px-3 py-2 text-offline hover:bg-offline/10">
Löschen
</button>
```
```php
// app/Livewire/Modals/ConfirmDelete.php (a real Livewire modal class)
namespace App\Livewire\Modals;
use App\Models\Server;
use LivewireUI\Modal\ModalComponent; // wire-elements/modal base class
class ConfirmDelete extends ModalComponent
{
public Server $server;
public function delete(): void
{
$this->server->delete(); // audit-logged in the domain layer
$this->closeModalWithEvents([/* refresh listeners */]);
}
public function render() { return view('livewire.modals.confirm-delete'); }
}
```
❌ **Forbidden**
```blade
<button x-data @click="if (confirm('Wirklich löschen?')) $wire.delete()">Löschen</button>
{{-- ❌ no native confirm(), no Alpine confirm popups for destructive actions --}}
```
---
## R6 — Clean, conventional folder structure
**Why.** Predictable structure = fast ramp-up, no duplication, no "where does this go?".
Follow the §5 map in `CLAUDE.md` exactly.
**Correct** — component, view, model, SSH wrapper each in their canonical place
```
app/Livewire/Servers/Show.php
resources/views/livewire/servers/show.blade.php
app/Models/Server.php
app/Support/Ssh/SshClient.php
```
❌ **Forbidden**
```
app/Http/Controllers/ServerController.php {{-- ❌ pages aren't controllers (R1) --}}
app/Livewire/show.blade.php {{-- ❌ view in the class namespace --}}
app/ServerSshHelperStuff.php {{-- ❌ unstructured dumping ground --}}
```
---
## R7 — Fully responsive: mobile + tablet + desktop
**Why.** Operators use phones and tablets in the field. Every screen must adapt — not just shrink.
✅ **Correct**
```blade
{{-- KPI grid reflows 1 → 2 → 4; sidebar is drawer on small, fixed on large --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"></div>
<aside class="hidden lg:block lg:w-[272px]"></aside> {{-- desktop sidebar --}}
<button class="lg:hidden min-h-11 min-w-11" wire:click="$toggle('navOpen')"></button> {{-- ≥44px touch --}}
```
❌ **Forbidden**
```blade
<div class="grid grid-cols-4"> {{-- ❌ desktop-only; breaks on mobile/tablet --}}
<aside class="w-[272px]"> {{-- ❌ always-on sidebar, no drawer --}}
```
Breakpoints to test: **375** (mobile), **768** (tablet), **1280+** (desktop).
---
## R8 — Docker-first, host stays clean
**Why.** The target host has **only Docker** + a sudo user — no PHP/Node/Composer. Everything
runs in containers, in dev and prod. Run all tooling through the `app` (or a throwaway) container.
✅ **Correct**
```bash
docker compose exec app php artisan make:livewire Dashboard
docker compose exec app composer require some/package
docker compose exec app php artisan migrate
```
❌ **Forbidden**
```bash
php artisan migrate # ❌ assumes PHP on the host — there is none
composer install # ❌ host has no Composer
npm run build # ❌ run node inside the vite/app container
```
---
## R9 — UI copy register: terse, operational, no emoji
**Why.** Console/status-page register: terse, operational, factual. Status is communicated
by **color/dots/pills**, never emoji. Technical identifiers stay native. German is the
default language, but every visible string is **localized** via lang files — see **R16**
(no hard-coded copy in views/components/services).
✅ **Correct**
```blade
<x-status-pill status="online">{{ __('common.online') }}</x-status-pill>
<span class="font-mono">nginx.service</span>
<button>{{ __('services.restart') }}</button>
```
❌ **Forbidden**
```blade
<span>🟢 Online ✅</span> {{-- ❌ emoji as status --}}
<button>Dienst neu starten</button> {{-- ❌ hard-coded copy (must be __('…'), R16) --}}
```
Keep native technical tokens as-is: `nginx.service`, `systemd`, `chmod 600`, `SSH`, `2FA`.
---
## R10 — Reuse the existing design system
**Why.** The visual system (tokens + components) is already designed ("Tactical Terminal",
softened orange). Don't reinvent colors or one-off widgets — compose from the shared kit.
**Correct** — use the Blade component kit + tokens
```blade
<x-panel title="Auslastung">
<x-kpi label="CPU" :value="$cpu" unit="%" />
<x-status-dot status="warning" />
</x-panel>
```
❌ **Forbidden**
```blade
<div class="rounded-xl bg-[#12161c] p-4 shadow"> {{-- ❌ ad-hoc panel, raw hex --}}
<div style="color:#ffa500">CPU</div> {{-- ❌ new orange, inline style --}}
</div>
```
---
## R11 — Public URLs use UUIDs, never the numeric primary key
**Why.** Auto-increment IDs in URLs are enumerable and leak record counts/ordering. Anything
addressable in a route is referenced by an unguessable **UUID**; the integer PK stays internal
(relations, performance).
✅ **Correct**
```php
// app/Models/Server.php — keep the bigint id internally, expose a uuid in URLs
use Illuminate\Support\Str;
class Server extends Model
{
protected static function booted(): void
{
static::creating(fn (self $m) => $m->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid'; // {server} in routes resolves by uuid, not the bigint id
}
}
// migration: $table->uuid('uuid')->unique();
```
```php
// routes/web.php — {server} binds by uuid
Route::get('/servers/{server}', \App\Livewire\Servers\Show::class)->name('servers.show');
// → /servers/9f3c0b1a-7e21-... (never /servers/42)
```
❌ **Forbidden**
```php
Route::get('/servers/{id}', ...); // integer PK in the URL — enumerable
route('servers.show', $server->id); // exposes the auto-increment id
```
---
## R12 — Browser-verified: HTTP 200, zero console errors
**Why.** A passing `Livewire::test(...)->assertOk()` only renders ONE state in PHP — it does not
catch client-side JS errors, and for a `wire:init`/lazy page it exercises the **skeleton**, not the
loaded view. Render-time Blade/PHP errors (e.g. `@disabled` on a component → "unexpected endif", a
500) only surface on the real request. A green unit test is **not** "done".
**Rule.** Before declaring any UI change done, load **every affected page in a real browser**
(headless is fine) and confirm, per page: **HTTP 200** (no 500), **zero console errors**, **zero
failed requests**. For lazy pages, verify the **loaded** state (after `wire:init`), not the skeleton.
Never use `@disabled`/`@checked`/`@required` on a Blade **component** — use the bound `:disabled="…"`
attribute (the component attribute bag drops a `false` value correctly).
**Correct** — headless probe over the routes (captures console + HTTP status):
```bash
# mount the script into the image's home dir or require('puppeteer') fails
docker run --rm --network host -v /tmp/probe.js:/home/pptruser/probe.js -w /home/pptruser \
ghcr.io/puppeteer/puppeteer:latest node probe.js
```
```js
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
const r = await page.goto(url, { waitUntil: 'networkidle2' }); // expect r.status() === 200
await new Promise(s => setTimeout(s, 4000)); // let wire:init load, then assert errors == 0
```
❌ **Forbidden**
```text
"Livewire::test rendered OK" → shipping without ever loading the page in a browser.
Testing only the skeleton state of a wire:init page and assuming the loaded state works.
@disabled($cond) on <x-btn > → injects if/endif into the component tag → 500.
```
---
## R13 — Routes and URL paths are English, always
**Why.** UI copy is German (R9), but code-level identifiers — route paths, route names,
URL segments, query keys — stay **English**, like every other identifier in the codebase.
German belongs in the visible label, never in the `href`.
✅ **Correct**
```php
Route::get('/settings', Settings\Index::class)->name('settings'); // path + name English
```
```blade
<x-nav-item href="/settings">Einstellungen</x-nav-item> {{-- German label, English path --}}
```
❌ **Forbidden**
```php
Route::get('/einstellungen', ...)->name('einstellungen'); // German in the URL/name
Route::get('/dateien', ...); // → use /files
```
---
## R14 — Fonts are self-hosted locally, never loaded from a CDN
**Why.** A self-hosted control plane runs in private/air-gapped networks and must not leak
requests to third parties or break when the internet is unreachable. Every font ships **with the
app** — `.woff2` files in `public/fonts/`, declared via `@font-face` in `resources/css/app.css`.
**No** `<link href="fonts.googleapis.com">`, no `@import url(...)` from a CDN, no `fonts.gstatic.com`.
✅ **Correct**
```css
/* resources/css/app.css — local file in resources/fonts/, bundled + hashed by Vite
(relative url so it resolves on the Vite dev server AND in the prod build) */
@font-face {
font-family: 'Space Grotesk';
font-weight: 300 700;
font-display: swap;
src: url('../fonts/space-grotesk.woff2') format('woff2');
}
```
> Use a **relative** `url('../fonts/…')` (not `/fonts/…`): an absolute path resolves against the
> Vite dev-server origin in dev → 404. Relative lets Vite resolve + emit the asset for both modes.
❌ **Forbidden**
```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk"> <!-- ❌ CDN -->
```
```css
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch'); /* ❌ CDN import */
```
---
## R15 — Codex must review every change before it is "done"
**Why.** A second, independent reviewer catches what the author misses — logic bugs, regressions,
and especially **security holes** in a control plane that holds the keys to a whole fleet. Self-review
is not enough; an external pass is mandatory before any task is considered complete.
**Rule.** After implementing and self-verifying (R12 etc.), run **`/codex:review`** over the change.
Codex must report **no errors and no security vulnerabilities**. If it flags anything, fix it and
re-run until Codex is clean. Only then is the task done. Reviewing for **security** specifically —
SSH/command injection, credential handling, authz, lock-out safety, CSP — is part of every pass.
✅ **Correct**
```text
implement → self-check (lint + R12 browser) → /codex:review → 0 findings → done → commit
↑__________________│ (loop until clean)
```
❌ **Forbidden**
```text
"tests pass, shipping it" without an independent Codex review.
Marking a task done while Codex still reports an error or a security issue.
```
---
## R16 — Every UI string is localized (German + English)
**Why.** Clusev ships in **two languages, user-selectable** (DE default, EN). There must be
**no hard-coded user-facing copy** anywhere — every visible string (views, Livewire components,
service messages, notifications, validation, modal copy) goes through Laravel localization so a
new string is never English-only or German-only.
**Structure (mandatory).** Translations live in `lang/<code>/<group>.php` returning a nested
array, one **group per feature area** mirroring the view tree, plus a shared `common` group:
```
lang/
de/ common.php auth.php dashboard.php servers.php services.php files.php
audit.php settings.php system.php versions.php modals.php shell.php
en/ (same files, same keys)
```
- Access with `__('group.key')` / `@lang`; interpolate with `:placeholders`
(`__('servers.banned', ['ip' => $ip])`).
- **Keys are identical across `de` and `en`** — every key MUST exist in BOTH. Reuse `common.*`
for shared actions/status (`save`, `cancel`, `online`, …) instead of duplicating.
- Supported locales are declared once in `App\Http\Middleware\SetLocale::SUPPORTED`; the active
locale comes from the user's saved preference → session → `config('app.locale')`.
✅ **Correct**
```blade
<x-btn>{{ __('common.save') }}</x-btn>
<h2>{{ __('servers.detail_title') }}</h2>
```
```php
$this->dispatch('notify', message: __('servers.credential_saved'));
```
❌ **Forbidden**
```blade
<x-btn>Speichern</x-btn> {{-- ❌ hard-coded — add a key to lang/{de,en} --}}
```
```php
$this->error = 'Server nicht gefunden.'; // ❌ use __('common.server_not_found')
```
Adding a string in only one language, or omitting a key from `en`/`de`, is a rule violation.
---
## R17 — Blade: block `@php`; never the literal directive tokens in a comment
Blade's raw-block precompiler matches `@php … @endphp` **non-greedily on the FIRST `@endphp`**.
- **Never** write the literal tokens `@php`/`@endphp` (or `@verbatim`/`@endverbatim`) inside a
Blade **comment or text** in a file that also has a real `@php` block — the matcher takes the
token in your comment as the block end and dumps the remainder as raw text. (This shipped a
garbled `<header>` on every signed-in page.) To mention a directive, break it (`@php`) or word
it ("the php directive").
- Use the **block** `@php … @endphp`, not **inline** `@php(...)`: inline silently mis-compiles
when the expression nests parens/calls (e.g. `@php($x ??= __('a.b'))`) and collides with any
later block in the same file.
- **Verify the rendered DOM, not just HTTP 200** (extends R12): leaked template text and missing
translations both return 200 with no console error. R12 must inspect the actual visible markup
(`<header>`/body) for stray `@`, `{{ }}`, `$var`, or `group.key` literals.
---
## Appendix — Secret hygiene
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in
`../.env.gitea` (in the parent home dir, untracked) and must later move into the app `.env`.
- `.env*` (incl. `.env.gitea`), keys, and local state are in `.gitignore` — keep it that way.
- Never commit or echo the `GIT_ACCESS_TOKEN`; read it only at push time.
- Before any `git push`, eyeball `git status` and the staged diff for stray secrets.