From f154ee8aafeb13a75fa996726618a6fa2d53db71 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 22:20:51 +0200 Subject: [PATCH] =?UTF-8?q?docs(wg):=20SP1=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20WireGuard=20gate=20+=20clusev=20wg=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-20-wireguard-gate-cli.md | 832 ++++++++++++++++++ 1 file changed, 832 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-wireguard-gate-cli.md diff --git a/docs/superpowers/plans/2026-06-20-wireguard-gate-cli.md b/docs/superpowers/plans/2026-06-20-wireguard-gate-cli.md new file mode 100644 index 0000000..93a67aa --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-wireguard-gate-cli.md @@ -0,0 +1,832 @@ +# WireGuard panel gate + `clusev wg` CLI (SP1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an operator put the Clusev panel behind a WireGuard tunnel — the VM becomes a WG server, operator devices peer in, and TCP 80/443 is reachable only from the WG subnet — driven entirely from a host CLI `clusev wg setup|up|down|status|add-peer|remove-peer`, off by default and impossible to lock yourself out of. + +**Architecture:** WireGuard (`wg0`, kernel) and the firewall live on the **host**; the Laravel app runs in a container with no host network access. So the work is **host shell scripts** invoked through the existing `clusev` wrapper (exactly like `update` → `update.sh`) — NOT an artisan command. The gate is a dedicated `CLUSEV-WG-GATE` iptables chain hung off `DOCKER-USER` (ufw can't filter Docker-published ports). The only application-side change is a Help topic. Spec: `docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md` (approved). + +**Tech Stack:** bash (host), `wireguard-tools` (`wg`, `wg-quick`), `qrencode`, `iptables` (nft backend, Debian 13), systemd (`.service` oneshot), Laravel 13 + Livewire 3 (Help topic only), Pint, shellcheck. + +**Why no PHPUnit for the scripts:** the test runner is the app container, which has no host WireGuard/firewall/kernel access. Host scripts are gated by **shellcheck** (automated) + a **manual runbook** on a throwaway Debian-13 VM (Task 8). Only the Help topic (Task 6) is PHPUnit-testable. + +**Run shellcheck portably (it is not installed on host or in the app image):** +```bash +docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable +``` + +--- + +## File Structure + +**New files** +- `docker/wg/clusev-wg.sh` — the whole CLI: setup (interactive, collision-checked), up/down (the gate), status, add-peer/remove-peer, and the `gate-apply` subcommand the systemd unit calls. One file, one responsibility (host WG control). +- `docker/wg/clusev-wg-gate.service` — boot/Docker-restart persistence oneshot; `ExecStart` calls `clusev-wg.sh gate-apply`. +- `resources/views/livewire/help/content/de/wireguard.blade.php` + `.../en/wireguard.blade.php` — Help topic content (mirrors the `security` partial). +- `docs/superpowers/runbooks/wireguard-gate-runbook.md` — manual verification steps (the real test for the host scripts). + +**Modified files** +- `install.sh` — `apt-get install -y wireguard-tools qrencode` in the package phase; render + `enable` the gate unit inside `install_host_watchers()`. +- `docker/clusev/clusev` (template) — a `wg)` case + a German usage line. +- `app/Livewire/Help/Index.php` — add `'wireguard'` to `TOPICS` + the `$labels` map. +- `lang/de/help.php`, `lang/en/help.php` — `topic_wireguard` key. + +**Not touched:** `docker/caddy/Caddyfile`, `docker-compose.prod.yml` (host-firewall gate — Caddy keeps listening on `0.0.0.0:80/443`), `FirewallService`/`Fail2banService` (remote fleet, unrelated), `trustProxies`/domain/TLS. + +**State files created at runtime (not in git):** `/etc/wireguard/wg0.conf` (source of truth for peers, `0600`), `/etc/clusev/wg.env` (subnet/port/endpoint/server-pubkey metadata, `0600`), `/etc/clusev/wg-gate.enabled` (gate marker). + +--- + +## Task 1: `clusev-wg.sh` — the CLI + +The whole host CLI in one file. Builds the gate logic, up/down, setup, add/remove-peer, status, and the `gate-apply` subcommand. + +**Files:** +- Create: `docker/wg/clusev-wg.sh` + +- [ ] **Step 1: Write the script** + +```bash +#!/usr/bin/env bash +# Clusev WireGuard gate + peer CLI (HOST-side, root). Invoked via `clusev wg ` (the host +# wrapper execs this from the deployed repo tree). Puts the panel behind a WG tunnel: the VM is the +# WG server, operator devices peer in, and TCP 80/443 is filtered to the WG subnet by a dedicated +# iptables chain hung off DOCKER-USER. SSH (22) and the WG UDP port are NEVER matched — you can +# always SSH in and run `clusev wg down`. The gate is OFF until `clusev wg up`. +set -euo pipefail + +WG_IF="wg0" +WG_DIR="/etc/wireguard" +WG_CONF="${WG_DIR}/${WG_IF}.conf" +GATE_CHAIN="CLUSEV-WG-GATE" +STATE_DIR="/etc/clusev" +WG_ENV="${STATE_DIR}/wg.env" +GATE_MARKER="${STATE_DIR}/wg-gate.enabled" +PANEL_PORTS="80,443" # see spec §3 post-DNAT note: matches the published container ports (Caddy 80/443) + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } +warn() { printf '\033[33m ! %s\033[0m\n' "$*" >&2; } +die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } +need_root() { [ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo clusev wg ${1}"; } + +# ── subnet helpers (SP1 assumes the default /24; documented in the runbook) ─────────────────── +subnet_first_ip() { local b="${1%%/*}"; printf '%s.%s\n' "${b%.*}" "$(( ${b##*.} + 1 ))"; } + +# Heuristic collision check: warn+re-prompt if the chosen subnet's /24 prefix already appears in the +# host's routes or addresses. Not a formal CIDR-overlap proof — it is the practical guard the spec +# asks for (catch a subnet that clashes with an existing interface/route), so a default cannot slip +# past a LAN collision. +subnet_collides() { + local net="${1%%/*}" prefix + prefix="${net%.*}" # first three octets, e.g. 10.99.0 + ip -o addr 2>/dev/null | awk '{print $4}' | grep -q "^${prefix}\." && return 0 + ip -o route 2>/dev/null | awk '{print $1}' | grep -q "^${prefix}\." && return 0 + return 1 +} + +detect_endpoint_ip() { + # Same lookup as install.sh: public IP via ipify, fall back to the first local address. + curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null \ + || hostname -I 2>/dev/null | awk '{print $1}' +} + +require_setup() { [ -f "$WG_CONF" ] && [ -f "$WG_ENV" ] || die "Keine WG-Konfiguration — erst 'clusev wg setup' ausfuehren."; } + +# ── the gate ────────────────────────────────────────────────────────────────────────────────── +gate_apply() { + # Called by `up` and by the systemd unit on boot/Docker-restart. No-op (success) unless the + # marker says the gate is enabled, so the boot unit never gates an operator who ran `down`. + [ -f "$GATE_MARKER" ] || { info "Gate-Marker fehlt — Gate nicht angewendet."; return 0; } + have iptables || die "iptables nicht gefunden." + # shellcheck disable=SC1090 + [ -f "$WG_ENV" ] && . "$WG_ENV" + [ -n "${WG_SUBNET:-}" ] || die "WG_SUBNET unbekannt (${WG_ENV} fehlt) — Gate nicht angewendet." + iptables -nL DOCKER-USER >/dev/null 2>&1 || die "DOCKER-USER-Chain fehlt (laeuft Docker?) — Gate nicht angewendet." + + iptables -N "$GATE_CHAIN" 2>/dev/null || iptables -F "$GATE_CHAIN" + iptables -A "$GATE_CHAIN" -i lo -j RETURN + iptables -A "$GATE_CHAIN" -s "$WG_SUBNET" -p tcp -m multiport --dport "$PANEL_PORTS" -j RETURN + iptables -A "$GATE_CHAIN" -p tcp -m multiport --dport "$PANEL_PORTS" -j DROP + # exactly one jump at the top of DOCKER-USER (idempotent) + iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true + iptables -I DOCKER-USER 1 -j "$GATE_CHAIN" +} + +gate_remove() { + have iptables || return 0 + iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true + iptables -F "$GATE_CHAIN" 2>/dev/null || true + iptables -X "$GATE_CHAIN" 2>/dev/null || true +} + +# ── commands ────────────────────────────────────────────────────────────────────────────────── +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'." + mkdir -p "$STATE_DIR" + printf 'enabled\n' > "$GATE_MARKER" + gate_apply + 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" +} + +cmd_down() { + need_root down + rm -f "$GATE_MARKER" + gate_remove + bold "WireGuard-Gate entfernt — Panel (80/443) wieder oeffentlich erreichbar (vorbehaltlich Cloud-/Host-Firewall)." +} + +next_free_ip() { + # shellcheck disable=SC1090 + . "$WG_ENV" + local prefix="${WG_SUBNET%%/*}"; prefix="${prefix%.*}" # first three octets (SP1: /24) + local used; used="$(awk -F= '/AllowedIPs/{gsub(/[ \t]/,"",$2);split($2,a,"/");print a[1]}' "$WG_CONF" 2>/dev/null || true)" + used="$(printf '%s\n%s\n' "$used" "${WG_SERVER_IP:-}")" + local i cand + for i in $(seq 2 254); do + cand="${prefix}.${i}" + printf '%s\n' "$used" | grep -qx "$cand" || { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +cmd_add_peer() { + need_root add-peer + local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer " + require_setup + # shellcheck disable=SC1090 + . "$WG_ENV" + grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && die "Peer '${name}' existiert bereits." + local ip; ip="$(next_free_ip)" || die "Kein freier Adressraum im Subnetz ${WG_SUBNET}." + local cpriv cpub + cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)" + + cat >> "$WG_CONF" <" + require_setup + local pub + pub="$(awk -v n="# clusev-peer: ${name}" '$0==n{f=1;next} f&&/PublicKey/{gsub(/[ \t]/,"");sub(/PublicKey=/,"");print;exit}' "$WG_CONF")" + [ -n "$pub" ] || die "Peer '${name}' nicht gefunden." + wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true + # drop the block from the marker comment up to (and including) the trailing blank line + local tmp; tmp="$(mktemp)" + awk -v n="# clusev-peer: ${name}" ' + $0==n {drop=1; next} + drop && /^[[:space:]]*$/ {drop=0; next} + drop {next} + {print} + ' "$WG_CONF" > "$tmp" + install -m 0600 "$tmp" "$WG_CONF"; rm -f "$tmp" + bold "Peer '${name}' entfernt." +} + +cmd_status() { + need_root status + bold "WireGuard (${WG_IF})" + if [ -e "/sys/class/net/${WG_IF}" ]; then wg show "$WG_IF" 2>/dev/null || true; else info "wg0 nicht aktiv (Setup/Tunnel noch nicht gestartet)."; fi + echo + bold "Gate" + if [ -f "$GATE_MARKER" ]; then info "Marker: aktiv (${GATE_MARKER})"; else info "Marker: aus"; fi + if iptables -nL "$GATE_CHAIN" >/dev/null 2>&1; then info "Chain ${GATE_CHAIN}: vorhanden"; else info "Chain ${GATE_CHAIN}: nicht vorhanden"; fi + echo + bold "Dienst wg-quick@${WG_IF}" + systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || true +} + +cmd_setup() { + need_root setup + have wg || die "wireguard-tools nicht installiert (erwartet via install.sh)." + if [ -f "$WG_CONF" ]; then + warn "${WG_CONF} existiert bereits — bestehende Peers gingen bei Neukonfiguration verloren." + read -rp " Trotzdem neu konfigurieren? [reconfigure/abort] (abort): " ans + [ "${ans:-abort}" = "reconfigure" ] || die "Abgebrochen — bestehende Konfiguration unberuehrt." + fi + + local subnet default_subnet="10.99.0.0/24" + while :; do + read -rp " WG-Subnetz (privates /24, darf NICHT mit LAN/VPN kollidieren) [${default_subnet}]: " subnet + subnet="${subnet:-$default_subnet}" + if subnet_collides "$subnet"; then warn "Subnetz ${subnet} ueberschneidet eine bestehende Route/Adresse — bitte ein anderes waehlen."; continue; fi + break + done + + local server_ip default_ip port endpoint default_ep peer1 + default_ip="$(subnet_first_ip "$subnet")" + read -rp " Server-Tunnel-IP [${default_ip}]: " server_ip; server_ip="${server_ip:-$default_ip}" + read -rp " Listen-Port (UDP) [51820]: " port; port="${port:-51820}" + default_ep="$(detect_endpoint_ip):${port}" + info "Hinweis: hinter NAT/Cloud-LB ist die erkannte IP evtl. NICHT die Wähl-Adresse der Clients — vor 'clusev wg up' pruefen." + read -rp " Oeffentlicher Endpoint (IP oder DNS:Port) [${default_ep}]: " endpoint; endpoint="${endpoint:-$default_ep}" + read -rp " Name des ersten Peers [client-1]: " peer1; peer1="${peer1:-client-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" < "$WG_ENV" < neuen Client anlegen (+ QR-Code) + clusev wg remove-peer Client entfernen + + SSH (22) und der WG-Port bleiben IMMER offen. 'clusev wg down' per SSH ist der Notausgang. +EOF +} + +cmd="${1:-help}"; shift 2>/dev/null || true +case "$cmd" in + setup) cmd_setup "$@" ;; + up) cmd_up "$@" ;; + down) cmd_down "$@" ;; + status) cmd_status "$@" ;; + add-peer) cmd_add_peer "$@" ;; + remove-peer) cmd_remove_peer "$@" ;; + gate-apply) need_root gate-apply; gate_apply ;; # called by clusev-wg-gate.service + help|-h|--help) usage ;; + *) printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;; +esac +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x docker/wg/clusev-wg.sh +``` + +- [ ] **Step 3: Syntax check** + +Run: `bash -n docker/wg/clusev-wg.sh` +Expected: no output, exit 0. + +- [ ] **Step 4: shellcheck clean** + +Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/wg/clusev-wg.sh` +Expected: no errors. (The intentional `# shellcheck disable=SC1090` lines cover the dynamic `.` sources.) + +- [ ] **Step 5: Smoke the no-root / usage paths (safe on the dev host — they never touch iptables/wg)** + +Run: `bash docker/wg/clusev-wg.sh help` +Expected: the usage block prints, exit 0. + +Run: `bash docker/wg/clusev-wg.sh bogus; echo "exit=$?"` +Expected: "Unbekannter Befehl: bogus" + usage, `exit=64`. + +Run (as non-root): `bash docker/wg/clusev-wg.sh status; echo "exit=$?"` +Expected: "Bitte mit sudo ausfuehren: sudo clusev wg status", non-zero exit. (Everything real is gated behind `need_root` + a Debian VM — see the runbook in Task 7.) + +- [ ] **Step 6: Commit** + +```bash +git add docker/wg/clusev-wg.sh +git commit -m "feat(wg): clusev-wg.sh — WireGuard gate + peer CLI (host)" +``` + +--- + +## Task 2: The gate persistence systemd unit + +A oneshot that re-applies the gate on boot/Docker-restart — but only when `wg0` is up (`ConditionPathExists`) and the marker is present (checked inside `gate-apply`). Modelled on `docker/restart-sentinel/clusev-update.service`. + +**Files:** +- Create: `docker/wg/clusev-wg-gate.service` + +- [ ] **Step 1: Write the unit** + +```ini +# Clusev WireGuard gate — persistence oneshot (HOST-side). +# +# Re-applies the CLUSEV-WG-GATE iptables chain after boot and after a Docker daemon restart (Docker +# flushes DOCKER-USER). It is a no-op unless BOTH are true: +# - wg0 is up (ConditionPathExists below — a failed wg0 on boot leaves the panel OPEN, not bricked) +# - the gate marker (/etc/clusev/wg-gate.enabled, checked inside `clusev-wg.sh gate-apply`) +# So `clusev wg down` (removes the marker) survives a reboot, and a tunnel that fails to come up never +# drops the panel with no way in. install.sh rewrites the /home/nexxo/clusev paths to the real tree. +[Unit] +Description=Clusev WireGuard gate — re-apply the panel firewall when wg0 is up +After=docker.service wg-quick@wg0.service +Wants=docker.service +ConditionPathExists=/sys/class/net/wg0 + +[Service] +Type=oneshot +User=root +Environment=CLUSEV_DIR=/home/nexxo/clusev +WorkingDirectory=/home/nexxo/clusev +ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh gate-apply + +[Install] +# Pulled in when docker.service starts (boot AND `systemctl restart docker`), so a Docker restart +# that flushes DOCKER-USER re-applies the gate. +WantedBy=docker.service +``` + +- [ ] **Step 2: Sanity-check the unit syntax** + +Run: `systemd-analyze verify docker/wg/clusev-wg-gate.service 2>&1 | grep -v 'Failed to prepare' || true` +Expected: no syntax errors reported about the `[Unit]`/`[Service]`/`[Install]` keys. (Path-not-found warnings for the not-yet-installed ExecStart are fine — the file is rendered at install time. If `systemd-analyze` is unavailable, just eyeball that the three sections + the directives match `clusev-update.service`.) + +- [ ] **Step 3: Commit** + +```bash +git add docker/wg/clusev-wg-gate.service +git commit -m "feat(wg): clusev-wg-gate.service — boot/docker-restart gate persistence" +``` + +--- + +## Task 3: `install.sh` — packages + install the gate unit + +Two idempotent additions. Packages near the Docker/preflight block; the unit inside `install_host_watchers()` (reuses its `systemctl` guard + single `daemon-reload`). + +**Files:** +- Modify: `install.sh` (package phase ~line 104; `install_host_watchers()` ~lines 269–299) + +- [ ] **Step 1: Add the WireGuard packages after the Docker/preflight block** + +Find the end of the preflight section — the line that confirms openssl/Docker, just before `# ── [2/9] user setup`. Add a standalone apt install (guarded by `IS_APT`, idempotent — apt is a no-op if present), right before the `phase 2/9` line (currently `install.sh:94`): + +```bash +# WireGuard gate (clusev wg ...) — present but inert until the operator runs `clusev wg setup`. +if [ "$IS_APT" = 1 ]; then + DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools qrencode >/dev/null 2>&1 \ + && info "wireguard-tools + qrencode vorhanden" \ + || warn "wireguard-tools/qrencode nicht installiert — 'clusev wg' erst nach 'apt-get install wireguard-tools qrencode' nutzbar." +fi +``` + +(`set -euo pipefail` is active — the `&& … || …` keeps a failed apt from aborting the install.) + +- [ ] **Step 2: Render + enable the gate unit inside `install_host_watchers()`** + +In `install_host_watchers()`, add the gate unit alongside the existing four. After the `tmp_usvc="$(mktemp)"` line, add a temp var: + +```bash + local tmp_gate; tmp_gate="$(mktemp)" +``` + +After the existing `sed ... clusev-update.service > "$tmp_usvc"` line, add: + +```bash + # Gate unit keeps User=root; only the path is rewritten. + sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-gate.service" > "$tmp_gate" +``` + +In the `if install -m 0644 ... && systemctl ...; then` block, add the install + an `enable` (NOT `--now`: the gate must not be applied at install time — it self-skips via the marker + `ConditionPathExists`, but `enable` registers it for boot/docker-restart). Add these two lines into the `&&` chain, before the final `; then`: + +```bash + && install -m 0644 "$tmp_gate" "${dst}/clusev-wg-gate.service" \ + && systemctl enable clusev-wg-gate.service \ +``` + +And add `$tmp_gate` to the closing `rm -f` cleanup line: + +```bash + rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" "$tmp_gate" +``` + +- [ ] **Step 3: Syntax + shellcheck** + +Run: `bash -n install.sh` +Expected: exit 0. + +Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable install.sh` +Expected: no NEW errors versus the pre-change baseline (run it once before editing to capture the baseline; the additions must not introduce new findings). + +- [ ] **Step 4: Commit** + +```bash +git add install.sh +git commit -m "feat(wg): install wireguard-tools/qrencode + enable the gate unit (inert by default)" +``` + +--- + +## Task 4: Host CLI wiring — `clusev wg` + +Add a `wg)` case + a usage line to the `clusev` wrapper template. The rendered `CLUSEV_DIR` makes the script path resolve on the host. + +**Files:** +- Modify: `docker/clusev/clusev` (case switch ~line 47; usage ~line 27) + +- [ ] **Step 1: Add the `wg)` case after `artisan)` and before `version)`** + +Find the `artisan)` arm: +```bash + artisan) compose exec app php artisan "$@" ;; +``` +Add directly below it: +```bash + wg) exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh" "$@" ;; +``` + +- [ ] **Step 2: Add the usage line after the `clusev artisan` line** + +Find in `usage()`: +```bash + clusev artisan <...> beliebiges artisan-Kommando im app-Container +``` +Add directly below it: +```bash + clusev wg <...> WireGuard-Zugang (setup|up|down|status|add-peer|remove-peer) +``` + +- [ ] **Step 3: Syntax + shellcheck the template** + +Run: `bash -n docker/clusev/clusev` +Expected: exit 0. (The `__CLUSEV_DIR__` token is a literal here; bash parses the unquoted assignment fine.) + +Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/clusev/clusev` +Expected: no new errors. + +- [ ] **Step 4: Commit** + +```bash +git add docker/clusev/clusev +git commit -m "feat(wg): wire 'clusev wg' into the host CLI wrapper" +``` + +--- + +## Task 5: Help topic registration (PHPUnit-testable) + +Register the `wireguard` topic + its label. This is the only part with real unit tests. + +**Files:** +- Modify: `app/Livewire/Help/Index.php` (TOPICS ~line 20; `$labels` ~line 53) +- Modify: `lang/de/help.php`, `lang/en/help.php` +- Test: `tests/Feature/HelpTest.php` (extend if it exists, else create) + +- [ ] **Step 1: Write the failing test** + +Create or extend `tests/Feature/HelpTest.php`: + +```php +actingAs(User::factory()->create(['must_change_password' => false])); + } + + public function test_wireguard_is_a_known_topic_and_renders(): void + { + Livewire::test(Index::class, ['topic' => 'wireguard']) + ->assertSet('topic', 'wireguard') // not clamped back to 'overview' + ->assertSee('WireGuard'); // the localized label + content render + } + + public function test_wireguard_label_exists_in_both_locales(): void + { + $this->assertSame('WireGuard-Zugang', __('help.topic_wireguard', [], 'de')); + $this->assertSame('WireGuard access', __('help.topic_wireguard', [], 'en')); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=HelpWireguardTopicTest'` +Expected: FAIL — `topic` clamps to `overview` (wireguard not in TOPICS) and `topic_wireguard` is missing. + +- [ ] **Step 3: Register the topic in `app/Livewire/Help/Index.php`** + +In the `TOPICS` const, add `'wireguard'` immediately before `'recovery'`: + +```php + private const TOPICS = [ + 'overview', 'domain-tls', 'security', 'updates', 'commands', + 'servers', 'sessions', 'email', 'audit', 'wireguard', 'recovery', + ]; +``` + +In the `$labels` map, add the entry after `'audit'`: + +```php + 'audit' => __('help.topic_audit'), + 'wireguard' => __('help.topic_wireguard'), + 'recovery' => __('help.topic_recovery'), +``` + +- [ ] **Step 4: Add the label key to both lang files** + +`lang/de/help.php` — after the `'topic_audit'` line: +```php + 'topic_wireguard' => 'WireGuard-Zugang', +``` + +`lang/en/help.php` — after the `'topic_audit'` line: +```php + 'topic_wireguard' => 'WireGuard access', +``` + +- [ ] **Step 5: Run the test (will still fail until Task 6 creates the content partials)** + +Run: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=HelpWireguardTopicTest'` +Expected: `test_wireguard_label_exists_in_both_locales` PASSES; `test_wireguard_is_a_known_topic_and_renders` still FAILS at `assertSee('WireGuard')` because the content partial `livewire.help.content.de.wireguard` does not exist yet (the index view falls back to the DE partial, which is also missing → render error). This is expected — Task 6 completes it. Do NOT commit a red test; proceed straight to Task 6, then return here. + +- [ ] **Step 6 (after Task 6): re-run + commit** + +Run: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=HelpWireguardTopicTest'` +Expected: both PASS. + +Run Pint: `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'vendor/bin/pint app/Livewire/Help/Index.php tests/Feature/HelpTest.php'` + +```bash +git add app/Livewire/Help/Index.php lang/de/help.php lang/en/help.php tests/Feature/HelpTest.php +git commit -m "feat(wg): register the WireGuard help topic (+ test)" +``` + +--- + +## Task 6: Help content partials (DE + EN) + +Mirror the `security` partial exactly (the `@php` `$h/$p/$li/$code` vars + plain divs, no `x-` components). Content: what the gate does, that it sits on top of 2FA/Anmeldeschutz, the `clusev wg setup` walkthrough, importing via QR, testing the tunnel, `clusev wg up`, split-vs-full tunnel, and the escape hatch prominently. DE+EN identical structure, no emoji (R9/R16), token utilities only (R3/R4), no leaked tokens (R17). + +**Files:** +- Create: `resources/views/livewire/help/content/de/wireguard.blade.php` +- Create: `resources/views/livewire/help/content/en/wireguard.blade.php` + +- [ ] **Step 1: Create the DE partial** + +`resources/views/livewire/help/content/de/wireguard.blade.php`: + +```blade +@php + $h = 'font-display text-base font-semibold text-ink'; + $p = 'text-sm leading-relaxed text-ink-2'; + $li = 'text-sm leading-relaxed text-ink-2'; + $code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text'; +@endphp + +
+

