# WireGuard dashboard — Phase 3 (peer management) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use `- [ ]` checkboxes.

**Goal:** Create + remove WireGuard client peers from the `/wireguard` dashboard, with the new client config (private key) + QR shown ONCE in a modal and never persisted.

**Architecture:** The security-critical **write-bridge** (spec §0 Bridge 2, variant A): the app writes `run/wg-request.json` `{id,action,args}`; a new host `.path` watcher runs `clusev-wg.sh serve-request`, which **whitelists the action + re-sanitises every arg host-side**, dispatches to a fixed `cmd_*`, and writes `run/wg-result-<id>.json` (0600, owned by the app uid) + for add-peer a `run/wg-qr-<id>.svg` sidecar. The container's strings are DATA, never code. The Livewire component polls its own pending id for the result (no public route → automatic id-binding) and shows the config+QR once.

**Tech Stack:** bash (host bridge), systemd `.path`/`.service`, Laravel 13 + Livewire 3 + wire-elements/modal, Tailwind tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` §0/§4/§6.

**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc '… php artisan test --filter=<X>'`; Pint `… vendor/bin/pint <files>`; shellcheck `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <script>`. Commit on `feat/v1-foundation`; push only at release.

**Security invariants (must hold):** (1) container input never reaches a shell command unquoted; action is whitelisted; each arg passes a strict host-side regex/`valid_name`. (2) The result file (private key) is 0600 + owned by the app uid + pruned after 60 s, never DB-persisted/logged. (3) An invalid/oversized id → no result written (un-claimable), drop silently. (4) Destructive (remove-peer) goes through an R5 confirm modal in the UI.

---

## File Structure

- **`docker/wg/clusev-wg.sh`** (modify) — refactor `cmd_add_peer`/`cmd_remove_peer`/`cmd_up` to share `_peer_add`/`_peer_remove`/`_gate_up_now` cores; add `serve-request` + `_write_result` + `_json_str`; prune stale result/QR files in `cmd_collect`.
- **`docker/wg/clusev-wg-request.service`** + **`docker/wg/clusev-wg-request.path`** (new) — the request watcher.
- **`install.sh`** (modify) — render + enable the request watcher in `install_host_watchers()`.
- **`app/Services/WgBridge.php`** (new) — `request(action,args): id` (writes `run/wg-request.json`), `result(id): ?array` (reads `run/wg-result-<id>.json` + the QR sidecar). One responsibility: the app side of the write-bridge.
- **`app/Livewire/Wireguard/Index.php`** (modify) — add-peer form, remove-peer (confirm), pending-id polling, show-once result, audit + throttle.
- **`resources/views/livewire/wireguard/index.blade.php`** (modify) — add-peer input, per-peer remove button, the show-once modal.
- **`lang/{de,en}/wireguard.php`** (modify) — peer-mgmt strings.
- **Tests:** `tests/Feature/WgBridgeTest.php`, extend `WireguardPageTest`.

---

## Task 1: the host write-bridge (`serve-request`) + refactor

**Files:** Modify `docker/wg/clusev-wg.sh`

- [ ] **Step 1: Extract `_peer_add` core** (returns ONLY the client config text on stdout; no prints). Add it just above the existing `cmd_add_peer`:

```bash
# Core: create a peer + print ONLY the client config to stdout (errors to stderr, non-zero on fail).
# Shared by the interactive cmd_add_peer and the write-bridge serve-request.
_peer_add() {
  local name="$1"
  require_setup
  # shellcheck disable=SC1090
  . "$WG_ENV"
  [ -n "${WG_SUBNET:-}" ] && [ -n "${WG_SERVER_PUBKEY:-}" ] && [ -n "${WG_ENDPOINT:-}" ] || { echo "wg.env unvollstaendig" >&2; return 1; }
  grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && { echo "Peer existiert bereits" >&2; return 1; }
  local ip cpriv cpub
  ip="$(next_free_ip)" || { echo "kein freier Adressraum" >&2; return 1; }
  cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)"
  cat >> "$WG_CONF" <<EOF

# clusev-peer: ${name}
[Peer]
PublicKey = ${cpub}
AllowedIPs = ${ip}/32
EOF
  wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32"
  cat <<EOF
[Interface]
PrivateKey = ${cpriv}
Address = ${ip}/32
DNS = 1.1.1.1

[Peer]
PublicKey = ${WG_SERVER_PUBKEY}
Endpoint = ${WG_ENDPOINT}
AllowedIPs = ${WG_SUBNET}
PersistentKeepalive = 25
EOF
}
```

- [ ] **Step 2: Rewrite `cmd_add_peer`** to wrap the core (preserving its prior output: heading, config, QR, hint):

