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

24 KiB
Raw Blame History

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):
# ── 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:
  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:

    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)):
  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

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):
        'wgGateToggle',
        'wgSetPort',
        'wgSetSubnet',
  • Step 2: Extend the page test — add to WireguardPageTest:
    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:
    // ── 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):

    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

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:
    '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>:
        <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

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.