Was der WireGuard-Zugang macht

+

Stellt das gesamte Panel hinter einen WireGuard-Tunnel: Der Server wird zum WG-Server, deine Geräte verbinden sich als Peers, und das Panel (HTTP/HTTPS, Ports 80/443) ist nur noch über den Tunnel erreichbar. Eine Netzwerk-Sperre zusätzlich zu 2FA und Anmeldeschutz: Die schützen das Login, dies verbirgt das ganze Panel vor dem öffentlichen Internet.

+

Alles läuft über die Host-CLI clusev wg … (per SSH auf dem Server). Standardmäßig ist nichts aktiv — du entscheidest, wann der Tunnel und die Sperre eingeschaltet werden.

+
+ +
+

Einrichten — clusev wg setup

+

Interaktiv, jeder Wert mit sinnvoller Vorgabe und Erklärung:

+
    +
  • WG-Subnetz (Vorgabe 10.99.0.0/24) — ein privates Netz, das nicht mit deinem LAN/VPN kollidieren darf. Eine Kollision wird erkannt und abgewiesen.
  • +
  • Öffentlicher Endpoint — die automatisch erkannte öffentliche IP. Hinter NAT oder einem Cloud-Load-Balancer ist das evtl. nicht die Adresse, die Clients wählen müssen — vor dem Aktivieren der Sperre prüfen.
  • +
  • Erster Peer — wird gleich angelegt; Konfiguration und QR-Code werden ausgegeben.
  • +