```bash
cmd_add_peer() {
  need_root add-peer
  local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer <name>"
  valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
  local conf; conf="$(_peer_add "$name")" || die "Anlegen fehlgeschlagen: ${conf}"
  # shellcheck disable=SC1090
  [ -f "$WG_ENV" ] && . "$WG_ENV"
  local ip; ip="$(printf '%s\n' "$conf" | awk '/^Address/{print $3; exit}')"
  bold "Peer '${name}' angelegt (${ip%%/*})."
  printf '%s\n' "$conf"
  if have qrencode; then
    printf '%s\n' "$conf" | qrencode -t ansiutf8 || warn "QR-Erzeugung fehlgeschlagen — nutze die Textkonfiguration oben."
  else
    warn "qrencode nicht installiert — nutze die Textkonfiguration oben."
  fi
  info "AllowedIPs=${WG_SUBNET:-} = Split-Tunnel (nur Panel ueber WG). Voll-Tunnel (0.0.0.0/0) moeglich, aber NICHT empfohlen — leitet allen Client-Verkehr ueber Clusev."
}
```

- [ ] **Step 3: Extract `_peer_remove` core** (no prints; non-zero on fail) and slim `cmd_remove_peer` to wrap it. Replace the body of `cmd_remove_peer` (everything after the `valid_name` line) with a call to the core, and add the core above it:

```bash
# Core: remove the named peer from the running interface + wg0.conf. Errors to stderr.
_peer_remove() {
  local name="$1"
  require_setup
  local pub
  pub="$(WANT="# clusev-peer: ${name}" awk '$0==ENVIRON["WANT"]{f=1;next} f&&/PublicKey/{gsub(/[ \t]/,"");sub(/PublicKey=/,"");print;exit}' "$WG_CONF")"
  [ -n "$pub" ] || { echo "Peer '${name}' nicht gefunden" >&2; return 1; }
  wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true
  local tmp; tmp="$(mktemp)"
  WANT="# clusev-peer: ${name}" awk '
    $0==ENVIRON["WANT"] {drop=1; next}
    drop && /^[[:space:]]*$/ {drop=0; next}
    drop {next}
    {print}
  ' "$WG_CONF" > "$tmp"
  install -m 0600 "$tmp" "$WG_CONF"; rm -f "$tmp"
}

cmd_remove_peer() {
  need_root remove-peer
  local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
  valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
  _peer_remove "$name" || die "Entfernen fehlgeschlagen."
  bold "Peer '${name}' entfernt."
}
```

- [ ] **Step 4: Extract `_gate_up_now` core** (marker + apply, no interactive warnings) and have `cmd_up` wrap it. Add the core above `cmd_up`, and replace `cmd_up`'s marker+gate_apply lines with a call:

```bash
# Core: enable the gate (write marker + apply). Refuses if wg0 is down. Errors to stderr.
_gate_up_now() {
  require_setup
  [ -e "/sys/class/net/${WG_IF}" ] || { echo "wg0 nicht aktiv" >&2; return 1; }
  mkdir -p "$STATE_DIR"
  printf 'enabled\n' > "$GATE_MARKER"
  gate_apply
}

cmd_up() {
  need_root up
  require_setup
  [ -e "/sys/class/net/${WG_IF}" ] || die "${WG_IF} ist nicht aktiv. Erst Tunnel testen (siehe 'clusev wg status'), dann 'clusev wg up'."
  warn "Vor dem Gate sicherstellen, dass der Tunnel das Panel erreicht. Notausgang ueber SSH: 'clusev wg down'."
  _gate_up_now
  bold "WireGuard-Gate aktiv — Panel (80/443) nur noch ueber den Tunnel erreichbar."
  info "Notausgang (per SSH, falls der Tunnel nicht erreicht): clusev wg down"
}
```

- [ ] **Step 5: Add the JSON-string escaper + result writer + serve-request.** Place these just before `cmd_collect` (or near the other helpers):

