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