+

Setup startet den Tunnel (übersteht Neustarts), aktiviert aber nicht die Sperre — das Panel bleibt zunächst öffentlich.

+
+ +
+

Client verbinden & testen

+

Den QR-Code aus setup (oder clusev wg add-peer <name>) in der WireGuard-App scannen. Standard ist Split-Tunnel: nur Panel-Verkehr läuft über WG, dein normales Internet nicht. Voll-Tunnel (0.0.0.0/0) ist möglich, aber nicht empfohlen.

+

Verbinden, dann http://<Server-Tunnel-IP> öffnen — erreicht das Panel? Erst wenn der Tunnel sicher funktioniert, die Sperre aktivieren.

+
+ +
+

Sperre ein/aus — clusev wg up / down

+

clusev wg up sperrt 80/443 auf das WG-Subnetz — von außen ist das Panel danach nicht mehr erreichbar. clusev wg status zeigt Peers, Handshakes und den Sperr-Status.

+

Notausgang: clusev wg down (per SSH) entfernt die Sperre sofort — das Panel ist wieder öffentlich. SSH (Port 22) und der WireGuard-Port sind von der Sperre nie betroffen, du kommst also immer per SSH auf den Server.

+

Schlägt wg0 nach einem Neustart fehl, wird die Sperre nicht angewendet — das Panel bleibt öffentlich erreichbar statt dich auszusperren.