```bash
# Minimal-but-correct JSON string escaper (quotes, backslashes, newlines, tabs; strip CR + other
# control). Our values are config text / short messages — this covers them safely.
_json_str() {
  local s="$1"
  s="${s//\\/\\\\}"
  s="${s//\"/\\\"}"
  s="${s//$'\r'/}"
  s="${s//$'\t'/\\t}"
  s="${s//$'\n'/\\n}"
  printf '"%s"' "$s"
}

# Write run/wg-result-<id>.json (0600, owned by the run/ owner = the app uid). config is the client
# config text (add-peer only) or empty.
_write_result() {
  local id="$1" ok="$2" msg="$3" config="$4"
  local f="${RUN_DIR}/wg-result-${id}.json" cfg='null'
  [ -n "$config" ] && cfg="$(_json_str "$config")"
  mkdir -p "$RUN_DIR" 2>/dev/null || true
  printf '{"id":"%s","ok":%s,"message":%s,"config":%s,"at":%s}\n' \
    "$id" "$ok" "$(_json_str "$msg")" "$cfg" "$(date +%s 2>/dev/null || echo 0)" > "${f}.tmp"
  mv -f "${f}.tmp" "$f" 2>/dev/null || { rm -f "${f}.tmp"; return 0; }
  chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$f" 2>/dev/null || true
  chmod 600 "$f" 2>/dev/null || true
}

# Process one UI write-request (run/wg-request.json). Whitelists the action + re-sanitises every arg
# HOST-SIDE (the container's strings are data, never code), runs the fixed cmd, writes the result.
cmd_serve_request() {
  need_root serve-request
  local req="${RUN_DIR}/wg-request.json" work="${RUN_DIR}/wg-request.processing"
  [ -f "$req" ] || return 0
  mv -f "$req" "$work" 2>/dev/null || return 0   # consume single-shot (re-arm can't double-run)
  local json id action name
  json="$(cat "$work" 2>/dev/null || true)"; rm -f "$work"

  # Extract with STRICT regexes — a value with any other char simply won't match (→ empty → rejected).
  id="$(printf '%s' "$json"      | grep -oE '"id":"[A-Za-z0-9]{16,64}"'       | head -n1 | sed -E 's/.*:"//; s/"$//')"
  [ -n "$id" ] || return 0   # no valid id → result un-claimable → drop silently
  action="$(printf '%s' "$json"  | grep -oE '"action":"[a-z-]{1,24}"'         | head -n1 | sed -E 's/.*:"//; s/"$//')"
  name="$(printf '%s' "$json"    | grep -oE '"name":"[A-Za-z0-9._-]{1,64}"'   | head -n1 | sed -E 's/.*:"//; s/"$//')"

  local ok=false msg='ok' config=''
  case "$action" in
    add-peer)
      if valid_name "$name"; then
        if config="$(_peer_add "$name" 2>/dev/null)"; then ok=true; else ok=false; msg='add-failed'; config=''; fi
      else msg='invalid-name'; fi ;;
    remove-peer)
      if valid_name "$name"; then
        if _peer_remove "$name" >/dev/null 2>&1; then ok=true; else msg='remove-failed'; fi
      else msg='invalid-name'; fi ;;
    gate-up)   if _gate_up_now >/dev/null 2>&1; then ok=true; else msg='gate-up-failed'; fi ;;
    gate-down) if cmd_down >/dev/null 2>&1; then ok=true; else msg='gate-down-failed'; fi ;;
    *)         msg='unknown-action' ;;
  esac

  _write_result "$id" "$ok" "$msg" "$config"
  if [ "$ok" = true ] && [ "$action" = add-peer ] && [ -n "$config" ] && have qrencode; then
    local qr="${RUN_DIR}/wg-qr-${id}.svg"
    if printf '%s\n' "$config" | qrencode -t SVG -o "$qr" 2>/dev/null; then
      chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$qr" 2>/dev/null || true
      chmod 600 "$qr" 2>/dev/null || true
    fi
  fi
}
```

- [ ] **Step 6: Prune stale result/QR files in `cmd_collect`.** At the very top of `cmd_collect` (after `mkdir -p "$RUN_DIR"`), add:

```bash
  # Drop result + QR sidecars older than 60 s (they carry a private key; the app reads them once).
  find "$RUN_DIR" -maxdepth 1 -type f \( -name 'wg-result-*.json' -o -name 'wg-qr-*.svg' \) -mmin +1 -delete 2>/dev/null || true
```

- [ ] **Step 7: Add dispatch arms.** In the `case "$cmd"` switch, after the `collect)` arm add:

```bash
  serve-request) cmd_serve_request ;;   # called by clusev-wg-request.service
```

- [ ] **Step 8: Verify.** `bash -n docker/wg/clusev-wg.sh` → clean. shellcheck → clean (the SC1090 disables stay). Smoke the safe paths:
  - `bash docker/wg/clusev-wg.sh help` → usage, exit 0.
  - `CLUSEV_DIR="$PWD" bash docker/wg/clusev-wg.sh collect; cat run/wg-status.json; rm -f run/wg-status.json` → `configured:false`, exit 0 (collect still works after the prune line).
  - Test `_json_str` in isolation: `bash -c 'source <(sed -n "/^_json_str/,/^}/p" docker/wg/clusev-wg.sh); _json_str "$(printf "a\"b\nc\\\\d")"'` → prints `"a\"b\nc\\d"` (quotes/newline/backslash escaped).

