diff --git a/docs/superpowers/plans/2026-06-21-wireguard-dashboard-p5-setup.md b/docs/superpowers/plans/2026-06-21-wireguard-dashboard-p5-setup.md new file mode 100644 index 0000000..b686d16 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-wireguard-dashboard-p5-setup.md @@ -0,0 +1,315 @@ +# 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" < "$WG_ENV" <&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 + +

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

+
+
+
+ + + @error('setupSubnet')

{{ $message }}

@enderror +
+
+ + + @error('setupPort')

{{ $message }}

@enderror +
+
+ + + @error('setupEndpoint')

{{ $message }}

@enderror +
+
+ + + @error('setupPeer')

{{ $message }}

@enderror +
+
+

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

+
+ + {{ __('wireguard.setup_submit') }} + + @if ($pendingId) + {{ __('wireguard.pending') }} + @endif +
+
+

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

+
sudo clusev wg setup
+
+``` + +(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-.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.