+
+``` + +- [ ] **Step 2: Create the EN partial (identical structure)** + +`resources/views/livewire/help/content/en/wireguard.blade.php`: + +```blade +@php + $h = 'font-display text-base font-semibold text-ink'; + $p = 'text-sm leading-relaxed text-ink-2'; + $li = 'text-sm leading-relaxed text-ink-2'; + $code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text'; +@endphp + +
+

What the WireGuard access does

+

Puts the whole panel behind a WireGuard tunnel: the server becomes a WG server, your devices peer in, and the panel (HTTP/HTTPS, ports 80/443) is reachable only through the tunnel. A network-layer gate on top of 2FA and the login protection: those guard the login; this hides the whole panel from the public internet.

+

Everything runs through the host CLI clusev wg … (over SSH on the server). Nothing is active by default — you decide when the tunnel and the gate go on.

+
+ +
+

Set up — clusev wg setup

+

Interactive, every value pre-filled with a sensible default and a hint:

+
    +
  • WG subnet (default 10.99.0.0/24) — a private network that must not clash with your LAN/VPN. A collision is detected and rejected.
  • +
  • Public endpoint — the auto-detected public IP. Behind NAT or a cloud load balancer this may not be the address clients should dial — verify it before enabling the gate.
  • +
  • First peer — created right away; its config and a QR code are printed.
  • +