- [ ] **Step 9: Commit**

```bash
git add docker/wg/clusev-wg.sh
git commit -m "feat(wg): serve-request write-bridge + peer/gate cores (host-side validation)"
```

---

## Task 2: the request watcher units

**Files:** Create `docker/wg/clusev-wg-request.service`, `docker/wg/clusev-wg-request.path`

- [ ] **Step 1: `docker/wg/clusev-wg-request.service`:**

```ini
# Clusev WireGuard write-request handler — oneshot (HOST-side). Triggered by clusev-wg-request.path
# when the dashboard writes run/wg-request.json. Runs `clusev-wg.sh serve-request`, which consumes
# the request, whitelists the action + re-validates args, runs the fixed command, and writes a
# result file the app reads once. install.sh rewrites the /home/nexxo/clusev paths.
[Unit]
Description=Clusev WireGuard write-request handler
After=docker.service

[Service]
Type=oneshot
User=root
Environment=CLUSEV_DIR=/home/nexxo/clusev
WorkingDirectory=/home/nexxo/clusev
ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh serve-request
```

- [ ] **Step 2: `docker/wg/clusev-wg-request.path`:**

```ini
# Watches for a dashboard WireGuard write-request and fires the handler the moment it appears. The
# handler consumes the request, so a re-arm cannot double-run it. install.sh rewrites the path.
[Unit]
Description=Clusev WireGuard write-request — watch for a dashboard request
After=docker.service
Wants=docker.service

[Path]
PathExists=/home/nexxo/clusev/run/wg-request.json
Unit=clusev-wg-request.service

[Install]
WantedBy=multi-user.target
```

- [ ] **Step 3: Verify** `systemd-analyze verify docker/wg/clusev-wg-request.path docker/wg/clusev-wg-request.service 2>&1` (path-not-found is fine). Confirm the `.path` `Unit=` matches the `.service` filename + `PathExists=` matches `run/wg-request.json`.

- [ ] **Step 4: Commit**

```bash
git add docker/wg/clusev-wg-request.service docker/wg/clusev-wg-request.path
git commit -m "feat(wg): clusev-wg-request watcher (path+service) for the UI write-bridge"
```

---

## Task 3: install.sh — render + enable the request watcher

**Files:** Modify `install.sh` (`install_host_watchers()`)

- [ ] **Step 1:** Mirror the collector wiring for the request watcher (a `.path` + `.service`, enabled with `--now` so it arms immediately). After the collector temp vars line (`local tmp_csvc tmp_ctimer; …`) add:

```bash
  local tmp_rsvc tmp_rpath; tmp_rsvc="$(mktemp)"; tmp_rpath="$(mktemp)"
```

After the collector sed lines add:

```bash
  sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.service" > "$tmp_rsvc"
  sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.path"    > "$tmp_rpath"
```

In the `&&` chain, after the collector install lines add:

```bash
     && install -m 0644 "$tmp_rsvc"  "${dst}/clusev-wg-request.service" \
     && install -m 0644 "$tmp_rpath" "${dst}/clusev-wg-request.path" \
```

and after the `systemctl enable --now clusev-wg-collect.timer` line add:

```bash
     && systemctl enable --now clusev-wg-request.path \
```

Append the two temps to the cleanup `rm -f` line.

- [ ] **Step 2: Verify.** `bash -n install.sh` → clean. shellcheck → no new findings vs `git show HEAD:install.sh`. Eyeball: `daemon-reload` still after all installs; the `.path` enabled among the enables.

- [ ] **Step 3: Commit**

```bash
git add install.sh
git commit -m "feat(wg): install + enable the WireGuard write-request watcher"
```

---

## Task 4: `WgBridge` app service

**Files:** Create `app/Services/WgBridge.php`; Test `tests/Feature/WgBridgeTest.php`

- [ ] **Step 1: Write the failing test** (`tests/Feature/WgBridgeTest.php`):

