docs(wg): SP2 Phase 3 plan — peer management + write-bridge

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-21 00:24:44 +02:00
parent 037d285148
commit f91d8ed834
1 changed files with 860 additions and 0 deletions

View File

@ -0,0 +1,860 @@
# 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 AZ az 09 . _ - (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 AZ az 09 . _ - (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 14. ✓
- 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.