docs(wg): SP1 implementation plan — WireGuard gate + clusev wg CLI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-20 22:20:51 +02:00
parent 084c21a869
commit f154ee8aaf
1 changed files with 832 additions and 0 deletions

View File

@ -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 <script-path>
```
---
## 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 <cmd>` (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 <name>"
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" <<EOF
# clusev-peer: ${name}
[Peer]
PublicKey = ${cpub}
AllowedIPs = ${ip}/32
EOF
wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32" # live, no restart
local client_conf
client_conf="$(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
)"
bold "Peer '${name}' angelegt (${ip})."
printf '%s\n' "$client_conf"
if have qrencode; then
printf '%s\n' "$client_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."
}
cmd_remove_peer() {
need_root remove-peer
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
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" <<EOF
[Interface]
Address = ${server_ip}/${prefix}
ListenPort = ${port}
PrivateKey = ${srv_priv}
EOF
chmod 600 "$WG_CONF"
cat > "$WG_ENV" <<EOF
WG_SUBNET=${subnet}
WG_SERVER_IP=${server_ip}
WG_PORT=${port}
WG_ENDPOINT=${endpoint}
WG_SERVER_PUBKEY=${srv_pub}
EOF
chmod 600 "$WG_ENV"
systemctl enable --now "wg-quick@${WG_IF}" || die "wg-quick@${WG_IF} konnte nicht gestartet werden — 'journalctl -u wg-quick@${WG_IF}' pruefen."
cmd_add_peer "$peer1"
echo
bold "Setup fertig. Tunnel ist OBEN, aber das Gate ist AUS (Panel weiter oeffentlich)."
info "1) Client importieren (QR oben), 2) http://${server_ip} ueber den Tunnel oeffnen, 3) dann: clusev wg up"
}
usage() {
cat <<'EOF'
clusev wg — WireGuard-Zugang (Host)
clusev wg setup WG einrichten (interaktiv): Subnetz, Keys, erster Peer
clusev wg up Gate aktivieren — Panel (80/443) nur ueber den Tunnel
clusev wg down Gate entfernen — Panel wieder oeffentlich (Notausgang!)
clusev wg status Tunnel, Peers, Gate-Status anzeigen
clusev wg add-peer <name> neuen Client anlegen (+ QR-Code)
clusev wg remove-peer <name> 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 269299)
- [ ] **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
<?php
namespace Tests\Feature;
use App\Livewire\Help\Index;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class HelpWireguardTopicTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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
<div class="space-y-3">
<h3 class="{{ $h }}">Was der WireGuard-Zugang macht</h3>
<p class="{{ $p }}">Stellt das <span class="text-ink">gesamte Panel hinter einen WireGuard-Tunnel</span>: Der Server wird zum WG-Server, deine Geräte verbinden sich als Peers, und das Panel (HTTP/HTTPS, Ports 80/443) ist <span class="text-ink">nur noch über den Tunnel</span> erreichbar. Eine Netzwerk-Sperre <span class="text-ink">zusätzlich</span> zu 2FA und Anmeldeschutz: Die schützen das <span class="text-ink">Login</span>, dies verbirgt das <span class="text-ink">ganze Panel</span> vor dem öffentlichen Internet.</p>
<p class="{{ $p }}">Alles läuft über die Host-CLI <code class="{{ $code }}">clusev wg …</code> (per SSH auf dem Server). Standardmäßig ist nichts aktiv — du entscheidest, wann der Tunnel und die Sperre eingeschaltet werden.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Einrichten — <code class="{{ $code }}">clusev wg setup</code></h3>
<p class="{{ $p }}">Interaktiv, jeder Wert mit sinnvoller Vorgabe und Erklärung:</p>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">WG-Subnetz</span> (Vorgabe <code class="{{ $code }}">10.99.0.0/24</code>) — ein privates Netz, das <span class="text-ink">nicht</span> mit deinem LAN/VPN kollidieren darf. Eine Kollision wird erkannt und abgewiesen.</li>
<li class="{{ $li }}"><span class="text-ink">Öffentlicher Endpoint</span> — die automatisch erkannte öffentliche IP. <span class="text-ink">Hinter NAT oder einem Cloud-Load-Balancer ist das evtl. nicht die Adresse, die Clients wählen müssen</span> — vor dem Aktivieren der Sperre prüfen.</li>
<li class="{{ $li }}"><span class="text-ink">Erster Peer</span> — wird gleich angelegt; Konfiguration und QR-Code werden ausgegeben.</li>
</ul>
<p class="{{ $p }}">Setup startet den Tunnel (übersteht Neustarts), aktiviert aber <span class="text-ink">nicht</span> die Sperre — das Panel bleibt zunächst öffentlich.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Client verbinden &amp; testen</h3>
<p class="{{ $p }}">Den QR-Code aus <code class="{{ $code }}">setup</code> (oder <code class="{{ $code }}">clusev wg add-peer &lt;name&gt;</code>) in der WireGuard-App scannen. Standard ist <span class="text-ink">Split-Tunnel</span>: nur Panel-Verkehr läuft über WG, dein normales Internet nicht. Voll-Tunnel (<code class="{{ $code }}">0.0.0.0/0</code>) ist möglich, aber <span class="text-ink">nicht empfohlen</span>.</p>
<p class="{{ $p }}">Verbinden, dann <code class="{{ $code }}">http://&lt;Server-Tunnel-IP&gt;</code> öffnen — erreicht das Panel? Erst wenn der Tunnel sicher funktioniert, die Sperre aktivieren.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Sperre ein/aus — <code class="{{ $code }}">clusev wg up</code> / <code class="{{ $code }}">down</code></h3>
<p class="{{ $p }}"><code class="{{ $code }}">clusev wg up</code> sperrt 80/443 auf das WG-Subnetz — von außen ist das Panel danach nicht mehr erreichbar. <code class="{{ $code }}">clusev wg status</code> zeigt Peers, Handshakes und den Sperr-Status.</p>
<p class="{{ $p }}"><span class="text-ink">Notausgang:</span> <code class="{{ $code }}">clusev wg down</code> (per SSH) entfernt die Sperre sofort — das Panel ist wieder öffentlich. <span class="text-ink">SSH (Port 22) und der WireGuard-Port sind von der Sperre nie betroffen</span>, du kommst also immer per SSH auf den Server.</p>
<p class="{{ $p }}">Schlägt <code class="{{ $code }}">wg0</code> nach einem Neustart fehl, wird die Sperre <span class="text-ink">nicht</span> angewendet — das Panel bleibt öffentlich erreichbar statt dich auszusperren.</p>
</div>
```
- [ ] **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
<div class="space-y-3">
<h3 class="{{ $h }}">What the WireGuard access does</h3>
<p class="{{ $p }}">Puts the <span class="text-ink">whole panel behind a WireGuard tunnel</span>: the server becomes a WG server, your devices peer in, and the panel (HTTP/HTTPS, ports 80/443) is reachable <span class="text-ink">only through the tunnel</span>. A network-layer gate <span class="text-ink">on top of</span> 2FA and the login protection: those guard the <span class="text-ink">login</span>; this hides the <span class="text-ink">whole panel</span> from the public internet.</p>
<p class="{{ $p }}">Everything runs through the host CLI <code class="{{ $code }}">clusev wg …</code> (over SSH on the server). Nothing is active by default — you decide when the tunnel and the gate go on.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Set up — <code class="{{ $code }}">clusev wg setup</code></h3>
<p class="{{ $p }}">Interactive, every value pre-filled with a sensible default and a hint:</p>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">WG subnet</span> (default <code class="{{ $code }}">10.99.0.0/24</code>) — a private network that must <span class="text-ink">not</span> clash with your LAN/VPN. A collision is detected and rejected.</li>
<li class="{{ $li }}"><span class="text-ink">Public endpoint</span> — the auto-detected public IP. <span class="text-ink">Behind NAT or a cloud load balancer this may not be the address clients should dial</span> — verify it before enabling the gate.</li>
<li class="{{ $li }}"><span class="text-ink">First peer</span> — created right away; its config and a QR code are printed.</li>
</ul>
<p class="{{ $p }}">Setup starts the tunnel (survives reboots) but does <span class="text-ink">not</span> enable the gate — the panel stays public for now.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Connect a client &amp; test</h3>
<p class="{{ $p }}">Scan the QR code from <code class="{{ $code }}">setup</code> (or <code class="{{ $code }}">clusev wg add-peer &lt;name&gt;</code>) in the WireGuard app. The default is <span class="text-ink">split tunnel</span>: only panel traffic goes through WG, your normal internet does not. Full tunnel (<code class="{{ $code }}">0.0.0.0/0</code>) is possible but <span class="text-ink">not recommended</span>.</p>
<p class="{{ $p }}">Connect, then open <code class="{{ $code }}">http://&lt;server-tunnel-ip&gt;</code> — does it reach the panel? Only once the tunnel reliably works, enable the gate.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Gate on/off — <code class="{{ $code }}">clusev wg up</code> / <code class="{{ $code }}">down</code></h3>
<p class="{{ $p }}"><code class="{{ $code }}">clusev wg up</code> restricts 80/443 to the WG subnet — from outside the panel is then unreachable. <code class="{{ $code }}">clusev wg status</code> shows peers, handshakes and the gate state.</p>
<p class="{{ $p }}"><span class="text-ink">Escape hatch:</span> <code class="{{ $code }}">clusev wg down</code> (over SSH) removes the gate immediately — the panel is public again. <span class="text-ink">SSH (port 22) and the WireGuard port are never affected by the gate</span>, so you can always reach the server over SSH.</p>
<p class="{{ $p }}">If <code class="{{ $code }}">wg0</code> fails to come up after a reboot, the gate is <span class="text-ink">not</span> applied — the panel stays publicly reachable rather than locking you out.</p>
</div>
```
- [ ] **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://<server-tunnel-ip>` reaches the panel.
- [ ] Public `http://<public-ip>` still reachable (gate is off).
## gate up
- [ ] `sudo clusev wg up` → prints the escape reminder; refuses if wg0 is down.
- [ ] Public `http://<public-ip>` 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 18. ✓
**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.