```php
<?php

namespace Tests\Feature;

use App\Services\WgBridge;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class WgBridgeTest extends TestCase
{
    use RefreshDatabase;

    private string $dir;

    protected function setUp(): void
    {
        parent::setUp();
        $this->dir = storage_path('app/restart-signal');
        @mkdir($this->dir, 0775, true);
        foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
            @unlink($f);
        }
    }

    protected function tearDown(): void
    {
        foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
            @unlink($f);
        }
        parent::tearDown();
    }

    public function test_request_writes_a_well_formed_request_and_returns_an_id(): void
    {
        $id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);

        $this->assertMatchesRegularExpression('/^[A-Za-z0-9]{16,64}$/', $id);
        $req = json_decode((string) file_get_contents($this->dir.'/wg-request.json'), true);
        $this->assertSame($id, $req['id']);
        $this->assertSame('add-peer', $req['action']);
        $this->assertSame('laptop', $req['name']);
    }

    public function test_request_rejects_an_unknown_action(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        app(WgBridge::class)->request('rm -rf', ['name' => 'x']);
    }

    public function test_request_rejects_an_invalid_name(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        app(WgBridge::class)->request('add-peer', ['name' => 'bad name; rm']);
    }

    public function test_result_only_returns_for_the_matching_id(): void
    {
        $id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);
        file_put_contents($this->dir."/wg-result-{$id}.json", json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "cfg", 'at' => time()]));

        $this->assertNull(app(WgBridge::class)->result('different-id-totally'));
        $r = app(WgBridge::class)->result($id);
        $this->assertTrue($r['ok']);
        $this->assertSame('cfg', $r['config']);
    }
}
```

- [ ] **Step 2: Run → fails.**

- [ ] **Step 3: Write `app/Services/WgBridge.php`:**

```php
<?php

namespace App\Services;

use Illuminate\Support\Str;

/**
 * The app side of the WireGuard write-bridge. `request()` writes a strictly-validated request to
 * run/wg-request.json (a host watcher runs it); `result()` reads the host-written result for an id
 * the app issued. Validation here is the FIRST gate — the host re-validates every value too.
 */
class WgBridge
{
    /** Actions the UI may request; the host whitelists the same set. */
    public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet'];

    private function dir(): string
    {
        return storage_path('app/restart-signal');
    }

    /**
     * Write a write-request; returns the request id. Throws InvalidArgumentException on a bad
     * action or arg (defence-in-depth; the host re-validates).
     *
     * @param  array<string,string>  $args
     */
    public function request(string $action, array $args = []): string
    {
        if (! in_array($action, self::ACTIONS, true)) {
            throw new \InvalidArgumentException('unknown action');
        }
        $clean = $this->validateArgs($action, $args);

        $id = Str::random(32);
        $payload = array_merge(['id' => $id, 'action' => $action, 'at' => time()], $clean);

        @mkdir($this->dir(), 0775, true);
        file_put_contents($this->dir().'/wg-request.json', json_encode($payload, JSON_UNESCAPED_SLASHES));

        return $id;
    }

    /**
     * Read the host result for an id THIS app issued. Returns null until the result exists.
     * Includes the QR sidecar SVG when present (add-peer). Never throws.
     *
     * @return array{ok:bool, message:string, config:?string, qr:?string}|null
     */
    public function result(string $id): ?array
    {
        if (preg_match('/^[A-Za-z0-9]{16,64}$/', $id) !== 1) {
            return null;
        }
        $file = $this->dir()."/wg-result-{$id}.json";
        if (! is_file($file)) {
            return null;
        }
        $data = json_decode((string) @file_get_contents($file), true);
        if (! is_array($data) || ($data['id'] ?? null) !== $id) {
            return null;
        }
        $qrFile = $this->dir()."/wg-qr-{$id}.svg";
        $qr = is_file($qrFile) ? (string) @file_get_contents($qrFile) : null;

        return [
            'ok' => (bool) ($data['ok'] ?? false),
            'message' => (string) ($data['message'] ?? ''),
            'config' => isset($data['config']) && is_string($data['config']) ? $data['config'] : null,
            'qr' => $qr,
        ];
    }

    /**
     * @param  array<string,string>  $args
     * @return array<string,string>
     */
    private function validateArgs(string $action, array $args): array
    {
        $name = (string) ($args['name'] ?? '');
        switch ($action) {
            case 'add-peer':
            case 'remove-peer':
                if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
                    throw new \InvalidArgumentException('invalid name');
                }

                return ['name' => $name];
            case 'gate-up':
            case 'gate-down':
                return [];
            case 'set-endpoint':   // P4
                $ep = (string) ($args['endpoint'] ?? '');
                if (preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $ep) !== 1) {
                    throw new \InvalidArgumentException('invalid endpoint');
                }

                return ['endpoint' => $ep];
            case 'set-port':       // P4
                $port = (string) ($args['port'] ?? '');
                if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) {
                    throw new \InvalidArgumentException('invalid port');
                }

                return ['port' => $port];
            case 'set-subnet':     // P4
                $subnet = (string) ($args['subnet'] ?? '');
                if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) {
                    throw new \InvalidArgumentException('invalid subnet');
                }

                return ['subnet' => $subnet];
        }

        return [];
    }
}
```

(`set-*` are validated now so P4 only adds the host `cmd_*` + UI — the app gate is ready.)

