clusev/docs/superpowers/plans/2026-06-20-wireguard-gate-c...

833 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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