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
parent
665c9f93bd
commit
06e9fcbad7
|
|
@ -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
266
CLAUDE.md
|
|
@ -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`.** R1–R11 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.
|
||||
|
|
@ -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?
|
||||
|
|
@ -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`.
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
# Clusev — Session Handoff (2026-06-14)
|
||||
|
||||
Self-contained ramp-up for the next session. Read `rules.md` (R1–R17) + `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.
|
||||
|
|
@ -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
File diff suppressed because it is too large
Load Diff
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 315–342)
|
||||
|
||||
- [ ] **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 5–6):
|
||||
|
||||
```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 <befehl></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 <...></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 <...></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 <command></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 <...></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 <...></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 21–22) 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 21–22) 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 162–164)
|
||||
- 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 43–45)
|
||||
- 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 162–164):
|
||||
|
||||
```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 43–45:
|
||||
|
||||
```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 4–6. ✓
|
||||
- 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.
|
||||
|
|
@ -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 1–4. ✓
|
||||
- **Spec §7 Test/Verify** → Tasks 1–4 (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.
|
||||
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 P2–P4). 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 1–2 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 269–299)
|
||||
|
||||
- [ ] **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 & testen</h3>
|
||||
<p class="{{ $p }}">Den QR-Code aus <code class="{{ $code }}">setup</code> (oder <code class="{{ $code }}">clusev wg add-peer <name></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://<Server-Tunnel-IP></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 & test</h3>
|
||||
<p class="{{ $p }}">Scan the QR code from <code class="{{ $code }}">setup</code> (or <code class="{{ $code }}">clusev wg add-peer <name></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://<server-tunnel-ip></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 1–8. ✓
|
||||
|
||||
**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.
|
||||
|
|
@ -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 A–Z a–z 0–9 . _ - (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 A–Z a–z 0–9 . _ - (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 1–4. ✓
|
||||
- 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.
|
||||
|
|
@ -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 P1–P3 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 1–65535.',
|
||||
'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 1–65535. / 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 1–3. ✓
|
||||
- Port edit (destructive, confirm + warning, restart) → Tasks 1–3. ✓
|
||||
- Subnet edit (very destructive, confirm + warning, collision check, restart) → Tasks 1–3. ✓
|
||||
- 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.
|
||||
|
|
@ -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 1–2). 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
|
|
@ -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. ✓
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 ~197–204) 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 ~185–191) 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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 0–3 + 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 56–89.
|
||||
- `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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 `1–65535`; 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.
|
||||
|
|
@ -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:255–286), 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:323–326 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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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 75–90, 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)
|
||||
- **R1–R11 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).
|
||||
306
handoff.md
306
handoff.md
|
|
@ -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 R1–R5). 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).
|
||||
|
|
@ -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 5–8 Stichpunkten, dass du Produkt, Stack und die
|
||||
STRICT RULES (R1–R10, §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 R1–R10 aus §4, jede mit kurzer Begründung +
|
||||
✅korrekt / ❌verboten Code-Beispiel (v.a. R1–R5: 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
527
rules.md
|
|
@ -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.
|
||||
Loading…
Reference in New Issue