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

42 KiB
Raw Blame History

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 updateupdate.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 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.shapt-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.phptopic_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

#!/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 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):

# 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 after artisan) and before version)

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 artisan line

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 &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:

@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:
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):

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