42 KiB
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):
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 thegate-applysubcommand the systemd unit calls. One file, one responsibility (host WG control).docker/wg/clusev-wg-gate.service— boot/Docker-restart persistence oneshot;ExecStartcallsclusev-wg.sh gate-apply.resources/views/livewire/help/content/de/wireguard.blade.php+.../en/wireguard.blade.php— Help topic content (mirrors thesecuritypartial).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 qrencodein the package phase; render +enablethe gate unit insideinstall_host_watchers().docker/clusev/clusev(template) — awg)case + a German usage line.app/Livewire/Help/Index.php— add'wireguard'toTOPICS+ the$labelsmap.lang/de/help.php,lang/en/help.php—topic_wireguardkey.
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
#!/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
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
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
# 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
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):
# 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:
local tmp_gate; tmp_gate="$(mktemp)"
After the existing sed ... clusev-update.service > "$tmp_usvc" line, add:
# 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:
&& 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:
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
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 afterartisan)and beforeversion)
Find the artisan) arm:
artisan) compose exec app php artisan "$@" ;;
Add directly below it:
wg) exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh" "$@" ;;
- Step 2: Add the usage line after the
clusev artisanline
Find in usage():
clusev artisan <...> beliebiges artisan-Kommando im app-Container
Add directly below it:
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
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
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':
private const TOPICS = [
'overview', 'domain-tls', 'security', 'updates', 'commands',
'servers', 'sessions', 'email', 'audit', 'wireguard', 'recovery',
];
In the $labels map, add the entry after 'audit':
'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:
'topic_wireguard' => 'WireGuard-Zugang',
lang/en/help.php — after the 'topic_audit' line:
'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'
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:
@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 & testen</h3>
<p class="{{ $p }}">Den QR-Code aus <code class="{{ $code }}">setup</code> (oder <code class="{{ $code }}">clusev wg add-peer <name></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://<Server-Tunnel-IP></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:
@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 & test</h3>
<p class="{{ $p }}">Scan the QR code from <code class="{{ $code }}">setup</code> (or <code class="{{ $code }}">clusev wg add-peer <name></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://<server-tunnel-ip></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:
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=wireguardin 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):
# 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
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
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 wgCLI), commitchore: release X.Y.Z, tagvX.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; runclusev wg setupthenclusev wg up.
Self-Review
Spec coverage:
- §0 safety (never lock out): gate only filters 80/443 (Task 1
gate_apply),downescape (Task 1cmd_down), isolatedCLUSEV-WG-GATEchain (Task 1), reboot-with-failed-wg0 stays open (ConditionPathExistsTask 2 + marker check Task 1), off by default (no marker untilup), 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 afterartisan), 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.envat setup sogate_apply(and the boot unit) andadd-peerhave a single, reliable source without re-deriving a network from an IP/prefix in bash.wg0.confremains 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'sWireGuard & 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.