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

18 KiB
Raw Blame History

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

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

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

    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:

        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:

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

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 12). 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.