clusev/docs/superpowers/plans/2026-06-21-wireguard-dashbo...

408 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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