+

Setup starts the tunnel (survives reboots) but does not enable the gate — the panel stays public for now.

+
+ +
+

Connect a client & test

+

Scan the QR code from setup (or clusev wg add-peer <name>) in the WireGuard app. The default is split tunnel: only panel traffic goes through WG, your normal internet does not. Full tunnel (0.0.0.0/0) is possible but not recommended.

+

Connect, then open http://<server-tunnel-ip> — does it reach the panel? Only once the tunnel reliably works, enable the gate.

+
+ +
+

Gate on/off — clusev wg up / down

+

clusev wg up restricts 80/443 to the WG subnet — from outside the panel is then unreachable. clusev wg status shows peers, handshakes and the gate state.

+

Escape hatch: clusev wg down (over SSH) removes the gate immediately — the panel is public again. SSH (port 22) and the WireGuard port are never affected by the gate, so you can always reach the server over SSH.

+

If wg0 fails to come up after a reboot, the gate is not applied — the panel stays publicly reachable rather than locking you out.

+
+``` + +- [ ] **Step 3: Return to Task 5 Step 6** — re-run `HelpWireguardTopicTest` (now both pass), Pint, and commit the partials together with the registration: + +```bash +git add resources/views/livewire/help/content/de/wireguard.blade.php resources/views/livewire/help/content/en/wireguard.blade.php +git commit -m "feat(wg): WireGuard help topic content (DE + EN)" +``` + +- [ ] **Step 4: R12 browser-verify** `/help?topic=wireguard` in DE and EN: HTTP 200, zero console errors, 3 breakpoints (375/768/1280), and inspect the rendered DOM for leaked tokens (`@`, `{{ }}`, `$var`, `group.key`). Use the established puppeteer + temp-password flow (see CLAUDE.md R12). Record the result. + +--- + +## Task 7: Manual runbook + +The real verification for the host scripts (they cannot run under PHPUnit). Write the runbook a future operator/agent follows on a throwaway Debian-13 VM with the prod stack. + +**Files:** +- Create: `docs/superpowers/runbooks/wireguard-gate-runbook.md` + +- [ ] **Step 1: Write the runbook** + +Content (each step states the expected result, mirroring spec §Testing): + +```markdown +# WireGuard gate (SP1) — manual runbook + +Run on a throwaway Debian-13 VM with the Clusev prod stack up. SSH session kept open the whole time +(the escape hatch). Records the expected result of each step. + +## Pre +- [ ] `clusev wg help` prints usage; `clusev wg status` shows wg0 not active, gate off. + +## setup +- [ ] `sudo clusev wg setup`, enter a subnet that overlaps the VM's LAN (e.g. the VM's own /24) → + **rejected**, re-prompts. +- [ ] Re-run, accept the default `10.99.0.0/24` → **accepted**; server keys + first peer created; + client config + QR printed; `wg-quick@wg0` enabled + active. +- [ ] `sudo clusev wg setup` again → it **warns** that wg0.conf exists and does NOT silently clobber it + (abort unless you type `reconfigure`). + +## tunnel (gate still OFF) +- [ ] Import the printed client (QR) into the WireGuard app; connect. +- [ ] Over the tunnel: `http://` reaches the panel. +- [ ] Public `http://` still reachable (gate is off). + +## gate up +- [ ] `sudo clusev wg up` → prints the escape reminder; refuses if wg0 is down. +- [ ] Public `http://` 80/443 now **refused/timed out**; the tunnel still serves the panel. +- [ ] **SSH still works** (port 22 untouched). +- [ ] `clusev wg status` shows the peer + gate on (marker + chain present). +- [ ] Add a manual rule: `sudo iptables -I DOCKER-USER -s 203.0.113.0/24 -j RETURN`. Run + `clusev wg down` then `clusev wg up` → the manual rule **survives** (only CLUSEV-WG-GATE is touched). + +## persistence +- [ ] Reboot → wg0 + the gate come back (public still refused, tunnel still serves). +- [ ] Force wg0 to fail on boot (e.g. `sudo systemctl disable wg-quick@wg0` + reboot) → the gate is + **NOT applied** (panel publicly reachable, not bricked); SSH + `clusev wg down` recover. + +## peers + down +- [ ] `clusev wg add-peer client-2` → new config + QR; `clusev wg remove-peer client-2` → peer gone + from `wg show` and from wg0.conf. +- [ ] A full-tunnel note is shown by add-peer. +- [ ] `clusev wg down` → public open again; marker + chain gone. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/runbooks/wireguard-gate-runbook.md +git commit -m "docs(wg): manual runbook for the WireGuard gate (SP1)" +``` + +--- + +## Task 8: Final sweep + release + +- [ ] **Step 1: shellcheck all touched scripts** + +Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/wg/clusev-wg.sh install.sh docker/clusev/clusev` +Expected: clean (or only the explicit, justified disables). + +- [ ] **Step 2: Pint + full test suite** + +```bash +docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'vendor/bin/pint --dirty' +docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test' +``` +Expected: Pint clean, all tests pass. + +- [ ] **Step 3: R15 Codex review** over the app-side diff + the shell scripts: `/codex:review`. Fix + re-run until no errors / no security issues. + +- [ ] **Step 4: Release** — bump `config/clusev.php` `'version'` to the next patch, add a CHANGELOG entry under a new version heading (Hinzugefügt: WireGuard-Gate + `clusev wg` CLI), commit `chore: release X.Y.Z`, tag `vX.Y.Z`, push branch + tag (token read only at push time from `/home/nexxo/.env.gitea`, sanitised in output). Note in the entry: the gate is OFF by default and host-side only; run `clusev wg setup` then `clusev wg up`. + +--- + +## Self-Review + +**Spec coverage:** +- §0 safety (never lock out): gate only filters 80/443 (Task 1 `gate_apply`), `down` escape (Task 1 `cmd_down`), isolated `CLUSEV-WG-GATE` chain (Task 1), reboot-with-failed-wg0 stays open (`ConditionPathExists` Task 2 + marker check Task 1), off by default (no marker until `up`), no Caddy/compose change (none touched). ✓ +- §1 install present-but-inert: apt packages + gate unit `enable` (not `--now`), script runs from repo path via wrapper. Tasks 3, 4. ✓ +- §2 setup (pre-filled, collision-checked, idempotent, keys/conf 0600, wg-quick enable, first peer, no silent clobber): Task 1 `cmd_setup`. ✓ +- §3 gate up/down (DOCKER-USER chain, ordered RETURN/RETURN/DROP, single jump, marker, persistence unit, up-guards): Task 1 `gate_apply`/`cmd_up`/`cmd_down` + Task 2. ✓ +- §4 status/add-peer/remove-peer (keypair, next IP, wg0.conf source of truth, live `wg set`, QR with fallback, split-tunnel default + full-tunnel warning, exit codes): Task 1. ✓ +- §5 CLI wiring (`wg)` case after `artisan)`, German usage): Task 4. ✓ +- §6 help topic (TOPICS + label + DE/EN partials, identical keys, route): Tasks 5, 6. ✓ +- §Testing (shellcheck + runbook + R12 + R15): Tasks 1–8. ✓ + +**Deviations from the spec, justified:** +- Spec §3 derives the subnet implicitly; this plan persists it in `/etc/clusev/wg.env` at setup so `gate_apply` (and the boot unit) and `add-peer` have a single, reliable source without re-deriving a network from an IP/prefix in bash. `wg0.conf` remains the source of truth for **peers** as the spec requires. +- Spec §6 suggested the topic before `'recovery'`; implemented exactly there (after `'audit'`). +- Help label uses the spec's wording (`WireGuard-Zugang` / `WireGuard access`), not the scout's `WireGuard & VPN`. + +**Placeholder scan:** none — every step has concrete code/commands. + +**Type/name consistency:** `gate_apply`/`gate_remove`/`cmd_up`/`cmd_down`/`cmd_setup`/`cmd_add_peer`/`cmd_remove_peer`/`cmd_status`/`next_free_ip`/`subnet_collides`/`subnet_first_ip`/`detect_endpoint_ip`/`require_setup` are referenced consistently in the dispatch and the systemd `gate-apply` subcommand. `WG_SUBNET`/`WG_SERVER_IP`/`WG_PORT`/`WG_ENDPOINT`/`WG_SERVER_PUBKEY` are written by `cmd_setup` and read by `gate_apply`/`cmd_add_peer`/`next_free_ip`. ✓ + +**Known SP1 limitation (documented, not a gap):** the subnet helpers (`next_free_ip`, `subnet_collides`, `subnet_first_ip`) assume the default /24. Non-/24 subnets work for the tunnel but peer-IP allocation + the collision heuristic are /24-shaped; noted in the runbook. SP2 territory.