From 95a5811544cba62f979e71933f27b3a1c70c8d15 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 21 Jun 2026 00:48:11 +0200 Subject: [PATCH] =?UTF-8?q?docs(wg):=20SP2=20Phase=204=20plan=20=E2=80=94?= =?UTF-8?q?=20settings=20+=20gate=20from=20the=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...6-06-21-wireguard-dashboard-p4-settings.md | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-21-wireguard-dashboard-p4-settings.md diff --git a/docs/superpowers/plans/2026-06-21-wireguard-dashboard-p4-settings.md b/docs/superpowers/plans/2026-06-21-wireguard-dashboard-p4-settings.md new file mode 100644 index 0000000..1b4c874 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-wireguard-dashboard-p4-settings.md @@ -0,0 +1,407 @@ +# 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 oeffentlichen Endpoint aendern + clusev wg set-port Listen-Port aendern (Clients neu) + clusev wg set-subnet 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 ``: + +```blade + +
+ {{-- Gate toggle --}} +
+
+

{{ __('wireguard.gate_toggle') }}

+

{{ ($status['gate']['marker'] && $status['gate']['chain']) ? __('wireguard.gate_on') : __('wireguard.gate_off') }}

+
+ @if ($status['gate']['marker'] && $status['gate']['chain']) + + @else + + @endif +
+ + {{-- Endpoint (safe) --}} +
+ +
+ + {{ __('wireguard.apply') }} +
+

{{ __('wireguard.endpoint_hint') }}

+ @error('newEndpoint')

{{ $message }}

@enderror +
+ + {{-- Port (destructive) --}} +
+ +
+ + +
+

{{ __('wireguard.port_hint') }}

+ @error('newPort')

{{ $message }}

@enderror +
+ + {{-- Subnet (very destructive) --}} +
+ +
+ + +
+

{{ __('wireguard.subnet_hint') }}

+ @error('newSubnet')

{{ $message }}

@enderror +
+
+
+``` + +Place it sensibly (e.g. in the aside `
` 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.