- [ ] **Step 4: Run → passes.** Pint the service + test.

- [ ] **Step 5: Commit**

```bash
git add app/Services/WgBridge.php tests/Feature/WgBridgeTest.php
git commit -m "feat(wg): WgBridge — app side of the write-bridge (validate + request + result)"
```

---

## Task 5: peer management UI

**Files:** Modify `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/{de,en}/wireguard.php`; Test extend `tests/Feature/WireguardPageTest.php`

- [ ] **Step 1: Extend the page test** — add to `WireguardPageTest`:

```php
    public function test_add_peer_writes_a_request_and_audits(): void
    {
        \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
            ->set('newPeer', 'laptop')
            ->call('addPeer')
            ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');

        $this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.add-peer')->exists());
        $this->assertFileExists(storage_path('app/restart-signal/wg-request.json'));
        @unlink(storage_path('app/restart-signal/wg-request.json'));
    }

    public function test_add_peer_rejects_an_invalid_name(): void
    {
        \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
            ->set('newPeer', 'bad name')
            ->call('addPeer')
            ->assertHasErrors('newPeer');
        $this->assertSame(0, \App\Models\AuditEvent::where('action', 'wg.add-peer')->count());
    }

    public function test_poll_result_surfaces_the_config_once(): void
    {
        $c = \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)->set('newPeer', 'laptop')->call('addPeer');
        $id = $c->get('pendingId');
        file_put_contents(storage_path("app/restart-signal/wg-result-{$id}.json"), json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "[Interface]\nPrivateKey = x", 'at' => time()]));

        $c->call('pollResult')
            ->assertSet('pendingId', null)
            ->assertSet('resultConfig', fn ($v) => str_contains((string) $v, 'PrivateKey'));
        @unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
    }
```

- [ ] **Step 2: Run → fails.**

- [ ] **Step 3: Update the component** — add to `app/Livewire/Wireguard/Index.php` (keep the existing `$window`/`setWindow`/`render`; add the peer-mgmt state + methods + inject `WgBridge`). The full updated class:

```php
<?php

namespace App\Livewire\Wireguard;

use App\Models\AuditEvent;
use App\Services\WgBridge;
use App\Services\WgStatus;
use App\Services\WgTraffic;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the
 * host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
 */
#[Layout('layouts.app')]
class Index extends Component
{
    public int $window = 3600;

    public const WINDOWS = [3600, 86400, 604800];

    // ── peer management ──
    public string $newPeer = '';

    /** id of an in-flight write-request we are polling for. */
    public ?string $pendingId = null;

    /** What we are waiting on ('add-peer'|'remove-peer'|…) — drives the toast/modal. */
    public ?string $pendingAction = null;

    /** Show-once add-peer result (config text + QR svg). Never persisted. */
    public ?string $resultConfig = null;

    public ?string $resultQr = null;

    public function setWindow(int $seconds): void
    {
        $this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600;
    }

    public function addPeer(WgBridge $bridge): void
    {
        $this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [
            'newPeer.regex' => __('wireguard.peer_name_invalid'),
        ]);
        if (! $this->throttle()) {
            return;
        }
        $name = $this->newPeer;
        $this->pendingId = $bridge->request('add-peer', ['name' => $name]);
        $this->pendingAction = 'add-peer';
        $this->newPeer = '';
        $this->audit('wg.add-peer', $name);
    }

    public function removePeer(WgBridge $bridge, string $name): void
    {
        if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) {
            return;
        }
        $this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
        $this->pendingAction = 'remove-peer';
        $this->audit('wg.remove-peer', $name);
    }

    /** Polled (wire:poll) while a request is in flight; surfaces the host result once. */
    public function pollResult(WgBridge $bridge): void
    {
        if ($this->pendingId === null) {
            return;
        }
        $res = $bridge->result($this->pendingId);
        if ($res === null) {
            return; // not ready yet
        }
        $id = $this->pendingId;
        $this->pendingId = null;
        if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) {
            $this->resultConfig = $res['config'];   // show once
            $this->resultQr = $res['qr'];
        } elseif (! $res['ok']) {
            $this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error');
        } else {
            $this->dispatch('notify', message: __('wireguard.action_done'));
        }
        $this->pendingAction = null;
    }

    public function dismissResult(): void
    {
        $this->resultConfig = null;
        $this->resultQr = null;
    }

    private function throttle(): bool
    {
        $key = 'wg-request:'.Auth::id();
        if (RateLimiter::tooManyAttempts($key, 10)) {
            $this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');

            return false;
        }
        RateLimiter::hit($key, 60);

        return true;
    }

    private function audit(string $action, string $target): void
    {
        AuditEvent::create([
            'user_id' => Auth::id(),
            'actor' => Auth::user()?->name ?? 'system',
            'action' => $action,
            'target' => $target,
            'ip' => request()->ip(),
        ]);
    }

    public function render(WgStatus $wg, WgTraffic $traffic)
    {
        $window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;

        return view('livewire.wireguard.index', [
            'status' => $wg->read(),
            'traffic' => $traffic->series($window),
            'windows' => self::WINDOWS,
        ])->title(__('wireguard.title'));
    }
}
```

- [ ] **Step 4: Add lang strings** (BOTH files, identical keys). DE:

```php
    'add_peer' => 'Peer hinzufügen',
    'peer_name_placeholder' => 'Name (z. B. laptop)',
    'peer_name_invalid' => 'Nur A–Z a–z 0–9 . _ - (max. 64).',
    'remove' => 'Entfernen',
    'remove_confirm_title' => 'Peer entfernen?',
    'remove_confirm_body' => 'Der Client verliert sofort den Zugang. Das lässt sich nicht rückgängig machen.',
    'pending' => 'Wird angewendet …',
    'action_done' => 'Aktion angewendet.',
    'action_failed' => 'Aktion fehlgeschlagen — auf dem Host prüfen.',
    'throttled' => 'Zu viele Aktionen — kurz warten.',
    'new_peer_title' => 'Neuer Peer — Konfiguration',
    'new_peer_once' => 'Diese Konfiguration wird NUR EINMAL angezeigt und nicht gespeichert. Jetzt importieren (QR scannen oder Text kopieren).',
    'close' => 'Schließen',
```

EN:

```php
    'add_peer' => 'Add peer',
    'peer_name_placeholder' => 'Name (e.g. laptop)',
    'peer_name_invalid' => 'Only A–Z a–z 0–9 . _ - (max 64).',
    'remove' => 'Remove',
    'remove_confirm_title' => 'Remove peer?',
    'remove_confirm_body' => 'The client loses access immediately. This cannot be undone.',
    'pending' => 'Applying …',
    'action_done' => 'Action applied.',
    'action_failed' => 'Action failed — check on the host.',
    'throttled' => 'Too many actions — wait a moment.',
    'new_peer_title' => 'New peer — configuration',
    'new_peer_once' => 'This configuration is shown ONCE and never stored. Import it now (scan the QR or copy the text).',
    'close' => 'Close',
```

- [ ] **Step 5: Update the view.** In `resources/views/livewire/wireguard/index.blade.php`:
  - Add `wire:poll.2s="pollResult"` is NOT global — instead, add a small polling element shown only while pending. Put this near the top of the configured branch (inside `@else`), above the Traffic panel:

```blade
        @if ($pendingId)
            <div class="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-4 py-3" wire:poll.2s="pollResult">
                <svg class="h-4 w-4 shrink-0 animate-spin text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
                <span class="font-mono text-[11px] text-ink-2">{{ __('wireguard.pending') }}</span>
            </div>
        @endif
```

  - In the Peers panel header (the `<x-panel :title="__('wireguard.peers_title')" …>`), add an add-peer form. Change the panel to include a header action — put this inside the peers panel, just after its opening, as a sub-header row:

```blade
            <form wire:submit="addPeer" class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-3 sm:px-5">
                <input wire:model="newPeer" type="text" maxlength="64" placeholder="{{ __('wireguard.peer_name_placeholder') }}"
                       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="primary" type="submit" wire:loading.attr="disabled" wire:target="addPeer">
                    <x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.add_peer') }}
                </x-btn>
                @error('newPeer') <span class="w-full font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
            </form>
```

  - In each peer row, add a remove control (a destructive action → R5 wire-elements/modal confirm via the project's confirm pattern). Add to the peer row, in the `ml-auto` area:

```blade
                            <button type="button"
                                wire:click="$dispatch('open-modal', { component: 'modals.confirm-delete', arguments: { title: @js(__('wireguard.remove_confirm_title')), body: @js(__('wireguard.remove_confirm_body')), confirm: @js(__('wireguard.remove')), method: 'removePeer', params: @js([$peer['name']]) }})"
                                class="rounded-md border border-line bg-raised px-2 py-1 font-mono text-[10px] text-ink-3 transition-colors hover:border-offline/40 hover:text-offline">{{ __('wireguard.remove') }}</button>
```

    NOTE: the implementer MUST first read how destructive confirms are actually wired in this project — find the existing `Modals\ConfirmDelete` (or equivalent) component and an existing call site (e.g. server delete or `clusev unban`), and replicate THAT exact pattern + arguments (component name, the dispatch event, how the confirmed method + params are invoked, the ConfirmToken if used per R5). Replace the snippet above with the project's real confirm-modal invocation calling `removePeer($peer['name'])`. Do not invent a modal API.

  - Add the show-once result modal at the END of the root `<div>` (before its closing tag):

```blade
    @if ($resultConfig)
        <div class="fixed inset-0 z-50 flex items-center justify-center bg-void/80 p-4" wire:key="wg-result-modal">
            <div class="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-panel">
                <h3 class="font-display text-lg font-semibold text-ink">{{ __('wireguard.new_peer_title') }}</h3>
                <p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p>
                @if ($resultQr)
                    <div class="mx-auto mt-4 h-44 w-44 [&>svg]:h-full [&>svg]:w-full">{!! $resultQr !!}</div>
                @endif
                <pre class="mt-4 max-h-48 overflow-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">{{ $resultConfig }}</pre>
                <div class="mt-4 flex justify-end">
                    <x-btn variant="secondary" wire:click="dismissResult">{{ __('wireguard.close') }}</x-btn>
                </div>
            </div>
        </div>
    @endif
```

    (The QR is host-generated SVG; `{!! !!}` is safe here — it is produced by `qrencode` on the host from our own config, not user HTML. The implementer should still confirm the SVG has no `<script>` — `qrencode -t SVG` emits only `<svg><rect/>…`.)

- [ ] **Step 6: Run the page tests → pass.** Pint. (If the confirm-modal pattern needs adjusting, fix until the remove path works in a Livewire test.)

- [ ] **Step 7: Commit**

```bash
git add app/Livewire/Wireguard/Index.php resources/views/livewire/wireguard/index.blade.php lang/de/wireguard.php lang/en/wireguard.php tests/Feature/WireguardPageTest.php
git commit -m "feat(wg): add/remove peers from the dashboard (show-once config + QR)"
```

---

## Task 6: review + R12 + release

- [ ] **Step 1: Adversarial security review** of the write-bridge (Task 1 + 4 + 5) — focus: can container input reach a shell command? is every arg whitelisted host-side AND app-side? is the result file un-leakable (0600, id-bound, pruned)? id-collision/claim-other-user's-result? the QR SVG injection surface? throttle + audit present? Fix every confirmed finding.
- [ ] **Step 2: shellcheck + Pint + full suite** all green.
- [ ] **Step 3: R12** — browser-verify `/wireguard` add-peer flow: stub a configured status, type a name → addPeer → (simulate the host by writing a `wg-result-<id>.json` with a config + a small `wg-qr-<id>.svg`) → pollResult surfaces the show-once modal with the config + QR; the remove confirm modal opens. 1280 + 375, DE+EN, no console errors, no leaked tokens. Restore the gkonrad password + clean stubs.
- [ ] **Step 4: Release** v0.9.36 — CHANGELOG (Hinzugefügt: Peer-Verwaltung aus dem Dashboard + Schreib-Brücke), bump, commit, tag, push.

---

## Self-Review

**Spec coverage (P3 = spec §4 + the §0 Bridge 2 + §6 security):**
- Write-bridge (request file → host watcher → serve-request whitelist+re-validate → result file) → Tasks 1–4. ✓
- add-peer (show-once config + QR, never persisted) → Tasks 1, 5. ✓
- remove-peer (R5 confirm) → Tasks 1, 5. ✓
- Host-side re-validation + app-side validation (defence in depth) → Tasks 1, 4. ✓
- Result 0600 + app-uid-owned + 60s prune + id-bound read → Tasks 1, 4. ✓
- Audit + throttle → Task 5. ✓
- gate-up/gate-down actions are wired in the bridge now (UI for them is P4). ✓

**Placeholder scan:** none, except Task 5 explicitly defers the confirm-modal markup to the project's real pattern (the implementer must read + replicate it) — flagged, not a silent gap.

**Type/name consistency:** `WgBridge::ACTIONS`/`request()`/`result()` shapes match the host serve-request whitelist (`add-peer/remove-peer/gate-up/gate-down/set-*`) and the component's calls. The request JSON keys (`id/action/name/at`) written by `WgBridge::request` are exactly what `cmd_serve_request` greps. The result keys (`id/ok/message/config`) written by `_write_result` match `WgBridge::result()`’s parse + the component's use. `pendingId/pendingAction/resultConfig/resultQr/newPeer` consistent across component + view + tests.

**Risk note:** the confirm-modal wiring (Task 5) is the one place the plan can't fully prescribe without the project's modal API — the implementer reads the existing pattern. Everything else is fully specified. The host bridge is exercised by shellcheck + the runbook (P3 manual section) + the app-side tests with stubbed result files; the true end-to-end (container→host→container) only runs on a deployed host.
